lumon-macrodata-refine / src/main.rs

raw file 74716 bytes
//! LUMON — Macrodata Refine
//!
//! Windowed recreation of the Lumon box-drawing game: a vast field of glowing,
//! breathing numbers. Left-drag boxes them in; boxes that touch merge into one.
//! The numbers glow (layered bloom) and grow/shrink in size; the selected box's
//! numbers glow hardest and its border pulses.
//!
//! Input is polled every frame from the windowing layer, so selection works
//! regardless of what motion events a terminal would have sent — press
//! anchors, the position at release always defines the box.

use std::collections::HashMap;
use std::collections::HashSet;

use macroquad::prelude::*;

mod sfx;
use sfx::{Sfx, SfxId};

const WORLD_COLS: i32 = 160;
const WORLD_ROWS: i32 = 120;

/// Unscaled cell size in "world units"; everything scales by `zoom`.
const CELL_W: f32 = 26.0;
const CELL_H: f32 = 34.0;

/// Base font size the glyph metrics are measured at.
const GLYPH_BASE: u16 = 24;

const ZOOM_MIN: f32 = 0.55;
const ZOOM_MAX: f32 = 2.2;

/// Box palette as RGB triples.
const BOX_COLORS: [[f32; 3]; 8] = [
    [0.30, 0.90, 1.00], // cyan
    [1.00, 0.45, 0.85], // magenta
    [0.40, 1.00, 0.60], // green
    [1.00, 0.85, 0.30], // yellow
    [0.45, 0.60, 1.00], // blue
    [1.00, 0.45, 0.40], // red
    [1.00, 0.65, 0.30], // orange
    [0.75, 0.50, 1.00], // purple
];

const FIELD_RGB: [f32; 3] = [0.40, 0.50, 0.62];
const SUSPECT_RGB: [f32; 3] = [1.00, 0.82, 0.25];
const SELECT_RGB: [f32; 3] = [0.95, 0.98, 1.00];

const MAX_UNDO: usize = 200;

// --- hidden temper map ------------------------------------------------------
/// Cell tags: 0..=3 = WOE/DREAD/MALICE/FROCLIC cluster, 4 = untagged.
const TEMPER_NONE: u8 = 4;
/// Sheet seed for shift 1; later shifts derive from it.
const BASE_SEED: u64 = 0x5EED_C0DE_F00D_BEEF;
/// Quota grows each shift: 400, 550, 700, ...
const QUOTA_BASE: u32 = 400;
const QUOTA_STEP: u32 = 150;
/// How long refined temper siblings keep pulsing on the sheet.
const TRAIL_SECS: f32 = 5.0;
/// Length of the shift-complete freeze before the next sheet loads.
const SETTLE_SECS: f32 = 4.0;
/// Refinement sigils are N x N silhouettes of the refined region.
const SIGIL_N: usize = 8;

#[derive(PartialEq, Clone, Copy, Debug)]
enum Phase {
    Work,
    /// Quota met: the sheet settles and a new one is about to load.
    Settling,
}

type Cell = (i32, i32); // (row, col)

struct MergedBox {
    id: u64,
    cells: HashSet<Cell>,
    color: [f32; 3],
    /// Grabbed with Space: the box is filed as refined output and goes quiet.
    refined: bool,
}

/// One entry in the refinement output panel.
struct Refinement {
    index: u64,
    mood: &'static str,
    line: &'static str,
    score: f32,
    digit_sum: u32,
    digit_count: u32,
    letter_count: u32,
    dominant: char,
    hue: [f32; 3],
    at: f32, // tick when grabbed (drives slide-in)
    /// Downsampled silhouette of the refined region (sigil_n * sigil_n).
    sigil: Vec<bool>,
    sigil_n: usize,
}

/// The four tempers, each with a deterministic classification and flavor lines.
const TEMPERS: [(&str, [&str; 3]); 4] = [
    (
        "WOE",
        [
            "The numbers wept. Refinement logged.",
            "A quiet sadness settles into the grid.",
            "Mood signature: elegiac. Filed.",
        ],
    ),
    (
        "DREAD",
        [
            "A cold certainty spreads across the sheet.",
            "Something is coming. The digits know.",
            "Mood signature: foreboding. Filed.",
        ],
    ),
    (
        "MALICE",
        [
            "The data resists. It always resists.",
            "A thin cruelty hums beneath the figures.",
            "Mood signature: hostile. Filed.",
        ],
    ),
    (
        "FROCLIC",
        [
            "Brief, inexplicable delight. Filed.",
            "For one moment, the numbers danced.",
            "Mood signature: whimsical. Filed.",
        ],
    ),
];

fn mood_color(mood: &str) -> [f32; 3] {
    match mood {
        "WOE" => [0.45, 0.60, 1.00],
        "DREAD" => [0.65, 0.70, 0.78],
        "MALICE" => [1.00, 0.45, 0.40],
        _ => [1.00, 0.85, 0.30],
    }
}

// ---------------------------------------------------------------------------
// Settings (`,` opens the panel; persisted to lumongame.cfg)
// ---------------------------------------------------------------------------

/// Picker labels. Index 0 is the built-in bitmap font; 1..=4 are the TTFs
/// embedded below.
const FONT_NAMES: [&str; 5] = [
    "BUILT-IN PIXEL",
    "JETBRAINS MONO",
    "SPACE MONO",
    "IBM PLEX MONO",
    "FIRA MONO",
];
const GLOW_NAMES: [&str; 4] = ["off", "subtle", "normal", "bright"];
/// Halo alpha behind glyphs per glow level (index 2 keeps the classic look).
const HALO_ALPHA: [f32; 4] = [0.0, 0.04, 0.08, 0.16];
/// How far the bright core pushes toward white per glow level.
const CORE_LIFT: [f32; 4] = [0.0, 0.20, 0.45, 0.70];

#[derive(Clone, Copy)]
struct Settings {
    /// 0 = built-in bitmap, 1..=4 = FONT_NAMES / FONT_FILES.
    font: usize,
    /// 0 off .. 3 bright.
    glow: usize,
    scanlines: bool,
    sound: bool,
}

impl Default for Settings {
    fn default() -> Self {
        Settings {
            font: 1,
            glow: 2,
            scanlines: true,
            sound: true,
        }
    }
}

impl Settings {
    fn load() -> Self {
        let mut s = Settings::default();
        if let Ok(txt) = std::fs::read_to_string("lumongame.cfg") {
            for line in txt.lines() {
                let Some((k, v)) = line.split_once('=') else {
                    continue;
                };
                match (k.trim(), v.trim()) {
                    ("font", v) => {
                        if let Ok(n) = v.parse::<usize>() {
                            s.font = n.min(FONT_NAMES.len() - 1);
                        }
                    }
                    ("glow", v) => {
                        if let Ok(n) = v.parse::<usize>() {
                            s.glow = n.min(GLOW_NAMES.len() - 1);
                        }
                    }
                    ("scanlines", v) => s.scanlines = v == "1",
                    ("sound", v) => s.sound = v == "1",
                    _ => {}
                }
            }
        }
        s
    }

    fn save(&self) {
        let _ = std::fs::write(
            "lumongame.cfg",
            format!(
                "font={}\nglow={}\nscanlines={}\nsound={}\n",
                self.font,
                self.glow,
                self.scanlines as u8,
                self.sound as u8
            ),
        );
    }
}

/// Deterministic xorshift64* so the macrodata sheet is identical every run.
struct Rng(u64);

impl Rng {
    fn new(seed: u64) -> Self {
        // xorshift needs a non-zero state; keep every other seed distinct
        // (forcing |1 here would make BASE_SEED and BASE_SEED^1 identical).
        Rng(if seed == 0 { 0x9E37_79B9_7F4A_7C15 } else { seed })
    }
    fn next_u64(&mut self) -> u64 {
        let mut x = self.0;
        x ^= x >> 12;
        x ^= x << 25;
        x ^= x >> 27;
        self.0 = x;
        x.wrapping_mul(0x2545_F491_4F6C_DD1D)
    }
    fn below(&mut self, n: u64) -> u64 {
        self.next_u64() % n
    }
}

struct Game {
    chars: Vec<char>,
    /// Suspect numbers ("the ones that feel wrong") — they flicker yellow.
    suspect: Vec<bool>,
    /// Hidden temper tags per cell (TEMPER_NONE = untagged).
    temper: Vec<u8>,

    boxes: Vec<MergedBox>,
    undo: Vec<Vec<MergedBox>>,
    next_id: u64,
    color_idx: usize,
    selected_id: Option<u64>,

    /// Camera in screen pixels. World point `wu` draws at `wu * zoom - cam`.
    cam: (f32, f32),
    zoom: f32,

    /// Left selection: (anchor cell, current cell).
    left_drag: Option<(Cell, Cell)>,
    right_drag: bool,
    last_mouse: (f32, f32),

    /// Keyboard box-cursor fallback ('b').
    cursor_mode: bool,
    cursor: Cell,
    anchor: Option<Cell>,

    /// Filed refinement outputs, newest first.
    refinements: Vec<Refinement>,
    next_refine: u64,

    /// Quota loop: refine to fill the bar, the sheet settles, next shift.
    base_seed: u64,
    shift_no: u32,
    shift_mass: u32,
    quota_target: u32,
    phase: Phase,
    settle_until: f32,
    /// After refining a temper cluster, hidden siblings pulse for a while.
    trail_temper: Option<u8>,
    trail_until: f32,

    settings: Settings,
    /// Indexed like FONT_NAMES; None = built-in or failed load (fallback).
    fonts: Vec<Option<Font>>,
    /// Synthesized sound effects (tones, not music).
    sfx: Sfx,
    settings_open: bool,
    /// Focused settings row: 0 font, 1 glow, 2 scanlines, 3 sound.
    settings_focus: usize,

