Skip to main content

sac/life/
field.rs

1use super::*;
2
3/// Configuration for the Life simulation injector.
4#[derive(Debug, Clone, Copy, PartialEq)]
5pub struct LifeConfig {
6    /// Heat decay factor (0.95-0.995). Higher = longer memory of activity.
7    pub heat_decay: f32,
8    /// Zone width for analysis (8, 16, 24)
9    pub zone_width: usize,
10    /// Zone height for analysis (8, 16, 24)
11    pub zone_height: usize,
12    /// How often to check for injection (8, 16, 32, 64 generations)
13    pub check_interval: u64,
14    /// Target global activity ratio (0.01-0.08 = 1-8%)
15    pub target_activity: f32,
16    /// Gain for converting activity deficit to injection chance (10-30)
17    pub injection_gain: f32,
18    /// Maximum injection probability per check (0.05-0.25)
19    pub max_injection_chance: f32,
20    /// Minimum zone cooldown after injection (generations)
21    pub zone_cooldown_min: u32,
22    /// Maximum zone cooldown after injection (generations)
23    pub zone_cooldown_max: u32,
24    /// Chance of pattern mutation (0.02-0.08)
25    pub mutation_chance: f32,
26    /// Initial soup density (0.015-0.06 = 1.5-6%)
27    pub initial_soup_density: f32,
28    /// How often to check for pruning (generations)
29    pub prune_interval: u64,
30    /// Zone density threshold that triggers pruning (0.0-1.0)
31    pub prune_density_threshold: f32,
32    /// Percentage of cells to remove from dense zones (0.0-1.0)
33    pub prune_percentage: f32,
34    /// How much to favor center zones (0.0 = no bias, 1.0 = strong center bias)
35    pub center_bias: f32,
36    /// How often to rebalance cell distribution (generations)
37    pub rebalance_interval: u64,
38}
39
40impl Default for LifeConfig {
41    fn default() -> Self {
42        Self {
43            heat_decay: 0.98,
44            zone_width: 8,  // Was 16 - smaller zones for better variance measurement
45            zone_height: 8, // Was 16
46            check_interval: 64,
47            target_activity: 0.005, // Was 0.008 - even lower (0.5%)
48            injection_gain: 5.0,
49            max_injection_chance: 0.05,
50            zone_cooldown_min: 800,
51            zone_cooldown_max: 4000,
52            mutation_chance: 0.05,
53            initial_soup_density: 0.01,    // Was 0.015 - 1% initial
54            prune_interval: 30,            // Moderate pruning frequency
55            prune_density_threshold: 0.25, // Prune zones above 25% density
56            prune_percentage: 0.45,        // Remove 45% from dense zones
57            center_bias: 0.3,              // Moderate center bias to counteract edge effects
58            rebalance_interval: 100,       // Rebalance every 100 generations
59        }
60    }
61}
62
63/// Statistics for a single zone in the life field.
64#[derive(Debug, Clone, Copy, Default)]
65pub struct ZoneStats {
66    /// Heat value representing recent activity (decays over time).
67    pub heat: f32,
68    /// Current cell density in this zone (0.0-1.0).
69    pub density: f32,
70    /// Cooldown counter preventing immediate re-injection.
71    pub cooldown: u32,
72    /// Count of cells that changed this generation.
73    pub changed_count: usize,
74    /// Generation when this zone was last injected (0 = never).
75    pub last_injection_generation: u64,
76}
77
78impl ZoneStats {
79    /// Records a cell change in this zone.
80    pub fn record_change(&mut self) {
81        self.changed_count += 1;
82    }
83
84    /// Decays the heat value by the given factor.
85    pub fn decay_heat(&mut self, decay: f32) {
86        self.heat *= decay;
87    }
88
89    /// Updates the density value.
90    pub fn update_density(&mut self, alive: usize, total: usize) {
91        self.density = if total == 0 {
92            0.0
93        } else {
94            alive as f32 / total as f32
95        };
96    }
97
98    /// Returns true if the zone is cool (no cooldown active).
99    pub fn is_cool(&self) -> bool {
100        self.cooldown == 0
101    }
102
103    /// Decrements cooldown if active.
104    pub fn tick_cooldown(&mut self) {
105        if self.cooldown > 0 {
106            self.cooldown -= 1;
107        }
108    }
109
110    /// Sets cooldown to a random value between min and max.
111    pub fn set_cooldown(&mut self, min: u32, max: u32, rng: &mut impl rand::Rng) {
112        self.cooldown = rng.random_range(min..=max);
113    }
114
115    /// Resets the changed count for a new generation.
116    pub fn reset_changed(&mut self) {
117        self.changed_count = 0;
118    }
119
120    /// Adds heat to the zone (e.g., from activity).
121    pub fn add_heat(&mut self, amount: f32) {
122        self.heat = (self.heat + amount).min(1.0);
123    }
124    /// Check if zone is on cooldown
125    pub fn is_on_cooldown(&self) -> bool {
126        self.cooldown > 0
127    }
128
129    /// Check if zone is cold (low heat) and empty (very low density)
130    pub fn is_cold_empty(&self) -> bool {
131        self.heat < 0.1 && self.density < 0.05
132    }
133
134    /// Check if zone is cold (low heat) and has ash/debris (moderate density)
135    pub fn is_cold_ash(&self) -> bool {
136        self.heat < 0.1 && self.density >= 0.05 && self.density < 0.5
137    }
138}
139
140/// The Game of Life field state.
141#[derive(Debug)]
142pub struct LifeField {
143    /// Width of the field in cells.
144    pub(super) width: usize,
145    /// Height of the field in cells.
146    pub(super) height: usize,
147    /// Cell states - true for alive, false for dead.
148    pub(super) cells: Vec<bool>,
149    /// Counter for consecutive ticks with low activity.
150    low_activity_ticks: usize,
151    /// Current phase for pattern injection rotation.
152    injection_phase: usize,
153    /// Configuration for the simulation.
154    config: LifeConfig,
155    /// Random number generator for probabilistic decisions.
156    rng: StdRng,
157    /// Heatmap tracking activity per cell.
158    heatmap: Vec<f32>,
159    /// Zone statistics for regional analysis.
160    zones: Vec<ZoneStats>,
161    /// Current generation counter.
162    generation: u64,
163    /// List of cells that changed in the last step.
164    last_changed_cells: Vec<(usize, usize)>,
165}
166
167impl Default for LifeField {
168    fn default() -> Self {
169        Self {
170            width: 0,
171            height: 0,
172            cells: Vec::new(),
173            low_activity_ticks: 0,
174            injection_phase: 0,
175            config: LifeConfig::default(),
176            rng: StdRng::from_rng(&mut rand::rng()),
177            heatmap: Vec::new(),
178            zones: Vec::new(),
179            generation: 0,
180            last_changed_cells: Vec::new(),
181        }
182    }
183}
184
185impl LifeField {
186    /// Create a new LifeField seeded from a prompt
187    pub fn from_seed(prompt: &str, width: usize, height: usize) -> Self {
188        let seed = hash_seed(prompt, width, height, "life-generator-v1");
189        let mut field = Self {
190            width,
191            height,
192            cells: vec![false; width * height],
193            low_activity_ticks: 0,
194            injection_phase: 0,
195            config: LifeConfig::default(),
196            rng: StdRng::from_seed(seed),
197            heatmap: vec![0.0; width * height],
198            zones: Vec::new(),
199            generation: 0,
200            last_changed_cells: Vec::new(),
201        };
202        field.initialize_zones();
203        field.seed_from_rng();
204        field
205    }
206
207    fn initialize_zones(&mut self) {
208        // Calculate number of zones based on config
209        let zone_cols = (self.width + self.config.zone_width - 1) / self.config.zone_width;
210        let zone_rows = (self.height + self.config.zone_height - 1) / self.config.zone_height;
211        self.zones = vec![ZoneStats::default(); zone_cols * zone_rows];
212    }
213
214    fn seed_from_rng(&mut self) {
215        use rand::prelude::IndexedRandom;
216
217        let width = self.width;
218        let height = self.height;
219
220        // 1. Create sparse random soup (1.5-6% density)
221        let density = self.config.initial_soup_density;
222        for y in 0..height {
223            for x in 0..width {
224                if self.rng.random::<f32>() < density {
225                    let idx = y * width + x;
226                    if idx < self.cells.len() {
227                        self.cells[idx] = true;
228                    }
229                }
230            }
231        }
232
233        // 2. Place several methuselahs at random positions
234        let methuselahs = [&R_PENTOMINO_PATTERN, &ACORN_PATTERN];
235        let num_methuselahs = self.rng.random_range(2..=5);
236        for _ in 0..num_methuselahs {
237            let pattern = methuselahs.choose(&mut self.rng).unwrap();
238            let x = self.rng.random_range(0..width);
239            let y = self.rng.random_range(0..height);
240            let rotation = self.rng.random_range(0..4);
241            let flip = self.rng.random_bool(0.5);
242
243            let placement = PatternPlacement {
244                pattern: **pattern,
245                dx: x as isize,
246                dy: y as isize,
247                rotation,
248                flip,
249            };
250            self.place_pattern(pattern, &placement);
251        }
252
253        // 3. Add moving patterns (gliders, LWSS) in balanced pairs to cancel drift
254        // Place patterns in mirrored pairs: (x, y) and (width-x, height-y) with opposite rotations
255        // This ensures left/right and up/down movement cancel out
256        let moving = [&GLIDER_PATTERN, &LWSS_PATTERN];
257        let num_pairs = self.rng.random_range(2..=4); // 2-4 pairs = 4-8 total patterns
258
259        for _ in 0..num_pairs {
260            let pattern = moving.choose(&mut self.rng).unwrap();
261
262            // Random position for first pattern
263            let x1 = self.rng.random_range(0..width);
264            let y1 = self.rng.random_range(0..height);
265            // Random rotation for first pattern
266            let rot1 = self.rng.random_range(0..4);
267            let flip1 = self.rng.random_bool(0.5);
268
269            // Mirrored position for second pattern (opposite side of torus)
270            let x2 = (width - x1) % width;
271            let y2 = (height - y1) % height;
272            // Opposite rotation to cancel movement direction
273            // rot2 = (rot1 + 2) % 4 gives opposite direction for both gliders and LWSS
274            let rot2 = (rot1 + 2) % 4;
275            let flip2 = flip1; // Same flip to maintain symmetry
276
277            // Place first pattern
278            let placement1 = PatternPlacement {
279                pattern: **pattern,
280                dx: x1 as isize,
281                dy: y1 as isize,
282                rotation: rot1,
283                flip: flip1,
284            };
285            self.place_pattern(pattern, &placement1);
286
287            // Place mirrored pattern with opposite rotation
288            let placement2 = PatternPlacement {
289                pattern: **pattern,
290                dx: x2 as isize,
291                dy: y2 as isize,
292                rotation: rot2,
293                flip: flip2,
294            };
295            self.place_pattern(pattern, &placement2);
296        }
297
298        // 4. Add a few small random blobs (4x4 to 6x6)
299        let num_blobs = self.rng.random_range(2..=4);
300        for _ in 0..num_blobs {
301            self.place_random_blob();
302        }
303    }
304
305    /// Place a small random blob pattern
306    fn place_random_blob(&mut self) {
307        let blob_size = self.rng.random_range(4..=6);
308        let x = self
309            .rng
310            .random_range(0..self.width.saturating_sub(blob_size));
311        let y = self
312            .rng
313            .random_range(0..self.height.saturating_sub(blob_size));
314
315        // Random blob with ~50% density
316        for dy in 0..blob_size {
317            for dx in 0..blob_size {
318                if self.rng.random_bool(0.5) {
319                    let px = x + dx;
320                    let py = y + dy;
321                    if px < self.width && py < self.height {
322                        let idx = py * self.width + px;
323                        if idx < self.cells.len() {
324                            self.cells[idx] = true;
325                        }
326                    }
327                }
328            }
329        }
330    }
331
332    /// Ensures the field has the specified dimensions, reseeding if changed.
333    ///
334    /// # Arguments
335    /// * `width` - Desired width in cells
336    /// * `height` - Desired height in cells
337    pub fn ensure_size(&mut self, width: usize, height: usize) {
338        let width = width.max(2);
339        let height = height.max(4);
340        if self.width == width && self.height == height && !self.cells.is_empty() {
341            return;
342        }
343
344        self.width = width;
345        self.height = height;
346        self.cells = vec![false; width * height];
347        self.heatmap = vec![0.0; width * height];
348        self.low_activity_ticks = 0;
349        self.injection_phase = 0;
350        self.generation = 0;
351        self.last_changed_cells.clear();
352
353        // Re-initialize zones and seed with RNG
354        self.initialize_zones();
355        self.seed_from_rng();
356    }
357
358    /// Advances the simulation by one generation using Conway's rules.
359    ///
360    /// Rules:
361    /// - Live cell with 2-3 neighbors survives
362    /// - Dead cell with 3 or 6 neighbors becomes alive (highlife variant for 6)
363    /// - All other cells die or stay dead
364    ///
365    /// The new heatmap-based injection system monitors activity across zones
366    /// and injects patterns when activity falls below target levels.
367    pub fn step(&mut self) {
368        if self.width == 0 || self.height == 0 || self.cells.is_empty() {
369            return;
370        }
371
372        // Store old state for heatmap comparison
373        let old_cells = self.cells.clone();
374
375        // Clear last changed cells tracking
376        self.last_changed_cells.clear();
377
378        // Perform Game of Life simulation (existing logic)
379        let width = self.width;
380        let height = self.height;
381        let mut next = vec![false; width * height];
382
383        for y in 0..height {
384            for x in 0..width {
385                let idx = y * width + x;
386                let alive = self.cells[idx];
387                let neighbors = self.live_neighbor_count(x, y);
388
389                let next_alive = match (alive, neighbors) {
390                    (true, 2) | (true, 3) => true,
391                    (true, 6) => true, // High life variation
392                    (false, 3) => true,
393                    _ => false,
394                };
395
396                next[idx] = next_alive;
397                if alive != next_alive {
398                    self.last_changed_cells.push((x, y));
399                }
400            }
401        }
402
403        self.cells = next;
404        self.generation += 1;
405
406        // Update heatmap with changes
407        self.update_heatmap(&old_cells);
408
409        // Update zone statistics
410        self.update_zones();
411
412        // Decay cooldowns
413        self.decay_cooldowns();
414
415        // Maybe inject new patterns
416        self.maybe_inject();
417
418        // Periodically rebalance cell distribution
419        self.rebalance();
420
421        // Prune dense areas periodically
422        if self.generation % self.config.prune_interval == 0 {
423            self.prune_dense_areas();
424        }
425    }
426
427    /// Counts the number of live neighbors for a cell.
428    ///
429    /// Uses toroidal wrapping - edges connect to the opposite side.
430    ///
431    /// # Arguments
432    /// * `x` - X coordinate of the cell
433    /// * `y` - Y coordinate of the cell
434    ///
435    /// # Returns
436    /// Count of live neighbors (0-8)
437    pub fn live_neighbor_count(&self, x: usize, y: usize) -> u8 {
438        let mut count = 0u8;
439        for dy in [-1isize, 0, 1] {
440            for dx in [-1isize, 0, 1] {
441                if dx == 0 && dy == 0 {
442                    continue;
443                }
444                let nx = wrap_index(x, dx, self.width);
445                let ny = wrap_index(y, dy, self.height);
446                if self.cells[self.index(nx, ny)] {
447                    count += 1;
448                }
449            }
450        }
451        count
452    }
453
454    /// Converts 2D coordinates to a 1D array index.
455    ///
456    /// # Arguments
457    /// * `x` - X coordinate
458    /// * `y` - Y coordinate
459    ///
460    /// # Returns
461    /// Index into the cells vector
462    pub fn index(&self, x: usize, y: usize) -> usize {
463        y * self.width + x
464    }
465
466    /// Update heatmap after a generation step
467    /// heat = heat * decay + (changed ? 1.0 : 0.0)
468    pub fn update_heatmap(&mut self, old_cells: &[bool]) {
469        let decay = self.config.heat_decay;
470        for y in 0..self.height {
471            for x in 0..self.width {
472                let idx = self.index(x, y);
473                let changed = old_cells[idx] != self.cells[idx];
474                let current_heat = self.heatmap[idx];
475                self.heatmap[idx] = current_heat * decay + if changed { 1.0 } else { 0.0 };
476            }
477        }
478    }
479
480    /// Update zone statistics based on current state
481    pub fn update_zones(&mut self) {
482        let zone_cols = (self.width + self.config.zone_width - 1) / self.config.zone_width;
483        let zone_rows = (self.height + self.config.zone_height - 1) / self.config.zone_height;
484
485        for zy in 0..zone_rows {
486            for zx in 0..zone_cols {
487                let zone_idx = zy * zone_cols + zx;
488                let zone = &mut self.zones[zone_idx];
489
490                // Calculate zone bounds
491                let x_start = zx * self.config.zone_width;
492                let y_start = zy * self.config.zone_height;
493                let x_end = (x_start + self.config.zone_width).min(self.width);
494                let y_end = (y_start + self.config.zone_height).min(self.height);
495
496                // Sum heat and count live cells
497                let mut total_heat = 0.0;
498                let mut live_cells = 0;
499                let mut changed_cells = 0;
500                let zone_area = (x_end - x_start) * (y_end - y_start);
501
502                for y in y_start..y_end {
503                    for x in x_start..x_end {
504                        let idx = y * self.width + x; // Compute index directly
505                        total_heat += self.heatmap[idx];
506                        if self.cells[idx] {
507                            live_cells += 1;
508                        }
509                        // Track changes from last step
510                        if self.last_changed_cells.contains(&(x, y)) {
511                            changed_cells += 1;
512                        }
513                    }
514                }
515
516                zone.heat = (total_heat / zone_area as f32).min(5.0); // Cap at 5.0 for better normalization
517                zone.density = live_cells as f32 / zone_area as f32;
518                zone.changed_count = changed_cells;
519            }
520        }
521    }
522
523    /// Decrement all zone cooldowns by 1
524    pub fn decay_cooldowns(&mut self) {
525        for zone in &mut self.zones {
526            if zone.cooldown > 0 {
527                zone.cooldown -= 1;
528            }
529        }
530    }
531
532    /// Choose a zone for injection using weighted randomness
533    /// Prefers cold zones near warm zones
534    pub fn choose_injection_zone(&mut self) -> Option<usize> {
535        let zone_cols = (self.width + self.config.zone_width - 1) / self.config.zone_width;
536        let zone_rows = (self.height + self.config.zone_height - 1) / self.config.zone_height;
537
538        let mut weights: Vec<(usize, f32)> = Vec::new();
539
540        for zy in 0..zone_rows {
541            for zx in 0..zone_cols {
542                let zone_idx = zy * zone_cols + zx;
543                let zone = &self.zones[zone_idx];
544
545                // Skip zones on cooldown
546                if zone.is_on_cooldown() {
547                    continue;
548                }
549
550                // Calculate coldness (1.0 = coldest, 0.0 = hottest)
551                // Heat is capped at 5.0, so normalize to [0, 1]
552                let coldness = 1.0 - (zone.heat / 5.0).min(1.0);
553
554                // Calculate neighbor heat (prefer cold zones near warm zones)
555                let neighbor_heat = self.get_neighbor_zone_heat(zx, zy, zone_cols, zone_rows);
556
557                // Density suitability (avoid too empty or too dense)
558                let density_suitability = if zone.density < 0.05 {
559                    0.3 // Too empty, less suitable
560                } else if zone.density > 0.8 {
561                    0.1 // Too dense, avoid
562                } else {
563                    1.0 - (zone.density - 0.4).abs() // Peak at 0.4 density
564                };
565
566                // Calculate center bias (favor zones near center)
567                let center_x = (zone_cols - 1) as f32 / 2.0;
568                let center_y = (zone_rows - 1) as f32 / 2.0;
569                let dist_from_center =
570                    ((zx as f32 - center_x).powi(2) + (zy as f32 - center_y).powi(2)).sqrt();
571                let max_dist = ((center_x.powi(2) + center_y.powi(2)).sqrt()).max(1.0);
572                let center_factor = 1.0 - (dist_from_center / max_dist) * self.config.center_bias;
573
574                let weight =
575                    coldness * (0.5 + neighbor_heat * 0.5) * density_suitability * center_factor;
576
577                if weight > 0.01 {
578                    weights.push((zone_idx, weight));
579                }
580            }
581        }
582
583        if weights.is_empty() {
584            return None;
585        }
586
587        // Weighted random selection
588        let total_weight: f32 = weights.iter().map(|(_, w)| w).sum();
589        let mut choice = self.rng.random::<f32>() * total_weight;
590
591        // Store last index in case we exhaust all weights
592        let last_idx = weights.last().unwrap().0;
593
594        for (idx, weight) in weights {
595            choice -= weight;
596            if choice <= 0.0 {
597                return Some(idx);
598            }
599        }
600
601        Some(last_idx)
602    }
603
604    /// Get average heat of neighboring zones (non-wrapping - only actual neighbors)
605    fn get_neighbor_zone_heat(&self, zx: usize, zy: usize, cols: usize, rows: usize) -> f32 {
606        let mut total_heat = 0.0;
607        let mut count = 0;
608
609        for dy in [-1, 0, 1] {
610            for dx in [-1, 0, 1] {
611                if dx == 0 && dy == 0 {
612                    continue;
613                }
614                // Only count actual neighbors, don't wrap around
615                let nx = zx as isize + dx;
616                let ny = zy as isize + dy;
617
618                // Check bounds - skip if outside the grid
619                if nx < 0 || nx >= cols as isize || ny < 0 || ny >= rows as isize {
620                    continue;
621                }
622
623                let nidx = ny as usize * cols + nx as usize;
624                if nidx < self.zones.len() {
625                    total_heat += self.zones[nidx].heat;
626                    count += 1;
627                }
628            }
629        }
630
631        if count > 0 {
632            total_heat / count as f32
633        } else {
634            0.0
635        }
636    }
637
638    /// Calculate global density (percentage of live cells)
639    fn global_density(&self) -> f32 {
640        let live_cells = self.cells.iter().filter(|&&c| c).count();
641        live_cells as f32 / self.cells.len() as f32
642    }
643
644    /// Check if injection should happen and perform it
645    pub fn maybe_inject(&mut self) {
646        // Only check every N generations
647        if self.generation % self.config.check_interval != 0 {
648            return;
649        }
650
651        // Don't inject if board is already too dense
652        let global_density = self.global_density();
653        if global_density > 0.15 {
654            // Was 0.30 - 15% cap
655            return;
656        }
657
658        // Calculate global activity
659        let total_cells = self.width * self.height;
660        let changed_cells: usize = self.zones.iter().map(|z| z.changed_count).sum();
661        let global_activity = changed_cells as f32 / total_cells as f32;
662
663        // Calculate deficit and injection chance
664        let deficit = (self.config.target_activity - global_activity).max(0.0);
665        let chance = (deficit * self.config.injection_gain).min(self.config.max_injection_chance);
666
667        // Random chance check
668        if self.rng.random::<f32>() >= chance {
669            return;
670        }
671
672        // Choose zone
673        let Some(zone_idx) = self.choose_injection_zone() else {
674            return;
675        };
676
677        // Get zone position
678        let zone_cols = (self.width + self.config.zone_width - 1) / self.config.zone_width;
679        let zy = zone_idx / zone_cols;
680        let zx = zone_idx % zone_cols;
681
682        // Choose and place pattern
683        let pattern = self.choose_pattern_for_zone(zone_idx);
684        let placement = self.choose_placement(zx, zy, &pattern);
685
686        // Place pattern with possible mutation
687        self.place_pattern(&pattern, &placement);
688
689        // Record injection
690        self.zones[zone_idx].last_injection_generation = self.generation;
691
692        // Apply cooldown
693        let cooldown_range = self.config.zone_cooldown_min..=self.config.zone_cooldown_max;
694        let cooldown = self.rng.random_range(cooldown_range);
695        self.zones[zone_idx].cooldown = cooldown;
696    }
697
698    /// Choose a pattern based on zone characteristics
699    fn choose_pattern_for_zone(&mut self, zone_idx: usize) -> &'static SeedPattern {
700        let zone = &self.zones[zone_idx];
701
702        // Define pattern categories
703        const METHUSELAHS: &[&SeedPattern] = &[&R_PENTOMINO_PATTERN, &ACORN_PATTERN];
704        const MOVING: &[&SeedPattern] = &[&GLIDER_PATTERN, &LWSS_PATTERN];
705        const RANDOM_BLOBS: &[&SeedPattern] =
706            &[&RANDOM_BLOB_4X4, &RANDOM_BLOB_5X5, &RANDOM_BLOB_6X6];
707
708        let choices: Vec<&SeedPattern> = if zone.is_cold_empty() {
709            // Cold empty: use methuselahs that generate activity
710            METHUSELAHS
711                .iter()
712                .chain(MOVING.iter())
713                .chain(RANDOM_BLOBS.iter())
714                .copied()
715                .collect()
716        } else if zone.is_cold_ash() {
717            // Cold ash: use moving patterns and blobs to stir up debris
718            MOVING.iter().chain(RANDOM_BLOBS.iter()).copied().collect()
719        } else {
720            // Default: any pattern
721            vec![
722                &GLIDER_PATTERN,
723                &R_PENTOMINO_PATTERN,
724                &ACORN_PATTERN,
725                &LWSS_PATTERN,
726                &RANDOM_BLOB_4X4,
727                &RANDOM_BLOB_5X5,
728                &RANDOM_BLOB_6X6,
729            ]
730        };
731
732        // Select random pattern from choices
733        let idx = self.rng.random_range(0..choices.len().max(1));
734        choices.get(idx).copied().unwrap_or(&GLIDER_PATTERN)
735    }
736
737    /// Choose placement parameters for a pattern in a zone
738    fn choose_placement(
739        &mut self,
740        zx: usize,
741        zy: usize,
742        pattern: &SeedPattern,
743    ) -> PatternPlacement {
744        // Calculate zone pixel bounds
745        let x_start = zx * self.config.zone_width;
746        let y_start = zy * self.config.zone_height;
747        let x_end = (x_start + self.config.zone_width).min(self.width);
748        let y_end = (y_start + self.config.zone_height).min(self.height);
749
750        // Random position within zone (with margin for pattern size)
751        let margin_x = pattern.width.min(4);
752        let margin_y = pattern.height.min(4);
753        let x = if x_end > x_start + margin_x * 2 {
754            self.rng.random_range(x_start + margin_x..x_end - margin_x)
755        } else {
756            x_start
757        };
758        let y = if y_end > y_start + margin_y * 2 {
759            self.rng.random_range(y_start + margin_y..y_end - margin_y)
760        } else {
761            y_start
762        };
763
764        // Random rotation (0-3) and flip
765        let rotation = self.rng.random_range(0..4) as u8;
766        let flip = self.rng.random::<f32>() < 0.5;
767
768        PatternPlacement {
769            pattern: *pattern,
770            dx: x as isize,
771            dy: y as isize,
772            rotation,
773            flip,
774        }
775    }
776
777    /// Place a pattern on the field with optional mutation
778    fn place_pattern(&mut self, pattern: &SeedPattern, placement: &PatternPlacement) {
779        for &(px, py) in pattern.cells {
780            // Apply rotation and flip
781            let (rx, ry) = rotate_cell(px, py, pattern.width, pattern.height, placement.rotation);
782            let (fx, fy) = if placement.flip {
783                (pattern.width - 1 - rx, ry)
784            } else {
785                (rx, ry)
786            };
787
788            // Calculate final position with toroidal wrapping
789            let x = wrap_index_signed(placement.dx + fx as isize, self.width);
790            let y = wrap_index_signed(placement.dy + fy as isize, self.height);
791            let idx = y * self.width + x;
792
793            // Apply mutation chance (randomly skip or add extra cell)
794            let mutation = self.rng.random::<f32>() < self.config.mutation_chance;
795            if mutation {
796                // 50% chance to skip this cell, 50% to also set neighbor
797                if self.rng.random::<f32>() < 0.5 {
798                    continue; // Skip this cell
799                } else {
800                    // Set this cell and maybe a neighbor
801                    if idx < self.cells.len() {
802                        self.cells[idx] = true;
803                    }
804                    // Occasionally add adjacent cell
805                    if self.rng.random::<f32>() < 0.3 {
806                        let dx = self.rng.random_range(-1i32..=1) as isize;
807                        let dy = self.rng.random_range(-1i32..=1) as isize;
808                        let nx = wrap_index_signed(x as isize + dx, self.width);
809                        let ny = wrap_index_signed(y as isize + dy, self.height);
810                        let nidx = ny * self.width + nx;
811                        if nidx < self.cells.len() {
812                            self.cells[nidx] = true;
813                        }
814                    }
815                    continue;
816                }
817            }
818
819            // Normal placement
820            if idx < self.cells.len() {
821                self.cells[idx] = true;
822            }
823        }
824    }
825
826    /// Remove cells from high-density zones to prevent local sprawl
827    fn prune_dense_areas(&mut self) {
828        use rand::seq::IteratorRandom;
829
830        let global_density = self.global_density();
831        let zone_cols = (self.width + self.config.zone_width - 1) / self.config.zone_width;
832        let zone_rows = (self.height + self.config.zone_height - 1) / self.config.zone_height;
833
834        // When global density exceeds 15%, use random global pruning to create "holes"
835        // This preserves clusters while creating empty patches (starfield effect)
836        if global_density > 0.15 {
837            let total_cells = self.width * self.height;
838            let target_cells = (total_cells as f32 * 0.12) as usize; // Target 12% density
839            let current_live: usize = self.cells.iter().filter(|&&c| c).count();
840
841            if current_live > target_cells {
842                let to_remove_total = current_live - target_cells;
843                // Remove randomly across entire board to create voids
844                let all_live: Vec<usize> = self
845                    .cells
846                    .iter()
847                    .enumerate()
848                    .filter(|(_, &c)| c)
849                    .map(|(i, _)| i)
850                    .collect();
851
852                let mut rng = &mut self.rng;
853                for idx in all_live
854                    .iter()
855                    .sample(&mut rng, to_remove_total.min(all_live.len()))
856                {
857                    self.cells[*idx] = false;
858                }
859            }
860            return;
861        }
862
863        // Normal zone-based pruning for locally dense zones
864        for zy in 0..zone_rows {
865            for zx in 0..zone_cols {
866                let zone_idx = zy * zone_cols + zx;
867                let zone = &self.zones[zone_idx];
868
869                if zone.density < self.config.prune_density_threshold {
870                    continue;
871                }
872
873                // Calculate zone bounds
874                let x_start = zx * self.config.zone_width;
875                let y_start = zy * self.config.zone_height;
876                let x_end = (x_start + self.config.zone_width).min(self.width);
877                let y_end = (y_start + self.config.zone_height).min(self.height);
878
879                // Collect live cells in this zone
880                let mut live_cells: Vec<(usize, usize)> = Vec::new();
881                for y in y_start..y_end {
882                    for x in x_start..x_end {
883                        let idx = y * self.width + x;
884                        if self.cells[idx] {
885                            live_cells.push((x, y));
886                        }
887                    }
888                }
889
890                // Randomly remove a percentage of cells
891                let to_remove = (live_cells.len() as f32 * self.config.prune_percentage) as usize;
892                let mut rng = &mut self.rng;
893                for (x, y) in live_cells.iter().sample(&mut rng, to_remove) {
894                    let idx = y * self.width + x;
895                    self.cells[idx] = false;
896                }
897            }
898        }
899    }
900
901    /// Periodically rebalance cell distribution to prevent drift accumulation
902    fn rebalance(&mut self) {
903        if self.generation % self.config.rebalance_interval != 0 {
904            return;
905        }
906
907        // Count cells in each third
908        let third_width = self.width / 3;
909        let left_count = self.count_cells_in_region(0, third_width);
910        let center_count = self.count_cells_in_region(third_width, third_width * 2);
911        let right_count = self.count_cells_in_region(third_width * 2, self.width);
912
913        let total = left_count + center_count + right_count;
914        if total == 0 {
915            return;
916        }
917
918        let left_pct = left_count as f32 / total as f32;
919        let center_pct = center_count as f32 / total as f32;
920        let right_pct = right_count as f32 / total as f32;
921
922        // If any region has >40% of cells, prune it
923        let threshold = 0.40;
924        if left_pct > threshold {
925            let to_prune = ((left_pct - 0.33) * total as f32) as usize;
926            self.prune_region(0, third_width, to_prune);
927        }
928        if center_pct > threshold {
929            let to_prune = ((center_pct - 0.33) * total as f32) as usize;
930            self.prune_region(third_width, third_width * 2, to_prune);
931        }
932        if right_pct > threshold {
933            let to_prune = ((right_pct - 0.33) * total as f32) as usize;
934            self.prune_region(third_width * 2, self.width, to_prune);
935        }
936    }
937
938    /// Count live cells in a horizontal region [x_start, x_end)
939    fn count_cells_in_region(&self, x_start: usize, x_end: usize) -> usize {
940        let x_start = x_start.min(self.width);
941        let x_end = x_end.min(self.width);
942        if x_start >= x_end {
943            return 0;
944        }
945
946        let mut count = 0;
947        for y in 0..self.height {
948            for x in x_start..x_end {
949                let idx = y * self.width + x;
950                if self.cells[idx] {
951                    count += 1;
952                }
953            }
954        }
955        count
956    }
957
958    /// Prune random cells from a horizontal region [x_start, x_end)
959    fn prune_region(&mut self, x_start: usize, x_end: usize, count: usize) {
960        use rand::seq::IteratorRandom;
961
962        let x_start = x_start.min(self.width);
963        let x_end = x_end.min(self.width);
964        if x_start >= x_end || count == 0 {
965            return;
966        }
967
968        // Collect live cells in the region
969        let mut live_cells: Vec<usize> = Vec::new();
970        for y in 0..self.height {
971            for x in x_start..x_end {
972                let idx = y * self.width + x;
973                if self.cells[idx] {
974                    live_cells.push(idx);
975                }
976            }
977        }
978
979        // Randomly remove 'count' cells (or all if fewer available)
980        let to_remove = count.min(live_cells.len());
981        let mut rng = &mut self.rng;
982        for idx in live_cells.iter().sample(&mut rng, to_remove) {
983            self.cells[*idx] = false;
984        }
985    }
986
987    /// Get the current generation counter
988    pub fn generation(&self) -> u64 {
989        self.generation
990    }
991
992    /// Get field dimensions
993    pub fn dimensions(&self) -> (usize, usize) {
994        (self.width, self.height)
995    }
996
997    /// Get all live cell positions
998    pub fn live_cells(&self) -> Vec<(usize, usize)> {
999        let mut cells = Vec::new();
1000        for y in 0..self.height {
1001            for x in 0..self.width {
1002                let idx = y * self.width + x;
1003                if idx < self.cells.len() && self.cells[idx] {
1004                    cells.push((x, y));
1005                }
1006            }
1007        }
1008        cells
1009    }
1010
1011    /// Get the number of live cells
1012    pub fn live_cell_count(&self) -> usize {
1013        self.cells.iter().filter(|&&c| c).count()
1014    }
1015
1016    /// Get zone statistics for debugging
1017    pub fn zone_stats(&self) -> Vec<((usize, usize), ZoneStats)> {
1018        let zone_cols = (self.width + self.config.zone_width - 1) / self.config.zone_width;
1019        let zone_rows = (self.height + self.config.zone_height - 1) / self.config.zone_height;
1020
1021        let mut result = Vec::new();
1022        for zy in 0..zone_rows {
1023            for zx in 0..zone_cols {
1024                let zone_idx = zy * zone_cols + zx;
1025                if zone_idx < self.zones.len() {
1026                    result.push(((zx, zy), self.zones[zone_idx]));
1027                }
1028            }
1029        }
1030        result
1031    }
1032
1033    /// Get configuration
1034    pub fn config(&self) -> &LifeConfig {
1035        &self.config
1036    }
1037}