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::state::{GameState, TICK_DT};
24
25/// Buy quantity for a fingerer purchase action. Modifier-key meaning is
26/// translated to this in the input router; sim only consumes the resolved
27/// value so the modifier mapping can change without touching tick logic.
28#[derive(Clone, Copy, Debug, PartialEq, Eq)]
29pub enum BuyQty {
30    One,
31    Ten,
32    Max,
33}
34
35/// Commands the input router produces and the sim consumes. The sim is
36/// the sole authority on [`GameState`] mutation — input handling translates
37/// raw events (key/mouse/wheel) into these and feeds them through.
38#[derive(Clone, Debug)]
39pub enum Action {
40    Click {
41        col: u16,
42        row: u16,
43    },
44    ClickCenter,
45    CatchGolden,
46    BuyFingerer {
47        idx: usize,
48        qty: BuyQty,
49    },
50    BuyUpgrade(usize),
51    PrestigeReset,
52    /// Latest render-computed biscuit geometry, so the sim can place goldens
53    /// and auto-particles inside the current layout. The golden rect lives
54    /// on the input/render side (only the click handler reads it).
55    UpdateGeometry {
56        biscuit: Rect,
57    },
58    /// Dev-only cheats (F-keys). Gated at the input router by `debug`;
59    /// the sim trusts whatever arrives.
60    DevAddCuques(f64),
61    DevForceGolden(GoldenVariant),
62    /// J10: a click that didn't hit anything actionable. Sim spawns a
63    /// short-lived "·" misclick particle at the screen point so dead-zone
64    /// clicks visibly register.
65    Misclick {
66        col: u16,
67        row: u16,
68    },
69}
70
71/// Geometry the sim needs to interpret screen-space events. Updated on
72/// every render via [`Action::UpdateGeometry`].
73#[derive(Clone, Copy, Default)]
74pub struct SimGeometry {
75    pub biscuit: Rect,
76}
77
78/// Apply one [`Action`] to the canonical [`GameState`]. Pure data: no I/O,
79/// no time, no threading. Called from both the native sim thread (on
80/// `mpsc::recv_timeout` returning Ok) and the web rAF loop.
81pub fn apply_action(state: &mut GameState, action: Action, geom: &mut SimGeometry) {
82    match action {
83        Action::Click { col, row } => {
84            let r = geom.biscuit;
85            if r.width > 0
86                && col >= r.x
87                && col < r.x + r.width
88                && row >= r.y
89                && row < r.y + r.height
90            {
91                state.click((col, row), r);
92            }
93        }
94        Action::ClickCenter => {
95            let r = geom.biscuit;
96            if r.width > 0 && r.height > 0 {
97                state.click((r.x + r.width / 2, r.y + r.height / 2), r);
98            }
99            // Mark this tick as "saw a spacebar press." `tick()` reads the
100            // flag, advances the held-streak counter, and clears it. A
101            // single tap → 1 tick of streak → resets immediately. A held
102            // key (terminal repeat) → streak climbs over time.
103            state.space_pressed_this_tick = true;
104        }
105        Action::CatchGolden => {
106            state.catch_golden();
107        }
108        Action::BuyFingerer { idx, qty } => match qty {
109            BuyQty::One => {
110                state.buy(idx);
111            }
112            BuyQty::Ten => {
113                state.buy_n(idx, 10);
114            }
115            BuyQty::Max => {
116                state.buy_max(idx);
117            }
118        },
119        Action::BuyUpgrade(idx) => {
120            state.buy_upgrade(idx);
121        }
122        Action::PrestigeReset => {
123            state.prestige_reset();
124        }
125        Action::UpdateGeometry { biscuit } => {
126            *geom = SimGeometry { biscuit };
127        }
128        Action::DevAddCuques(n) => {
129            state.dev_add_cuques(n);
130        }
131        Action::DevForceGolden(variant) => {
132            force_spawn_golden(state, geom, variant);
133        }
134        Action::Misclick { col, row } => {
135            state.spawn_misclick(col, row);
136        }
137    }
138}
139
140/// Run the platform-agnostic body of one sim tick: state updates + ambient
141/// spawn helpers. Save scheduling and demo-driver autopilot are the
142/// **caller's** concern (they live in `app.rs::sim_loop` on native).
143pub fn sim_tick(state: &mut GameState, geom: &SimGeometry) {
144    state.tick();
145    state.tick_golden();
146    maybe_spawn_golden(state, geom);
147    maybe_spawn_auto_particle(state, geom);
148    maybe_idle_clench(state);
149}
150
151fn maybe_idle_clench(state: &mut GameState) {
152    if state.clench_ticks > 0 {
153        return;
154    }
155    // ~1 per 45s average at 20Hz
156    if rand::rng().random::<f64>() < 1.0 / 900.0 {
157        state.trigger_clench();
158    }
159}
160
161fn maybe_spawn_auto_particle(state: &mut GameState, geom: &SimGeometry) {
162    let fps = state.fps();
163    if fps <= 0.0 || geom.biscuit.width < 4 || geom.biscuit.height < 4 {
164        return;
165    }
166    let target_rate = fps.sqrt().clamp(0.5, 8.0);
167    let prob = target_rate * TICK_DT;
168    let mut rng = rand::rng();
169    if rng.random::<f64>() >= prob {
170        return;
171    }
172    // Random anchor within the biscuit, with a small inset so the "+N" text
173    // doesn't clip into the border.
174    let frac_x = rng.random_range(0.05_f32..=0.95);
175    let frac_y = rng.random_range(0.10_f32..=0.95);
176    state.spawn_auto_particle(frac_x, frac_y);
177}
178
179fn maybe_spawn_golden(state: &mut GameState, geom: &SimGeometry) {
180    if state.golden.is_some() || state.golden_cooldown > 0 {
181        return;
182    }
183    if geom.biscuit.width < 8 || geom.biscuit.height < 5 {
184        return;
185    }
186    state.golden = Some(golden::spawn_in(geom.biscuit));
187}
188
189fn force_spawn_golden(state: &mut GameState, geom: &SimGeometry, variant: GoldenVariant) {
190    if geom.biscuit.width < 8 || geom.biscuit.height < 5 {
191        return;
192    }
193    let mut g = golden::spawn_in(geom.biscuit);
194    g.variant = variant;
195    state.golden = Some(g);
196}