    /// Seconds since start — drives all animation.
    tick: f32,
    /// Time of last commit; a quick grow-pop radiates from the selection.
    pop_at: f32,
    status: String,
    status_at: f32,

    frames: u32,
}

fn generate_field(seed: u64) -> (Vec<char>, Vec<bool>, Vec<u8>) {
    let mut rng = Rng::new(seed);
    let n = (WORLD_ROWS * WORLD_COLS) as usize;
    let mut chars = Vec::with_capacity(n);
    let mut suspect = vec![false; n];

    let digits: Vec<char> = "0123456789".chars().collect();
    let letters: Vec<char> = "ABCDEFGHIJKLMNOPQRSTUVWXYZ".chars().collect();

    for slot in suspect.iter_mut() {
        let roll = rng.below(100);
        let ch = if roll < 78 {
            digits[rng.below(digits.len() as u64) as usize]
        } else {
            letters[rng.below(letters.len() as u64) as usize]
        };
        chars.push(ch);
        *slot = rng.below(100) < 2 && ch.is_ascii_digit();
    }

    // Hidden temper map: scattered clusters with distance falloff. The
    // mood of a refined box is read from these tags, so the output carries
    // real information about what you boxed — never rendered as text.
    let mut temper = vec![TEMPER_NONE; n];
    for _ in 0..26 {
        let t = rng.below(4) as u8;
        let r0 = rng.below(WORLD_ROWS as u64) as i32;
        let c0 = rng.below(WORLD_COLS as u64) as i32;
        let radius = 3 + rng.below(6) as i32; // 3..=8
        for r in (r0 - radius).max(0)..=(r0 + radius).min(WORLD_ROWS - 1) {
            for c in (c0 - radius).max(0)..=(c0 + radius).min(WORLD_COLS - 1) {
                let d = (r - r0).abs().max((c - c0).abs());
                if d > radius {
                    continue;
                }
                // Dense core, sparse edge.
                let keep = 100 - (d * 100 / (radius + 1));
                if rng.below(100) < keep as u64 {
                    let idx = (r * WORLD_COLS + c) as usize;
                    temper[idx] = t;
                    // Suspects bias toward cluster interiors — the flickering
                    // yellows mark where meaning lives.
                    if d <= radius / 2 && rng.below(100) < 6 && chars[idx].is_ascii_digit() {
                        suspect[idx] = true;
                    }
                }
            }
        }
    }

    (chars, suspect, temper)
}

fn phase_of(r: i32, c: i32) -> f32 {
    let mut h = (r as u32).wrapping_mul(374_761_393) ^ (c as u32).wrapping_mul(668_265_263);
    h = (h ^ (h >> 13)).wrapping_mul(1_274_126_177);
    h ^= h >> 16;
    h as f32 / u32::MAX as f32 * std::f32::consts::TAU
}

fn touches(a: &HashSet<Cell>, b: &HashSet<Cell>) -> bool {
    for &(r, c) in a {
        if b.contains(&(r, c))
            || b.contains(&(r - 1, c))
            || b.contains(&(r + 1, c))
            || b.contains(&(r, c - 1))
            || b.contains(&(r, c + 1))
        {
            return true;
        }
    }
    false
}

/// Split a cell set into 4-connected components — a carve that cuts a box
/// in two leaves two independent boxes.
fn connected_components(cells: &HashSet<Cell>) -> Vec<HashSet<Cell>> {
    let mut seen: HashSet<Cell> = HashSet::new();
    let mut comps = Vec::new();
    for &start in cells {
        if seen.contains(&start) {
            continue;
        }
        let mut comp = HashSet::new();
        let mut stack = vec![start];
        seen.insert(start);
        while let Some((r, c)) = stack.pop() {
            comp.insert((r, c));
            for n in [(r - 1, c), (r + 1, c), (r, c - 1), (r, c + 1)] {
                if cells.contains(&n) && seen.insert(n) {
                    stack.push(n);
                }
            }
        }
        comps.push(comp);
    }
    comps
}

fn quota_target_for(shift: u32) -> u32 {
    QUOTA_BASE + (shift.saturating_sub(1)) * QUOTA_STEP
}

/// Downsample a region's cells into an N x N silhouette — the sigil that
/// represents this refinement in the output gallery.
fn sigil_of(cells: &[Cell]) -> (Vec<bool>, usize) {
    let (mut rmin, mut rmax, mut cmin, mut cmax) = (i32::MAX, i32::MIN, i32::MAX, i32::MIN);
    for &(r, c) in cells {
        rmin = rmin.min(r);
        rmax = rmax.max(r);
        cmin = cmin.min(c);
        cmax = cmax.max(c);
    }
    let set: HashSet<Cell> = cells.iter().copied().collect();
    let h = (rmax - rmin + 1).max(1) as usize;
    let w = (cmax - cmin + 1).max(1) as usize;
    let mut bits = vec![false; SIGIL_N * SIGIL_N];
    for r in 0..SIGIL_N {
        for c in 0..SIGIL_N {
            // Sample the center of each band.
            let sr = rmin + ((r * 2 + 1) * h / (SIGIL_N * 2)) as i32;
            let sc = cmin + ((c * 2 + 1) * w / (SIGIL_N * 2)) as i32;
            bits[r * SIGIL_N + c] = set.contains(&(sr, sc));
        }
    }
    (bits, SIGIL_N)
}

impl Game {
    fn new(fonts: Vec<Option<Font>>) -> Self {
        let (chars, suspect, temper) = generate_field(BASE_SEED);
        let settings = Settings::load();
        Game {
            chars,
            suspect,
            temper,
            boxes: Vec::new(),
            undo: Vec::new(),
            next_id: 0,
            color_idx: 0,
            selected_id: None,
            cam: (0.0, 0.0),
            zoom: 1.0,
            left_drag: None,
            right_drag: false,
            last_mouse: (0.0, 0.0),
            cursor_mode: false,
            cursor: (0, 0),
            anchor: None,
            refinements: Vec::new(),
            next_refine: 1,
            base_seed: BASE_SEED,
            shift_no: 1,
            shift_mass: 0,
            quota_target: quota_target_for(1),
            phase: Phase::Work,
            settle_until: 0.0,
            trail_temper: None,
            trail_until: 0.0,
            settings,
            fonts,
            sfx: Sfx::default(),
            settings_open: false,
            settings_focus: 0,
            tick: 0.0,
            pop_at: -10.0,
            status: String::new(),
            status_at: -10.0,
            frames: 0,
        }
    }

    fn active_font(&self) -> Option<&Font> {
        self.fonts.get(self.settings.font).and_then(|f| f.as_ref())
    }

    fn settling(&self) -> bool {
        self.phase == Phase::Settling
    }

    fn temper_at(&self, row: i32, col: i32) -> u8 {
        if (0..WORLD_ROWS).contains(&row) && (0..WORLD_COLS).contains(&col) {
            self.temper[(row * WORLD_COLS + col) as usize]
        } else {
            TEMPER_NONE
        }
    }

    /// Quota met: the shift freezes, then a fresh sheet loads.
    fn next_shift(&mut self) {
        self.shift_no += 1;
        let seed = self
            .base_seed
            ^ (self.shift_no as u64).wrapping_mul(0x9E37_79B9_7F4A_7C15);
        let (chars, suspect, temper) = generate_field(seed);
        self.chars = chars;
        self.suspect = suspect;
        self.temper = temper;
        self.boxes.clear();
        self.undo.clear();
        self.selected_id = None;
        self.left_drag = None;
        self.anchor = None;
        self.cursor_mode = false;
        self.shift_mass = 0;
        self.quota_target = quota_target_for(self.shift_no);
        self.trail_temper = None;
        self.pop_at = -10.0;
        self.status.clear();
        self.status_at = -10.0;
        self.phase = Phase::Work;
        self.flash(format!("shift {} - a new sheet arrives", self.shift_no));
        self.sfx.play(SfxId::Sheet, 0.7);
    }

    fn char_at(&self, row: i32, col: i32) -> char {
        if (0..WORLD_ROWS).contains(&row) && (0..WORLD_COLS).contains(&col) {
            self.chars[(row * WORLD_COLS + col) as usize]
        } else {
            ' '
        }
    }

    fn suspect_at(&self, row: i32, col: i32) -> bool {
        (0..WORLD_ROWS).contains(&row)
            && (0..WORLD_COLS).contains(&col)
            && self.suspect[(row * WORLD_COLS + col) as usize]
    }

    fn flash(&mut self, msg: impl Into<String>) {
        self.status = msg.into();
        self.status_at = self.tick;
    }

    fn push_undo(&mut self) {
        self.undo.push(
            self.boxes
                .iter()
                .map(|b| MergedBox {
                    id: b.id,
                    cells: b.cells.clone(),
                    color: b.color,
                    refined: b.refined,
                })
                .collect(),
        );
        if self.undo.len() > MAX_UNDO {
            self.undo.remove(0);
        }
    }

    fn undo_last(&mut self) {
        if let Some(prev) = self.undo.pop() {
            self.boxes = prev;
            self.selected_id = self.boxes.last().map(|b| b.id);
            self.flash("undo");
            self.sfx.play(SfxId::Undo, 0.7);
        } else {
            self.flash("nothing to undo");
        }
    }

    fn clear_boxes(&mut self) {
        if self.boxes.is_empty() {
            self.flash("no boxes to clear");
            return;
        }
        self.push_undo();
        self.boxes.clear();
        self.selected_id = None;
        self.flash("cleared");
        self.sfx.play(SfxId::Clear, 0.7);
    }

