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}