Skip to main content

steel_core/behavior/blocks/vegetation/
turtle_egg_block.rs

1//! Vanilla turtle egg block behavior.
2//!
3//! Turtle eggs are placed in clusters of one to four in a single block space.
4//! While sitting on sand they slowly crack (three stages) and then hatch, and
5//! they are trampled by most living entities that walk or fall onto them.
6
7use std::sync::Arc;
8
9use steel_macros::block_behavior;
10use steel_registry::blocks::BlockRef;
11use steel_registry::blocks::block_state_ext::BlockStateExt;
12use steel_registry::blocks::properties::{BlockStateProperties, IntProperty};
13use steel_registry::item_stack::ItemStack;
14use steel_registry::vanilla_block_tags::BlockTag;
15use steel_registry::{
16    level_events, sound_events, vanilla_entities, vanilla_game_events, vanilla_game_rules,
17    vanilla_items, vanilla_world_clocks,
18};
19use steel_utils::types::UpdateFlags;
20use steel_utils::{BlockPos, BlockStateId};
21
22use crate::behavior::block::{
23    BlockBehavior, EntityFallDamage, EntityFallOnContext, default_can_be_replaced,
24};
25use crate::behavior::context::BlockPlaceContext;
26use crate::block_entity::SharedBlockEntity;
27use crate::entity::Entity;
28use crate::player::Player;
29use crate::world::World;
30use crate::world::game_event::GameEventContext;
31
32/// Cracking stages a turtle egg passes through before it hatches.
33const MAX_HATCH_LEVEL: u8 = 2;
34/// Maximum number of eggs that can occupy a single block space.
35const MAX_EGGS: u8 = 4;
36
37/// Length of one full Minecraft day, in ticks.
38const TICKS_PER_DAY: i64 = 24_000;
39/// Base per-random-tick chance that an egg advances a cracking stage.
40const BASE_HATCH_CHANCE: f32 = 0.002;
41/// Start (inclusive) of the pre-dawn window where eggs always advance.
42const HATCH_WINDOW_START: i64 = 21_062;
43/// End (exclusive) of the pre-dawn window where eggs always advance.
44const HATCH_WINDOW_END: i64 = 21_905;
45
46/// One in this many chance to trample an egg while standing on it each tick.
47const STEP_TRAMPLE_ODDS: i32 = 100;
48/// One in this many chance to trample an egg by falling onto it.
49const FALL_TRAMPLE_ODDS: i32 = 3;
50
51/// Particle count for the "egg placed on sand" effect.
52const TURTLE_EGG_PLACEMENT_PARTICLE_COUNT: i32 = 15;
53
54/// Dimension timeline tag that carries the day timeline. Only dimensions on this
55/// tag (the overworld and its caves variant) apply the pre-dawn hatch boost;
56/// nether and end eggs stay at the base chance, matching vanilla's per-dimension
57/// resolution of the `gameplay/turtle_egg_hatch_chance` attribute.
58const OVERWORLD_TIMELINE_TAG: &str = "#minecraft:in_overworld";
59
60const HATCH: &IntProperty = &BlockStateProperties::HATCH;
61const EGGS: &IntProperty = &BlockStateProperties::EGGS;
62
63/// Behavior for vanilla turtle eggs.
64#[block_behavior]
65pub struct TurtleEggBlock {
66    block: BlockRef,
67}
68
69impl TurtleEggBlock {
70    /// Creates a new turtle egg block behavior.
71    #[must_use]
72    pub const fn new(block: BlockRef) -> Self {
73        Self { block }
74    }
75
76    /// Returns whether the block below `pos` is a sand type (sand, red sand, or
77    /// suspicious sand). Turtle eggs only crack and hatch on top of sand.
78    fn on_sand(world: &Arc<World>, pos: BlockPos) -> bool {
79        world
80            .get_block_state(pos.below())
81            .get_block()
82            .has_tag(&BlockTag::SAND)
83    }
84
85    /// Rolls whether an egg should advance its cracking stage this random tick.
86    ///
87    /// In 26.2 this chance comes from the `gameplay/turtle_egg_hatch_chance`
88    /// environment attribute: base 0.002, raised to 1.0 by a day-timeline
89    /// `maximum` modifier during the pre-dawn window (ticks 21062 to 21904).
90    /// Vanilla resolves the attribute per dimension, and only dimensions tagged
91    /// `#minecraft:in_overworld` include that day timeline, so eggs in the nether
92    /// or end keep the 0.002 base regardless of the overworld time of day.
93    ///
94    /// Steel's timeline sampler in `world::environment` only handles
95    /// `multiply`/replace modifiers and exposes sky light and sun angle, so it
96    /// cannot resolve this attribute yet; the turtle curve is reproduced inline
97    /// off the overworld day clock, gated on the same timeline tag.
98    // TODO(environment-attributes): replace this inline curve with a single
99    // attribute lookup once the timeline sampler learns the `maximum` modifier
100    // and exposes a public environment-attribute getter.
101    fn should_update_hatch_level(world: &Arc<World>) -> bool {
102        let day_time = world
103            .clock_total_ticks(&vanilla_world_clocks::OVERWORLD)
104            .unwrap_or(0)
105            .rem_euclid(TICKS_PER_DAY);
106
107        let in_hatch_window = (HATCH_WINDOW_START..HATCH_WINDOW_END).contains(&day_time)
108            && world.dimension_type.timelines == Some(OVERWORLD_TIMELINE_TAG);
109        let chance = if in_hatch_window {
110            1.0
111        } else {
112            BASE_HATCH_CHANCE
113        };
114
115        chance > 0.0 && rand::random::<f32>() < chance
116    }
117
118    /// Vanilla `TurtleEggBlock.canDestroyEgg`: which entities are able to trample
119    /// an egg. Turtles and bats never do, and only living entities can; players
120    /// always may, other mobs only when `mobGriefing` is enabled.
121    fn can_destroy_egg(world: &Arc<World>, entity: &dyn Entity) -> bool {
122        let entity_type = entity.entity_type();
123        if entity_type == &vanilla_entities::TURTLE || entity_type == &vanilla_entities::BAT {
124            return false;
125        }
126        if !entity.is_living_entity() {
127            return false;
128        }
129        entity_type == &vanilla_entities::PLAYER
130            || world.get_game_rule(&vanilla_game_rules::MOB_GRIEFING)
131    }
132
133    /// Vanilla `TurtleEggBlock.destroyEgg`: a random chance to remove one egg
134    /// from the cluster when a qualifying entity steps or falls on it.
135    fn destroy_egg(
136        &self,
137        world: &Arc<World>,
138        state: BlockStateId,
139        pos: BlockPos,
140        entity: &dyn Entity,
141        odds: i32,
142    ) {
143        if state.get_block() == self.block
144            && Self::can_destroy_egg(world, entity)
145            && rand::random_range(0..odds) == 0
146        {
147            Self::decrease_eggs(world, pos, state);
148        }
149    }
150
151    /// Vanilla `TurtleEggBlock.decreaseEggs`: removes one egg from the cluster,
152    /// destroying the block once the last egg is gone.
153    fn decrease_eggs(world: &Arc<World>, pos: BlockPos, state: BlockStateId) {
154        world.play_block_sound(
155            &sound_events::ENTITY_TURTLE_EGG_BREAK,
156            pos,
157            0.7,
158            rand::random_range(0.9..1.1),
159            None,
160        );
161
162        let eggs = state.get_value(EGGS);
163        if eggs <= 1 {
164            world.destroy_block(pos, false);
165        } else {
166            world.set_block(
167                pos,
168                state.set_value(EGGS, eggs - 1),
169                UpdateFlags::UPDATE_CLIENTS,
170            );
171            world.game_event(
172                &vanilla_game_events::BLOCK_DESTROY,
173                pos,
174                &GameEventContext::new(None, Some(state)),
175            );
176            world.level_event(
177                level_events::PARTICLES_DESTROY_BLOCK,
178                pos,
179                level_events::encode_block_state_data(u32::from(state.0)),
180                None,
181            );
182        }
183    }
184}
185
186impl BlockBehavior for TurtleEggBlock {
187    fn get_state_for_placement(&self, context: &BlockPlaceContext<'_>) -> Option<BlockStateId> {
188        let existing = context.world.get_block_state(context.place_pos());
189        if existing.get_block() == self.block {
190            // Clicking an existing cluster with another egg adds to it.
191            Some(existing.set_value(EGGS, (existing.get_value(EGGS) + 1).min(MAX_EGGS)))
192        } else {
193            Some(self.block.default_state())
194        }
195    }
196
197    fn can_be_replaced(&self, state: BlockStateId, context: &BlockPlaceContext<'_>) -> bool {
198        if !context.is_secondary_use_active()
199            && context.with_item(|item| item.is(&vanilla_items::TURTLE_EGG))
200            && state.get_value(EGGS) < MAX_EGGS
201        {
202            true
203        } else {
204            default_can_be_replaced(state, context)
205        }
206    }
207
208    fn on_place(
209        &self,
210        _state: BlockStateId,
211        world: &Arc<World>,
212        pos: BlockPos,
213        _old_state: BlockStateId,
214        _moved_by_piston: bool,
215    ) {
216        // Play the "placed on sand" particle effect (data is the particle count).
217        if Self::on_sand(world, pos) {
218            world.level_event(
219                level_events::PARTICLES_TURTLE_EGG_PLACEMENT,
220                pos,
221                TURTLE_EGG_PLACEMENT_PARTICLE_COUNT,
222                None,
223            );
224        }
225    }
226
227    fn random_tick(&self, state: BlockStateId, world: &Arc<World>, pos: BlockPos) {
228        if !Self::should_update_hatch_level(world) || !Self::on_sand(world, pos) {
229            return;
230        }
231
232        let hatch = state.get_value(HATCH);
233        if hatch < MAX_HATCH_LEVEL {
234            world.play_block_sound(
235                &sound_events::ENTITY_TURTLE_EGG_CRACK,
236                pos,
237                0.7,
238                rand::random_range(0.9..1.1),
239                None,
240            );
241            world.set_block(
242                pos,
243                state.set_value(HATCH, hatch + 1),
244                UpdateFlags::UPDATE_CLIENTS,
245            );
246            world.game_event(
247                &vanilla_game_events::BLOCK_CHANGE,
248                pos,
249                &GameEventContext::new(None, Some(state)),
250            );
251        } else {
252            world.play_block_sound(
253                &sound_events::ENTITY_TURTLE_EGG_HATCH,
254                pos,
255                0.7,
256                rand::random_range(0.9..1.1),
257                None,
258            );
259            world.remove_block(pos, false);
260            world.game_event(
261                &vanilla_game_events::BLOCK_DESTROY,
262                pos,
263                &GameEventContext::new(None, Some(state)),
264            );
265
266            let eggs = state.get_value(EGGS);
267            for _ in 0..eggs {
268                world.level_event(
269                    level_events::PARTICLES_DESTROY_BLOCK,
270                    pos,
271                    level_events::encode_block_state_data(u32::from(state.0)),
272                    None,
273                );
274                // TODO(turtle-entity): spawn one baby Turtle per egg here once the
275                // Turtle entity lands. Vanilla creates it with age -24000, calls
276                // setHomePos(pos), snaps it just above the nest, and adds it to the
277                // world. Wired up in the follow-up entity PR.
278            }
279        }
280    }
281
282    fn step_on(&self, state: BlockStateId, world: &Arc<World>, pos: BlockPos, entity: &dyn Entity) {
283        // Sneaking ("stepping carefully") avoids trampling.
284        if !entity.is_stepping_carefully() {
285            self.destroy_egg(world, state, pos, entity, STEP_TRAMPLE_ODDS);
286        }
287        self.default_step_on(state, world, pos, entity);
288    }
289
290    fn fall_on(
291        &self,
292        state: BlockStateId,
293        world: &Arc<World>,
294        pos: BlockPos,
295        context: EntityFallOnContext<'_>,
296    ) -> Option<EntityFallDamage> {
297        if let Some(entity) = context.source_entity()
298            && entity.entity_type() != &vanilla_entities::ZOMBIE
299        {
300            // Vanilla excludes `instanceof Zombie`, which also covers husks,
301            // drowned, zombie villagers, and zombified piglins. None of those
302            // exist in Steel yet, so this single-type check is currently exact.
303            // TODO(zombie-family): widen this to the whole zombie set (a shared
304            // "is a zombie" predicate or an entity-type tag) once those entities
305            // land, so falling zombies do not double up with their trample AI.
306            self.destroy_egg(world, state, pos, entity, FALL_TRAMPLE_ODDS);
307        }
308        self.default_fall_on(state, world, pos, context)
309    }
310
311    fn player_destroy(
312        &self,
313        world: &Arc<World>,
314        _player: &Player,
315        pos: BlockPos,
316        state: BlockStateId,
317        _block_entity: Option<&SharedBlockEntity>,
318        _tool: &ItemStack,
319    ) {
320        // Vanilla calls super.playerDestroy (loot/stats) and then decreaseEggs so a
321        // cluster is broken one egg at a time. Steel's break pipeline
322        // (game_mode::block_breaking::destroy_block) already removes the block
323        // before invoking player_destroy, exactly like vanilla, so decrease_eggs
324        // re-places the cluster with one fewer egg when more than one remained.
325        Self::decrease_eggs(world, pos, state);
326    }
327}
328
329#[cfg(test)]
330mod tests {
331    use steel_registry::{init_vanilla_registry, vanilla_blocks, vanilla_world_clocks};
332    use steel_utils::ChunkPos;
333
334    use super::*;
335    use crate::behavior::{BLOCK_BEHAVIORS, init_behaviors};
336    use crate::test_support::{fresh_test_world, insert_ready_full_chunk};
337
338    /// A day-time tick inside the pre-dawn window where eggs always advance, so
339    /// random ticks are deterministic in tests.
340    const ALWAYS_HATCH_DAY_TIME: i64 = 21_500;
341
342    fn prepare(key: &'static str) -> (Arc<World>, BlockPos) {
343        init_vanilla_registry();
344        init_behaviors();
345        let world = fresh_test_world(key);
346        let pos = BlockPos::new(8, 64, 8);
347        insert_ready_full_chunk(&world, ChunkPos::from_block_pos(pos));
348        world.set_clock_total_ticks(&vanilla_world_clocks::OVERWORLD, ALWAYS_HATCH_DAY_TIME);
349        (world, pos)
350    }
351
352    #[test]
353    fn eggs_crack_twice_then_hatch_on_sand() {
354        let (world, pos) = prepare("turtle_egg_hatch");
355        assert!(world.set_block(
356            pos.below(),
357            vanilla_blocks::SAND.default_state(),
358            UpdateFlags::UPDATE_NONE,
359        ));
360        assert!(world.set_block(
361            pos,
362            vanilla_blocks::TURTLE_EGG.default_state(),
363            UpdateFlags::UPDATE_NONE,
364        ));
365        let behavior = BLOCK_BEHAVIORS.get_behavior(&vanilla_blocks::TURTLE_EGG);
366
367        behavior.random_tick(world.get_block_state(pos), &world, pos);
368        assert_eq!(world.get_block_state(pos).get_value(HATCH), 1);
369
370        behavior.random_tick(world.get_block_state(pos), &world, pos);
371        assert_eq!(world.get_block_state(pos).get_value(HATCH), 2);
372
373        // Final advance hatches the egg and removes the block. Spawning the baby
374        // turtle is stubbed until the Turtle entity lands, so only the removal is
375        // asserted here.
376        behavior.random_tick(world.get_block_state(pos), &world, pos);
377        assert!(world.get_block_state(pos).is_air());
378    }
379
380    #[test]
381    fn eggs_do_not_advance_off_sand() {
382        let (world, pos) = prepare("turtle_egg_off_sand");
383        assert!(world.set_block(
384            pos.below(),
385            vanilla_blocks::STONE.default_state(),
386            UpdateFlags::UPDATE_NONE,
387        ));
388        assert!(world.set_block(
389            pos,
390            vanilla_blocks::TURTLE_EGG.default_state(),
391            UpdateFlags::UPDATE_NONE,
392        ));
393        let behavior = BLOCK_BEHAVIORS.get_behavior(&vanilla_blocks::TURTLE_EGG);
394
395        behavior.random_tick(world.get_block_state(pos), &world, pos);
396        assert_eq!(world.get_block_state(pos).get_value(HATCH), 0);
397    }
398}