    fn rect_cells(a: Cell, b: Cell) -> HashSet<Cell> {
        let (r0, r1) = (a.0.min(b.0), a.0.max(b.0));
        let (c0, c1) = (a.1.min(b.1), a.1.max(b.1));
        let mut set = HashSet::new();
        for r in r0..=r1 {
            for c in c0..=c1 {
                set.insert((r, c));
            }
        }
        set
    }

    /// Commit a drawn box.
    ///
    /// - Reselecting a box's exact rectangle toggles it off (remove).
    /// - A selection lying entirely inside existing boxes is a **carve**:
    ///   only the selected cells are removed from those boxes; the rest of
    ///   each box stays (splitting into separate boxes if the cut
    ///   disconnects it). No new box is drawn.
    /// - Otherwise the selection draws a new box that merges with whatever
    ///   it touches.
    fn commit_box(&mut self, a: Cell, b: Cell) {
        let cells = Self::rect_cells(a, b);
        if cells.is_empty() {
            return;
        }

        // Exact re-selection -> toggle that box off.
        if let Some(pos) = self.boxes.iter().position(|bx| bx.cells == cells) {
            self.push_undo();
            let removed = self.boxes.remove(pos);
            if self.selected_id == Some(removed.id) {
                self.selected_id = self.boxes.last().map(|bx| bx.id);
            }
            if std::env::var("LUMON_DEBUG").is_ok() {
                eprintln!("REMOVE id={} area={}", removed.id, removed.cells.len());
            }
            self.flash(format!("removed box ({} chars)", removed.cells.len()));
            self.sfx.play(SfxId::Remove, 0.8);
            return;
        }

        self.push_undo();

        // Is every selected cell already inside some box? Then this is a
        // carve gesture: bite the subset out of the boxes that contain it.
        let boxed_now: HashSet<Cell> = self
            .boxes
            .iter()
            .flat_map(|bx| bx.cells.iter().copied())
            .collect();
        if cells.is_subset(&boxed_now) {
            let mut carved = 0usize;
            let mut trimmed = 0usize;
            let mut splits = 0usize;
            let mut i = 0;
            while i < self.boxes.len() {
                if self.boxes[i].cells.is_disjoint(&cells) {
                    i += 1;
                    continue;
                }
                let bx = self.boxes.remove(i);
                carved += bx.cells.intersection(&cells).count();
                if self.selected_id == Some(bx.id) {
                    self.selected_id = None;
                }
                let remaining: HashSet<Cell> = bx.cells.difference(&cells).copied().collect();
                if remaining.is_empty() {
                    continue; // fully carved away
                }
                trimmed += 1;
                let comps = connected_components(&remaining);
                splits += comps.len().saturating_sub(1);
                for comp in comps {
                    let id = self.next_id;
                    self.next_id += 1;
                    self.boxes.push(MergedBox {
                        id,
                        cells: comp,
                        color: bx.color,
                        refined: bx.refined,
                    });
                }
            }
            self.pop_at = self.tick;
            if std::env::var("LUMON_DEBUG").is_ok() {
                eprintln!("CARVE carved={carved} trimmed={trimmed} splits={splits}");
            }
            let mut msg = format!("carved {carved} chars from {trimmed} box(es)");
            if splits > 0 {
                msg.push_str(&format!("  ·  split x{splits}"));
            }
            self.flash(msg);
            self.sfx.play(SfxId::Carve, 0.8);
            return;
        }

        // Normal draw: the selection becomes a new box.
        let color = BOX_COLORS[self.color_idx % BOX_COLORS.len()];
        self.color_idx += 1;
        let id = self.next_id;
        self.next_id += 1;
        let mut merged = MergedBox {
            id,
            cells,
            color,
            refined: false,
        };

        // Touching (or contained) boxes merge into it.
        let mut absorbed = 0usize;
        let mut i = 0;
        while i < self.boxes.len() {
            if touches(&merged.cells, &self.boxes[i].cells) {
                let other = self.boxes.remove(i);
                absorbed += 1;
                merged.cells.extend(other.cells);
            } else {
                i += 1;
            }
        }

        let area = merged.cells.len();
        if std::env::var("LUMON_DEBUG").is_ok() {
            eprintln!("COMMIT id={id} area={area} absorbed={absorbed}");
        }
        self.selected_id = Some(id);
        self.pop_at = self.tick;
        self.boxes.push(merged);

        let mut msg = format!("box drawn ({area} chars)");
        if absorbed > 0 {
            msg.push_str(&format!("  ·  merged x{absorbed}"));
        }
        self.flash(msg);
        if absorbed > 0 {
            self.sfx.play(SfxId::Merge, 0.85);
        } else {
            self.sfx.play(SfxId::Draw, 0.7);
        }
    }

    fn enclosed_total(&self) -> usize {
        self.boxes.iter().map(|b| b.cells.len()).sum()
    }

    /// Space: grab the current selection — read its hidden temper, extract
    /// its data, file it in the output panel, and feed the quota. The box
    /// is stamped refined and goes quiet.
    fn grab_selection(&mut self) {
        let Some(id) = self.selected_id else {
            self.flash("nothing to refine - draw a box first");
            return;
        };
        let Some(pos) = self.boxes.iter().position(|b| b.id == id) else {
            return;
        };

        // Deterministic hash over the selected cells: coords + glyphs.
        let mut cells: Vec<Cell> = self.boxes[pos].cells.iter().copied().collect();
        cells.sort_unstable();
        let mut h: u32 = 0x811C_9DC5;
        let mut digit_sum = 0u32;
        let mut digit_count = 0u32;
        let mut letter_count = 0u32;
        let mut freq: HashMap<char, u32> = HashMap::new();
        let mut temper_hits = [0u32; 4];
        for &(r, c) in &cells {
            let ch = self.char_at(r, c);
            h = h
                .wrapping_mul(31)
                .wrapping_add((r as u32).wrapping_mul(7919) ^ (c as u32) ^ (ch as u32));
            if ch.is_ascii_digit() {
                digit_sum += ch as u32 - '0' as u32;
                digit_count += 1;
            } else if ch.is_ascii_uppercase() {
                letter_count += 1;
            }
            *freq.entry(ch).or_default() += 1;
            let t = self.temper_at(r, c);
            if t < TEMPER_NONE {
                temper_hits[t as usize] += 1;
            }
        }
        let dominant = freq
            .iter()
            .max_by_key(|&(_, n)| *n)
            .map(|(c, _)| *c)
            .unwrap_or('?');

        // The hidden temper map drives the mood when the selection covers
        // enough tagged cells; otherwise fall back to the hash. The output
        // carries real information about what you boxed — never as text.
        let tagged: u32 = temper_hits.iter().sum();
        let dom_t = temper_hits
            .iter()
            .enumerate()
            .max_by_key(|&(_, n)| *n)
            .map(|(i, _)| i)
            .unwrap_or(0);
        let temper_hit = tagged >= 4 && temper_hits[dom_t] * 3 >= tagged * 2;
        for &t in &temper_hits {
            h = h.wrapping_mul(31).wrapping_add(t);
        }

        let (mood, lines) = if temper_hit {
            TEMPERS[dom_t]
        } else {
            TEMPERS[(h % 4) as usize]
        };
        let line = lines[((h >> 3) % 3) as usize];
        let score = 62.0 + ((h >> 6) % 380) as f32 / 10.0; // 62.0 .. 99.9

        let (sigil, sigil_n) = sigil_of(&cells);
        let area = cells.len() as u32;
        let hue = self.boxes[pos].color;

        self.refinements.insert(
            0,
            Refinement {
                index: self.next_refine,
                mood,
                line,
                score,
                digit_sum,
                digit_count,
                letter_count,
                dominant,
                hue,
                at: self.tick,
                sigil,
                sigil_n,
            },
        );
        if self.refinements.len() > 7 {
            self.refinements.pop();
        }
        let index = self.next_refine;
        self.next_refine += 1;
        self.boxes[pos].refined = true;
        self.selected_id = None;
        self.pop_at = self.tick;

        // Sibling clusters of the same temper pulse on the sheet — a trail
        // to follow, never a translation.
        if temper_hit {
            self.trail_temper = Some(dom_t as u8);
            self.trail_until = self.tick + TRAIL_SECS;
        }

        // Quota: filed mass feeds the bar; a met quota settles the shift.
        self.shift_mass += area;
        let quota_met = self.shift_mass >= self.quota_target;
        if quota_met {
            self.phase = Phase::Settling;
            self.settle_until = self.tick + SETTLE_SECS;
        }

        if std::env::var("LUMON_DEBUG").is_ok() {
            eprintln!(
                "REFINE index={index} mood={mood} temper_hit={temper_hit} tagged={tagged} area={area} quota={}/{}",
                self.shift_mass, self.quota_target
            );
        }

        let msg = if quota_met {
            format!("quota met - shift complete  ·  {mood} {score:.1}%")
        } else {
            format!(
                "refined: {mood} {score:.1}%  ->  #{index:03}  ·  quota {}/{}",
                self.shift_mass, self.quota_target
            )
        };
        self.flash(msg);
        if quota_met {
            self.sfx.play(SfxId::Quota, 0.9);
        } else {
            self.sfx.play(SfxId::Refine, 0.8);
        }
        if temper_hit {
            self.sfx.play(SfxId::Trail, 0.5);
        }
    }

    /// Terminal position -> world cell (clamped to the field).
    fn cell_at(&self, sx: f32, sy: f32) -> Cell {
        let wu = ((sx + self.cam.0) / self.zoom, (sy + self.cam.1) / self.zoom);
        let c = (wu.0 / CELL_W).floor() as i32;
        let r = (wu.1 / CELL_H).floor() as i32;
        (r.clamp(0, WORLD_ROWS - 1), c.clamp(0, WORLD_COLS - 1))
    }

