Skip to main content

steel_core/block_entity/entities/
piston_moving.rs

1//! Vanilla moving-piston block entity.
2
3use std::cell::Cell;
4use std::sync::{Arc, Weak};
5
6use glam::DVec3;
7use simdnbt::borrow::{BaseNbtCompound as BorrowedNbtCompound, NbtCompound as NbtCompoundView};
8use simdnbt::owned::NbtCompound;
9use steel_registry::blocks::behavior::PushReaction;
10use steel_registry::blocks::block_state_ext::BlockStateExt as _;
11use steel_registry::blocks::properties::{BlockStateProperties, Direction, PistonType};
12use steel_registry::{vanilla_block_entity_types, vanilla_blocks};
13use steel_utils::axis::Axis;
14use steel_utils::types::UpdateFlags;
15use steel_utils::{
16    BlockLocalAabb, BlockPos, BlockStateId, DowncastType, DowncastTypeKey, WorldAabb,
17    locks::SyncMutex,
18};
19
20use crate::behavior::{BLOCK_BEHAVIORS, BlockCollisionBoxes, BlockCollisionContext};
21use crate::block_entity::block_state_nbt;
22use crate::block_entity::{BlockEntity, BlockEntityBase, BlockEntityLifecycleExt as _};
23use crate::entity::Entity;
24use crate::physics::MoverType;
25use crate::world::{LevelReader, World};
26
27const PUSH_OFFSET: f64 = 0.01;
28
29thread_local! {
30    static NOCLIP: Cell<Option<Direction>> = const { Cell::new(None) };
31}
32
33struct NoClipGuard;
34
35impl NoClipGuard {
36    fn set(direction: Direction) -> Self {
37        NOCLIP.set(Some(direction));
38        Self
39    }
40}
41
42impl Drop for NoClipGuard {
43    fn drop(&mut self) {
44        NOCLIP.set(None);
45    }
46}
47
48/// Vanilla `PistonMovingBlockEntity`.
49pub struct PistonMovingBlockEntity {
50    base: BlockEntityBase,
51    moving: SyncMutex<PistonMovingState>,
52}
53
54#[derive(Clone, Copy)]
55struct PistonMovingState {
56    moved_state: BlockStateId,
57    direction: Direction,
58    extending: bool,
59    source_piston: bool,
60    progress: f32,
61    progress_o: f32,
62    last_ticked: i64,
63}
64
65// SAFETY: This key is owned by Steel and uniquely identifies `PistonMovingBlockEntity`.
66unsafe impl DowncastType for PistonMovingBlockEntity {
67    const TYPE_KEY: DowncastTypeKey = DowncastTypeKey::new("steel:block_entity/piston_moving");
68}
69
70impl PistonMovingBlockEntity {
71    /// Creates the default instance used while loading a piston block entity.
72    #[must_use]
73    pub fn new(world: Weak<World>, pos: BlockPos, state: BlockStateId) -> Self {
74        Self::new_moving(
75            world,
76            pos,
77            state,
78            vanilla_blocks::AIR.default_state(),
79            Direction::Down,
80            false,
81            false,
82        )
83    }
84
85    /// Creates a moving block or source-piston entity.
86    #[must_use]
87    pub fn new_moving(
88        world: Weak<World>,
89        pos: BlockPos,
90        state: BlockStateId,
91        moved_state: BlockStateId,
92        direction: Direction,
93        extending: bool,
94        source_piston: bool,
95    ) -> Self {
96        Self {
97            base: BlockEntityBase::new(&vanilla_block_entity_types::PISTON, world, pos, state),
98            moving: SyncMutex::new(PistonMovingState {
99                moved_state,
100                direction,
101                extending,
102                source_piston,
103                progress: 0.0,
104                progress_o: 0.0,
105                last_ticked: 0,
106            }),
107        }
108    }
109
110    /// Returns whether this block is extending.
111    #[must_use]
112    pub fn is_extending(&self) -> bool {
113        self.moving.lock().extending
114    }
115
116    /// Returns the piston facing direction.
117    #[must_use]
118    pub fn direction(&self) -> Direction {
119        self.moving.lock().direction
120    }
121
122    /// Returns whether this entity represents the source piston or its head.
123    #[must_use]
124    pub fn is_source_piston(&self) -> bool {
125        self.moving.lock().source_piston
126    }
127
128    /// Returns the state being moved.
129    #[must_use]
130    pub fn moved_state(&self) -> BlockStateId {
131        self.moving.lock().moved_state
132    }
133
134    /// Returns the last game time at which this entity ticked.
135    #[must_use]
136    pub fn last_ticked(&self) -> i64 {
137        self.moving.lock().last_ticked
138    }
139
140    /// Returns interpolated movement progress.
141    #[must_use]
142    pub fn progress(&self, partial_tick: f32) -> f32 {
143        let partial_tick = partial_tick.min(1.0);
144        let moving = self.moving.lock();
145        (moving.progress - moving.progress_o).mul_add(partial_tick, moving.progress_o)
146    }
147
148    /// Returns the movement direction, which reverses while retracting.
149    #[must_use]
150    pub fn movement_direction(&self) -> Direction {
151        self.moving.lock().movement_direction()
152    }
153
154    /// Returns the direction used for the final neighbor notification.
155    #[must_use]
156    pub fn push_direction(&self) -> Direction {
157        self.moving.lock().movement_direction()
158    }
159
160    /// Resolves the transient collision boxes at the current progress.
161    #[must_use]
162    pub fn collision_boxes(&self, world: &dyn LevelReader, pos: BlockPos) -> BlockCollisionBoxes {
163        let moving = *self.moving.lock();
164        moving.collision_boxes(world, pos)
165    }
166
167    /// Completes an in-flight moving block before its piston starts retracting.
168    pub fn final_tick(&self, world: &Arc<World>) -> bool {
169        let moving = {
170            let mut moving = self.moving.lock();
171            if moving.progress_o >= 1.0 {
172                return false;
173            }
174            moving.progress = 1.0;
175            moving.progress_o = 1.0;
176            *moving
177        };
178        let pos = self.get_block_pos();
179        let owned_position = world.remove_block_entity_if_same(self);
180        self.set_removed();
181        if owned_position {
182            moving.finish_movement_early(world, pos);
183        }
184        true
185    }
186}
187
188impl PistonMovingState {
189    // Vanilla's two-argument `EntityGetter.getEntities` applies
190    // `EntitySelector.NO_SPECTATORS` before the piston loop.
191    fn can_move_collided_entity(entity: &dyn Entity, cause_bounce: bool) -> bool {
192        !entity.is_spectator()
193            && entity.piston_push_reaction() != PushReaction::Ignore
194            && (!cause_bounce || entity.as_player().is_none())
195    }
196
197    const fn movement_direction(self) -> Direction {
198        if self.extending {
199            self.direction
200        } else {
201            self.direction.opposite()
202        }
203    }
204
205    fn extended_progress(self, progress: f32) -> f32 {
206        if self.extending {
207            progress - 1.0
208        } else {
209            1.0 - progress
210        }
211    }
212
213    fn collision_related_state(self) -> BlockStateId {
214        let behavior = BLOCK_BEHAVIORS.get_behavior(self.moved_state.get_block());
215        if !self.extending && self.source_piston && behavior.is_piston_base() {
216            vanilla_blocks::PISTON_HEAD
217                .default_state()
218                .set_value(&BlockStateProperties::SHORT, self.progress > 0.25)
219                .set_value(
220                    &BlockStateProperties::PISTON_TYPE,
221                    if self.moved_state.get_block() == &vanilla_blocks::STICKY_PISTON {
222                        PistonType::Sticky
223                    } else {
224                        PistonType::Normal
225                    },
226                )
227                .set_value(
228                    &BlockStateProperties::FACING,
229                    self.moved_state.get_value(&BlockStateProperties::FACING),
230                )
231        } else {
232            self.moved_state
233        }
234    }
235
236    fn state_collision_boxes(
237        state: BlockStateId,
238        world: &dyn LevelReader,
239        pos: BlockPos,
240    ) -> BlockCollisionBoxes {
241        BLOCK_BEHAVIORS
242            .get_behavior(state.get_block())
243            .get_collision_boxes(state, world, pos, BlockCollisionContext::empty())
244    }
245
246    fn boxes_bounds(boxes: &BlockCollisionBoxes) -> Option<BlockLocalAabb> {
247        let mut boxes = boxes.iter().filter(|aabb| !aabb.is_empty());
248        let mut bounds = *boxes.next()?;
249        for aabb in boxes {
250            bounds = BlockLocalAabb::encapsulating(&bounds, aabb);
251        }
252        Some(bounds)
253    }
254
255    /// Resolves the transient collision boxes at the current progress.
256    #[must_use]
257    pub fn collision_boxes(&self, world: &dyn LevelReader, pos: BlockPos) -> BlockCollisionBoxes {
258        let mut result = BlockCollisionBoxes::new();
259        let moved_behavior = BLOCK_BEHAVIORS.get_behavior(self.moved_state.get_block());
260        if !self.extending && self.source_piston && moved_behavior.is_piston_base() {
261            let extended = self
262                .moved_state
263                .set_value(&BlockStateProperties::EXTENDED, true);
264            result.extend(Self::state_collision_boxes(extended, world, pos));
265        }
266
267        let no_clip_direction = NOCLIP.get();
268        if self.progress < 1.0 && no_clip_direction == Some(self.movement_direction()) {
269            return result;
270        }
271
272        let moving_state = if self.source_piston {
273            vanilla_blocks::PISTON_HEAD
274                .default_state()
275                .set_value(&BlockStateProperties::FACING, self.direction)
276                .set_value(
277                    &BlockStateProperties::SHORT,
278                    self.extending != ((1.0 - self.progress) < 0.25),
279                )
280        } else {
281            self.moved_state
282        };
283        let amount = f64::from(self.extended_progress(self.progress));
284        let (x, y, z) = self.direction.offset();
285        let offset = DVec3::new(
286            f64::from(x) * amount,
287            f64::from(y) * amount,
288            f64::from(z) * amount,
289        );
290        result.extend(
291            Self::state_collision_boxes(moving_state, world, pos)
292                .into_iter()
293                .map(|aabb| aabb.translate(offset)),
294        );
295        result
296    }
297
298    fn move_by_position_and_progress(&self, pos: BlockPos, aabb: BlockLocalAabb) -> WorldAabb {
299        let amount = f64::from(self.extended_progress(self.progress));
300        let (x, y, z) = self.direction.offset();
301        aabb.at_block(pos).translate(DVec3::new(
302            f64::from(x) * amount,
303            f64::from(y) * amount,
304            f64::from(z) * amount,
305        ))
306    }
307
308    fn movement_area(aabb: WorldAabb, direction: Direction, amount: f64) -> WorldAabb {
309        let signed_amount = if matches!(
310            direction,
311            Direction::West | Direction::Down | Direction::North
312        ) {
313            -amount
314        } else {
315            amount
316        };
317        let min = signed_amount.min(0.0);
318        let max = signed_amount.max(0.0);
319        match direction {
320            Direction::West => WorldAabb::new(
321                aabb.min_x() + min,
322                aabb.min_y(),
323                aabb.min_z(),
324                aabb.min_x() + max,
325                aabb.max_y(),
326                aabb.max_z(),
327            ),
328            Direction::East => WorldAabb::new(
329                aabb.max_x() + min,
330                aabb.min_y(),
331                aabb.min_z(),
332                aabb.max_x() + max,
333                aabb.max_y(),
334                aabb.max_z(),
335            ),
336            Direction::Down => WorldAabb::new(
337                aabb.min_x(),
338                aabb.min_y() + min,
339                aabb.min_z(),
340                aabb.max_x(),
341                aabb.min_y() + max,
342                aabb.max_z(),
343            ),
344            Direction::Up => WorldAabb::new(
345                aabb.min_x(),
346                aabb.max_y() + min,
347                aabb.min_z(),
348                aabb.max_x(),
349                aabb.max_y() + max,
350                aabb.max_z(),
351            ),
352            Direction::North => WorldAabb::new(
353                aabb.min_x(),
354                aabb.min_y(),
355                aabb.min_z() + min,
356                aabb.max_x(),
357                aabb.max_y(),
358                aabb.min_z() + max,
359            ),
360            Direction::South => WorldAabb::new(
361                aabb.min_x(),
362                aabb.min_y(),
363                aabb.max_z() + min,
364                aabb.max_x(),
365                aabb.max_y(),
366                aabb.max_z() + max,
367            ),
368        }
369    }
370
371    fn overlap_movement(outside: WorldAabb, movement: Direction, entity: WorldAabb) -> f64 {
372        match movement {
373            Direction::East => outside.max_x() - entity.min_x(),
374            Direction::West => entity.max_x() - outside.min_x(),
375            Direction::Up => outside.max_y() - entity.min_y(),
376            Direction::Down => entity.max_y() - outside.min_y(),
377            Direction::South => outside.max_z() - entity.min_z(),
378            Direction::North => entity.max_z() - outside.min_z(),
379        }
380    }
381
382    fn move_entity_by_piston(
383        piston_direction: Direction,
384        entity: &dyn Entity,
385        delta: f64,
386        movement: Direction,
387    ) {
388        let _no_clip = NoClipGuard::set(piston_direction);
389        let (x, y, z) = movement.offset();
390        let previous_position = entity.position();
391        entity.move_entity(
392            MoverType::Piston,
393            DVec3::new(
394                delta * f64::from(x),
395                delta * f64::from(y),
396                delta * f64::from(z),
397            ),
398        );
399        entity.apply_effects_from_blocks_between(previous_position, entity.position());
400        entity.remove_latest_movement_recording();
401    }
402
403    fn fix_entity_within_piston_base(
404        pos: BlockPos,
405        entity: &dyn Entity,
406        direction: Direction,
407        delta_progress: f64,
408    ) {
409        let entity_aabb = entity.bounding_box();
410        let box_at_pos = BlockLocalAabb::FULL_BLOCK.at_block(pos);
411        if !entity_aabb.intersects(box_at_pos) {
412            return;
413        }
414
415        let opposite = direction.opposite();
416        let delta = Self::overlap_movement(box_at_pos, opposite, entity_aabb) + PUSH_OFFSET;
417        let intersection = WorldAabb::new(
418            entity_aabb.min_x().max(box_at_pos.min_x()),
419            entity_aabb.min_y().max(box_at_pos.min_y()),
420            entity_aabb.min_z().max(box_at_pos.min_z()),
421            entity_aabb.max_x().min(box_at_pos.max_x()),
422            entity_aabb.max_y().min(box_at_pos.max_y()),
423            entity_aabb.max_z().min(box_at_pos.max_z()),
424        );
425        let intersected_delta =
426            Self::overlap_movement(box_at_pos, opposite, intersection) + PUSH_OFFSET;
427        if (delta - intersected_delta).abs() < PUSH_OFFSET {
428            let delta = delta.min(delta_progress) + PUSH_OFFSET;
429            Self::move_entity_by_piston(direction, entity, delta, opposite);
430        }
431    }
432
433    fn move_collided_entities(self, world: &Arc<World>, pos: BlockPos, new_progress: f32) {
434        let movement = self.movement_direction();
435        let delta_progress = f64::from(new_progress - self.progress);
436        let shape =
437            Self::state_collision_boxes(self.collision_related_state(), world.as_ref(), pos);
438        let Some(bounds) = Self::boxes_bounds(&shape) else {
439            return;
440        };
441        let aabb = self.move_by_position_and_progress(pos, bounds);
442        let query =
443            WorldAabb::encapsulating(&Self::movement_area(aabb, movement, delta_progress), &aabb);
444        let entities = world.get_entities_in_aabb(&query);
445        let cause_bounce = self.moved_state.get_block() == &vanilla_blocks::SLIME_BLOCK;
446
447        for entity in entities {
448            if !Self::can_move_collided_entity(entity.as_ref(), cause_bounce) {
449                continue;
450            }
451            if cause_bounce {
452                let mut velocity = entity.velocity();
453                let (x, y, z) = movement.offset();
454                match movement.axis() {
455                    Axis::X => velocity.x = f64::from(x),
456                    Axis::Y => velocity.y = f64::from(y),
457                    Axis::Z => velocity.z = f64::from(z),
458                }
459                entity.set_velocity(velocity);
460            }
461
462            let mut delta: f64 = 0.0;
463            let entity_aabb = entity.bounding_box();
464            for shape_aabb in &shape {
465                let moving_aabb = Self::movement_area(
466                    self.move_by_position_and_progress(pos, *shape_aabb),
467                    movement,
468                    delta_progress,
469                );
470                if moving_aabb.intersects(entity_aabb) {
471                    delta = delta.max(Self::overlap_movement(moving_aabb, movement, entity_aabb));
472                    if delta >= delta_progress {
473                        break;
474                    }
475                }
476            }
477
478            if delta <= 0.0 {
479                continue;
480            }
481            let delta = delta.min(delta_progress) + PUSH_OFFSET;
482            Self::move_entity_by_piston(movement, entity.as_ref(), delta, movement);
483            if !self.extending && self.source_piston {
484                Self::fix_entity_within_piston_base(pos, entity.as_ref(), movement, delta_progress);
485            }
486        }
487    }
488
489    fn move_stuck_entities(self, world: &Arc<World>, pos: BlockPos, new_progress: f32) {
490        if self.moved_state.get_block() != &vanilla_blocks::HONEY_BLOCK {
491            return;
492        }
493        let movement = self.movement_direction();
494        if !movement.is_horizontal() {
495            return;
496        }
497
498        let collision = Self::state_collision_boxes(self.moved_state, world.as_ref(), pos);
499        let sticky_top = collision
500            .iter()
501            .map(BlockLocalAabb::max_y)
502            .fold(f64::NEG_INFINITY, f64::max);
503        let local = BlockLocalAabb::new(0.0, sticky_top, 0.0, 1.0, 1.500_001, 1.0);
504        let aabb = self.move_by_position_and_progress(pos, local);
505        let entities = world.get_entities_in_aabb_matching(&aabb, |entity| {
506            let position = entity.position();
507            entity.piston_push_reaction() == PushReaction::Normal
508                && entity.on_ground()
509                && (entity.is_supported_by(pos)
510                    || (position.x >= aabb.min_x()
511                        && position.x <= aabb.max_x()
512                        && position.z >= aabb.min_z()
513                        && position.z <= aabb.max_z()))
514        });
515        let delta_progress = f64::from(new_progress - self.progress);
516        for entity in entities {
517            Self::move_entity_by_piston(movement, entity.as_ref(), delta_progress, movement);
518        }
519    }
520
521    fn finish_tick(self, world: &Arc<World>, pos: BlockPos) {
522        if world.get_block_state(pos).get_block() != &vanilla_blocks::MOVING_PISTON {
523            return;
524        }
525
526        let mut new_state = world.update_from_neighbor_shapes(self.moved_state, pos);
527        if new_state.get_block() == &vanilla_blocks::AIR {
528            world.set_block(
529                pos,
530                self.moved_state,
531                UpdateFlags::UPDATE_INVISIBLE
532                    | UpdateFlags::UPDATE_KNOWN_SHAPE
533                    | UpdateFlags::UPDATE_MOVE_BY_PISTON
534                    | UpdateFlags::UPDATE_SKIP_BLOCK_ENTITY_SIDEEFFECTS,
535            );
536            world.update_or_destroy(
537                self.moved_state,
538                new_state,
539                pos,
540                UpdateFlags::UPDATE_ALL,
541                512,
542            );
543            return;
544        }
545
546        if new_state.try_get_value(&BlockStateProperties::WATERLOGGED) == Some(true) {
547            new_state = new_state.set_value(&BlockStateProperties::WATERLOGGED, false);
548        }
549        world.set_block(
550            pos,
551            new_state,
552            UpdateFlags::UPDATE_ALL | UpdateFlags::UPDATE_MOVE_BY_PISTON,
553        );
554        world.neighbor_changed(pos, new_state.get_block());
555    }
556
557    fn finish_movement_early(self, world: &Arc<World>, pos: BlockPos) {
558        if world.get_block_state(pos).get_block() == &vanilla_blocks::MOVING_PISTON {
559            let new_state = if self.source_piston {
560                vanilla_blocks::AIR.default_state()
561            } else {
562                world.update_from_neighbor_shapes(self.moved_state, pos)
563            };
564            world.set_block(pos, new_state, UpdateFlags::UPDATE_ALL);
565            world.neighbor_changed(pos, new_state.get_block());
566        }
567    }
568
569    const fn direction_from_legacy_id(id: i8) -> Direction {
570        let remainder = (id as i32) % 6;
571        let normalized = if remainder < 0 { -remainder } else { remainder };
572        match normalized {
573            1 => Direction::Up,
574            2 => Direction::North,
575            3 => Direction::South,
576            4 => Direction::West,
577            5 => Direction::East,
578            _ => Direction::Down,
579        }
580    }
581
582    const fn direction_legacy_id(direction: Direction) -> i8 {
583        match direction {
584            Direction::Down => 0,
585            Direction::Up => 1,
586            Direction::North => 2,
587            Direction::South => 3,
588            Direction::West => 4,
589            Direction::East => 5,
590        }
591    }
592}
593
594impl BlockEntity for PistonMovingBlockEntity {
595    fn base(&self) -> &BlockEntityBase {
596        &self.base
597    }
598
599    fn pre_remove_side_effects(&self, _pos: BlockPos, _state: BlockStateId) {
600        if let Some(world) = self.get_level() {
601            self.final_tick(&world);
602        }
603    }
604
605    fn load_additional(&self, nbt: &BorrowedNbtCompound<'_>) {
606        let view = NbtCompoundView::from(nbt);
607        let mut moving = self.moving.lock();
608        moving.moved_state = view
609            .compound("blockState")
610            .and_then(block_state_nbt::load)
611            .unwrap_or_else(|| vanilla_blocks::AIR.default_state());
612        moving.direction =
613            PistonMovingState::direction_from_legacy_id(view.byte("facing").unwrap_or(0));
614        moving.progress = view.float("progress").unwrap_or(0.0);
615        moving.progress_o = moving.progress;
616        moving.extending = view.byte("extending").is_some_and(|value| value != 0);
617        moving.source_piston = view.byte("source").is_some_and(|value| value != 0);
618    }
619
620    fn save_additional(&self, nbt: &mut NbtCompound) {
621        let moving = self.moving.lock();
622        nbt.insert("blockState", block_state_nbt::save(moving.moved_state));
623        nbt.insert(
624            "facing",
625            PistonMovingState::direction_legacy_id(moving.direction),
626        );
627        nbt.insert("progress", moving.progress_o);
628        nbt.insert("extending", i8::from(moving.extending));
629        nbt.insert("source", i8::from(moving.source_piston));
630    }
631
632    fn get_update_tag(&self) -> Option<NbtCompound> {
633        Some(self.save_custom_only())
634    }
635
636    fn tick(&self, world: &Arc<World>) {
637        let game_time = world.game_time();
638        let (moving, new_progress) = {
639            let mut moving = self.moving.lock();
640            moving.last_ticked = game_time;
641            moving.progress_o = moving.progress;
642            let new_progress = (moving.progress_o < 1.0).then(|| moving.progress + 0.5);
643            (*moving, new_progress)
644        };
645        let pos = self.get_block_pos();
646        let Some(new_progress) = new_progress else {
647            let owned_position = world.remove_block_entity_if_same(self);
648            self.set_removed();
649            if owned_position {
650                moving.finish_tick(world, pos);
651            }
652            return;
653        };
654
655        moving.move_collided_entities(world, pos, new_progress);
656        moving.move_stuck_entities(world, pos, new_progress);
657        self.moving.lock().progress = new_progress.min(1.0);
658    }
659}
660
661#[cfg(test)]
662mod tests {
663    use std::io::Cursor;
664
665    use glam::DVec3;
666    use simdnbt::borrow::read_compound as read_borrowed_compound;
667    use simdnbt::owned::NbtTag;
668    use steel_registry::{init_vanilla_registry, vanilla_entities};
669    use steel_utils::{ChunkPos, types::GameType};
670    use uuid::Uuid;
671
672    use super::*;
673    use crate::behavior::init_behaviors;
674    use crate::block_entity::SharedBlockEntity;
675    use crate::entity::{SharedEntity, entities::RawEntity};
676    use crate::player::Player;
677    use crate::test_support::{TestPlayerBuilder, fresh_test_world, insert_ready_full_chunk};
678
679    fn test_player(world: Arc<World>) -> Arc<Player> {
680        TestPlayerBuilder::new(world, Uuid::from_u128(1), "PistonTestPlayer", 1).build()
681    }
682
683    #[test]
684    fn moving_state_and_progress_round_trip_with_vanilla_keys() {
685        init_vanilla_registry();
686        let state = vanilla_blocks::MOVING_PISTON
687            .default_state()
688            .set_value(&BlockStateProperties::FACING, Direction::West)
689            .set_value(&BlockStateProperties::PISTON_TYPE, PistonType::Sticky);
690        let moved = vanilla_blocks::PISTON_HEAD
691            .default_state()
692            .set_value(&BlockStateProperties::FACING, Direction::West)
693            .set_value(&BlockStateProperties::PISTON_TYPE, PistonType::Sticky)
694            .set_value(&BlockStateProperties::SHORT, true);
695        let source = PistonMovingBlockEntity::new_moving(
696            Weak::new(),
697            BlockPos::new(8, 64, -3),
698            state,
699            moved,
700            Direction::West,
701            true,
702            true,
703        );
704        source.moving.lock().progress_o = 0.5;
705
706        let mut nbt = NbtCompound::new();
707        source.save_additional(&mut nbt);
708        assert!(matches!(nbt.get("facing"), Some(NbtTag::Byte(4))));
709        let mut bytes = Vec::new();
710        nbt.write(&mut bytes);
711        let borrowed = read_borrowed_compound(&mut Cursor::new(bytes.as_slice()))
712            .expect("test NBT should reborrow");
713        let view = NbtCompoundView::from(&borrowed);
714        assert_eq!(view.byte("facing"), Some(4));
715        assert_eq!(view.int("facing"), None);
716
717        let loaded = PistonMovingBlockEntity::new(Weak::new(), source.get_block_pos(), state);
718        loaded.load_additional(&borrowed);
719        assert_eq!(loaded.moved_state(), moved);
720        assert_eq!(loaded.direction(), Direction::West);
721        assert!(loaded.is_extending());
722        assert!(loaded.is_source_piston());
723        assert!((loaded.progress(0.0) - 0.5).abs() < f32::EPSILON);
724    }
725
726    #[test]
727    fn collided_entity_filter_matches_vanilla_player_and_spectator_rules() {
728        init_vanilla_registry();
729        init_behaviors();
730        let world = fresh_test_world("piston_entity_filter");
731        let player = test_player(Arc::clone(&world));
732
733        assert!(PistonMovingState::can_move_collided_entity(
734            player.as_ref(),
735            false
736        ));
737        assert!(!PistonMovingState::can_move_collided_entity(
738            player.as_ref(),
739            true
740        ));
741
742        player.restore_game_modes(GameType::Spectator, None);
743        assert!(!PistonMovingState::can_move_collided_entity(
744            player.as_ref(),
745            false
746        ));
747
748        let raw = RawEntity::new(
749            8_000,
750            DVec3::ZERO,
751            Arc::downgrade(&world),
752            &vanilla_entities::MINECART,
753        );
754        assert!(PistonMovingState::can_move_collided_entity(&raw, true));
755    }
756
757    #[test]
758    fn movement_area_matches_vanilla_directional_sweep() {
759        let aabb = WorldAabb::new(1.0, 2.0, 3.0, 2.0, 3.0, 4.0);
760        assert_eq!(
761            PistonMovingState::movement_area(aabb, Direction::East, 0.5),
762            WorldAabb::new(2.0, 2.0, 3.0, 2.5, 3.0, 4.0)
763        );
764        assert_eq!(
765            PistonMovingState::movement_area(aabb, Direction::North, 0.5),
766            WorldAabb::new(1.0, 2.0, 2.5, 2.0, 3.0, 3.0)
767        );
768    }
769
770    #[test]
771    fn piston_entity_move_can_reenter_moving_block_collision() {
772        init_vanilla_registry();
773        init_behaviors();
774        let world = fresh_test_world("piston_collision_reentry");
775        let pos = BlockPos::new(8, 64, 8);
776        insert_ready_full_chunk(&world, ChunkPos::from_block_pos(pos));
777        let state = vanilla_blocks::MOVING_PISTON
778            .default_state()
779            .set_value(&BlockStateProperties::FACING, Direction::East);
780        assert!(world.set_block(pos, state, UpdateFlags::UPDATE_NONE));
781
782        let piston = Arc::new(PistonMovingBlockEntity::new_moving(
783            Arc::downgrade(&world),
784            pos,
785            state,
786            vanilla_blocks::STONE.default_state(),
787            Direction::East,
788            true,
789            false,
790        ));
791        let block_entity: SharedBlockEntity = piston.clone();
792        assert!(world.set_block_entity(block_entity));
793
794        let start = DVec3::new(f64::from(pos.x()) + 0.1, f64::from(pos.y()), 8.5);
795        let entity: SharedEntity = Arc::new(RawEntity::new(
796            8_001,
797            start,
798            Arc::downgrade(&world),
799            &vanilla_entities::MINECART,
800        ));
801        world
802            .try_add_entity(Arc::clone(&entity))
803            .expect("test entity should enter the loaded chunk");
804
805        piston.tick(&world);
806
807        assert!((piston.progress(1.0) - 0.5).abs() < f32::EPSILON);
808        assert!(entity.position().x > start.x);
809    }
810
811    #[test]
812    fn final_tick_marks_a_detached_moving_entity_removed() {
813        init_vanilla_registry();
814        init_behaviors();
815        let world = fresh_test_world("detached_piston_final_tick");
816        let pos = BlockPos::new(8, 64, 8);
817        insert_ready_full_chunk(&world, ChunkPos::from_block_pos(pos));
818        let state = vanilla_blocks::MOVING_PISTON
819            .default_state()
820            .set_value(&BlockStateProperties::FACING, Direction::East);
821        let piston = PistonMovingBlockEntity::new_moving(
822            Arc::downgrade(&world),
823            pos,
824            state,
825            vanilla_blocks::STONE.default_state(),
826            Direction::East,
827            true,
828            false,
829        );
830
831        assert!(piston.final_tick(&world));
832        assert!(piston.is_removed());
833    }
834
835    #[test]
836    fn stale_final_tick_cannot_remove_or_finish_a_replacement() {
837        init_vanilla_registry();
838        init_behaviors();
839        let world = fresh_test_world("stale_piston_final_tick");
840        let pos = BlockPos::new(8, 64, 8);
841        insert_ready_full_chunk(&world, ChunkPos::from_block_pos(pos));
842        let state = vanilla_blocks::MOVING_PISTON
843            .default_state()
844            .set_value(&BlockStateProperties::FACING, Direction::East);
845        assert!(world.set_block(pos, state, UpdateFlags::UPDATE_NONE));
846        let stale_piston = Arc::new(PistonMovingBlockEntity::new_moving(
847            Arc::downgrade(&world),
848            pos,
849            state,
850            vanilla_blocks::STONE.default_state(),
851            Direction::East,
852            true,
853            false,
854        ));
855        let stale_entity: SharedBlockEntity = stale_piston.clone();
856        assert!(world.set_block_entity(stale_entity));
857        let replacement = Arc::new(PistonMovingBlockEntity::new_moving(
858            Arc::downgrade(&world),
859            pos,
860            state,
861            vanilla_blocks::GOLD_BLOCK.default_state(),
862            Direction::East,
863            true,
864            false,
865        ));
866        let replacement_entity: SharedBlockEntity = replacement.clone();
867        assert!(world.set_block_entity(Arc::clone(&replacement_entity)));
868
869        assert!(stale_piston.final_tick(&world));
870
871        let Some(current) = world.get_block_entity(pos) else {
872            panic!("the replacement should remain stored");
873        };
874        assert!(Arc::ptr_eq(&current, &replacement_entity));
875        assert_eq!(world.get_block_state(pos), state);
876        assert!(!replacement.is_removed());
877    }
878}