cuqueclicker 0.6.5

A TUI idle clicker where you finger an ASCII ass instead of clicking a cookie.
Documentation
//! Platform-agnostic simulation core.
//!
//! Owns the [`Action`] / [`BuyQty`] types (the input router produces them;
//! [`apply_action`] is the only thing that interprets them) and the per-tick
//! `state.tick()` + ambient spawn helpers.
//!
//! What lives **outside** this module:
//! - the threaded sim loop on native (`app.rs::sim_loop`), which wraps
//!   [`sim_tick`] + [`apply_action`] with `mpsc::recv_timeout`, save
//!   scheduling via the [`Persistence`](crate::platform::Persistence) impl,
//!   and the demo-recorder driver.
//! - the requestAnimationFrame-driven loop on web (added when the wasm
//!   port lands), which calls the same [`sim_tick`] + [`apply_action`]
//!   single-threaded.
//!
//! The split is: this module is cross-platform; threading + I/O scheduling
//! around it isn't. See tracking issue #13 for rationale.

use rand::RngExt;
use ratatui::layout::Rect;

use crate::game::golden::{self, GoldenVariant};
use crate::game::green_coin;
use crate::game::state::{GameState, TICK_DT};

/// Buy quantity for a fingerer purchase action. Modifier-key meaning is
/// translated to this in the input router; sim only consumes the resolved
/// value so the modifier mapping can change without touching tick logic.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum BuyQty {
    One,
    Ten,
    Max,
}

/// Commands the input router produces and the sim consumes. The sim is
/// the sole authority on [`GameState`] mutation — input handling translates
/// raw events (key/mouse/wheel) into these and feeds them through.
#[derive(Clone, Debug)]
pub enum Action {
    Click {
        col: u16,
        row: u16,
    },
    ClickCenter,
    /// Catch the Golden Cuque of the given variant. Each variant has its
    /// own independent on-screen slot, so this only catches the targeted
    /// one — never vacuums up a neighbor.
    CatchGolden(GoldenVariant),
    /// Catch the on-screen Green Coin specifically.
    CatchGreenCoin,
    BuyFingerer {
        idx: usize,
        qty: BuyQty,
    },
    BuyUpgrade(usize),
    PrestigeReset,
    /// Latest render-computed biscuit geometry, so the sim can place goldens
    /// and auto-particles inside the current layout. The golden rect lives
    /// on the input/render side (only the click handler reads it).
    UpdateGeometry {
        biscuit: Rect,
    },
    /// Dev-only cheats (F-keys). Gated at the input router by `debug`;
    /// the sim trusts whatever arrives.
    DevAddCuques(f64),
    DevForceGolden(GoldenVariant),
    /// Force-spawn a Green Coin (F5 in dev). Bypasses the spawn pity
    /// counter and the golden-spawn-event tie-in entirely.
    DevSpawnGreenCoin,
    /// J10: a click that didn't hit anything actionable. Sim spawns a
    /// short-lived "·" misclick particle at the screen point so dead-zone
    /// clicks visibly register.
    Misclick {
        col: u16,
        row: u16,
    },
}

/// Geometry the sim needs to interpret screen-space events. Updated on
/// every render via [`Action::UpdateGeometry`].
#[derive(Clone, Copy, Default)]
pub struct SimGeometry {
    pub biscuit: Rect,
}

/// Apply one [`Action`] to the canonical [`GameState`]. Pure data: no I/O,
/// no time, no threading. Called from both the native sim thread (on
/// `mpsc::recv_timeout` returning Ok) and the web rAF loop.
pub fn apply_action(state: &mut GameState, action: Action, geom: &mut SimGeometry) {
    match action {
        Action::Click { col, row } => {
            let r = geom.biscuit;
            if r.width > 0
                && col >= r.x
                && col < r.x + r.width
                && row >= r.y
                && row < r.y + r.height
            {
                state.click((col, row), r);
            }
        }
        Action::ClickCenter => {
            let r = geom.biscuit;
            if r.width > 0 && r.height > 0 {
                state.click((r.x + r.width / 2, r.y + r.height / 2), r);
            }
            // Mark this tick as "saw a spacebar press." `tick()` reads the
            // flag, advances the held-streak counter, and clears it. A
            // single tap → 1 tick of streak → resets immediately. A held
            // key (terminal repeat) → streak climbs over time.
            state.space_pressed_this_tick = true;
        }
        Action::CatchGolden(variant) => {
            state.catch_golden(variant);
        }
        Action::CatchGreenCoin => {
            state.catch_green_coin();
        }
        Action::BuyFingerer { idx, qty } => match qty {
            BuyQty::One => {
                state.buy(idx);
            }
            BuyQty::Ten => {
                state.buy_n(idx, 10);
            }
            BuyQty::Max => {
                state.buy_max(idx);
            }
        },
        Action::BuyUpgrade(idx) => {
            state.buy_upgrade(idx);
        }
        Action::PrestigeReset => {
            state.prestige_reset();
        }
        Action::UpdateGeometry { biscuit } => {
            *geom = SimGeometry { biscuit };
        }
        Action::DevAddCuques(n) => {
            state.dev_add_cuques(n);
        }
        Action::DevForceGolden(variant) => {
            force_spawn_golden(state, geom, variant);
        }
        Action::DevSpawnGreenCoin => {
            force_spawn_green_coin(state, geom);
        }
        Action::Misclick { col, row } => {
            state.spawn_misclick(col, row);
        }
    }
}