    fn clamp_cam(&mut self, view_w: f32, view_h: f32) {
        let ww = WORLD_COLS as f32 * CELL_W * self.zoom;
        let wh = WORLD_ROWS as f32 * CELL_H * self.zoom;
        self.cam.0 = self.cam.0.clamp(0.0, (ww - view_w).max(0.0));
        self.cam.1 = self.cam.1.clamp(0.0, (wh - view_h).max(0.0));
    }

    fn cell_size(&self) -> (f32, f32) {
        (CELL_W * self.zoom, CELL_H * self.zoom)
    }
}

// ---------------------------------------------------------------------------
// Glyph drawing — integer pixel sizes (macroquad blurs when font_scale
// drifts from 1.0), one tight halo for glow, per-cell scale for growth
// ---------------------------------------------------------------------------

/// Split a target pixel size into an integer raster size and the tiny
/// residual font_scale that keeps macroquad's rasterizer crisp.
fn crisp(target_px: f32) -> (u16, f32) {
    let n = target_px.round().max(1.0);
    (n as u16, target_px / n)
}

/// draw_text_ex at an exact pixel size, using the active font.
fn draw_text_px(
    font: Option<&Font>,
    txt: &str,
    x: f32,
    y: f32,
    target_px: f32,
    color: Color,
) {
    let (n, res) = crisp(target_px);
    draw_text_ex(
        txt,
        x,
        y,
        TextParams {
            font,
            font_size: n,
            font_scale: res,
            font_scale_aspect: 1.0,
            rotation: 0.0,
            color,
        },
    );
}

/// measure_text matching draw_text_px.
fn measure_px(font: Option<&Font>, txt: &str, target_px: f32) -> TextDimensions {
    let (n, res) = crisp(target_px);
    measure_text(txt, font, n, res)
}

/// Draw one character centered at (cx, cy) with a subtle glow.
///
/// `rgb` is the glyph's hue, `glow` its luminance (0..~1.4), `scale` its
/// size multiplier. One tight halo layer keeps a hint of bloom without the
/// stacked-shadow look; the core is pushed toward white as glow rises.
#[allow(clippy::too_many_arguments)]
fn draw_glyph(
    txt: &str,
    cx: f32,
    cy: f32,
    scale: f32,
    glow: f32,
    rgb: [f32; 3],
    m: &TextDimensions,
    deep: bool,
    font: Option<&Font>,
    glow_level: usize,
) {
    // draw_text renders into Rect(x, y - offset_y, width, height); the
    // metrics were measured at GLYPH_BASE, so scale them by the pixel ratio.
    let (n, res) = crisp(GLYPH_BASE as f32 * scale);
    let k = n as f32 / GLYPH_BASE as f32;
    let x = cx - m.width * k * 0.5;
    let y = cy + (m.offset_y - m.height * 0.5) * k;

    if deep {
        // Single tight halo — a breath of glow, not a shadow stack.
        let halo_a = HALO_ALPHA[glow_level] * glow;
        if halo_a > 0.004 {
            let (hn, hres) = crisp(GLYPH_BASE as f32 * scale * 1.22);
            let hk = hn as f32 / GLYPH_BASE as f32;
            draw_text_ex(
                txt,
                cx - m.width * hk * 0.5,
                cy + (m.offset_y - m.height * 0.5) * hk,
                TextParams {
                    font,
                    font_size: hn,
                    font_scale: hres,
                    font_scale_aspect: 1.0,
                    rotation: 0.0,
                    color: Color::new(rgb[0], rgb[1], rgb[2], halo_a),
                },
            );
        }
    }
    let lift = CORE_LIFT[glow_level] * glow;
    let core_a = (0.55 + 0.45 * glow).min(1.0);
    let core = Color::new(
        (rgb[0] + (1.0 - rgb[0]) * lift).min(1.0),
        (rgb[1] + (1.0 - rgb[1]) * lift).min(1.0),
        (rgb[2] + (1.0 - rgb[2]) * lift).min(1.0),
        core_a,
    );
    draw_text_ex(
        txt,
        x,
        y,
        TextParams {
            font,
            font_size: n,
            font_scale: res,
            font_scale_aspect: 1.0,
            rotation: 0.0,
            color: core,
        },
    );
}

#[allow(clippy::too_many_arguments)]
fn dashed_rect(x0: f32, y0: f32, x1: f32, y1: f32, dash: f32, gap: f32, thick: f32, color: Color) {
    let mut x = x0;
    while x < x1 {
        let xe = (x + dash).min(x1);
        draw_line(x, y0, xe, y0, thick, color);
        draw_line(x, y1, xe, y1, thick, color);
        x = xe + gap;
    }
    let mut y = y0;
    while y < y1 {
        let ye = (y + dash).min(y1);
        draw_line(x0, y, x0, ye, thick, color);
        draw_line(x1, y, x1, ye, thick, color);
        y = ye + gap;
    }
}

// ---------------------------------------------------------------------------
// Rendering
// ---------------------------------------------------------------------------

