Skip to main content

cuqueclicker_lib/
sim.rs

1//! Platform-agnostic simulation core.
2//!
3//! Owns the [`Action`] / [`BuyQty`] types (the input router produces them;
4//! [`apply_action`] is the only thing that interprets them) and the per-tick
5//! `state.tick()` + ambient spawn helpers.
6//!
7//! What lives **outside** this module:
8//! - the threaded sim loop on native (`app.rs::sim_loop`), which wraps
9//!   [`sim_tick`] + [`apply_action`] with `mpsc::recv_timeout`, save
10//!   scheduling via the [`Persistence`](crate::platform::Persistence) impl,
11//!   and the demo-recorder driver.
12//! - the requestAnimationFrame-driven loop on web (added when the wasm
13//!   port lands), which calls the same [`sim_tick`] + [`apply_action`]
14//!   single-threaded.
15//!
16//! The split is: this module is cross-platform; threading + I/O scheduling
17//! around it isn't. See tracking issue #13 for rationale.
18
19use rand::RngExt;
20use ratatui::layout::Rect;
21
22use crate::game::golden::{self, GoldenVariant};
23use crate::game::green_coin;
24use crate::game::state::{GameState, TICK_DT};
25
26/// Buy quantity for a fingerer purchase action. Modifier-key meaning is
27/// translated to this in the input router; sim only consumes the resolved
28/// value so the modifier mapping can change without touching tick logic.
29#[derive(Clone, Copy, Debug, PartialEq, Eq)]
30pub enum BuyQty {
31    One,
32    Ten,
33    Max,
34}
35
36/// Commands the input router produces and the sim consumes. The sim is
37/// the sole authority on [`GameState`] mutation — input handling translates
38/// raw events (key/mouse/wheel) into these and feeds them through.
39#[derive(Clone, Debug)]
40pub enum Action {
41    Click {
42        col: u16,
43        row: u16,
44    },
45    ClickCenter,
46    /// Catch the Golden Cuque of the given variant. Each variant has its
47    /// own independent on-screen slot, so this only catches the targeted
48    /// one — never vacuums up a neighbor.
49    CatchGolden(GoldenVariant),
50    /// Catch the on-screen Green Coin specifically.
51    CatchGreenCoin,
52    BuyFingerer {
53        idx: usize,
54        qty: BuyQty,
55    },
56    BuyUpgrade(usize),
57    PrestigeReset,
58    /// Latest render-computed biscuit geometry, so the sim can place goldens
59    /// and auto-particles inside the current layout. The golden rect lives
60    /// on the input/render side (only the click handler reads it).
61    UpdateGeometry {
62        biscuit: Rect,
63    },
64    /// Dev-only cheats (F-keys). Gated at the input router by `debug`;
65    /// the sim trusts whatever arrives.
66    DevAddCuques(f64),
67    DevForceGolden(GoldenVariant),
68    /// Force-spawn a Green Coin (F5 in dev). Bypasses the spawn pity
69    /// counter and the golden-spawn-event tie-in entirely.
70    DevSpawnGreenCoin,
71    /// J10: a click that didn't hit anything actionable. Sim spawns a
72    /// short-lived "·" misclick particle at the screen point so dead-zone
73    /// clicks visibly register.
74    Misclick {
75        col: u16,
76        row: u16,
77    },
78}
79
80/// Geometry the sim needs to interpret screen-space events. Updated on
81/// every render via [`Action::UpdateGeometry`].
82#[derive(Clone, Copy, Default)]
83pub struct SimGeometry {
84    pub biscuit: Rect,
85}
86
87/// Apply one [`Action`] to the canonical [`GameState`]. Pure data: no I/O,
88/// no time, no threading. Called from both the native sim thread (on
89/// `mpsc::recv_timeout` returning Ok) and the web rAF loop.
90pub fn apply_action(state: &mut GameState, action: Action, geom: &mut SimGeometry) {
91    match action {
92        Action::Click { col, row } => {
93            let r = geom.biscuit;
94            if r.width > 0
95                && col >= r.x
96                && col < r.x + r.width
97                && row >= r.y
98                && row < r.y + r.height
99            {
100                state.click((col, row), r);
101            }
102        }
103        Action::ClickCenter => {
104            let r = geom.biscuit;
105            if r.width > 0 && r.height > 0 {
106                state.click((r.x + r.width / 2, r.y + r.height / 2), r);
107            }
108            // Mark this tick as "saw a spacebar press." `tick()` reads the
109            // flag, advances the held-streak counter, and clears it. A
110            // single tap → 1 tick of streak → resets immediately. A held
111            // key (terminal repeat) → streak climbs over time.
112            state.space_pressed_this_tick = true;
113        }
114        Action::CatchGolden(variant) => {
115            state.catch_golden(variant);
116        }
117        Action::CatchGreenCoin => {
118            state.catch_green_coin();
119        }
120        Action::BuyFingerer { idx, qty } => match qty {
121            BuyQty::One => {
122                state.buy(idx);
123            }
124            BuyQty::Ten => {
125                state.buy_n(idx, 10);
126            }
127            BuyQty::Max => {
128                state.buy_max(idx);
129            }
130        },
131        Action::BuyUpgrade(idx) => {
132            state.buy_upgrade(idx);
133        }
134        Action::PrestigeReset => {
135            state.prestige_reset();
136        }
137        Action::UpdateGeometry { biscuit } => {
138            *geom = SimGeometry { biscuit };
139        }
140        Action::DevAddCuques(n) => {
141            state.dev_add_cuques(n);
142        }
143        Action::DevForceGolden(variant) => {
144            force_spawn_golden(state, geom, variant);
145        }
146        Action::DevSpawnGreenCoin => {
147            force_spawn_green_coin(state, geom);
148        }
149        Action::Misclick { col, row } => {
150            state.spawn_misclick(col, row);
151        }
152    }
153}
154
155/// Run the platform-agnostic body of one sim tick: state updates + ambient
156/// spawn helpers. Save scheduling and demo-driver autopilot are the
157/// **caller's** concern (they live in `app.rs::sim_loop` on native).
158pub fn sim_tick(state: &mut GameState, geom: &SimGeometry) {
159    state.tick();
160    state.tick_golden();
161    state.tick_green_coin();
162    maybe_spawn_golden(state, geom);
163    maybe_spawn_auto_particle(state, geom);
164    maybe_idle_clench(state);
165}
166
167fn maybe_idle_clench(state: &mut GameState) {
168    if state.clench_ticks > 0 {
169        return;
170    }
171    // ~1 per 45s average at 20Hz
172    if rand::rng().random::<f64>() < 1.0 / 900.0 {
173        state.trigger_clench();
174    }
175}
176
177fn maybe_spawn_auto_particle(state: &mut GameState, geom: &SimGeometry) {
178    let fps = state.fps();
179    if fps <= 0.0 || geom.biscuit.width < 4 || geom.biscuit.height < 4 {
180        return;
181    }
182    let target_rate = fps.sqrt().clamp(0.5, 8.0);
183    let prob = target_rate * TICK_DT;
184    let mut rng = rand::rng();
185    if rng.random::<f64>() >= prob {
186        return;
187    }
188    // Random anchor within the biscuit, with a small inset so the "+N" text
189    // doesn't clip into the border.
190    let frac_x = rng.random_range(0.05_f32..=0.95);
191    let frac_y = rng.random_range(0.10_f32..=0.95);
192    state.spawn_auto_particle(frac_x, frac_y);
193}
194
195fn maybe_spawn_golden(state: &mut GameState, geom: &SimGeometry) {
196    if geom.biscuit.width < 8 || geom.biscuit.height < 5 {
197        return;
198    }
199    // Each variant runs on its own clock — find the variants whose
200    // cooldown has hit zero and whose slot is empty, and spawn each.
201    // Cooldown reset for that variant happens here too, so a stuck
202    // zero-cooldown can't re-spawn every tick.
203    for variant in GoldenVariant::ALL {
204        let i = variant as usize;
205        if state.golden_cooldowns[i] > 0 || state.goldens[i].is_some() {
206            continue;
207        }
208        let mut g = golden::spawn_in(geom.biscuit);
209        g.variant = variant;
210        state.goldens[i] = Some(g);
211        state.golden_cooldowns[i] = crate::game::golden::next_cooldown();
212
213        // Green Coin spawn pity: each regular Golden spawn bumps the
214        // chance by 1%. Counter resets the moment a Green Coin appears,
215        // regardless of whether the player catches it or it expires.
216        state.goldens_since_green_coin = state.goldens_since_green_coin.saturating_add(1);
217        if state.green_coin.is_none() {
218            let p = state.goldens_since_green_coin as f64 * 0.01;
219            if rand::rng().random::<f64>() < p {
220                state.green_coin = Some(green_coin::spawn_in(geom.biscuit));
221                state.goldens_since_green_coin = 0;
222            }
223        }
224    }
225}
226
227fn force_spawn_golden(state: &mut GameState, geom: &SimGeometry, variant: GoldenVariant) {
228    if geom.biscuit.width < 8 || geom.biscuit.height < 5 {
229        return;
230    }
231    // Per-variant slot: F-key cheats only spawn into their own slot —
232    // pressing F2 (Frenzy) never displaces an active Lucky, etc. If
233    // that slot is already occupied, the press is a no-op.
234    if state.goldens[variant as usize].is_some() {
235        return;
236    }
237    let mut g = golden::spawn_in(geom.biscuit);
238    g.variant = variant;
239    state.goldens[variant as usize] = Some(g);
240}
241
242fn force_spawn_green_coin(state: &mut GameState, geom: &SimGeometry) {
243    if state.green_coin.is_some() {
244        return;
245    }
246    if geom.biscuit.width < 8 || geom.biscuit.height < 5 {
247        return;
248    }
249    state.green_coin = Some(green_coin::spawn_in(geom.biscuit));
250}
251
252#[cfg(test)]
253mod tests {
254    use super::*;
255    use crate::game::state::GameState;
256    use ratatui::layout::Rect;
257
258    fn geom_with_biscuit() -> SimGeometry {
259        SimGeometry {
260            biscuit: Rect::new(0, 0, 40, 20),
261        }
262    }
263
264    #[test]
265    fn force_spawn_does_not_clobber_other_variants() {
266        // Regression: prior to per-variant slots, pressing F2 (Frenzy)
267        // would replace an existing Lucky on screen. With per-variant
268        // slots, every variant has independent state.
269        let mut state = GameState::default();
270        let geom = geom_with_biscuit();
271        force_spawn_golden(&mut state, &geom, GoldenVariant::Lucky);
272        force_spawn_golden(&mut state, &geom, GoldenVariant::Frenzy);
273        force_spawn_golden(&mut state, &geom, GoldenVariant::Buff);
274        assert!(state.goldens[GoldenVariant::Lucky as usize].is_some());
275        assert!(state.goldens[GoldenVariant::Frenzy as usize].is_some());
276        assert!(state.goldens[GoldenVariant::Buff as usize].is_some());
277    }
278
279    #[test]
280    fn catch_golden_only_consumes_targeted_variant() {
281        let mut state = GameState::default();
282        let geom = geom_with_biscuit();
283        force_spawn_golden(&mut state, &geom, GoldenVariant::Lucky);
284        force_spawn_golden(&mut state, &geom, GoldenVariant::Frenzy);
285        // Catching Lucky leaves Frenzy alone.
286        state.catch_golden(GoldenVariant::Lucky);
287        assert!(state.goldens[GoldenVariant::Lucky as usize].is_none());
288        assert!(state.goldens[GoldenVariant::Frenzy as usize].is_some());
289    }
290}