Skip to main content

blizz_ui/components/mascot/
fx.rs

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}