fn render(game: &Game, m: &HashMap<char, TextDimensions>) {
    let (sw, sh) = (screen_width(), screen_height());
    let (cw, chh) = game.cell_size();
    // Settling freezes every sine on the sheet — the numbers hold still.
    let t = if game.settling() { 1_000.0 } else { game.tick };

    clear_background(Color::new(0.030, 0.040, 0.058, 1.0));

    // Visible cell range.
    let c0 = ((game.cam.0 / cw).floor() as i32 - 1).max(0);
    let r0 = ((game.cam.1 / chh).floor() as i32 - 1).max(0);
    let c1 = (((game.cam.0 + sw) / cw).ceil() as i32 + 1).min(WORLD_COLS - 1);
    let r1 = (((game.cam.1 + sh) / chh).ceil() as i32 + 1).min(WORLD_ROWS - 1);

    let pop = if t - game.pop_at < 0.7 {
        1.0 - (t - game.pop_at) / 0.7
    } else {
        0.0
    };

    let font = game.active_font();
    let glow_level = game.settings.glow;

    // --- glyphs -----------------------------------------------------------
    for r in r0..=r1 {
        for c in c0..=c1 {
            let ch = game.char_at(r, c);
            if ch == ' ' {
                continue;
            }
            let cx = c as f32 * cw - game.cam.0 + cw * 0.5;
            let cy = r as f32 * chh - game.cam.1 + chh * 0.5;
            let phase = phase_of(r, c);

            // Which box owns this cell, if any?
            let mut rgb = FIELD_RGB;
            // Region fill keeps the box hue even on suspect cells.
            let mut fill_rgb = FIELD_RGB;
            let mut boxed = false;
            let mut selected = false;
            let mut refined = false;
            for b in &game.boxes {
                if b.cells.contains(&(r, c)) {
                    boxed = true;
                    rgb = b.color;
                    fill_rgb = b.color;
                    refined = b.refined;
                    if game.selected_id == Some(b.id) {
                        selected = true;
                    }
                }
            }

            let (mut scale, mut glow, deep);
            if selected {
                // The selection breathes hardest: big slow growth + full glow.
                let v = (t * 2.0 + phase).sin() * 0.5 + 0.5;
                scale = 0.92 + 0.50 * v;
                glow = 0.85 + 0.25 * v;
                rgb = SELECT_RGB;
                deep = true;
                if pop > 0.0 {
                    scale *= 1.0 + 0.45 * pop;
                    glow = (glow * (1.0 + 0.6 * pop)).min(1.5);
                }
            } else if boxed && refined {
                // Refined numbers are spent: calm, dim, in their box's hue.
                let v = (t * 0.4 + phase).sin() * 0.5 + 0.5;
                scale = 0.93 + 0.05 * v;
                glow = 0.16 + 0.06 * v;
                deep = false;
            } else if boxed {
                let v = (t * 1.15 + phase).sin() * 0.5 + 0.5;
                scale = 0.88 + 0.34 * v;
                glow = 0.55 + 0.40 * v;
                deep = true;
            } else if game.suspect_at(r, c) {
                // Suspect numbers flicker like a failing bulb.
                let p = (t * 2.6 + phase * 2.0).sin() * 0.5 + 0.5;
                let p = p * p; // sharpen
                scale = 0.97 + 0.24 * p;
                glow = 0.35 + 0.75 * p;
                rgb = SUSPECT_RGB;
                deep = true;
            } else {
                // Idle field: a barely-perceptible breath, no flashing.
                let v = (t * 0.45 + phase).sin() * 0.5 + 0.5;
                scale = 0.96 + 0.06 * v;
                glow = 0.22 + 0.10 * v;
                deep = false;
            }

            // Suspect yellows keep their hue even inside a box — boxing a
            // suspect number must not repaint it in the box's color.
            if boxed && game.suspect_at(r, c) {
                rgb = SUSPECT_RGB;
            }

            // Trail: hidden siblings of a freshly refined temper pulse on
            // the sheet — a map to follow, never a translation.
            if let Some(tt) = game.trail_temper
                && game.tick < game.trail_until
                && game.temper_at(r, c) == tt
            {
                let pulse = (game.tick * 3.0 + phase).sin() * 0.5 + 0.5;
                glow = (glow + 0.22 * pulse).min(1.5);
                scale += 0.06 * pulse;
            }

            // Wiggle: boxed numbers drift sideways mid-cycle (refined stay still).
            let mut dx = 0.0;
            if boxed && !refined {
                let amp = if selected { 2.4 } else { 1.1 };
                dx = (t * 5.0 + phase * 1.7).sin() * amp;
            }

            // Soft region fill so boxed areas read as one lit surface.
            if boxed {
                let a = if selected {
                    0.055
                } else if refined {
                    0.018
                } else {
                    0.035
                };
                draw_rectangle(
                    c as f32 * cw - game.cam.0,
                    r as f32 * chh - game.cam.1,
                    cw,
                    chh,
                    Color::new(fill_rgb[0], fill_rgb[1], fill_rgb[2], a),
                );
            }

            let txt = ch.to_string();
            let metrics = m.get(&ch).copied().unwrap_or(TextDimensions {
                width: GLYPH_BASE as f32 * 0.6,
                height: GLYPH_BASE as f32,
                offset_y: GLYPH_BASE as f32 * 0.8,
            });
            draw_glyph(
                &txt,
                cx + dx,
                cy,
                scale,
                glow,
                rgb,
                &metrics,
                deep,
                font,
                glow_level,
            );
        }
    }

    // --- box borders ------------------------------------------------------
    for b in &game.boxes {
        let is_sel = game.selected_id == Some(b.id);
        let pulse = (t * 6.0).sin() * 0.5 + 0.5;
        let rgb = if is_sel {
            // Selection border breathes between its hue and white.
            [
                b.color[0] + (1.0 - b.color[0]) * 0.55 * pulse,
                b.color[1] + (1.0 - b.color[1]) * 0.55 * pulse,
                b.color[2] + (1.0 - b.color[2]) * 0.55 * pulse,
            ]
        } else {
            b.color
        };
        // Refined boxes settle into a quiet, dimmed outline.
        let (core_th, glow_th, core_a, glow_a) = if is_sel {
            ((2.6 + 1.3 * pulse) * game.zoom, (10.0 + 5.0 * pulse) * game.zoom, 0.92, 0.10)
        } else if b.refined {
            (1.6 * game.zoom, 5.0 * game.zoom, 0.38, 0.04)
        } else {
            (2.4 * game.zoom, 8.5 * game.zoom, 0.92, 0.10)
        };

        for &(r, c) in &b.cells {
            // Skip cells far off-screen.
            let sx = c as f32 * cw - game.cam.0;
            let sy = r as f32 * chh - game.cam.1;
            if sx + cw < -20.0 || sy + chh < -20.0 || sx > sw + 20.0 || sy > sh + 20.0 {
                continue;
            }
            let x1 = sx + cw;
            let y1 = sy + chh;
            let edge = |ax: f32, ay: f32, bx: f32, by: f32| {
                draw_line(ax, ay, bx, by, glow_th, Color::new(rgb[0], rgb[1], rgb[2], glow_a));
                draw_line(ax, ay, bx, by, core_th, Color::new(rgb[0], rgb[1], rgb[2], core_a));
            };
            if !b.cells.contains(&(r - 1, c)) {
                edge(sx, sy, x1, sy);
            }
            if !b.cells.contains(&(r + 1, c)) {
                edge(sx, y1, x1, y1);
            }
            if !b.cells.contains(&(r, c - 1)) {
                edge(sx, sy, sx, y1);
            }
            if !b.cells.contains(&(r, c + 1)) {
                edge(x1, sy, x1, y1);
            }
        }

        // Selected boxes get an inner echo border.
        if is_sel {
            let inset = 5.0 * game.zoom;
            for &(r, c) in &b.cells {
                let sx = c as f32 * cw - game.cam.0 + inset;
                let sy = r as f32 * chh - game.cam.1 + inset;
                let x1 = sx + cw - inset * 2.0;
                let y1 = sy + chh - inset * 2.0;
                if x1 < sx || y1 < sy {
                    continue;
                }
                if x1 < -20.0 || y1 < -20.0 || sx > sw + 20.0 || sy > sh + 20.0 {
                    continue;
                }
                let a = 0.22 + 0.18 * pulse;
                let col = Color::new(rgb[0], rgb[1], rgb[2], a);
                let th = 1.4 * game.zoom;
                if !b.cells.contains(&(r - 1, c)) {
                    draw_line(sx, sy, x1, sy, th, col);
                }
                if !b.cells.contains(&(r + 1, c)) {
                    draw_line(sx, y1, x1, y1, th, col);
                }
                if !b.cells.contains(&(r, c - 1)) {
                    draw_line(sx, sy, sx, y1, th, col);
                }
                if !b.cells.contains(&(r, c + 1)) {
                    draw_line(x1, sy, x1, y1, th, col);
                }
            }
        }
    }

    // --- refined stamps ----------------------------------------------------
    for b in &game.boxes {
        if !b.refined {
            continue;
        }
        let (mut rmin, mut rmax, mut cmin, mut cmax) = (i32::MAX, i32::MIN, i32::MAX, i32::MIN);
        for &(r, c) in &b.cells {
            rmin = rmin.min(r);
            rmax = rmax.max(r);
            cmin = cmin.min(c);
            cmax = cmax.max(c);
        }
        let sx = cmin as f32 * cw - game.cam.0;
        let sy = rmin as f32 * chh - game.cam.1;
        if sx > sw || sy > sh || sx + cw < 0.0 || sy + chh < 0.0 {
            continue;
        }
        // Small "R" pill at the box's top-left corner.
        let pw = 17.0 * game.zoom.max(0.8);
        let ph = 15.0 * game.zoom.max(0.8);
        let (hx, hy, hw, hh) = (sx + 2.0, sy + 2.0, pw, ph);
        draw_rectangle(
            hx,
            hy,
            hw,
            hh,
            Color::new(b.color[0], b.color[1], b.color[2], 0.90),
        );
        draw_rectangle_lines(hx, hy, hw, hh, 1.2, Color::new(0.02, 0.03, 0.05, 0.9));
        let rm = measure_px(font, "R", GLYPH_BASE as f32 * 0.55);
        draw_text_px(
            font,
            "R",
            hx + (hw - rm.width) * 0.5,
            hy + (hh + rm.height * 0.5 - rm.offset_y) * 0.5,
            GLYPH_BASE as f32 * 0.55,
            Color::new(0.03, 0.05, 0.08, 1.0),
        );
    }

    // --- refinement output panel (right side) ------------------------------
    {
        let panel_w = 460.0f32;
        let margin = 14.0f32;
        let px0 = sw - panel_w - margin;
        let py0 = margin;
        let py1 = sh - 78.0 - margin;
        let ph = py1 - py0;

        draw_rectangle(px0, py0, panel_w, ph, Color::new(0.012, 0.020, 0.034, 0.88));
        draw_rectangle_lines(px0, py0, panel_w, ph, 1.5, Color::new(0.25, 0.70, 0.80, 0.55));

        let tx = px0 + 18.0;
        draw_text_px(
            font,
            "REFINEMENT OUTPUT",
            tx,
            py0 + 20.0,
            GLYPH_BASE as f32 * 1.05,
            Color::new(0.35, 0.85, 0.95, 1.0),
        );
        draw_text_px(
            font,
            "space grabs the selection",
            tx,
            py0 + 48.0,
            GLYPH_BASE as f32 * 0.74,
            Color::new(0.40, 0.50, 0.60, 1.0),
        );

        // --- quota bar ----------------------------------------------------
        let qtxt = format!(
            "SHIFT {}   QUOTA {} / {}",
            game.shift_no, game.shift_mass, game.quota_target
        );
        draw_text_px(
            font,
            &qtxt,
            tx,
            py0 + 72.0,
            GLYPH_BASE as f32 * 0.72,
            Color::new(0.70, 0.78, 0.86, 1.0),
        );
        let bar_x = tx - 4.0;
        let bar_y = py0 + 94.0;
        let bar_w = panel_w - 28.0;
        let bar_h = 14.0f32;
        draw_rectangle(bar_x, bar_y, bar_w, bar_h, Color::new(0.05, 0.08, 0.12, 1.0));
        let frac = (game.shift_mass as f32 / game.quota_target as f32).clamp(0.0, 1.0);
        let full = frac >= 1.0;
        let pulse = (t * 4.0).sin() * 0.5 + 0.5;
        let fill_c = if full {
            Color::new(1.0, 0.92, 0.45, 0.75 + 0.25 * pulse)
        } else {
            Color::new(0.30, 0.80, 0.90, 0.85)
        };
        if frac > 0.0 {
            draw_rectangle(bar_x, bar_y, bar_w * frac, bar_h, fill_c);
        }
        draw_rectangle_lines(bar_x, bar_y, bar_w, bar_h, 1.2, Color::new(0.25, 0.55, 0.65, 0.70));

        if game.refinements.is_empty() {
            draw_text_px(
                font,
                "no refinements yet.",
                tx,
                py0 + 140.0,
                GLYPH_BASE as f32 * 0.82,
                Color::new(0.45, 0.52, 0.60, 1.0),
            );
            draw_text_px(
                font,
                "box some numbers, then",
                tx,
                py0 + 170.0,
                GLYPH_BASE as f32 * 0.82,
                Color::new(0.45, 0.52, 0.60, 1.0),
            );
            draw_text_px(
                font,
                "press space to refine.",
                tx,
                py0 + 200.0,
                GLYPH_BASE as f32 * 0.82,
                Color::new(0.45, 0.52, 0.60, 1.0),
            );
        }

        // --- output entries with sigils ------------------------------------
        let mut ey = py0 + 124.0;
        for rf in &game.refinements {
            // Slide-in + flash for freshly filed entries.
            let age = t - rf.at;
            let anim = (age / 0.28).clamp(0.0, 1.0);
            let ease = 1.0 - (1.0 - anim).powi(3);
            let dx = -(1.0 - ease) * 70.0;
            let flash = if age < 0.45 { 1.0 - age / 0.45 } else { 0.0 };
            let eh = 78.0f32;
            if ey + eh > py1 - 6.0 {
                break;
            }

            let ex = tx + dx;
            // Flash bed for new entries.
            if flash > 0.0 {
                draw_rectangle(
                    tx - 10.0,
                    ey - 6.0,
                    panel_w - 20.0,
                    eh - 6.0,
                    Color::new(0.9, 0.95, 1.0, 0.10 * flash),
                );
            }
            // Left accent bar in the source box's hue.
            draw_rectangle(
                tx - 10.0,
                ey - 6.0,
                4.0,
                eh - 12.0,
                Color::new(rf.hue[0], rf.hue[1], rf.hue[2], 0.85),
            );

            // Sigil: the silhouette of the region you refined.
            let sig_px = 46.0f32;
            let s = sig_px / rf.sigil_n as f32;
            let sx0 = ex;
            let sy0 = ey - 2.0;
            draw_rectangle(
                sx0 - 1.0,
                sy0 - 1.0,
                sig_px + 2.0,
                sig_px + 2.0,
                Color::new(0.04, 0.07, 0.10, 0.9),
            );
            for sr in 0..rf.sigil_n {
                for sc in 0..rf.sigil_n {
                    if rf.sigil[sr * rf.sigil_n + sc] {
                        draw_rectangle(
                            sx0 + sc as f32 * s,
                            sy0 + sr as f32 * s,
                            s - 0.6,
                            s - 0.6,
                            Color::new(rf.hue[0], rf.hue[1], rf.hue[2], 0.92),
                        );
                    }
                }
            }
            draw_rectangle_lines(
                sx0 - 1.0,
                sy0 - 1.0,
                sig_px + 2.0,
                sig_px + 2.0,
                1.0,
                Color::new(rf.hue[0], rf.hue[1], rf.hue[2], 0.45),
            );

            let tc = ex + 60.0; // text column, right of the sigil
            let mc = mood_color(rf.mood);
            let l1 = format!("#{:03}   {}   {:.1}%", rf.index, rf.mood, rf.score);
            draw_text_px(
                font,
                &l1,
                tc,
                ey,
                GLYPH_BASE as f32 * 0.92,
                Color::new(0.90, 0.94, 1.00, 1.0),
            );
            // Recolor just the mood word: draw mood in its temper color on top.
            let head = format!("#{:03}   ", rf.index);
            let hm = measure_px(font, &head, GLYPH_BASE as f32 * 0.92);
            draw_text_px(
                font,
                rf.mood,
                tc + hm.width,
                ey,
                GLYPH_BASE as f32 * 0.92,
                Color::new(mc[0], mc[1], mc[2], 1.0),
            );

            let l2 = format!(
                "Σ {}  ·  {}d  ·  {}l  ·  dom '{}'",
                rf.digit_sum, rf.digit_count, rf.letter_count, rf.dominant
            );
            draw_text_px(
                font,
                &l2,
                tc,
                ey + 27.0,
                GLYPH_BASE as f32 * 0.70,
                Color::new(0.55, 0.63, 0.72, 1.0),
            );
            draw_text_px(
                font,
                rf.line,
                tc,
                ey + 51.0,
                GLYPH_BASE as f32 * 0.62,
                Color::new(0.48, 0.55, 0.64, 1.0),
            );

            ey += eh;
        }
    }

    // --- in-progress selection preview ------------------------------------
    let preview: Option<(Cell, Cell)> = if game.cursor_mode {
        game.anchor.map(|a| (a, game.cursor))
    } else {
        game.left_drag
    };
    if let Some((a, b)) = preview {
        let (r0, r1) = (a.0.min(b.0), a.0.max(b.0));
        let (c0, c1) = (a.1.min(b.1), a.1.max(b.1));
        let x0 = c0 as f32 * cw - game.cam.0;
        let y0 = r0 as f32 * chh - game.cam.1;
        let x1 = (c1 + 1) as f32 * cw - game.cam.0;
        let y1 = (r1 + 1) as f32 * chh - game.cam.1;
        draw_rectangle(
            x0,
            y0,
            x1 - x0,
            y1 - y0,
            Color::new(1.0, 0.92, 0.45, 0.06),
        );
        let pc = Color::new(1.0, 0.92, 0.45, 0.85);
        dashed_rect(x0, y0, x1, y1, 9.0, 7.0, 2.2, pc);
    }

    // --- keyboard box-cursor crosshair ------------------------------------
    if game.cursor_mode {
        let cx = game.cursor.1 as f32 * cw - game.cam.0 + cw * 0.5;
        let cy = game.cursor.0 as f32 * chh - game.cam.1 + chh * 0.5;
        let len = 13.0 * game.zoom.max(0.8);
        let th = 2.4 * game.zoom.max(0.8);
        let col = Color::new(0.30, 0.90, 1.00, 0.95);
        let glow_col = Color::new(0.30, 0.90, 1.00, 0.18);
        draw_line(cx - len, cy, cx + len, cy, th * 3.0, glow_col);
        draw_line(cx, cy - len, cx, cy + len, th * 3.0, glow_col);
        draw_line(cx - len, cy, cx + len, cy, th, col);
        draw_line(cx, cy - len, cx, cy + len, th, col);
    }

    // --- CRT scanlines (subtle, toggleable) --------------------------------
    if game.settings.scanlines {
        let mut y = 0.0;
        while y < sh {
            draw_rectangle(0.0, y, sw, 1.0, Color::new(0.0, 0.0, 0.0, 0.045));
            y += 3.0;
        }
    }

    // --- HUD --------------------------------------------------------------
    draw_rectangle(0.0, sh - 78.0, sw, 78.0, Color::new(0.010, 0.016, 0.028, 0.88));

    let mode_tag = if game.cursor_mode { "   MODE: BOX-CURSOR" } else { "" };
    let title = "LUMON // MACRODATA REFINE";
    let fs1 = GLYPH_BASE as f32 * 0.82;
    let tm = measure_px(font, title, fs1);
    draw_text_px(
        font,
        title,
        16.0,
        sh - 78.0 + 28.0,
        fs1,
        Color::new(0.35, 0.85, 0.95, 1.0),
    );

    let cell_x = (game.cam.0 / cw).floor() as i32;
    let cell_y = (game.cam.1 / chh).floor() as i32;
    let stats = format!(
        "SHIFT {}  BOXES: {}  ENCLOSED: {}  REFINED: {}  VIEW: ({}, {})  ZOOM: {:.2}x{}",
        game.shift_no,
        game.boxes.len(),
        game.enclosed_total(),
        game.next_refine - 1,
        cell_x,
        cell_y,
        game.zoom,
        mode_tag,
    );
    let stats_x = 16.0 + tm.width + 28.0;
    draw_text_px(
        font,
        &stats,
        stats_x,
        sh - 78.0 + 28.0,
        fs1,
        Color::new(0.55, 0.65, 0.75, 1.0),
    );

    if t - game.status_at < 2.4 && !game.status.is_empty() {
        let sm = measure_px(font, &stats, fs1);
        draw_text_px(
            font,
            &format!("  >> {}", game.status),
            stats_x + sm.width + 24.0,
            sh - 78.0 + 28.0,
            fs1,
            Color::new(1.0, 0.85, 0.30, 1.0),
        );
    }

    let hint = "L-drag: draw   R-drag: pan   wheel: zoom   space: refine   b: box-cursor   ,: settings   z: undo   c: clear   q: quit";
    draw_text_px(
        font,
        hint,
        16.0,
        sh - 78.0 + 58.0,
        GLYPH_BASE as f32 * 0.68,
        Color::new(0.42, 0.50, 0.60, 1.0),
    );

    // --- shift-complete settle overlay -------------------------------------
    if game.settling() {
        let prog = ((game.tick - (game.settle_until - SETTLE_SECS)) / SETTLE_SECS).clamp(0.0, 1.0);
        draw_rectangle(
            0.0,
            0.0,
            sw,
            sh,
            Color::new(0.0, 0.0, 0.0, 0.35 + 0.30 * prog),
        );
        let big = GLYPH_BASE as f32 * 2.4;
        let m1 = measure_px(font, "SHIFT COMPLETE", big);
        let y1 = sh * 0.5 - 46.0 + m1.offset_y;
        draw_text_px(
            font,
            "SHIFT COMPLETE",
            (sw - m1.width) * 0.5,
            y1,
            big,
            Color::new(0.35, 0.85, 0.95, 1.0),
        );
        let sub = format!(
            "quota {}/{} met  ·  sheet {} incoming",
            game.shift_mass,
            game.quota_target,
            game.shift_no + 1
        );
        let fs2 = GLYPH_BASE as f32 * 0.95;
        let m2 = measure_px(font, &sub, fs2);
        let y2 = sh * 0.5 + 16.0 + m2.offset_y;
        draw_text_px(
            font,
            &sub,
            (sw - m2.width) * 0.5,
            y2,
            fs2,
            Color::new(0.55, 0.65, 0.75, 1.0),
        );
    }

    // --- settings panel ----------------------------------------------------
    if game.settings_open {
        render_settings(game, font, sw, sh);
    }
}

