Skip to main content

blizz_ui/components/mascot/
fx.rs

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}