cuqueclicker_lib/app.rs
1//! Native runner. Two threads:
2//!
3//! - **main thread**: owns the terminal, renders snapshots of the game state,
4//! captures crossterm events, normalizes them into [`crate::input::InputEvent`]s,
5//! and feeds them to the platform-agnostic input router (which produces
6//! [`Action`]s + mutates [`UiState`]). Never blocks the sim — a slow render
7//! (SSH lag, terminal resize, stuck flush) is invisible to game logic.
8//! - **sim thread**: owns the canonical [`GameState`], runs the 20Hz tick
9//! loop via [`crate::sim::sim_tick`], drains [`Action`]s, saves to disk
10//! through the [`Persistence`] impl, and publishes snapshots via
11//! [`ArcSwap`]. Tick cadence is driven by `mpsc::recv_timeout(until_next_tick)`,
12//! so it wakes exactly on tick deadlines or incoming actions — no busy spin,
13//! no lost ticks under arbitrary render delay.
14//!
15//! The cross-platform half (input router, `apply_action`, `sim_tick`) is in
16//! `src/input.rs` and `src/sim.rs`. This file owns native-specific glue:
17//! crossterm event translation, threading, save scheduling, and the
18//! demo-recorder autopilot.
19
20use anyhow::Result;
21use arc_swap::ArcSwap;
22use crossterm::event::{
23 self, Event, KeyCode as CtKeyCode, KeyEventKind, KeyModifiers, MouseButton as CtMouseButton,
24 MouseEvent as CtMouseEvent, MouseEventKind,
25};
26use rand::RngExt;
27use ratatui::{Terminal, prelude::*};
28use std::sync::{
29 Arc,
30 atomic::{AtomicBool, Ordering},
31 mpsc,
32};
33use std::thread;
34use std::time::{Duration, Instant};
35
36use crate::game::achievement::ACHIEVEMENTS;
37use crate::game::fingerer;
38use crate::game::fingerer::FINGERERS;
39use crate::game::powerup::{self, Powerup, PowerupKind};
40use crate::game::state::{GameState, TICK_HZ};
41use crate::game::upgrade::UPGRADES;
42use crate::input::{
43 self, InputContext, InputEvent, KeyCode as InKeyCode, Modifiers, MouseButton as InMouseButton,
44 UiState, WheelDelta,
45};
46use crate::platform::Persistence;
47use crate::sim::{self, Action, SimGeometry};
48use crate::ui::{self, Mode};
49
50const SAVE_INTERVAL_TICKS: u64 = TICK_HZ as u64 * 10;
51// Golden cooldown override used only during demo recording so the viewer
52// sees buffs/flashes frequently within the short clip.
53const DEMO_GOLDEN_COOLDOWN: u32 = 40;
54// Input poll timeout on the main thread — sets render responsiveness
55// when there's no input. At 16ms we redraw ~60Hz; snapshots advance
56// at 20Hz, so visual updates land within one frame of their tick.
57const INPUT_POLL_MS: u64 = 16;
58// How far behind we let the sim fall before we give up on catching up
59// (post-sleep, suspended SSH, etc.) and resync to wall clock. 20 ticks
60// = 1s at 20Hz.
61const MAX_TICK_CATCHUP: u32 = 20;
62
63/// Messages the sim thread sends back to main. Used exclusively by the
64/// demo driver to steer the on-camera panel cycle and to request a clean
65/// shutdown when the recording duration elapses.
66enum SimMsg {
67 DemoSetMode(Mode),
68 DemoQuit,
69}
70
71pub struct App {
72 state: GameState,
73 debug: bool,
74 demo_seconds: Option<u32>,
75 persistence: Persistence,
76}
77
78impl App {
79 pub fn new(
80 state: GameState,
81 debug: bool,
82 demo_seconds: Option<u32>,
83 persistence: Persistence,
84 ) -> Self {
85 Self {
86 state,
87 debug,
88 demo_seconds,
89 persistence,
90 }
91 }
92
93 pub fn run<B: Backend>(self, terminal: &mut Terminal<B>) -> Result<()>
94 where
95 B::Error: Send + Sync + 'static,
96 {
97 let App {
98 state,
99 debug,
100 demo_seconds,
101 persistence,
102 } = self;
103
104 let snapshot = Arc::new(ArcSwap::from_pointee(state.clone()));
105 let shutdown = Arc::new(AtomicBool::new(false));
106 let (action_tx, action_rx) = mpsc::channel::<Action>();
107 let (sim_msg_tx, sim_msg_rx) = mpsc::channel::<SimMsg>();
108
109 let sim_handle = {
110 let snapshot = snapshot.clone();
111 let shutdown = shutdown.clone();
112 thread::Builder::new()
113 .name("cuque-sim".into())
114 .spawn(move || {
115 sim_loop(
116 state,
117 snapshot,
118 action_rx,
119 sim_msg_tx,
120 shutdown,
121 demo_seconds,
122 persistence,
123 );
124 })
125 .expect("spawn sim thread")
126 };
127
128 let mut ui = UiState::new();
129 // Single per-frame layout snapshot — the source of truth for every
130 // clickable region. The input router consumes it via
131 // `InputContext::from_layout`. Adding a new rect/list goes through
132 // `DrawOutput` + the projection, never here.
133 let mut layout: ui::DrawOutput = Default::default();
134 // Reused per-event scratch buffer — `process_input_event` appends
135 // produced actions here, then we drain it into the mpsc channel.
136 let mut actions: Vec<Action> = Vec::with_capacity(4);
137
138 while ui.running && !shutdown.load(Ordering::Relaxed) {
139 // Drain any panel/quit requests from the demo driver before we draw,
140 // so the frame we render reflects them.
141 for msg in sim_msg_rx.try_iter() {
142 match msg {
143 SimMsg::DemoSetMode(m) => ui.mode = m,
144 SimMsg::DemoQuit => ui.running = false,
145 }
146 }
147
148 let current = snapshot.load_full();
149 terminal.draw(|f| {
150 layout = ui::draw(f, ¤t, ui.mode, ui.zoom_idx, debug, ui.last_mouse_pos);
151 })?;
152
153 // Hand fresh geometry to the sim. Ordering is preserved by mpsc,
154 // so the sim always uses the most recently drawn layout.
155 let _ = action_tx.send(Action::UpdateGeometry {
156 biscuit: layout.biscuit_rect,
157 });
158
159 if event::poll(Duration::from_millis(INPUT_POLL_MS))? {
160 let ctx = InputContext::from_layout(&layout, ¤t, debug);
161 loop {
162 let ev = event::read()?;
163 if let Some(input_ev) = translate_crossterm(ev) {
164 actions.clear();
165 input::process_input_event(input_ev, &mut ui, &ctx, &mut actions);
166 for a in actions.drain(..) {
167 let _ = action_tx.send(a);
168 }
169 }
170 if !event::poll(Duration::ZERO)? {
171 break;
172 }
173 }
174 }
175 }
176
177 // Tell sim to wind down, wait for it to flush state to disk.
178 shutdown.store(true, Ordering::Relaxed);
179 drop(action_tx);
180 sim_handle.join().expect("sim thread panicked");
181 Ok(())
182 }
183}
184
185// --- Sim thread ---------------------------------------------------------
186
187fn sim_loop(
188 mut state: GameState,
189 snapshot: Arc<ArcSwap<GameState>>,
190 actions: mpsc::Receiver<Action>,
191 sim_msg_tx: mpsc::Sender<SimMsg>,
192 shutdown: Arc<AtomicBool>,
193 demo_seconds: Option<u32>,
194 persistence: Persistence,
195) {
196 let tick_dt = Duration::from_micros(1_000_000 / TICK_HZ as u64);
197 let mut next_tick = Instant::now() + tick_dt;
198 let mut ticks_since_save: u64 = 0;
199 let mut demo_ticks: u64 = 0;
200 let mut demo_golden_spawns: u32 = 0;
201 let mut geom = SimGeometry::default();
202
203 loop {
204 if shutdown.load(Ordering::Relaxed) {
205 break;
206 }
207
208 // Block until the next tick deadline OR an action arrives — whichever
209 // comes first. No busy spin, no tick drift.
210 let timeout = next_tick.saturating_duration_since(Instant::now());
211 match actions.recv_timeout(timeout) {
212 Ok(action) => sim::apply_action(&mut state, action, &mut geom),
213 Err(mpsc::RecvTimeoutError::Timeout) => {}
214 Err(mpsc::RecvTimeoutError::Disconnected) => break,
215 }
216
217 // Run every tick we're behind on. If we've fallen absurdly far
218 // behind (laptop sleep), snap forward rather than grind through
219 // thousands of catch-up ticks.
220 let mut catchup = 0u32;
221 while Instant::now() >= next_tick {
222 sim::sim_tick(&mut state, &geom);
223 // Native-only post-tick concerns: demo-recorder autopilot and
224 // periodic save scheduling. Both are wrapped around the
225 // platform-agnostic `sim::sim_tick` call above.
226 if demo_seconds.is_some() {
227 demo_driver_tick(
228 &mut state,
229 &geom,
230 demo_seconds,
231 &mut demo_ticks,
232 &mut demo_golden_spawns,
233 &sim_msg_tx,
234 );
235 } else {
236 ticks_since_save += 1;
237 if ticks_since_save >= SAVE_INTERVAL_TICKS {
238 ticks_since_save = 0;
239 let _ = persistence.save(&state);
240 }
241 }
242 next_tick += tick_dt;
243 catchup += 1;
244 if catchup >= MAX_TICK_CATCHUP && Instant::now() > next_tick {
245 next_tick = Instant::now() + tick_dt;
246 break;
247 }
248 }
249
250 // Publish the new snapshot. Cheap clone (few small HashMaps + a
251 // short Vec of particles); Arc swap is lock-free.
252 snapshot.store(Arc::new(state.clone()));
253 }
254
255 // Graceful shutdown: one last achievement sweep and a final save. Demo
256 // mode runs on ephemeral state and never touches disk.
257 if demo_seconds.is_none() {
258 state.tick_achievements();
259 let _ = persistence.save(&state);
260 }
261}
262
263/// Demo-mode autopilot, running on the sim thread. Mutates state directly
264/// for clicks/buys; sends `SimMsg` back to main for panel swaps and the
265/// final quit signal since `mode` lives on the render thread.
266fn demo_driver_tick(
267 state: &mut GameState,
268 geom: &SimGeometry,
269 demo_seconds: Option<u32>,
270 demo_ticks: &mut u64,
271 demo_golden_spawns: &mut u32,
272 sim_msg_tx: &mpsc::Sender<SimMsg>,
273) {
274 *demo_ticks += 1;
275 let t = *demo_ticks;
276 let mut rng = rand::rng();
277
278 // ~1.5 clicks/s. Real play is faster; on camera it'd smear.
279 if t.is_multiple_of(13) {
280 let r = geom.biscuit;
281 if r.width > 0 && r.height > 0 {
282 state.click((r.x + r.width / 2, r.y + r.height / 2), r);
283 }
284 }
285
286 // Keep the screen busy with powerups: deterministically cycle
287 // Buff → Frenzy → Lucky on a tight cadence so the clip definitely
288 // catches each variant. Skip GreenCoin in the cycle — it's the
289 // rarest variant in real play, but the demo is short and the green
290 // catch shows up just fine via the natural Vec-based spawn path
291 // when its cooldown rolls in. We push directly so we don't have to
292 // race the cooldowns in `maybe_spawn_powerups`.
293 if state.powerups.is_empty() && (*demo_ticks).is_multiple_of(DEMO_GOLDEN_COOLDOWN as u64) {
294 let kind = match *demo_golden_spawns % 3 {
295 0 => PowerupKind::Buff,
296 1 => PowerupKind::Frenzy,
297 _ => PowerupKind::Lucky,
298 };
299 *demo_golden_spawns += 1;
300 let r = geom.biscuit;
301 if r.width >= 8 && r.height >= 5 {
302 let spawn_id = state.mint_spawn_id();
303 state.powerups.push(Powerup {
304 kind,
305 spawn_id,
306 frac_x: 0.5,
307 frac_y: 0.4,
308 life_ticks: kind.lifetime_ticks(),
309 });
310 }
311 }
312
313 // Auto-catch any powerup that's been on screen long enough so the
314 // marker reads on camera before disappearing. Iterate by spawn_id
315 // so a swap_remove inside the loop doesn't trip us up.
316 let to_catch: Vec<u64> = state
317 .powerups
318 .iter()
319 .filter(|p| p.life_ticks + 20 < p.kind.lifetime_ticks())
320 .map(|p| p.spawn_id)
321 .collect();
322 for id in to_catch {
323 state.catch_powerup(id);
324 }
325
326 // Every ~4s, buy 1-2 of a random affordable fingerer.
327 if t.is_multiple_of(80) {
328 let candidates: Vec<usize> = (0..fingerer::count())
329 .filter(|&i| state.can_buy(i))
330 .collect();
331 if !candidates.is_empty() {
332 let idx = candidates[rng.random_range(0..candidates.len())];
333 state.buy_n(idx, rng.random_range(1..=2));
334 }
335 }
336
337 // Every ~8s, buy the cheapest available upgrade.
338 if t.is_multiple_of(160) {
339 let available = crate::game::upgrade::available_ids(state);
340 if let Some(&u_idx) = available
341 .iter()
342 .min_by(|&&a, &&b| UPGRADES[a].cost.partial_cmp(&UPGRADES[b].cost).unwrap())
343 {
344 state.buy_upgrade(u_idx);
345 }
346 }
347
348 // Every ~15s, show a non-game panel for ~2s.
349 let phase = t % 300;
350 let panel_swap = if phase == 100 {
351 Some(Mode::Stats)
352 } else if phase == 140 {
353 Some(Mode::Achievements)
354 } else if phase == 180 {
355 Some(Mode::Upgrades)
356 } else if phase == 220 {
357 Some(Mode::Game)
358 } else {
359 None
360 };
361 if let Some(m) = panel_swap {
362 let _ = sim_msg_tx.send(SimMsg::DemoSetMode(m));
363 }
364
365 // Deadline: auto-quit when the user's requested duration elapses so the
366 // asciinema recording sees a clean exit.
367 if let Some(secs) = demo_seconds
368 && t >= (secs as u64) * (TICK_HZ as u64)
369 {
370 let _ = sim_msg_tx.send(SimMsg::DemoQuit);
371 }
372}
373
374// --- crossterm → InputEvent translation --------------------------------
375
376/// Normalize one crossterm event into our platform-neutral [`InputEvent`].
377/// Returns `None` for events we drop entirely (focus, paste, resize, key
378/// release/repeat, and unsupported mouse kinds).
379fn translate_crossterm(ev: Event) -> Option<InputEvent> {
380 match ev {
381 Event::Key(k) if k.kind == KeyEventKind::Press => {
382 let code = translate_key_code(k.code)?;
383 Some(InputEvent::KeyPress {
384 code,
385 mods: translate_mods(k.modifiers),
386 })
387 }
388 Event::Mouse(m) => translate_mouse(m),
389 _ => None,
390 }
391}
392
393fn translate_key_code(code: CtKeyCode) -> Option<InKeyCode> {
394 match code {
395 CtKeyCode::Char(c) => Some(InKeyCode::Char(c)),
396 CtKeyCode::Esc => Some(InKeyCode::Esc),
397 CtKeyCode::F(n) => Some(InKeyCode::F(n)),
398 _ => None,
399 }
400}
401
402fn translate_mods(mods: KeyModifiers) -> Modifiers {
403 Modifiers {
404 shift: mods.contains(KeyModifiers::SHIFT),
405 alt: mods.contains(KeyModifiers::ALT),
406 ctrl: mods.contains(KeyModifiers::CONTROL),
407 }
408}
409
410/// Narrow crossterm's mouse button to the subset the game cares about.
411/// Middle-click is intentionally dropped at the adapter boundary so it
412/// stays a no-op (matching pre-refactor behavior, where `handle_event`
413/// only matched `Down(Left)` / `Down(Right)`).
414fn translate_mouse_button(button: CtMouseButton) -> Option<InMouseButton> {
415 match button {
416 CtMouseButton::Left => Some(InMouseButton::Left),
417 CtMouseButton::Right => Some(InMouseButton::Right),
418 CtMouseButton::Middle => None,
419 }
420}
421
422fn translate_mouse(m: CtMouseEvent) -> Option<InputEvent> {
423 let mods = translate_mods(m.modifiers);
424 match m.kind {
425 MouseEventKind::Down(button) => Some(InputEvent::MouseDown {
426 col: m.column,
427 row: m.row,
428 button: translate_mouse_button(button)?,
429 mods,
430 }),
431 MouseEventKind::ScrollUp => Some(InputEvent::Wheel {
432 col: m.column,
433 row: m.row,
434 delta: WheelDelta::Up,
435 }),
436 MouseEventKind::ScrollDown => Some(InputEvent::Wheel {
437 col: m.column,
438 row: m.row,
439 delta: WheelDelta::Down,
440 }),
441 // K5: track mouse position for hover highlighting. Crossterm only
442 // emits Moved/Drag events when AnyMotion mouse mode is enabled
443 // (it is). Drag-with-left collapses to a plain Moved on the
444 // platform-neutral side; the renderer doesn't care.
445 MouseEventKind::Moved | MouseEventKind::Drag(CtMouseButton::Left) => {
446 Some(InputEvent::MouseMoved {
447 col: m.column,
448 row: m.row,
449 })
450 }
451 _ => None,
452 }
453}
454
455// --- Demo state --------------------------------------------------------
456
457/// Rich starting state for `--demo-for-recording`. Tuned so a viewer
458/// sees **numbers moving fast** (high FPS → counter spins visibly) and
459/// **many rings of hands** around the biscuit (heavy owned counts).
460/// Starting cuques is intentionally modest relative to FPS so the HUD
461/// counter grows by a visible fraction every frame instead of looking
462/// frozen.
463pub fn build_demo_state() -> GameState {
464 let mut s = GameState {
465 // Low relative to FPS so the counter clearly grows throughout
466 // the clip, and cheap enough to buy early tiers often.
467 cuques: 500_000.0,
468 lifetime_cuques: 500_000_000.0, // unlocks all tiers via the visibility gate
469 total_clicks: 500,
470 total_play_ticks: 3600 * TICK_HZ as u64, // pretend we've been at this an hour
471 prestige: 3,
472 golden_caught: 7,
473 // Default seeds each per-kind cooldown from a fresh exponential
474 // sample (60-240s mean depending on kind). For the demo we zero
475 // them all so the first powerup (a Buff, per the cycle in
476 // demo_driver_tick) lands well within the first few seconds.
477 powerup_cooldowns: [0; powerup::N_KINDS],
478 best_fps: 50_000.0,
479 ..GameState::default()
480 };
481 // Seed counts/flags BY CATALOG INDEX rather than by hardcoded id strings,
482 // so a future rename/reorder/removal of a fingerer or upgrade can never
483 // silently degrade the demo (the live id at that slot is always used).
484 //
485 // Per-tier owned counts ramp down 40→10 across the first 8 fingerers.
486 // The per-type cap in `ui/hands.rs` is 40, so anything beyond that is
487 // visually identical — 8 types owned = thick crust of hands.
488 const DEMO_FINGERER_COUNTS: &[u32] = &[40, 40, 35, 30, 25, 20, 15, 10];
489 for (idx, &count) in DEMO_FINGERER_COUNTS.iter().enumerate() {
490 if let Some(f) = FINGERERS.get(idx)
491 && count > 0
492 {
493 s.fingerers_state.entry(f.id.to_string()).or_default().count = count;
494 }
495 }
496 // Take the first 10 upgrades from the catalog (deterministic regardless
497 // of how UPGRADES is reordered) — gives a spread of click + per-tier
498 // multipliers so the sidebar shows (xN) on several tiers.
499 for u in UPGRADES.iter().take(10) {
500 s.upgrades_earned.insert(u.id.to_string());
501 }
502 // First 6 achievements for visual variety in that panel.
503 for a in ACHIEVEMENTS.iter().take(6) {
504 s.achievements_earned.insert(a.id.to_string());
505 }
506 s
507}