/// Modal settings panel rect (x, y, w, h) — single source of truth so the
/// render and the input hit-tests can never drift apart.
fn settings_panel(sw: f32, sh: f32) -> (f32, f32, f32, f32) {
    let w = 560.0f32;
    let h = 400.0f32;
    (
        (sw - w) * 0.5,
        (sh - h) * 0.5,
        w,
        h,
    )
}

/// Geometry of the four settings rows inside the panel.
/// Returns (x, y, w, h) per row: 0 font, 1 glow, 2 scanlines, 3 sound.
fn settings_rows(sw: f32, sh: f32) -> [(f32, f32, f32, f32); 4] {
    let (px, py, pw, _ph) = settings_panel(sw, sh);
    let row_w = pw - 48.0;
    let row_x = px + 24.0;
    let row_h = 48.0f32;
    let top = py + 78.0;
    let row = |i: usize| (row_x, top + i as f32 * (row_h + 10.0), row_w, row_h);
    [row(0), row(1), row(2), row(3)]
}

/// Draw text vertically centered inside a rect, left- or right-aligned.
///
/// macroquad anchors `y` at the baseline (text top = y - offset_y), so the
/// rect center is converted to a baseline using the measured metrics — this
/// keeps every label optically centered in its row for any font.
fn panel_text(
    font: Option<&Font>,
    txt: &str,
    rect: (f32, f32, f32, f32),
    target_px: f32,
    color: Color,
    right: bool,
) {
    let (rx, ry, rw, rh) = rect;
    let m = measure_px(font, txt, target_px);
    let y = ry + (rh - m.height) * 0.5 + m.offset_y;
    let x = if right {
        (rx + rw - m.width - 18.0).max(rx + 150.0)
    } else {
        rx + 18.0
    };
    draw_text_px(font, txt, x, y, target_px, color);
}

