Skip to main content

steel_core/behavior/blocks/decoration/
sign_block.rs

1//! Sign block behavior implementation.
2//!
3//! Handles sign placement and block entity creation for all sign types.
4
5use std::cmp::Ordering;
6use std::sync::{Arc, Weak};
7
8use steel_macros::block_behavior;
9use steel_math::{DEGREE_90, DEGREE_180, DEGREE_360, RAD_TO_DEG_F64, convert_to_rotation_segment};
10use steel_registry::REGISTRY;
11use steel_registry::block_entity_type::BlockEntityTypeRef;
12use steel_registry::blocks::BlockRef;
13use steel_registry::blocks::block_state_ext::BlockStateExt;
14use steel_registry::blocks::properties::{
15    BlockStateProperties, BoolProperty, Direction, EnumProperty, IntProperty,
16};
17use steel_registry::blocks::shapes::SupportType;
18use steel_registry::{vanilla_block_entity_types, vanilla_blocks};
19use steel_utils::{BlockPos, BlockStateId, Downcast as _};
20
21use crate::behavior::InventoryAccess;
22use crate::behavior::block::{
23    BlockBehavior, BlockEntityCreation, schedule_water_tick_if_waterlogged,
24};
25use crate::behavior::context::{BlockHitResult, BlockPlaceContext, InteractionResult};
26use crate::block_entity::{BlockEntityTicker, entities::SignBlockEntity};
27use crate::entity::Entity;
28use crate::player::Player;
29use crate::world::{LevelReader, ScheduledTickAccess, World};
30
31/// Gets the nearest looking directions from the player's rotation.
32///
33/// Returns horizontal directions in order of how closely they match the player's look direction.
34fn get_nearest_looking_directions(rotation: f32, clicked_face: Direction) -> Vec<Direction> {
35    // Build list of directions in order of preference
36    // Start with the opposite of the clicked face (most natural for wall signs)
37    // Then add directions based on player facing
38    let mut directions = Vec::with_capacity(4);
39
40    // Add horizontal directions in order of how closely they match player's look
41    let all_horizontal = [
42        Direction::North,
43        Direction::East,
44        Direction::South,
45        Direction::West,
46    ];
47
48    // Calculate angle for each direction and sort by distance to player's rotation
49    let mut scored: Vec<(Direction, f32)> = all_horizontal
50        .iter()
51        .map(|&dir| {
52            let dir_angle = dir.to_yaw();
53            let diff = (rotation - dir_angle + DEGREE_180).rem_euclid(DEGREE_360) - DEGREE_180;
54            (dir, diff.abs())
55        })
56        .collect();
57
58    scored.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(Ordering::Equal));
59
60    for (dir, _) in scored {
61        directions.push(dir);
62    }
63
64    // If clicked face is horizontal, prefer placing on the opposite side
65    if clicked_face.is_horizontal() {
66        let opposite = clicked_face.opposite();
67        if let Some(pos) = directions.iter().position(|&d| d == opposite) {
68            directions.remove(pos);
69            directions.insert(0, opposite);
70        }
71    }
72
73    directions
74}
75
76/// Calculates whether the player is facing the front of a sign.
77///
78/// Uses the sign's rotation (from block state) and the player's position
79/// relative to the sign to determine which side they're looking at.
80pub fn is_facing_front_text(state: BlockStateId, pos: BlockPos, player: &Player) -> bool {
81    // Get the sign's Y rotation in degrees from the block state
82    let sign_y_rot = get_sign_rotation_degrees(state);
83
84    // Calculate player's angle relative to the sign center
85    let player_pos = player.position();
86    let dx = player_pos.x - (f64::from(pos.0.x) + 0.5);
87    let dz = player_pos.z - (f64::from(pos.0.z) + 0.5);
88
89    // Calculate angle from sign to player (in degrees, -90 to account for Minecraft's coordinate system)
90    let player_angle = (dz.atan2(dx) * RAD_TO_DEG_F64) as f32 - DEGREE_90;
91
92    // Front text if the angle difference is <= 90 degrees
93    let diff = (sign_y_rot - player_angle + DEGREE_180).rem_euclid(DEGREE_360) - DEGREE_180;
94    diff.abs() <= DEGREE_90
95}
96
97/// Gets the Y rotation of a sign in degrees from its block state.
98fn get_sign_rotation_degrees(state: BlockStateId) -> f32 {
99    // Standing signs use "rotation" property (0-15, each step is 22.5 degrees)
100    if let Some(rotation) = state.try_get_value(ROTATION_16) {
101        return f32::from(rotation) * 22.5;
102    }
103
104    // Wall signs use "facing" property
105    if let Some(facing) = state.try_get_value(HORIZONTAL_FACING) {
106        return facing.to_yaw();
107    }
108
109    0.0
110}
111
112/// Checks if a block state can support a standing sign.
113///
114/// Vanilla uses `isSolid()` which checks if the collision shape is a full cube.
115/// This means signs cannot be placed on other signs, fences, walls, etc.
116fn can_support_standing_sign(world: &dyn LevelReader, pos: BlockPos) -> bool {
117    let below_pos = BlockPos::new(pos.x(), pos.y() - 1, pos.z());
118    let below_state = world.get_block_state(below_pos);
119    below_state.is_solid()
120}
121
122/// Checks if a wall sign can survive at the given position with the given facing.
123///
124/// Vanilla uses `isSolid()` which allows wall signs to be placed on other signs
125/// (since signs have `forceSolidOn`).
126fn can_wall_sign_survive(world: &dyn LevelReader, pos: BlockPos, facing: Direction) -> bool {
127    // Wall sign needs a solid block behind it
128    let behind_pos = facing.opposite().relative(pos);
129    let behind_state = world.get_block_state(behind_pos);
130    behind_state.is_solid()
131}
132
133/// Checks if a ceiling hanging sign can survive at the given position.
134fn can_ceiling_hanging_sign_survive(world: &dyn LevelReader, pos: BlockPos) -> bool {
135    let above_pos = BlockPos::new(pos.x(), pos.y() + 1, pos.z());
136    let above_state = world.get_block_state(above_pos);
137    world.is_face_sturdy_for(above_state, above_pos, Direction::Down, SupportType::Center)
138}
139
140/// Checks if a wall hanging sign can attach to a neighboring block.
141///
142/// Vanilla's `WallHangingSignBlock.canAttachTo` checks:
143/// 1. If the neighbor is a wall hanging sign on the same axis, allow attachment
144/// 2. Otherwise, check if the face is sturdy with FULL support type
145fn can_attach_to(
146    world: &dyn LevelReader,
147    sign_facing: Direction,
148    attach_pos: BlockPos,
149    attach_face: Direction,
150) -> bool {
151    let attach_state = world.get_block_state(attach_pos);
152    let attach_block = REGISTRY.blocks.by_state_id(attach_state);
153
154    // Check if it's another wall hanging sign (vanilla uses BlockTags.WALL_HANGING_SIGNS)
155    if let Some(block) = attach_block
156        && block.key.path.contains("wall_hanging_sign")
157    {
158        // Wall hanging signs can chain if they're on the same axis
159        if let Some(neighbor_facing) = attach_state.try_get_value(HORIZONTAL_FACING) {
160            return neighbor_facing.axis() == sign_facing.axis();
161        }
162    }
163
164    // Otherwise, check for sturdy face with FULL support
165    world.is_face_sturdy_for(attach_state, attach_pos, attach_face, SupportType::Full)
166}
167
168/// Checks if a wall hanging sign can survive at the given position.
169///
170/// Wall hanging signs need support on at least one side perpendicular to facing.
171/// This matches vanilla's `WallHangingSignBlock.canPlace`.
172fn can_wall_hanging_sign_survive(
173    world: &dyn LevelReader,
174    pos: BlockPos,
175    facing: Direction,
176) -> bool {
177    let clockwise = facing.rotate_y_clockwise();
178    let counter_clockwise = facing.rotate_y_counter_clockwise();
179
180    let can_attach_clockwise = {
181        let attach_pos = clockwise.relative(pos);
182        can_attach_to(world, facing, attach_pos, counter_clockwise)
183    };
184
185    let can_attach_counter = {
186        let attach_pos = counter_clockwise.relative(pos);
187        can_attach_to(world, facing, attach_pos, clockwise)
188    };
189
190    can_attach_clockwise || can_attach_counter
191}
192
193// TODO: Implement sign applicators (use_with_item):
194// - Dye items: Change sign text color (front or back based on player facing)
195//   - Check if sign is not waxed
196//   - Get the SignText for the side player is facing
197//   - If color differs from dye color, update it and consume the dye
198//   - Play DYE_USE sound
199// - Glow Ink Sac: Make sign text glow
200//   - Check if sign is not waxed
201//   - If text is not already glowing, set has_glowing_text = true
202//   - Consume the ink sac
203//   - Play GLOW_INK_SAC_USE sound
204// - Ink Sac: Remove glow from sign text
205//   - Check if sign is not waxed
206//   - If text is glowing, set has_glowing_text = false
207//   - Consume the ink sac
208//   - Play INK_SAC_USE sound
209// - Honeycomb: Wax the sign (prevents future edits)
210//   - If sign is not already waxed, set is_waxed = true
211//   - Consume the honeycomb
212//   - Play HONEYCOMB_WAX_ON sound
213//   - Spawn WAX_ON particles
214
215/// Attempts to open the sign editor for a player.
216///
217/// Checks all conditions required by vanilla:
218/// 1. Block entity exists and is a sign
219/// 2. Sign is not waxed
220/// 3. No other player is currently editing
221/// 4. Player has build permission (`may_build`)
222///
223/// Returns `Success` if the editor was opened, `Pass` otherwise.
224fn try_open_sign_editor(
225    state: BlockStateId,
226    world: &Arc<World>,
227    pos: BlockPos,
228    player: &Player,
229) -> InteractionResult {
230    // Get the block entity
231    let Some(block_entity) = world.get_block_entity(pos) else {
232        return InteractionResult::Pass;
233    };
234
235    let Some(sign) = block_entity.downcast_ref::<SignBlockEntity>() else {
236        return InteractionResult::Pass;
237    };
238
239    // Check 1: Is the sign waxed?
240    if sign.is_waxed() {
241        // TODO: Play waxed sign interaction fail sound
242        return InteractionResult::Success; // Vanilla returns SUCCESS even when waxed
243    }
244
245    // Check 2: Is another player editing?
246    if sign.is_other_player_editing(player.gameprofile.id) {
247        return InteractionResult::Pass;
248    }
249
250    // Check 3: Player must have build permission
251    // TODO: Implement may_build check properly
252    // if !player.may_build() {
253    //     return InteractionResult::Pass;
254    // }
255
256    // Determine which side the player is facing
257    let is_front_text = is_facing_front_text(state, pos, player);
258
259    // Set the editing player lock
260    sign.set_player_who_may_edit(Some(player.gameprofile.id));
261
262    // Open the editor
263    player.open_sign_editor(pos, is_front_text);
264    InteractionResult::Success
265}
266
267/// Behavior for standing sign blocks (placed on ground).
268#[block_behavior]
269pub struct StandingSignBlock {
270    block: BlockRef,
271}
272
273const ATTACHED: &BoolProperty = &BlockStateProperties::ATTACHED;
274const HORIZONTAL_FACING: &EnumProperty<Direction> = &BlockStateProperties::HORIZONTAL_FACING;
275const ROTATION_16: &IntProperty = &BlockStateProperties::ROTATION_16;
276
277impl StandingSignBlock {
278    /// Creates a new standing sign block behavior.
279    #[must_use]
280    pub const fn new(block: BlockRef) -> Self {
281        Self { block }
282    }
283}
284
285impl BlockBehavior for StandingSignBlock {
286    fn is_possible_to_respawn_in_this(&self, _state: BlockStateId) -> bool {
287        true
288    }
289
290    fn update_shape(
291        &self,
292        state: BlockStateId,
293        world: &dyn ScheduledTickAccess,
294        pos: BlockPos,
295        direction: Direction,
296        _neighbor_pos: BlockPos,
297        _neighbor_state: BlockStateId,
298    ) -> BlockStateId {
299        // Standing signs break when the block below is removed
300        if direction == Direction::Down && !can_support_standing_sign(world, pos) {
301            return REGISTRY.blocks.get_default_state_id(&vanilla_blocks::AIR);
302        }
303        schedule_water_tick_if_waterlogged(state, world, pos);
304        state
305    }
306
307    fn get_state_for_placement(&self, context: &BlockPlaceContext<'_>) -> Option<BlockStateId> {
308        // Check if we can place on the block below
309        if !can_support_standing_sign(context.world, context.place_pos()) {
310            return None;
311        }
312
313        // Calculate rotation from player's yaw
314        // Vanilla: RotationSegment.convertToSegment(context.getRotation() + 180.0F)
315        let rotation = convert_to_rotation_segment(context.rotation() + DEGREE_180);
316
317        Some(self.block.default_state().set_value(ROTATION_16, rotation))
318    }
319
320    fn new_block_entity(
321        &self,
322        level: Weak<World>,
323        pos: BlockPos,
324        state: BlockStateId,
325    ) -> BlockEntityCreation {
326        BlockEntityCreation::Created(Arc::new(SignBlockEntity::new(level, pos, state)))
327    }
328
329    fn get_block_entity_ticker(
330        &self,
331        _world: &Arc<World>,
332        _state: BlockStateId,
333        block_entity_type: BlockEntityTypeRef,
334    ) -> Option<BlockEntityTicker> {
335        BlockEntityTicker::for_matching_entity_tick(
336            block_entity_type,
337            &vanilla_block_entity_types::SIGN,
338        )
339    }
340
341    fn use_without_item(
342        &self,
343        state: BlockStateId,
344        world: &Arc<World>,
345        pos: BlockPos,
346        player: &Player,
347        _hit_result: &BlockHitResult,
348        _inv: &mut InventoryAccess,
349    ) -> InteractionResult {
350        try_open_sign_editor(state, world, pos, player)
351    }
352}
353
354/// Behavior for wall sign blocks (attached to walls).
355#[block_behavior]
356pub struct WallSignBlock {
357    block: BlockRef,
358}
359
360impl WallSignBlock {
361    /// Creates a new wall sign block behavior.
362    #[must_use]
363    pub const fn new(block: BlockRef) -> Self {
364        Self { block }
365    }
366}
367
368impl BlockBehavior for WallSignBlock {
369    fn is_possible_to_respawn_in_this(&self, _state: BlockStateId) -> bool {
370        true
371    }
372
373    fn update_shape(
374        &self,
375        state: BlockStateId,
376        world: &dyn ScheduledTickAccess,
377        pos: BlockPos,
378        direction: Direction,
379        _neighbor_pos: BlockPos,
380        _neighbor_state: BlockStateId,
381    ) -> BlockStateId {
382        // Wall signs break when the block they're attached to is removed
383        // The sign is attached to the block opposite of its facing direction
384        if let Some(facing) = state.try_get_value(HORIZONTAL_FACING)
385            && direction.opposite() == facing
386            && !can_wall_sign_survive(world, pos, facing)
387        {
388            return REGISTRY.blocks.get_default_state_id(&vanilla_blocks::AIR);
389        }
390        schedule_water_tick_if_waterlogged(state, world, pos);
391        state
392    }
393
394    fn get_state_for_placement(&self, context: &BlockPlaceContext<'_>) -> Option<BlockStateId> {
395        // Try each horizontal direction based on player's look direction
396        let directions = get_nearest_looking_directions(context.rotation(), context.clicked_face());
397
398        for direction in directions {
399            // The sign faces the opposite direction of where it's attached
400            let facing = direction.opposite();
401
402            // Check if sign can survive with this facing
403            if can_wall_sign_survive(context.world, context.place_pos(), facing) {
404                return Some(
405                    self.block
406                        .default_state()
407                        .set_value(HORIZONTAL_FACING, facing),
408                );
409            }
410        }
411
412        // No valid placement found
413        None
414    }
415
416    fn new_block_entity(
417        &self,
418        level: Weak<World>,
419        pos: BlockPos,
420        state: BlockStateId,
421    ) -> BlockEntityCreation {
422        BlockEntityCreation::Created(Arc::new(SignBlockEntity::new(level, pos, state)))
423    }
424
425    fn get_block_entity_ticker(
426        &self,
427        _world: &Arc<World>,
428        _state: BlockStateId,
429        block_entity_type: BlockEntityTypeRef,
430    ) -> Option<BlockEntityTicker> {
431        BlockEntityTicker::for_matching_entity_tick(
432            block_entity_type,
433            &vanilla_block_entity_types::SIGN,
434        )
435    }
436
437    fn use_without_item(
438        &self,
439        state: BlockStateId,
440        world: &Arc<World>,
441        pos: BlockPos,
442        player: &Player,
443        _hit_result: &BlockHitResult,
444        _inv: &mut InventoryAccess,
445    ) -> InteractionResult {
446        try_open_sign_editor(state, world, pos, player)
447    }
448}
449
450/// Behavior for ceiling hanging sign blocks.
451#[block_behavior]
452pub struct CeilingHangingSignBlock {
453    block: BlockRef,
454}
455
456impl CeilingHangingSignBlock {
457    /// Creates a new ceiling hanging sign block behavior.
458    #[must_use]
459    pub const fn new(block: BlockRef) -> Self {
460        Self { block }
461    }
462}
463
464impl BlockBehavior for CeilingHangingSignBlock {
465    fn is_possible_to_respawn_in_this(&self, _state: BlockStateId) -> bool {
466        true
467    }
468
469    fn update_shape(
470        &self,
471        state: BlockStateId,
472        world: &dyn ScheduledTickAccess,
473        pos: BlockPos,
474        direction: Direction,
475        _neighbor_pos: BlockPos,
476        _neighbor_state: BlockStateId,
477    ) -> BlockStateId {
478        // Ceiling hanging signs break when the block above is removed
479        if direction == Direction::Up && !can_ceiling_hanging_sign_survive(world, pos) {
480            return REGISTRY.blocks.get_default_state_id(&vanilla_blocks::AIR);
481        }
482        schedule_water_tick_if_waterlogged(state, world, pos);
483        state
484    }
485
486    fn get_state_for_placement(&self, context: &BlockPlaceContext<'_>) -> Option<BlockStateId> {
487        // Check if we can hang from the block above
488        if !can_ceiling_hanging_sign_survive(context.world, context.place_pos()) {
489            return None;
490        }
491
492        let above_pos = BlockPos::new(
493            context.place_pos().x(),
494            context.place_pos().y() + 1,
495            context.place_pos().z(),
496        );
497        let above_state = context.world.get_block_state(above_pos);
498
499        // Determine if we should attach to the middle or not based on block above
500        let direction = Direction::from_yaw(context.rotation());
501        let is_above_full = context.world.is_face_sturdy_for(
502            above_state,
503            above_pos,
504            Direction::Down,
505            SupportType::Full,
506        );
507
508        // Check if block above is also a hanging sign
509        let above_block = REGISTRY.blocks.by_state_id(above_state);
510        let is_below_hanging_sign =
511            above_block.is_some_and(|b| b.key.path.contains("hanging_sign"));
512
513        // Determine if attached to middle based on vanilla logic
514        let attached_to_middle = if is_below_hanging_sign {
515            // When below another hanging sign, check if we can chain
516            if let Some(above_facing) = above_state.try_get_value(HORIZONTAL_FACING) {
517                // Wall hanging sign above - check axis alignment
518                above_facing.axis() != direction.axis()
519            } else if let Some(above_rotation) = above_state.try_get_value(ROTATION_16) {
520                // Ceiling hanging sign above - check if we can align
521                let above_direction = rotation_to_direction(above_rotation);
522                above_direction.is_none_or(|d| d.axis() != direction.axis())
523            } else {
524                !is_above_full
525            }
526        } else {
527            !is_above_full
528        };
529
530        // Calculate rotation
531        let rotation = if attached_to_middle {
532            // Attached to middle - use player rotation
533            convert_to_rotation_segment(context.rotation() + DEGREE_180)
534        } else {
535            // Attached to chains - align with direction
536            convert_to_rotation_segment(direction.opposite().to_yaw())
537        };
538
539        Some(
540            self.block
541                .default_state()
542                .set_value(ROTATION_16, rotation)
543                .set_value(ATTACHED, attached_to_middle),
544        )
545    }
546
547    fn new_block_entity(
548        &self,
549        level: Weak<World>,
550        pos: BlockPos,
551        state: BlockStateId,
552    ) -> BlockEntityCreation {
553        BlockEntityCreation::Created(Arc::new(SignBlockEntity::new_hanging(level, pos, state)))
554    }
555
556    fn get_block_entity_ticker(
557        &self,
558        _world: &Arc<World>,
559        _state: BlockStateId,
560        block_entity_type: BlockEntityTypeRef,
561    ) -> Option<BlockEntityTicker> {
562        BlockEntityTicker::for_matching_entity_tick(
563            block_entity_type,
564            &vanilla_block_entity_types::HANGING_SIGN,
565        )
566    }
567
568    fn use_without_item(
569        &self,
570        state: BlockStateId,
571        world: &Arc<World>,
572        pos: BlockPos,
573        player: &Player,
574        _hit_result: &BlockHitResult,
575        _inv: &mut InventoryAccess,
576    ) -> InteractionResult {
577        try_open_sign_editor(state, world, pos, player)
578    }
579}
580
581/// Converts a rotation segment (0-15) to a cardinal direction, if applicable.
582const fn rotation_to_direction(rotation: u8) -> Option<Direction> {
583    match rotation {
584        0 => Some(Direction::South),
585        4 => Some(Direction::West),
586        8 => Some(Direction::North),
587        12 => Some(Direction::East),
588        _ => None,
589    }
590}
591
592/// Behavior for wall hanging sign blocks.
593#[block_behavior]
594pub struct WallHangingSignBlock {
595    block: BlockRef,
596}
597
598impl WallHangingSignBlock {
599    /// Creates a new wall hanging sign block behavior.
600    #[must_use]
601    pub const fn new(block: BlockRef) -> Self {
602        Self { block }
603    }
604}
605
606impl BlockBehavior for WallHangingSignBlock {
607    fn is_possible_to_respawn_in_this(&self, _state: BlockStateId) -> bool {
608        true
609    }
610
611    fn update_shape(
612        &self,
613        state: BlockStateId,
614        world: &dyn ScheduledTickAccess,
615        pos: BlockPos,
616        direction: Direction,
617        _neighbor_pos: BlockPos,
618        _neighbor_state: BlockStateId,
619    ) -> BlockStateId {
620        // Wall hanging signs break when blocks on the perpendicular axis are removed
621        // and they can no longer survive
622        if let Some(facing) = state.try_get_value(HORIZONTAL_FACING) {
623            // Check if the change is on the perpendicular axis (clockwise/counterclockwise)
624            if direction.axis() == facing.rotate_y_clockwise().axis()
625                && !can_wall_hanging_sign_survive(world, pos, facing)
626            {
627                return REGISTRY.blocks.get_default_state_id(&vanilla_blocks::AIR);
628            }
629        }
630        schedule_water_tick_if_waterlogged(state, world, pos);
631        state
632    }
633
634    fn get_state_for_placement(&self, context: &BlockPlaceContext<'_>) -> Option<BlockStateId> {
635        // Try each horizontal direction based on player's look direction
636        let directions = get_nearest_looking_directions(context.rotation(), context.clicked_face());
637
638        for direction in directions {
639            // Wall hanging signs face perpendicular to the wall they're attached to
640            // Skip if the clicked face is on the same axis
641            if direction.axis() == context.clicked_face().axis() {
642                continue;
643            }
644
645            let facing = direction.opposite();
646
647            // Check if sign can survive with this facing
648            if can_wall_hanging_sign_survive(context.world, context.place_pos(), facing) {
649                return Some(
650                    self.block
651                        .default_state()
652                        .set_value(HORIZONTAL_FACING, facing),
653                );
654            }
655        }
656
657        // No valid placement found
658        None
659    }
660
661    fn new_block_entity(
662        &self,
663        level: Weak<World>,
664        pos: BlockPos,
665        state: BlockStateId,
666    ) -> BlockEntityCreation {
667        BlockEntityCreation::Created(Arc::new(SignBlockEntity::new_hanging(level, pos, state)))
668    }
669
670    fn get_block_entity_ticker(
671        &self,
672        _world: &Arc<World>,
673        _state: BlockStateId,
674        block_entity_type: BlockEntityTypeRef,
675    ) -> Option<BlockEntityTicker> {
676        BlockEntityTicker::for_matching_entity_tick(
677            block_entity_type,
678            &vanilla_block_entity_types::HANGING_SIGN,
679        )
680    }
681
682    fn use_without_item(
683        &self,
684        state: BlockStateId,
685        world: &Arc<World>,
686        pos: BlockPos,
687        player: &Player,
688        _hit_result: &BlockHitResult,
689        _inv: &mut InventoryAccess,
690    ) -> InteractionResult {
691        try_open_sign_editor(state, world, pos, player)
692    }
693}
694
695#[cfg(test)]
696mod tests {
697    use steel_registry::init_vanilla_registry;
698
699    use super::*;
700    use crate::test_support::{TestLevel, fresh_test_world};
701
702    const WATERLOGGED: &BoolProperty = &BlockStateProperties::WATERLOGGED;
703
704    #[test]
705    fn standing_sign_only_schedules_water_when_support_survives() {
706        init_vanilla_registry();
707        let pos = BlockPos::new(0, 64, 0);
708        let sign = StandingSignBlock::new(&vanilla_blocks::OAK_SIGN);
709        let state = vanilla_blocks::OAK_SIGN
710            .default_state()
711            .set_value(WATERLOGGED, true);
712        let supported =
713            TestLevel::default().with_block(pos.below(), vanilla_blocks::STONE.default_state());
714
715        assert_eq!(
716            sign.update_shape(
717                state,
718                &supported,
719                pos,
720                Direction::North,
721                pos.north(),
722                vanilla_blocks::AIR.default_state(),
723            ),
724            state
725        );
726        assert!(supported.scheduled_water_tick());
727
728        let unsupported = TestLevel::default();
729        assert!(
730            sign.update_shape(
731                state,
732                &unsupported,
733                pos,
734                Direction::Down,
735                pos.below(),
736                vanilla_blocks::AIR.default_state(),
737            )
738            .is_air()
739        );
740        assert!(!unsupported.scheduled_water_tick());
741    }
742
743    #[test]
744    fn sign_variants_select_their_matching_vanilla_tickers() {
745        init_vanilla_registry();
746        let world = fresh_test_world("sign_ticker_selection");
747
748        let standing = StandingSignBlock::new(&vanilla_blocks::OAK_SIGN);
749        assert!(
750            standing
751                .get_block_entity_ticker(
752                    &world,
753                    vanilla_blocks::OAK_SIGN.default_state(),
754                    &vanilla_block_entity_types::SIGN,
755                )
756                .is_some()
757        );
758
759        let wall = WallSignBlock::new(&vanilla_blocks::OAK_WALL_SIGN);
760        assert!(
761            wall.get_block_entity_ticker(
762                &world,
763                vanilla_blocks::OAK_WALL_SIGN.default_state(),
764                &vanilla_block_entity_types::SIGN,
765            )
766            .is_some()
767        );
768
769        let ceiling_hanging = CeilingHangingSignBlock::new(&vanilla_blocks::OAK_HANGING_SIGN);
770        assert!(
771            ceiling_hanging
772                .get_block_entity_ticker(
773                    &world,
774                    vanilla_blocks::OAK_HANGING_SIGN.default_state(),
775                    &vanilla_block_entity_types::HANGING_SIGN,
776                )
777                .is_some()
778        );
779
780        let wall_hanging = WallHangingSignBlock::new(&vanilla_blocks::OAK_WALL_HANGING_SIGN);
781        assert!(
782            wall_hanging
783                .get_block_entity_ticker(
784                    &world,
785                    vanilla_blocks::OAK_WALL_HANGING_SIGN.default_state(),
786                    &vanilla_block_entity_types::HANGING_SIGN,
787                )
788                .is_some()
789        );
790        assert!(
791            wall_hanging
792                .get_block_entity_ticker(
793                    &world,
794                    vanilla_blocks::OAK_WALL_HANGING_SIGN.default_state(),
795                    &vanilla_block_entity_types::SIGN,
796                )
797                .is_none()
798        );
799    }
800}