1use std::sync::Arc;
4
5use steel_macros::block_behavior;
6use steel_registry::blocks::BlockRef;
7use steel_registry::blocks::block_state_ext::BlockStateExt;
8use steel_registry::blocks::properties::{BlockStateProperties, IntProperty};
9use steel_registry::item_stack::ItemStack;
10use steel_utils::types::UpdateFlags;
11use steel_utils::{BlockPos, BlockStateId, Direction};
12
13use super::ice_block::{BASE_MELT_LIGHT_LEVEL, IceBlock};
14use crate::behavior::{BlockBehavior, BlockPlaceContext};
15use crate::block_entity::SharedBlockEntity;
16use crate::chunk::light::LightLayer;
17use crate::player::Player;
18use crate::world::{LevelReader, World};
19
20const AGE: &IntProperty = &BlockStateProperties::AGE_3;
21const MAX_AGE: u8 = 3;
22const NEIGHBORS_TO_AGE: u8 = 4;
23const NEIGHBORS_TO_MELT: u8 = 2;
24const PLACE_TICK_MIN: i32 = 60;
25const PLACE_TICK_MAX: i32 = 120;
26const MELT_TICK_MIN: i32 = 20;
27const MELT_TICK_MAX: i32 = 40;
28
29#[block_behavior]
31pub struct FrostedIceBlock {
32 block: BlockRef,
33}
34
35impl FrostedIceBlock {
36 #[must_use]
38 pub const fn new(block: BlockRef) -> Self {
39 Self { block }
40 }
41
42 fn fewer_neighbors_than(&self, world: &dyn LevelReader, pos: BlockPos, limit: u8) -> bool {
44 let mut count = 0;
45 for direction in Direction::ALL {
46 if world.get_block_state(pos.relative(direction)).get_block() == self.block {
47 count += 1;
48 if count >= limit {
49 return false;
50 }
51 }
52 }
53 true
54 }
55
56 fn slightly_melt(state: BlockStateId, world: &Arc<World>, pos: BlockPos) -> bool {
60 let age = state.get_value(AGE);
61 if age < MAX_AGE {
62 world.set_block(
63 pos,
64 state.set_value(AGE, age + 1),
65 UpdateFlags::UPDATE_CLIENTS,
66 );
67 false
68 } else {
69 IceBlock::melt(state, world, pos);
70 true
71 }
72 }
73
74 fn melt_tick_delay() -> i32 {
75 rand::random_range(MELT_TICK_MIN..=MELT_TICK_MAX)
76 }
77
78 fn melt_brightness(world: &Arc<World>, pos: BlockPos, state: BlockStateId) -> bool {
79 let brightness = if world.is_end_dimension_type() {
80 world.light_value_at(LightLayer::Block, pos)
81 } else {
82 world.max_local_raw_brightness(pos, world.sky_darkening())
83 };
84 i32::from(brightness)
85 > i32::from(BASE_MELT_LIGHT_LEVEL)
86 - i32::from(state.get_value(AGE))
87 - i32::from(state.get_light_dampening())
88 }
89}
90
91impl BlockBehavior for FrostedIceBlock {
92 fn get_state_for_placement(&self, _context: &BlockPlaceContext<'_>) -> Option<BlockStateId> {
93 Some(self.block.default_state())
94 }
95
96 fn on_place(
97 &self,
98 _state: BlockStateId,
99 world: &Arc<World>,
100 pos: BlockPos,
101 _old_state: BlockStateId,
102 _moved_by_piston: bool,
103 ) {
104 world.schedule_block_tick_default(
105 pos,
106 self.block,
107 rand::random_range(PLACE_TICK_MIN..=PLACE_TICK_MAX),
108 );
109 }
110
111 #[expect(
112 clippy::collapsible_if,
113 reason = "matches vanilla FrostedIceBlock.tick control flow"
114 )]
115 fn tick(&self, state: BlockStateId, world: &Arc<World>, pos: BlockPos) {
116 if rand::random_range(0..3) == 0
117 || self.fewer_neighbors_than(world.as_ref(), pos, NEIGHBORS_TO_AGE)
118 {
119 if Self::melt_brightness(world, pos, state) && Self::slightly_melt(state, world, pos) {
120 for direction in Direction::ALL {
121 let neighbor_pos = pos.relative(direction);
122 let neighbor = world.get_block_state(neighbor_pos);
123 if neighbor.get_block() == self.block
124 && !Self::slightly_melt(neighbor, world, neighbor_pos)
125 {
126 world.schedule_block_tick_default(
127 neighbor_pos,
128 self.block,
129 Self::melt_tick_delay(),
130 );
131 }
132 }
133 return;
134 }
135 }
136
137 world.schedule_block_tick_default(pos, self.block, Self::melt_tick_delay());
138 }
139
140 fn handle_neighbor_changed(
141 &self,
142 state: BlockStateId,
143 world: &Arc<World>,
144 pos: BlockPos,
145 source_block: BlockRef,
146 _moved_by_piston: bool,
147 ) {
148 if source_block == self.block
149 && self.fewer_neighbors_than(world.as_ref(), pos, NEIGHBORS_TO_MELT)
150 {
151 IceBlock::melt(state, world, pos);
152 }
153 }
154
155 fn get_clone_item_stack(
156 &self,
157 _block: BlockRef,
158 _state: BlockStateId,
159 _include_data: bool,
160 ) -> Option<ItemStack> {
161 None
162 }
163
164 fn player_destroy(
165 &self,
166 world: &Arc<World>,
167 player: &Player,
168 pos: BlockPos,
169 state: BlockStateId,
170 block_entity: Option<&SharedBlockEntity>,
171 tool: &ItemStack,
172 ) {
173 IceBlock::new(self.block).player_destroy(world, player, pos, state, block_entity, tool);
174 }
175
176 fn random_tick(&self, state: BlockStateId, world: &Arc<World>, pos: BlockPos) {
177 IceBlock::new(self.block).random_tick(state, world, pos);
178 }
179}
180
181#[cfg(test)]
182mod tests {
183 use steel_registry::blocks::block_state_ext::BlockStateExt;
184 use steel_registry::blocks::properties::BlockStateProperties;
185 use steel_registry::{init_vanilla_registry, vanilla_blocks, vanilla_dimension_types};
186 use steel_utils::{BlockPos, ChunkPos, Direction, Identifier, types::UpdateFlags};
187
188 use super::*;
189 use crate::behavior::init_behaviors;
190 use crate::test_support::{
191 TestLevel, fresh_test_world, fresh_test_world_with_dimension_type, insert_ready_full_chunk,
192 };
193
194 fn behavior() -> FrostedIceBlock {
195 FrostedIceBlock::new(&vanilla_blocks::FROSTED_ICE)
196 }
197
198 fn aged(age: u8) -> BlockStateId {
199 vanilla_blocks::FROSTED_ICE
200 .default_state()
201 .set_value(&BlockStateProperties::AGE_3, age)
202 }
203
204 fn world_with_block(key: &'static str, pos: BlockPos, state: BlockStateId) -> Arc<World> {
205 init_vanilla_registry();
206 init_behaviors();
207 let world = fresh_test_world(key);
208 insert_ready_full_chunk(&world, ChunkPos::from_block_pos(pos));
209 assert!(world.set_block(pos, state, UpdateFlags::UPDATE_NONE));
210 world
211 }
212
213 #[test]
214 fn pick_block_returns_empty() {
215 init_vanilla_registry();
216 let state = vanilla_blocks::FROSTED_ICE.default_state();
217 assert!(
218 behavior()
219 .get_clone_item_stack(&vanilla_blocks::FROSTED_ICE, state, false)
220 .is_none()
221 );
222 }
223
224 #[test]
225 fn custom_world_key_retains_end_dimension_semantics() {
226 let world = fresh_test_world_with_dimension_type(
227 "other",
228 "the_end",
229 &vanilla_dimension_types::THE_END,
230 );
231
232 assert_ne!(world.key, Identifier::vanilla_static("the_end"));
233 assert!(world.is_end_dimension_type());
234 }
235
236 #[test]
237 fn fewer_neighbors_than_counts_adjacent_frosted_ice() {
238 init_vanilla_registry();
239 let pos = BlockPos::ZERO;
240 let frosted = vanilla_blocks::FROSTED_ICE.default_state();
241 let isolated = TestLevel::default().with_block(pos, frosted);
242 assert!(behavior().fewer_neighbors_than(&isolated, pos, NEIGHBORS_TO_AGE));
243 assert!(behavior().fewer_neighbors_than(&isolated, pos, NEIGHBORS_TO_MELT));
244
245 let mut packed = TestLevel::default().with_block(pos, frosted);
246 for direction in Direction::ALL {
247 packed = packed.with_block(pos.relative(direction), frosted);
248 }
249 assert!(!behavior().fewer_neighbors_than(&packed, pos, NEIGHBORS_TO_AGE));
250 assert!(!behavior().fewer_neighbors_than(&packed, pos, NEIGHBORS_TO_MELT));
251
252 let one_neighbor = TestLevel::default()
253 .with_block(pos, frosted)
254 .with_block(pos.above(), frosted);
255 assert!(behavior().fewer_neighbors_than(&one_neighbor, pos, NEIGHBORS_TO_AGE));
256 assert!(behavior().fewer_neighbors_than(&one_neighbor, pos, NEIGHBORS_TO_MELT));
257 }
258
259 #[test]
260 fn slightly_melt_increments_age_before_melting() {
261 init_vanilla_registry();
262 let pos = BlockPos::new(8, 64, 8);
263 let world = world_with_block("frosted_ice_age", pos, aged(0));
264
265 assert!(!FrostedIceBlock::slightly_melt(
266 world.get_block_state(pos),
267 &world,
268 pos
269 ));
270 assert_eq!(world.get_block_state(pos).get_value(AGE), 1);
271
272 assert!(!FrostedIceBlock::slightly_melt(
273 world.get_block_state(pos),
274 &world,
275 pos
276 ));
277 assert_eq!(world.get_block_state(pos).get_value(AGE), 2);
278
279 assert!(!FrostedIceBlock::slightly_melt(
280 world.get_block_state(pos),
281 &world,
282 pos
283 ));
284 assert_eq!(world.get_block_state(pos).get_value(AGE), 3);
285
286 assert!(FrostedIceBlock::slightly_melt(
287 world.get_block_state(pos),
288 &world,
289 pos
290 ));
291 assert_eq!(
292 world.get_block_state(pos),
293 vanilla_blocks::WATER.default_state()
294 );
295 }
296
297 #[test]
298 fn on_place_schedules_a_tick() {
299 init_vanilla_registry();
300 let pos = BlockPos::new(8, 64, 8);
301 let world = world_with_block(
302 "frosted_ice_place",
303 pos,
304 vanilla_blocks::STONE.default_state(),
305 );
306 let state = vanilla_blocks::FROSTED_ICE.default_state();
307 assert!(world.set_block(
308 pos,
309 vanilla_blocks::AIR.default_state(),
310 UpdateFlags::UPDATE_NONE
311 ));
312 behavior().on_place(
313 state,
314 &world,
315 pos,
316 vanilla_blocks::AIR.default_state(),
317 false,
318 );
319 assert!(world.has_scheduled_block_tick(pos, &vanilla_blocks::FROSTED_ICE));
320 }
321
322 #[test]
323 fn neighbor_change_melts_isolated_frosted_ice() {
324 init_vanilla_registry();
325 let pos = BlockPos::new(8, 64, 8);
326 let world = world_with_block("frosted_ice_neighbor", pos, aged(0));
327
328 behavior().handle_neighbor_changed(
329 world.get_block_state(pos),
330 &world,
331 pos,
332 &vanilla_blocks::FROSTED_ICE,
333 false,
334 );
335 assert_eq!(
336 world.get_block_state(pos),
337 vanilla_blocks::WATER.default_state()
338 );
339 }
340
341 #[test]
342 fn neighbor_change_keeps_well_supported_frosted_ice() {
343 init_vanilla_registry();
344 let pos = BlockPos::new(8, 64, 8);
345 let world = world_with_block("frosted_ice_supported", pos, aged(0));
346 assert!(world.set_block(pos.above(), aged(0), UpdateFlags::UPDATE_NONE,));
347 assert!(world.set_block(pos.below(), aged(0), UpdateFlags::UPDATE_NONE,));
348
349 behavior().handle_neighbor_changed(
350 world.get_block_state(pos),
351 &world,
352 pos,
353 &vanilla_blocks::FROSTED_ICE,
354 false,
355 );
356 assert_eq!(
357 world.get_block_state(pos).get_block(),
358 &vanilla_blocks::FROSTED_ICE
359 );
360 }
361}