fn render_settings(game: &Game, font: Option<&Font>, sw: f32, sh: f32) {
    let (px, py, pw, ph) = settings_panel(sw, sh);

    // Dim the scene behind the panel.
    draw_rectangle(0.0, 0.0, sw, sh, Color::new(0.0, 0.0, 0.0, 0.45));
    draw_rectangle(px, py, pw, ph, Color::new(0.012, 0.020, 0.034, 0.97));
    draw_rectangle_lines(px, py, pw, ph, 2.0, Color::new(0.35, 0.85, 0.95, 0.80));

    // Title centered in its header band.
    panel_text(
        font,
        "SETTINGS",
        (px, py + 10.0, pw, 48.0),
        GLYPH_BASE as f32 * 1.15,
        Color::new(0.35, 0.85, 0.95, 1.0),
        false,
    );

    let rows = settings_rows(sw, sh);
    let labels = ["FONT", "GLOW", "SCANLINES", "SOUND"];
    let values = [
        FONT_NAMES[game.settings.font].to_string(),
        GLOW_NAMES[game.settings.glow].to_string(),
        if game.settings.scanlines {
            "on".to_string()
        } else {
            "off".to_string()
        },
        if game.settings.sound {
            "on".to_string()
        } else {
            "off".to_string()
        },
    ];

    for i in 0..4 {
        let rect = rows[i];
        let (rx, ry, rw, rh) = rect;
        let focused = game.settings_focus == i;
        if focused {
            draw_rectangle(rx, ry, rw, rh, Color::new(0.30, 0.75, 0.85, 0.16));
            draw_rectangle_lines(rx, ry, rw, rh, 1.5, Color::new(0.35, 0.85, 0.95, 0.75));
        }
        let label_c = if focused {
            Color::new(0.90, 0.96, 1.00, 1.0)
        } else {
            Color::new(0.55, 0.63, 0.72, 1.0)
        };
        let value_c = if focused {
            Color::new(1.0, 0.85, 0.30, 1.0)
        } else {
            Color::new(0.70, 0.78, 0.86, 1.0)
        };
        panel_text(font, labels[i], rect, GLYPH_BASE as f32 * 0.85, label_c, false);
        // Value with arrows as a change affordance when focused.
        let vtxt = if focused {
            format!("<  {}  >", values[i])
        } else {
            values[i].clone()
        };
        panel_text(font, &vtxt, rect, GLYPH_BASE as f32 * 0.85, value_c, true);
    }

    // Footers inside the panel, spaced by measured height.
    panel_text(
        font,
        "click a row or arrows to change  ·  , or esc closes",
        (px, py + ph - 60.0, pw, 22.0),
        GLYPH_BASE as f32 * 0.66,
        Color::new(0.42, 0.50, 0.60, 1.0),
        false,
    );
    panel_text(
        font,
        "settings are saved automatically",
        (px, py + ph - 32.0, pw, 20.0),
        GLYPH_BASE as f32 * 0.60,
        Color::new(0.35, 0.42, 0.50, 1.0),
        false,
    );
}

// ---------------------------------------------------------------------------
// Main loop
// ---------------------------------------------------------------------------

async fn run() {
    // Embedded TTF fonts (index 0 in the picker is the built-in bitmap).
    let ttf: [&[u8]; 4] = [
        include_bytes!("../assets/fonts/JetBrainsMono-Regular.ttf"),
        include_bytes!("../assets/fonts/SpaceMono-Regular.ttf"),
        include_bytes!("../assets/fonts/IBMPlexMono-Regular.ttf"),
        include_bytes!("../assets/fonts/FiraMono-Regular.ttf"),
    ];
    let mut fonts: Vec<Option<Font>> = vec![None];
    for bytes in ttf {
        fonts.push(load_ttf_font_from_bytes(bytes).ok());
    }

    let mut game = Game::new(fonts);
    game.sfx = Sfx::load(game.settings.sound).await;

    // Glyph metrics cache (one entry per distinct character), rebuilt when
    // the active font changes.
    let charset: Vec<char> = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ".chars().collect();
    let build_metrics = |font: Option<&Font>| -> HashMap<char, TextDimensions> {
        charset
            .iter()
            .map(|&ch| {
                let txt = ch.to_string();
                (ch, measure_text(&txt, font, GLYPH_BASE, 1.0))
            })
            .collect()
    };
    let mut metrics = build_metrics(game.active_font());
    let mut metrics_font = game.settings.font;

    loop {
        game.tick = get_time() as f32;
        game.frames += 1;
        let (sw, sh) = (screen_width(), screen_height());
        let (mx, my) = mouse_position();

        if game.settings.font != metrics_font {
            metrics = build_metrics(game.active_font());
            metrics_font = game.settings.font;
        }

        // Quit works in every phase.
        if is_key_pressed(KeyCode::Q) {
            break;
        }

        // --- settings panel (blocks game input while open) ----------------
        if game.settings_open {
            if is_key_pressed(KeyCode::Comma) || is_key_pressed(KeyCode::Escape) {
                game.settings_open = false;
                game.settings.save();
            }
            if is_key_pressed(KeyCode::Up) {
                game.settings_focus = (game.settings_focus + 3) % 4;
            }
            if is_key_pressed(KeyCode::Down) {
                game.settings_focus = (game.settings_focus + 1) % 4;
            }
            let mut changed = false;
            if is_key_pressed(KeyCode::Left) {
                cycle_setting(&mut game, -1);
                changed = true;
            }
            if is_key_pressed(KeyCode::Right) || is_key_pressed(KeyCode::Enter) {
                cycle_setting(&mut game, 1);
                changed = true;
            }
            if is_mouse_button_pressed(MouseButton::Left) {
                for (i, (rx, ry, rw, rh)) in settings_rows(sw, sh).into_iter().enumerate() {
                    if mx >= rx && mx <= rx + rw && my >= ry && my <= ry + rh {
                        game.settings_focus = i;
                        cycle_setting(&mut game, 1);
                        changed = true;
                    }
                }
            }
            if changed {
                game.settings.save();
            }
        } else if game.phase == Phase::Work {
        // --- zoom (scroll wheel, anchored at the cursor) ------------------
        let (_, wheel) = mouse_wheel();
        // Sanity guard: real notches are ~±1; ignore noisy environments.
        if wheel.abs() > 0.01 && wheel.abs() < 4.0 && !game.right_drag {
            let old = game.zoom;
            game.zoom = (game.zoom * (1.0 + wheel * 0.10)).clamp(ZOOM_MIN, ZOOM_MAX);
            let wu = ((mx + game.cam.0) / old, (my + game.cam.1) / old);
            game.cam = (wu.0 * game.zoom - mx, wu.1 * game.zoom - my);
        }

        // --- right-drag pan ------------------------------------------------
        if is_mouse_button_pressed(MouseButton::Right) {
            game.right_drag = true;
            game.last_mouse = (mx, my);
        }
        if game.right_drag && is_mouse_button_down(MouseButton::Right) {
            game.cam.0 -= mx - game.last_mouse.0;
            game.cam.1 -= my - game.last_mouse.1;
            game.last_mouse = (mx, my);
        }
        if is_mouse_button_released(MouseButton::Right) {
            game.right_drag = false;
        }

        // --- left selection (polled — no reliance on drag-motion events) --
        if !game.cursor_mode {
            let cell_here = game.cell_at(mx, my);
            let down = is_mouse_button_down(MouseButton::Left);
            let released = is_mouse_button_released(MouseButton::Left);

            if is_mouse_button_pressed(MouseButton::Left) {
                game.left_drag = Some((cell_here, cell_here));
            }
            if down && let Some(slot) = &mut game.left_drag {
                slot.1 = cell_here;
            }
            if released && let Some((a, _)) = game.left_drag.take() {
                // The release position always defines the far corner.
                game.commit_box(a, cell_here);
            }
            // The preview may only exist while the button is physically held.
            // WM focus quirks can replay presses without releases; anything
            // not backed by a real held button is dropped.
            if !down {
                game.left_drag = None;
            }
        }

        // --- keyboard ------------------------------------------------------
        if is_key_pressed(KeyCode::Escape) {
            if game.cursor_mode {
                game.cursor_mode = false;
                game.anchor = None;
                game.left_drag = None;
                game.flash("normal mode");
            } else {
                break;
            }
        }
        if is_key_pressed(KeyCode::Z) {
            game.undo_last();
        }
        if is_key_pressed(KeyCode::C) {
            game.clear_boxes();
        }
        if is_key_pressed(KeyCode::Space) && !game.cursor_mode {
            // Grab the selection: file it as refinement output.
            game.grab_selection();
        }
        if is_key_pressed(KeyCode::B) {
            game.cursor_mode = !game.cursor_mode;
            game.anchor = None;
            game.left_drag = None;
            if game.cursor_mode {
                game.cursor = game.cell_at(sw * 0.5, sh * 0.5);
                game.flash("box cursor: arrows move, space anchors, enter draws, esc exits");
            }
        }
        if is_key_pressed(KeyCode::Home) {
            game.cam = (0.0, 0.0);
        }

        let big = is_key_down(KeyCode::LeftShift) || is_key_down(KeyCode::RightShift);

        if game.cursor_mode {
            // Cursor movement in cells.
            let cs = if big { 4 } else { 1 };
            let mut moved = false;
            if is_key_pressed(KeyCode::Left) || is_key_pressed(KeyCode::A) {
                game.cursor.1 -= cs;
                moved = true;
            }
            if is_key_pressed(KeyCode::Right) || is_key_pressed(KeyCode::D) {
                game.cursor.1 += cs;
                moved = true;
            }
            if is_key_pressed(KeyCode::Up) || is_key_pressed(KeyCode::W) {
                game.cursor.0 -= cs;
                moved = true;
            }
            if is_key_pressed(KeyCode::Down) || is_key_pressed(KeyCode::S) {
                game.cursor.0 += cs;
                moved = true;
            }
            if moved {
                game.cursor = (
                    game.cursor.0.clamp(0, WORLD_ROWS - 1),
                    game.cursor.1.clamp(0, WORLD_COLS - 1),
                );
                if let Some(a) = game.anchor {
                    game.left_drag = Some((a, game.cursor));
                }
            }
            if is_key_pressed(KeyCode::Space) {
                if game.anchor == Some(game.cursor) {
                    game.anchor = None;
                    game.left_drag = None;
                    game.flash("anchor cleared");
                } else {
                    game.anchor = Some(game.cursor);
                    game.left_drag = Some((game.cursor, game.cursor));
                    game.flash("anchor set - move, enter draws");
                }
            }
            if is_key_pressed(KeyCode::Enter) {
                if let Some(a) = game.anchor.take() {
                    game.left_drag = None;
                    game.commit_box(a, game.cursor);
                } else {
                    game.flash("space first to anchor");
                }
            }
        } else {
            // Camera pan in pixels (free, not grid-snapped).
            let step = if big { 240.0 } else { 60.0 };
            if is_key_down(KeyCode::Left) || is_key_down(KeyCode::A) {
                game.cam.0 -= step;
            }
            if is_key_down(KeyCode::Right) || is_key_down(KeyCode::D) {
                game.cam.0 += step;
            }
            if is_key_down(KeyCode::Up) || is_key_down(KeyCode::W) {
                game.cam.1 -= step;
            }
            if is_key_down(KeyCode::Down) || is_key_down(KeyCode::S) {
                game.cam.1 += step;
            }
        }

        if is_key_pressed(KeyCode::Comma) && game.phase == Phase::Work {
            game.settings_open = true;
            game.settings_focus = 0;
        }
        } // end input blocked while settings open

        // Quota met: the settle finishes, a fresh sheet loads.
        if game.phase == Phase::Settling && game.tick >= game.settle_until {
            game.next_shift();
        }

        game.clamp_cam(sw, sh);
        render(&game, &metrics);

        // Headless smoke test: run a few seconds of frames, then exit cleanly.
        if std::env::var("LUMON_SMOKE").is_ok() && game.frames > 150 {
            break;
        }

        next_frame().await;
    }

    game.settings.save();
}

