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    CatchGolden,
47    BuyFingerer {
48        idx: usize,
49        qty: BuyQty,
50    },
51    BuyUpgrade(usize),
52    PrestigeReset,
53    /// Latest render-computed biscuit geometry, so the sim can place goldens
54    /// and auto-particles inside the current layout. The golden rect lives
55    /// on the input/render side (only the click handler reads it).
56    UpdateGeometry {
57        biscuit: Rect,
58    },
59    /// Dev-only cheats (F-keys). Gated at the input router by `debug`;
60    /// the sim trusts whatever arrives.
61    DevAddCuques(f64),
62    DevForceGolden(GoldenVariant),
63    /// Force-spawn a Green Coin (F5 in dev). Bypasses the spawn pity
64    /// counter and the golden-spawn-event tie-in entirely.
65    DevSpawnGreenCoin,
66    /// J10: a click that didn't hit anything actionable. Sim spawns a
67    /// short-lived "·" misclick particle at the screen point so dead-zone
68    /// clicks visibly register.
69    Misclick {
70        col: u16,
71        row: u16,
72    },
73}
74
75/// Geometry the sim needs to interpret screen-space events. Updated on
76/// every render via [`Action::UpdateGeometry`].
77#[derive(Clone, Copy, Default)]
78pub struct SimGeometry {
79    pub biscuit: Rect,
80}
81
82/// Apply one [`Action`] to the canonical [`GameState`]. Pure data: no I/O,
83/// no time, no threading. Called from both the native sim thread (on
84/// `mpsc::recv_timeout` returning Ok) and the web rAF loop.
85pub fn apply_action(state: &mut GameState, action: Action, geom: &mut SimGeometry) {
86    match action {
87        Action::Click { col, row } => {
88            let r = geom.biscuit;
89            if r.width > 0
90                && col >= r.x
91                && col < r.x + r.width
92                && row >= r.y
93                && row < r.y + r.height
94            {
95                state.click((col, row), r);
96            }
97        }
98        Action::ClickCenter => {
99            let r = geom.biscuit;
100            if r.width > 0 && r.height > 0 {
101                state.click((r.x + r.width / 2, r.y + r.height / 2), r);
102            }
103            // Mark this tick as "saw a spacebar press." `tick()` reads the
104            // flag, advances the held-streak counter, and clears it. A
105            // single tap → 1 tick of streak → resets immediately. A held
106            // key (terminal repeat) → streak climbs over time.
107            state.space_pressed_this_tick = true;
108        }
109        Action::CatchGolden => {
110            // Catch button is unified across the on-screen powerups — try
111            // both and let whichever's present consume the press. Order
112            // doesn't matter (independent slots) but Golden goes first to
113            // match the legacy code path.
114            state.catch_golden();
115            state.catch_green_coin();
116        }
117        Action::BuyFingerer { idx, qty } => match qty {
118            BuyQty::One => {
119                state.buy(idx);
120            }
121            BuyQty::Ten => {
122                state.buy_n(idx, 10);
123            }
124            BuyQty::Max => {
125                state.buy_max(idx);
126            }
127        },
128        Action::BuyUpgrade(idx) => {
129            state.buy_upgrade(idx);
130        }
131        Action::PrestigeReset => {
132            state.prestige_reset();
133        }
134        Action::UpdateGeometry { biscuit } => {
135            *geom = SimGeometry { biscuit };
136        }
137        Action::DevAddCuques(n) => {
138            state.dev_add_cuques(n);
139        }
140        Action::DevForceGolden(variant) => {
141            force_spawn_golden(state, geom, variant);
142        }
143        Action::DevSpawnGreenCoin => {
144            force_spawn_green_coin(state, geom);
145        }
146        Action::Misclick { col, row } => {
147            state.spawn_misclick(col, row);
148        }
149    }
150}
151
152/// Run the platform-agnostic body of one sim tick: state updates + ambient
153/// spawn helpers. Save scheduling and demo-driver autopilot are the
154/// **caller's** concern (they live in `app.rs::sim_loop` on native).
155pub fn sim_tick(state: &mut GameState, geom: &SimGeometry) {
156    state.tick();
157    state.tick_golden();
158    state.tick_green_coin();
159    maybe_spawn_golden(state, geom);
160    maybe_spawn_auto_particle(state, geom);
161    maybe_idle_clench(state);
162}
163
164fn maybe_idle_clench(state: &mut GameState) {
165    if state.clench_ticks > 0 {
166        return;
167    }
168    // ~1 per 45s average at 20Hz
169    if rand::rng().random::<f64>() < 1.0 / 900.0 {
170        state.trigger_clench();
171    }
172}
173
174fn maybe_spawn_auto_particle(state: &mut GameState, geom: &SimGeometry) {
175    let fps = state.fps();
176    if fps <= 0.0 || geom.biscuit.width < 4 || geom.biscuit.height < 4 {
177        return;
178    }
179    let target_rate = fps.sqrt().clamp(0.5, 8.0);
180    let prob = target_rate * TICK_DT;
181    let mut rng = rand::rng();
182    if rng.random::<f64>() >= prob {
183        return;
184    }
185    // Random anchor within the biscuit, with a small inset so the "+N" text
186    // doesn't clip into the border.
187    let frac_x = rng.random_range(0.05_f32..=0.95);
188    let frac_y = rng.random_range(0.10_f32..=0.95);
189    state.spawn_auto_particle(frac_x, frac_y);
190}
191
192fn maybe_spawn_golden(state: &mut GameState, geom: &SimGeometry) {
193    if state.golden.is_some() || state.golden_cooldown > 0 {
194        return;
195    }
196    if geom.biscuit.width < 8 || geom.biscuit.height < 5 {
197        return;
198    }
199    state.golden = Some(golden::spawn_in(geom.biscuit));
200
201    // Green Coin spawn pity: each regular Golden spawn bumps the chance
202    // by 1%. The roll fires whether or not a Green Coin slot is currently
203    // free; if one's already on screen, the counter still increments but
204    // the roll is *not* re-cast (the existing coin keeps its turn). The
205    // counter resets the moment a Green Coin appears, regardless of
206    // whether the player catches it or it expires.
207    state.goldens_since_green_coin = state.goldens_since_green_coin.saturating_add(1);
208    if state.green_coin.is_none() {
209        let p = state.goldens_since_green_coin as f64 * 0.01;
210        if rand::rng().random::<f64>() < p {
211            state.green_coin = Some(green_coin::spawn_in(geom.biscuit));
212            state.goldens_since_green_coin = 0;
213        }
214    }
215}
216
217fn force_spawn_golden(state: &mut GameState, geom: &SimGeometry, variant: GoldenVariant) {
218    if geom.biscuit.width < 8 || geom.biscuit.height < 5 {
219        return;
220    }
221    let mut g = golden::spawn_in(geom.biscuit);
222    g.variant = variant;
223    state.golden = Some(g);
224}
225
226fn force_spawn_green_coin(state: &mut GameState, geom: &SimGeometry) {
227    if state.green_coin.is_some() {
228        return;
229    }
230    if geom.biscuit.width < 8 || geom.biscuit.height < 5 {
231        return;
232    }
233    state.green_coin = Some(green_coin::spawn_in(geom.biscuit));
234}