1use rand::{Rng, RngExt};
2
3use super::math::{self, CORE_MAX_RADIUS, CROSSFADE_TICKS};
4
5pub use math::{DistanceMap, build_distance_map, distance_at};
6
7const GARBLE_CHARS: &[u8] =
8 b"!@#$%^&*()_+-=[]{}|;:,.<>?`~0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
9
10pub const REVEAL_SPEED: f64 = 0.6;
11pub const GARBLE_FRINGE: f64 = 6.0;
12
13pub const DISSOLVE_TILE_COLS: usize = 6;
14pub const DISSOLVE_TILE_ROWS: usize = 5;
15const DISSOLVE_TICKS_PER_TILE: usize = 1;
16const DISSOLVE_GARBLE_TICKS_MIN: usize = 4;
17const DISSOLVE_GARBLE_TICKS_MAX: usize = 8;
18const DISSOLVE_START_JITTER: usize = 4;
19const DISSOLVE_BOUNDARY_NOISE: usize = 6;
20
21const REVEAL_NOISE: f64 = 8.0;
22const DISSOLVE_CHAR_NOISE_TICKS: usize = 2;
23
24pub fn radial_reveal_frame<R: Rng>(
25 lines: &[&str],
26 map: &DistanceMap,
27 garble_radius: f64,
28 resolve_radius: f64,
29 rng: &mut R,
30) -> Vec<String> {
31 lines
32 .iter()
33 .enumerate()
34 .map(|(row, line)| {
35 line
36 .chars()
37 .enumerate()
38 .map(|(col, ch)| radial_classify(ch, row, col, map, garble_radius, resolve_radius, rng))
39 .collect()
40 })
41 .collect()
42}
43
44fn radial_classify<R: Rng>(
45 ch: char,
46 row: usize,
47 col: usize,
48 map: &DistanceMap,
49 garble_radius: f64,
50 resolve_radius: f64,
51 rng: &mut R,
52) -> char {
53 if ch == ' ' {
54 return ' ';
55 }
56 let noise = rng.random_range(-REVEAL_NOISE..REVEAL_NOISE);
57 let d = distance_at(map, row, col) + noise;
58 if d <= resolve_radius {
59 ch
60 } else if d <= garble_radius {
61 random_garble_char(rng)
62 } else {
63 ' '
64 }
65}
66
67pub fn entrance_frame<R: Rng>(
68 lines: &[&str],
69 map: &DistanceMap,
70 tick: usize,
71 rng: &mut R,
72) -> Vec<String> {
73 let core_end = math::core_total_ticks();
74 let reveal_start = core_end.saturating_sub(CROSSFADE_TICKS);
75 let in_core = tick < core_end;
76 let in_reveal = tick >= reveal_start;
77
78 lines
79 .iter()
80 .enumerate()
81 .map(|(row, line)| {
82 line
83 .chars()
84 .enumerate()
85 .map(|(col, ch)| {
86 let reveal_char = if in_reveal {
87 reveal_char_at(ch, row, col, tick, reveal_start, map, rng)
88 } else {
89 ' '
90 };
91
92 if reveal_char != ' ' {
93 return reveal_char;
94 }
95
96 if in_core {
97 core_char_at(row, col, tick, rng)
98 } else {
99 ' '
100 }
101 })
102 .collect()
103 })
104 .collect()
105}
106
107fn core_char_at<R: Rng>(row: usize, col: usize, tick: usize, rng: &mut R) -> char {
108 let radius = math::core_radius(tick);
109 let d = math::core_distance(row as f64, col as f64);
110 let noise = rng.random_range(-REVEAL_NOISE..REVEAL_NOISE);
111 if d + noise <= radius {
112 random_garble_char(rng)
113 } else {
114 ' '
115 }
116}
117
118fn reveal_char_at<R: Rng>(
119 ch: char,
120 row: usize,
121 col: usize,
122 tick: usize,
123 reveal_start: usize,
124 map: &DistanceMap,
125 rng: &mut R,
126) -> char {
127 if ch == ' ' {
128 return ' ';
129 }
130 let reveal_tick = tick - reveal_start;
131 let garble_radius = CORE_MAX_RADIUS + reveal_tick as f64 * REVEAL_SPEED;
132 let resolve_radius = garble_radius - GARBLE_FRINGE;
133 let noise = rng.random_range(-REVEAL_NOISE..REVEAL_NOISE);
134 let d = distance_at(map, row, col) + noise;
135 if d <= resolve_radius {
136 ch
137 } else if d <= garble_radius {
138 random_garble_char(rng)
139 } else {
140 ' '
141 }
142}
143
144pub fn is_entrance_complete(map: &DistanceMap, tick: usize) -> bool {
145 let reveal_start = math::core_total_ticks().saturating_sub(CROSSFADE_TICKS);
146 if tick < reveal_start {
147 return false;
148 }
149 let reveal_tick = tick - reveal_start;
150 let garble_radius = CORE_MAX_RADIUS + reveal_tick as f64 * REVEAL_SPEED;
151 let resolve_radius = garble_radius - GARBLE_FRINGE;
152 resolve_radius >= map.max_distance
153}
154
155#[derive(Clone, Debug)]
156pub struct DissolveState {
157 tile_assignments: Vec<Vec<usize>>,
158 tile_starts: Vec<usize>,
159 tile_garble_durations: Vec<usize>,
160 pub total_ticks: usize,
161}
162
163pub fn build_dissolve_state<R: Rng>(lines: &[&str], rng: &mut R) -> DissolveState {
164 let rows = lines.len();
165 let cols = lines.iter().map(|l| l.chars().count()).max().unwrap_or(0);
166 let tile_cols = cols.div_ceil(DISSOLVE_TILE_COLS);
167 let tile_rows = rows.div_ceil(DISSOLVE_TILE_ROWS);
168 let tile_count = tile_cols * tile_rows;
169
170 let tile_assignments = build_noisy_tile_assignments(lines, tile_cols, tile_count, rng);
171 let tile_starts = build_tile_starts(tile_count, rng);
172 let tile_garble_durations = build_tile_garble_durations(tile_count, rng);
173
174 let total_ticks = tile_starts
175 .iter()
176 .zip(tile_garble_durations.iter())
177 .map(|(start, dur)| start + dur + DISSOLVE_CHAR_NOISE_TICKS)
178 .max()
179 .unwrap_or(0);
180
181 DissolveState {
182 tile_assignments,
183 tile_starts,
184 tile_garble_durations,
185 total_ticks,
186 }
187}
188
189fn build_noisy_tile_assignments<R: Rng>(
190 lines: &[&str],
191 tile_cols: usize,
192 tile_count: usize,
193 rng: &mut R,
194) -> Vec<Vec<usize>> {
195 lines
196 .iter()
197 .enumerate()
198 .map(|(row, line)| {
199 line
200 .chars()
201 .enumerate()
202 .map(|(col, _)| {
203 let base = tile_index(row, col, tile_cols);
204 if !is_tile_boundary(row, col) {
205 return base;
206 }
207 let offset: i32 =
208 rng.random_range(-(DISSOLVE_BOUNDARY_NOISE as i32)..=(DISSOLVE_BOUNDARY_NOISE as i32));
209 (base as i32 + offset).clamp(0, tile_count as i32 - 1) as usize
210 })
211 .collect()
212 })
213 .collect()
214}
215
216fn is_tile_boundary(row: usize, col: usize) -> bool {
217 let row_pos = row % DISSOLVE_TILE_ROWS;
218 let col_pos = col % DISSOLVE_TILE_COLS;
219 row_pos == 0
220 || row_pos == DISSOLVE_TILE_ROWS - 1
221 || col_pos == 0
222 || col_pos == DISSOLVE_TILE_COLS - 1
223}
224
225fn build_tile_starts<R: Rng>(tile_count: usize, rng: &mut R) -> Vec<usize> {
226 let mut order: Vec<usize> = (0..tile_count).collect();
227 shuffle(&mut order, rng);
228
229 let mut starts = vec![0usize; tile_count];
230 for (position, &tile_idx) in order.iter().enumerate() {
231 let base = position * DISSOLVE_TICKS_PER_TILE;
232 let jitter = rng.random_range(0..=DISSOLVE_START_JITTER);
233 starts[tile_idx] = base.saturating_sub(jitter);
234 }
235 starts
236}
237
238fn build_tile_garble_durations<R: Rng>(tile_count: usize, rng: &mut R) -> Vec<usize> {
239 (0..tile_count)
240 .map(|_| rng.random_range(DISSOLVE_GARBLE_TICKS_MIN..=DISSOLVE_GARBLE_TICKS_MAX))
241 .collect()
242}
243
244pub fn dissolve_frame<R: Rng>(
245 lines: &[&str],
246 state: &DissolveState,
247 tick: usize,
248 rng: &mut R,
249) -> Vec<String> {
250 lines
251 .iter()
252 .enumerate()
253 .map(|(row, line)| {
254 line
255 .chars()
256 .enumerate()
257 .map(|(col, ch)| {
258 if ch == ' ' {
259 return ' ';
260 }
261 let tile_idx = state.tile_assignments[row][col];
262 let char_noise = rng.random_range(0..=DISSOLVE_CHAR_NOISE_TICKS);
263 let effective_tick = tick.saturating_sub(char_noise);
264 dissolve_char_from_state(ch, tile_idx, effective_tick, state, rng)
265 })
266 .collect()
267 })
268 .collect()
269}
270
271fn dissolve_char_from_state<R: Rng>(
272 ch: char,
273 tile_idx: usize,
274 tick: usize,
275 state: &DissolveState,
276 rng: &mut R,
277) -> char {
278 let start = state.tile_starts[tile_idx];
279 if tick < start {
280 return ch;
281 }
282 let elapsed = tick - start;
283 let garble_duration = state.tile_garble_durations[tile_idx];
284 if elapsed < garble_duration {
285 random_garble_char(rng)
286 } else {
287 ' '
288 }
289}
290
291fn tile_index(row: usize, col: usize, tile_cols: usize) -> usize {
292 let tr = row / DISSOLVE_TILE_ROWS;
293 let tc = col / DISSOLVE_TILE_COLS;
294 tr * tile_cols + tc
295}
296
297fn random_garble_char<R: Rng>(rng: &mut R) -> char {
298 GARBLE_CHARS[rng.random_range(0..GARBLE_CHARS.len())] as char
299}
300
301fn shuffle<R: Rng>(slice: &mut [usize], rng: &mut R) {
302 for i in (1..slice.len()).rev() {
303 let j = rng.random_range(0..=i);
304 slice.swap(i, j);
305 }
306}
307
308#[cfg(test)]
309mod tests {
310 use super::math::{
311 ANCHOR_COL, ANCHOR_ROW, CORE_ROW_OFFSET, CROSSFADE_TICKS, core_grow_ticks, core_total_ticks,
312 };
313 use super::*;
314
315 fn sample_lines() -> Vec<&'static str> {
316 vec![" ▓▓▓ ", " ▓▓▓▓▓ ", "▓▓▓▓▓▓▓", " ▓▓▓▓▓ ", " ▓▓▓ "]
317 }
318
319 #[test]
320 fn radial_reveal_shows_nothing_at_zero_radius() {
321 let lines = sample_lines();
322 let map = build_distance_map(&lines);
323 let mut rng = rand::rng();
324
325 let frame = radial_reveal_frame(&lines, &map, 0.0, -6.0, &mut rng);
326
327 for line in &frame {
328 assert!(line.chars().all(|c| c == ' '));
329 }
330 }
331
332 #[test]
333 fn radial_reveal_shows_all_at_large_radius() {
334 let lines = sample_lines();
335 let map = build_distance_map(&lines);
336 let mut rng = rand::rng();
337
338 let frame = radial_reveal_frame(&lines, &map, 100.0, 100.0, &mut rng);
339
340 assert_eq!(frame[2], lines[2]);
341 }
342
343 #[test]
344 fn dissolve_frame_preserves_all_at_tick_zero() {
345 let lines = sample_lines();
346 let mut rng = rand::rng();
347 let state = build_dissolve_state(&lines, &mut rng);
348
349 let frame = dissolve_frame(&lines, &state, 0, &mut rng);
350
351 for (original, result) in lines.iter().zip(frame.iter()) {
352 for (oc, rc) in original.chars().zip(result.chars()) {
353 if oc == ' ' {
354 assert_eq!(rc, ' ');
355 } else {
356 assert_ne!(rc, ' ');
357 }
358 }
359 }
360 }
361
362 #[test]
363 fn dissolve_frame_clears_all_after_completion() {
364 let lines = sample_lines();
365 let mut rng = rand::rng();
366 let state = build_dissolve_state(&lines, &mut rng);
367
368 let frame = dissolve_frame(&lines, &state, state.total_ticks + 50, &mut rng);
369
370 for line in &frame {
371 assert!(line.chars().all(|c| c == ' '));
372 }
373 }
374
375 #[test]
376 fn dissolve_state_has_valid_total_ticks() {
377 let lines = sample_lines();
378 let mut rng = rand::rng();
379 let state = build_dissolve_state(&lines, &mut rng);
380
381 assert!(state.total_ticks > 0);
382 }
383
384 #[test]
385 fn dissolve_state_tile_assignments_cover_grid() {
386 let lines = sample_lines();
387 let mut rng = rand::rng();
388 let state = build_dissolve_state(&lines, &mut rng);
389
390 assert_eq!(state.tile_assignments.len(), lines.len());
391 for (row_assignments, line) in state.tile_assignments.iter().zip(lines.iter()) {
392 assert_eq!(row_assignments.len(), line.chars().count());
393 }
394 }
395
396 #[test]
397 fn entrance_frame_shows_content_during_reveal() {
398 let lines = sample_lines();
399 let map = build_distance_map(&lines);
400 let mut rng = rand::rng();
401
402 let reveal_start = core_total_ticks().saturating_sub(CROSSFADE_TICKS);
403 let tick = reveal_start + 200;
404 let frame = entrance_frame(&lines, &map, tick, &mut rng);
405 let has_non_space = frame.iter().any(|l| l.chars().any(|c| c != ' '));
406 assert!(has_non_space);
407 }
408
409 #[test]
410 fn entrance_frame_resolves_fully_at_large_tick() {
411 let lines = sample_lines();
412 let map = build_distance_map(&lines);
413 let mut rng = rand::rng();
414
415 let frame = entrance_frame(&lines, &map, 5000, &mut rng);
416 assert_eq!(frame[2], lines[2]);
417 }
418
419 #[test]
420 fn is_entrance_complete_false_at_start() {
421 let lines = sample_lines();
422 let map = build_distance_map(&lines);
423
424 assert!(!is_entrance_complete(&map, 0));
425 }
426
427 #[test]
428 fn is_entrance_complete_true_at_large_tick() {
429 let lines = sample_lines();
430 let map = build_distance_map(&lines);
431
432 assert!(is_entrance_complete(&map, 5000));
433 }
434
435 #[test]
436 fn dissolve_frame_garbles_midway() {
437 let lines = sample_lines();
438 let mut rng = rand::rng();
439 let state = build_dissolve_state(&lines, &mut rng);
440
441 let mid = state.total_ticks / 2;
442 let frame = dissolve_frame(&lines, &state, mid, &mut rng);
443
444 let has_garble = frame.iter().any(|l| {
445 l.chars()
446 .any(|c| c != ' ' && !lines.iter().any(|orig| orig.contains(c)))
447 });
448 assert!(has_garble);
449 }
450
451 #[test]
452 fn tile_index_maps_positions_to_tiles() {
453 assert_eq!(tile_index(0, 0, 3), 0);
454 assert_eq!(tile_index(0, DISSOLVE_TILE_COLS, 3), 1);
455 assert_eq!(tile_index(DISSOLVE_TILE_ROWS, 0, 3), 3);
456 }
457
458 #[test]
459 fn is_tile_boundary_detects_edges() {
460 assert!(is_tile_boundary(0, 0));
461 assert!(is_tile_boundary(DISSOLVE_TILE_ROWS - 1, 0));
462 assert!(is_tile_boundary(0, DISSOLVE_TILE_COLS - 1));
463 assert!(!is_tile_boundary(1, 1));
464 }
465
466 #[test]
467 fn shuffle_produces_permutation() {
468 let mut rng = rand::rng();
469 let mut data: Vec<usize> = (0..10).collect();
470 shuffle(&mut data, &mut rng);
471
472 let mut sorted = data.clone();
473 sorted.sort();
474 assert_eq!(sorted, (0..10).collect::<Vec<_>>());
475 }
476
477 #[test]
478 fn radial_reveal_garbles_in_fringe_zone() {
479 let lines = sample_lines();
480 let map = build_distance_map(&lines);
481 let mut rng = rand::rng();
482
483 let garble_radius = map.max_distance + 10.0;
484 let resolve_radius = -100.0;
485 let frame = radial_reveal_frame(&lines, &map, garble_radius, resolve_radius, &mut rng);
486
487 let has_non_original = frame[2] != lines[2];
488 assert!(has_non_original);
489 }
490
491 fn large_sample_lines() -> Vec<&'static str> {
492 vec![
493 "▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓",
494 "▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓",
495 "▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓",
496 "▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓",
497 "▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓",
498 "▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓",
499 "▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓",
500 "▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓",
501 "▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓",
502 "▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓",
503 "▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓",
504 "▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓",
505 "▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓",
506 "▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓",
507 "▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓",
508 "▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓",
509 "▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓",
510 "▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓",
511 "▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓",
512 "▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓",
513 ]
514 }
515
516 #[test]
517 fn entrance_frame_core_produces_garble_near_anchor() {
518 let lines = large_sample_lines();
519 let map = build_distance_map(&lines);
520 let mut rng = rand::rng();
521
522 let tick = core_grow_ticks();
523 let frame = entrance_frame(&lines, &map, tick, &mut rng);
524
525 let core_row = (ANCHOR_ROW + CORE_ROW_OFFSET) as usize;
526 if core_row < frame.len() {
527 let has_garble = frame[core_row].chars().any(|c| c != ' ');
528 assert!(has_garble);
529 }
530 }
531
532 #[test]
533 fn entrance_frame_crossfade_blends_core_and_reveal() {
534 let lines = large_sample_lines();
535 let map = build_distance_map(&lines);
536 let mut rng = rand::rng();
537
538 let reveal_start = core_total_ticks().saturating_sub(CROSSFADE_TICKS);
539 let tick = reveal_start + CROSSFADE_TICKS / 2;
540 let frame = entrance_frame(&lines, &map, tick, &mut rng);
541 let has_content = frame.iter().any(|l| l.chars().any(|c| c != ' '));
542 assert!(has_content);
543 }
544
545 #[test]
546 fn entrance_before_reveal_only_has_core() {
547 let lines = large_sample_lines();
548 let map = build_distance_map(&lines);
549 let mut rng = rand::rng();
550
551 let reveal_start = core_total_ticks().saturating_sub(CROSSFADE_TICKS);
552 let tick = reveal_start.saturating_sub(1).max(1);
553 let frame = entrance_frame(&lines, &map, tick, &mut rng);
554 let has_content = frame.iter().any(|l| l.chars().any(|c| c != ' '));
555 assert!(has_content);
556 }
557
558 #[test]
559 fn build_dissolve_state_large_grid() {
560 let lines = large_sample_lines();
561 let mut rng = rand::rng();
562 let state = build_dissolve_state(&lines, &mut rng);
563
564 assert!(state.total_ticks > 0);
565 assert!(!state.tile_starts.is_empty());
566 assert!(!state.tile_garble_durations.is_empty());
567 }
568
569 #[test]
570 fn dissolve_frame_large_grid_at_various_ticks() {
571 let lines = large_sample_lines();
572 let mut rng = rand::rng();
573 let state = build_dissolve_state(&lines, &mut rng);
574
575 let early = dissolve_frame(&lines, &state, 1, &mut rng);
576 assert_eq!(early.len(), lines.len());
577
578 let late = dissolve_frame(&lines, &state, state.total_ticks + 100, &mut rng);
579 for line in &late {
580 assert!(line.chars().all(|c| c == ' '));
581 }
582 }
583
584 #[test]
585 fn random_garble_char_produces_valid_chars() {
586 let mut rng = rand::rng();
587 for _ in 0..100 {
588 let ch = random_garble_char(&mut rng);
589 assert!(GARBLE_CHARS.contains(&(ch as u8)));
590 }
591 }
592
593 #[test]
594 fn build_tile_starts_covers_all_tiles() {
595 let mut rng = rand::rng();
596 let starts = build_tile_starts(10, &mut rng);
597 assert_eq!(starts.len(), 10);
598 }
599
600 #[test]
601 fn build_tile_garble_durations_in_range() {
602 let mut rng = rand::rng();
603 let durations = build_tile_garble_durations(10, &mut rng);
604 assert_eq!(durations.len(), 10);
605 for d in &durations {
606 assert!(*d >= DISSOLVE_GARBLE_TICKS_MIN);
607 assert!(*d <= DISSOLVE_GARBLE_TICKS_MAX);
608 }
609 }
610
611 #[test]
612 fn build_noisy_tile_assignments_has_boundary_noise() {
613 let lines = large_sample_lines();
614 let mut rng = rand::rng();
615 let tile_cols = lines[0].chars().count().div_ceil(DISSOLVE_TILE_COLS);
616 let tile_rows = lines.len().div_ceil(DISSOLVE_TILE_ROWS);
617 let tile_count = tile_cols * tile_rows;
618
619 let assignments = build_noisy_tile_assignments(&lines, tile_cols, tile_count, &mut rng);
620 assert_eq!(assignments.len(), lines.len());
621 assert_eq!(assignments[0].len(), lines[0].chars().count());
622 }
623
624 #[test]
625 fn dissolve_char_from_state_before_start() {
626 let mut rng = rand::rng();
627 let lines = large_sample_lines();
628 let state = build_dissolve_state(&lines, &mut rng);
629
630 let max_start = *state.tile_starts.iter().max().unwrap();
631 let tile_with_max = state
632 .tile_starts
633 .iter()
634 .position(|&s| s == max_start)
635 .unwrap();
636
637 let before = dissolve_char_from_state('▓', tile_with_max, 0, &state, &mut rng);
638 assert_eq!(before, '▓');
639 }
640
641 #[test]
642 fn dissolve_char_from_state_during_garble() {
643 let mut rng = rand::rng();
644 let lines = large_sample_lines();
645 let state = build_dissolve_state(&lines, &mut rng);
646
647 let start = state.tile_starts[0];
648 let during = dissolve_char_from_state('▓', 0, start + 1, &state, &mut rng);
649 assert!(GARBLE_CHARS.contains(&(during as u8)));
650 }
651
652 #[test]
653 fn dissolve_char_from_state_after_garble() {
654 let mut rng = rand::rng();
655 let lines = large_sample_lines();
656 let state = build_dissolve_state(&lines, &mut rng);
657
658 let start = state.tile_starts[0];
659 let end_tick = start + state.tile_garble_durations[0] + 1;
660 let after = dissolve_char_from_state('▓', 0, end_tick, &state, &mut rng);
661 assert_eq!(after, ' ');
662 }
663
664 #[test]
665 fn reveal_char_at_space_returns_space() {
666 let lines = sample_lines();
667 let map = build_distance_map(&lines);
668 let mut rng = rand::rng();
669
670 let ch = reveal_char_at(' ', 0, 0, 100, 0, &map, &mut rng);
671 assert_eq!(ch, ' ');
672 }
673
674 #[test]
675 fn reveal_char_at_resolved_returns_original() {
676 let lines = large_sample_lines();
677 let map = build_distance_map(&lines);
678 let mut rng = rand::rng();
679
680 let ch = reveal_char_at('▓', 10, 19, 50000, 0, &map, &mut rng);
681 assert_eq!(ch, '▓');
682 }
683
684 #[test]
685 fn core_char_at_within_radius() {
686 let mut rng = rand::rng();
687 let core_row = (ANCHOR_ROW + CORE_ROW_OFFSET) as usize;
688 let anchor_col = ANCHOR_COL as usize;
689
690 let mut found_garble = false;
691 for _ in 0..50 {
692 let ch = core_char_at(core_row, anchor_col, core_grow_ticks(), &mut rng);
693 if ch != ' ' {
694 found_garble = true;
695 break;
696 }
697 }
698 assert!(found_garble);
699 }
700
701 #[test]
702 fn core_char_at_outside_radius() {
703 let mut rng = rand::rng();
704 let ch = core_char_at(0, 0, 0, &mut rng);
705 assert_eq!(ch, ' ');
706 }
707}