/// Cycle the focused setting. `dir` is -1 or +1.
fn cycle_setting(game: &mut Game, dir: i32) {
    match game.settings_focus {
        0 => {
            let n = FONT_NAMES.len() as i32;
            game.settings.font = ((game.settings.font as i32 + dir).rem_euclid(n)) as usize;
        }
        1 => {
            let n = GLOW_NAMES.len() as i32;
            game.settings.glow = ((game.settings.glow as i32 + dir).rem_euclid(n)) as usize;
        }
        2 => game.settings.scanlines = !game.settings.scanlines,
        _ => {
            game.settings.sound = !game.settings.sound;
            game.sfx.enabled = game.settings.sound;
        }
    }
    // A tick confirms every change — including re-enabling sound, which you
    // should hear immediately.
    game.sfx.play(SfxId::Tick, 0.6);
}

fn window_conf() -> Conf {
    Conf {
        window_title: "Lumon — Macrodata Refine".to_owned(),
        window_width: 1280,
        window_height: 800,
        high_dpi: true,
        sample_count: 4,
        ..Default::default()
    }
}

#[macroquad::main(window_conf)]
async fn main() {
    run().await;
}

// ---------------------------------------------------------------------------
// Headless logic tests — no window, no display.
// ---------------------------------------------------------------------------

#[cfg(test)]
mod tests {
    use super::*;

    fn game() -> Game {
        Game::new(vec![None])
    }

    #[test]
    fn field_is_deterministic_and_tagged() {
        let (a1, s1, t1) = generate_field(BASE_SEED);
        let (a2, s2, t2) = generate_field(BASE_SEED);
        assert_eq!(a1, a2);
        assert_eq!(s1, s2);
        assert_eq!(t1, t2);
        assert!(
            t1.iter().any(|&t| t != TEMPER_NONE),
            "field should contain temper clusters"
        );
        let (a3, _, _) = generate_field(BASE_SEED ^ 1);
        assert_ne!(a1, a3, "a different seed must produce a different sheet");
    }

    #[test]
    fn components_split_disconnected_sets() {
        let mut set = HashSet::new();
        set.insert((0, 0));
        set.insert((0, 1));
        set.insert((5, 5));
        assert_eq!(connected_components(&set).len(), 2);
    }

    #[test]
    fn quota_grows_each_shift() {
        assert_eq!(quota_target_for(1), QUOTA_BASE);
        assert_eq!(quota_target_for(2), QUOTA_BASE + QUOTA_STEP);
    }

    #[test]
    fn carve_removes_only_subset_and_splits() {
        let mut g = game();
        g.commit_box((2, 2), (5, 5));
        assert_eq!(g.boxes.len(), 1);
        assert_eq!(g.boxes[0].cells.len(), 16);

        // Carve the middle column: the 4x4 splits into two strips.
        g.commit_box((2, 3), (5, 3));
        assert_eq!(g.boxes.len(), 2, "carve should split the box in two");
        let total: usize = g.boxes.iter().map(|b| b.cells.len()).sum();
        assert_eq!(total, 12, "only the carved column should be gone");
        assert!(g.boxes.iter().any(|b| b.cells.contains(&(2, 2))));
        assert!(!g.boxes.iter().any(|b| b.cells.contains(&(2, 3))));
    }

    #[test]
    fn exact_reselect_toggles_off() {
        let mut g = game();
        g.commit_box((1, 1), (3, 4));
        assert_eq!(g.boxes.len(), 1);
        g.commit_box((1, 1), (3, 4));
        assert!(g.boxes.is_empty());
    }

    #[test]
    fn refine_reads_temper_and_feeds_quota() {
        let mut g = game();
        // Find a 2x2 block tagged with a single temper.
        let mut found: Option<(Cell, u8)> = None;
        'outer: for r in 0..WORLD_ROWS - 2 {
            for c in 0..WORLD_COLS - 2 {
                let t = g.temper_at(r, c);
                if t != TEMPER_NONE
                    && g.temper_at(r, c + 1) == t
                    && g.temper_at(r + 1, c) == t
                    && g.temper_at(r + 1, c + 1) == t
                {
                    found = Some(((r, c), t));
                    break 'outer;
                }
            }
        }
        let ((r, c), t) = found.expect("field should contain temper clusters");
        g.commit_box((r, c), (r + 1, c + 1));
        g.grab_selection();
        assert_eq!(g.refinements.len(), 1);
        let rf = &g.refinements[0];
        assert_eq!(
            rf.mood, TEMPERS[t as usize].0,
            "mood must come from the hidden tag, not the hash"
        );
        assert!(g.trail_temper.is_some(), "temper hit should start a trail");
        assert!(g.shift_mass > 0, "refining should feed the quota");
        assert_eq!(rf.sigil.len(), SIGIL_N * SIGIL_N);
    }

    #[test]
    fn quota_met_settles_shift_and_reloads() {
        let mut g = game();
        g.quota_target = 1; // tiny quota for the test
        g.commit_box((0, 0), (1, 1));
        g.grab_selection();
        assert_eq!(g.phase, Phase::Settling);
        g.next_shift();
        assert_eq!(g.phase, Phase::Work);
        assert_eq!(g.shift_no, 2);
        assert_eq!(g.shift_mass, 0);
        assert!(g.boxes.is_empty(), "a new sheet starts empty");
    }

    #[test]
    fn sigil_captures_shape() {
        let mut solid = HashSet::new();
        for r in 0..4 {
            for c in 0..4 {
                solid.insert((r, c));
            }
        }
        let cells: Vec<Cell> = solid.iter().copied().collect();
        let (bits, n) = sigil_of(&cells);
        assert_eq!(n, SIGIL_N);
        assert!(bits.iter().all(|&b| b), "a solid square -> solid sigil");

        let mut ell = HashSet::new();
        for r in 0..4 {
            ell.insert((r, 0));
            ell.insert((3, r));
        }
        let cells: Vec<Cell> = ell.iter().copied().collect();
        let (bits, _) = sigil_of(&cells);
        assert!(
            bits.iter().any(|&b| b) && bits.iter().any(|&b| !b),
            "an L-shape -> mixed sigil"
        );
    }
}