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}