/// Run the platform-agnostic body of one sim tick: state updates + ambient
/// spawn helpers. Save scheduling and demo-driver autopilot are the
/// **caller's** concern (they live in `app.rs::sim_loop` on native).
pub fn sim_tick(state: &mut GameState, geom: &SimGeometry) {
    state.tick();
    state.tick_golden();
    state.tick_green_coin();
    maybe_spawn_golden(state, geom);
    maybe_spawn_auto_particle(state, geom);
    maybe_idle_clench(state);
}

fn maybe_idle_clench(state: &mut GameState) {
    if state.clench_ticks > 0 {
        return;
    }
    // ~1 per 45s average at 20Hz
    if rand::rng().random::<f64>() < 1.0 / 900.0 {
        state.trigger_clench();
    }
}

fn maybe_spawn_auto_particle(state: &mut GameState, geom: &SimGeometry) {
    let fps = state.fps();
    if fps <= 0.0 || geom.biscuit.width < 4 || geom.biscuit.height < 4 {
        return;
    }
    let target_rate = fps.sqrt().clamp(0.5, 8.0);
    let prob = target_rate * TICK_DT;
    let mut rng = rand::rng();
    if rng.random::<f64>() >= prob {
        return;
    }
    // Random anchor within the biscuit, with a small inset so the "+N" text
    // doesn't clip into the border.
    let frac_x = rng.random_range(0.05_f32..=0.95);
    let frac_y = rng.random_range(0.10_f32..=0.95);
    state.spawn_auto_particle(frac_x, frac_y);
}

fn maybe_spawn_golden(state: &mut GameState, geom: &SimGeometry) {
    if geom.biscuit.width < 8 || geom.biscuit.height < 5 {
        return;
    }
    // Each variant runs on its own clock — find the variants whose
    // cooldown has hit zero and whose slot is empty, and spawn each.
    // Cooldown reset for that variant happens here too, so a stuck
    // zero-cooldown can't re-spawn every tick.
    for variant in GoldenVariant::ALL {
        let i = variant as usize;
        if state.golden_cooldowns[i] > 0 || state.goldens[i].is_some() {
            continue;
        }
        let mut g = golden::spawn_in(geom.biscuit);
        g.variant = variant;
        state.goldens[i] = Some(g);
        state.golden_cooldowns[i] = crate::game::golden::next_cooldown();

        // Green Coin spawn pity: each regular Golden spawn bumps the
        // chance by 1%. Counter resets the moment a Green Coin appears,
        // regardless of whether the player catches it or it expires.
        state.goldens_since_green_coin = state.goldens_since_green_coin.saturating_add(1);
        if state.green_coin.is_none() {
            let p = state.goldens_since_green_coin as f64 * 0.01;
            if rand::rng().random::<f64>() < p {
                state.green_coin = Some(green_coin::spawn_in(geom.biscuit));
                state.goldens_since_green_coin = 0;
            }
        }
    }
}

fn force_spawn_golden(state: &mut GameState, geom: &SimGeometry, variant: GoldenVariant) {
    if geom.biscuit.width < 8 || geom.biscuit.height < 5 {
        return;
    }
    // Per-variant slot: F-key cheats only spawn into their own slot —
    // pressing F2 (Frenzy) never displaces an active Lucky, etc. If
    // that slot is already occupied, the press is a no-op.
    if state.goldens[variant as usize].is_some() {
        return;
    }
    let mut g = golden::spawn_in(geom.biscuit);
    g.variant = variant;
    state.goldens[variant as usize] = Some(g);
}

fn force_spawn_green_coin(state: &mut GameState, geom: &SimGeometry) {
    if state.green_coin.is_some() {
        return;
    }
    if geom.biscuit.width < 8 || geom.biscuit.height < 5 {
        return;
    }
    state.green_coin = Some(green_coin::spawn_in(geom.biscuit));
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::game::state::GameState;
    use ratatui::layout::Rect;

    fn geom_with_biscuit() -> SimGeometry {
        SimGeometry {
            biscuit: Rect::new(0, 0, 40, 20),
        }
    }

    #[test]
    fn force_spawn_does_not_clobber_other_variants() {
        // Regression: prior to per-variant slots, pressing F2 (Frenzy)
        // would replace an existing Lucky on screen. With per-variant
        // slots, every variant has independent state.
        let mut state = GameState::default();
        let geom = geom_with_biscuit();
        force_spawn_golden(&mut state, &geom, GoldenVariant::Lucky);
        force_spawn_golden(&mut state, &geom, GoldenVariant::Frenzy);
        force_spawn_golden(&mut state, &geom, GoldenVariant::Buff);
        assert!(state.goldens[GoldenVariant::Lucky as usize].is_some());
        assert!(state.goldens[GoldenVariant::Frenzy as usize].is_some());
        assert!(state.goldens[GoldenVariant::Buff as usize].is_some());
    }

    #[test]
    fn catch_golden_only_consumes_targeted_variant() {
        let mut state = GameState::default();
        let geom = geom_with_biscuit();
        force_spawn_golden(&mut state, &geom, GoldenVariant::Lucky);
        force_spawn_golden(&mut state, &geom, GoldenVariant::Frenzy);
        // Catching Lucky leaves Frenzy alone.
        state.catch_golden(GoldenVariant::Lucky);
        assert!(state.goldens[GoldenVariant::Lucky as usize].is_none());
        assert!(state.goldens[GoldenVariant::Frenzy as usize].is_some());
    }
}