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