Skip to main content

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::golden::{self, GoldenVariant};
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        let mut upgrade_rows: Vec<(usize, Rect)> = Vec::new();
130        let mut fingerer_rows: Vec<(usize, Rect)> = Vec::new();
131        let mut biscuit_rect = Rect::default();
132        let mut golden_rect = Rect::default();
133        let mut green_coin_rect = Rect::default();
134        let mut play_area = Rect::default();
135        // M1+M2: help-bar hint click rects + prestige-reset confirm rect.
136        // Both are recomputed every frame and consumed by the click router
137        // so the mouse-first player has equivalents for `[u]`, `[p]`,
138        // `[s]`, `[a]`, `[g]`, `[q]`, and the `[r] reset` confirm.
139        let mut help_hits: Vec<(crate::ui::HelpAction, Rect)> = Vec::new();
140        let mut prestige_reset_rect = Rect::default();
141        // Reused per-event scratch buffer — `process_input_event` appends
142        // produced actions here, then we drain it into the mpsc channel.
143        let mut actions: Vec<Action> = Vec::with_capacity(4);
144
145        while ui.running && !shutdown.load(Ordering::Relaxed) {
146            // Drain any panel/quit requests from the demo driver before we draw,
147            // so the frame we render reflects them.
148            for msg in sim_msg_rx.try_iter() {
149                match msg {
150                    SimMsg::DemoSetMode(m) => ui.mode = m,
151                    SimMsg::DemoQuit => ui.running = false,
152                }
153            }
154
155            let current = snapshot.load_full();
156            terminal.draw(|f| {
157                let out = ui::draw(f, &current, ui.mode, ui.zoom_idx, debug, ui.last_mouse_pos);
158                biscuit_rect = out.biscuit_rect;
159                golden_rect = out.golden_rect;
160                green_coin_rect = out.green_coin_rect;
161                play_area = out.play_area;
162                upgrade_rows = out.upgrade_rows;
163                fingerer_rows = out.fingerer_rows;
164                help_hits = out.help_hits;
165                prestige_reset_rect = out.prestige_reset_rect;
166            })?;
167
168            // Hand fresh geometry to the sim. Ordering is preserved by mpsc,
169            // so the sim always uses the most recently drawn layout.
170            let _ = action_tx.send(Action::UpdateGeometry {
171                biscuit: biscuit_rect,
172            });
173
174            if event::poll(Duration::from_millis(INPUT_POLL_MS))? {
175                let ctx = InputContext {
176                    fingerer_rows: &fingerer_rows,
177                    upgrade_rows: &upgrade_rows,
178                    help_hits: &help_hits,
179                    biscuit_rect,
180                    golden_rect,
181                    green_coin_rect,
182                    play_area,
183                    prestige_reset_rect,
184                    debug,
185                    current: &current,
186                };
187                loop {
188                    let ev = event::read()?;
189                    if let Some(input_ev) = translate_crossterm(ev) {
190                        actions.clear();
191                        input::process_input_event(input_ev, &mut ui, &ctx, &mut actions);
192                        for a in actions.drain(..) {
193                            let _ = action_tx.send(a);
194                        }
195                    }
196                    if !event::poll(Duration::ZERO)? {
197                        break;
198                    }
199                }
200            }
201        }
202
203        // Tell sim to wind down, wait for it to flush state to disk.
204        shutdown.store(true, Ordering::Relaxed);
205        drop(action_tx);
206        sim_handle.join().expect("sim thread panicked");
207        Ok(())
208    }
209}
210
211// --- Sim thread ---------------------------------------------------------
212
213fn sim_loop(
214    mut state: GameState,
215    snapshot: Arc<ArcSwap<GameState>>,
216    actions: mpsc::Receiver<Action>,
217    sim_msg_tx: mpsc::Sender<SimMsg>,
218    shutdown: Arc<AtomicBool>,
219    demo_seconds: Option<u32>,
220    persistence: Persistence,
221) {
222    let tick_dt = Duration::from_micros(1_000_000 / TICK_HZ as u64);
223    let mut next_tick = Instant::now() + tick_dt;
224    let mut ticks_since_save: u64 = 0;
225    let mut demo_ticks: u64 = 0;
226    let mut demo_golden_spawns: u32 = 0;
227    let mut geom = SimGeometry::default();
228
229    loop {
230        if shutdown.load(Ordering::Relaxed) {
231            break;
232        }
233
234        // Block until the next tick deadline OR an action arrives — whichever
235        // comes first. No busy spin, no tick drift.
236        let timeout = next_tick.saturating_duration_since(Instant::now());
237        match actions.recv_timeout(timeout) {
238            Ok(action) => sim::apply_action(&mut state, action, &mut geom),
239            Err(mpsc::RecvTimeoutError::Timeout) => {}
240            Err(mpsc::RecvTimeoutError::Disconnected) => break,
241        }
242
243        // Run every tick we're behind on. If we've fallen absurdly far
244        // behind (laptop sleep), snap forward rather than grind through
245        // thousands of catch-up ticks.
246        let mut catchup = 0u32;
247        while Instant::now() >= next_tick {
248            sim::sim_tick(&mut state, &geom);
249            // Native-only post-tick concerns: demo-recorder autopilot and
250            // periodic save scheduling. Both are wrapped around the
251            // platform-agnostic `sim::sim_tick` call above.
252            if demo_seconds.is_some() {
253                demo_driver_tick(
254                    &mut state,
255                    &geom,
256                    demo_seconds,
257                    &mut demo_ticks,
258                    &mut demo_golden_spawns,
259                    &sim_msg_tx,
260                );
261            } else {
262                ticks_since_save += 1;
263                if ticks_since_save >= SAVE_INTERVAL_TICKS {
264                    ticks_since_save = 0;
265                    let _ = persistence.save(&state);
266                }
267            }
268            next_tick += tick_dt;
269            catchup += 1;
270            if catchup >= MAX_TICK_CATCHUP && Instant::now() > next_tick {
271                next_tick = Instant::now() + tick_dt;
272                break;
273            }
274        }
275
276        // Publish the new snapshot. Cheap clone (few small HashMaps + a
277        // short Vec of particles); Arc swap is lock-free.
278        snapshot.store(Arc::new(state.clone()));
279    }
280
281    // Graceful shutdown: one last achievement sweep and a final save. Demo
282    // mode runs on ephemeral state and never touches disk.
283    if demo_seconds.is_none() {
284        state.tick_achievements();
285        let _ = persistence.save(&state);
286    }
287}
288
289/// Demo-mode autopilot, running on the sim thread. Mutates state directly
290/// for clicks/buys; sends `SimMsg` back to main for panel swaps and the
291/// final quit signal since `mode` lives on the render thread.
292fn demo_driver_tick(
293    state: &mut GameState,
294    geom: &SimGeometry,
295    demo_seconds: Option<u32>,
296    demo_ticks: &mut u64,
297    demo_golden_spawns: &mut u32,
298    sim_msg_tx: &mpsc::Sender<SimMsg>,
299) {
300    *demo_ticks += 1;
301    let t = *demo_ticks;
302    let mut rng = rand::rng();
303
304    // ~1.5 clicks/s. Real play is faster; on camera it'd smear.
305    if t.is_multiple_of(13) {
306        let r = geom.biscuit;
307        if r.width > 0 && r.height > 0 {
308            state.click((r.x + r.width / 2, r.y + r.height / 2), r);
309        }
310    }
311
312    // Keep the screen busy with goldens: tighter cooldown than normal.
313    if state.golden.is_none() && state.golden_cooldown == 0 {
314        state.golden_cooldown = DEMO_GOLDEN_COOLDOWN;
315    }
316
317    // Force the variant on freshly-spawned goldens so the clip deterministically
318    // cycles Buff → Frenzy → Lucky. Buff comes first so a viewer definitely
319    // sees the purple powerup.
320    if let Some(g) = &mut state.golden
321        && g.life_ticks == golden::GOLDEN_LIFE_TICKS
322    {
323        g.variant = match *demo_golden_spawns % 3 {
324            0 => GoldenVariant::Buff,
325            1 => GoldenVariant::Frenzy,
326            _ => GoldenVariant::Lucky,
327        };
328        *demo_golden_spawns += 1;
329    }
330
331    // Auto-catch whatever golden is on screen after a brief "reaction
332    // time" so the marker is actually visible before disappearing.
333    if let Some(g) = &state.golden
334        && g.life_ticks + 20 < golden::GOLDEN_LIFE_TICKS
335    {
336        state.catch_golden();
337    }
338
339    // Every ~4s, buy 1-2 of a random affordable fingerer.
340    if t.is_multiple_of(80) {
341        let candidates: Vec<usize> = (0..fingerer::count())
342            .filter(|&i| state.can_buy(i))
343            .collect();
344        if !candidates.is_empty() {
345            let idx = candidates[rng.random_range(0..candidates.len())];
346            state.buy_n(idx, rng.random_range(1..=2));
347        }
348    }
349
350    // Every ~8s, buy the cheapest available upgrade.
351    if t.is_multiple_of(160) {
352        let available = crate::game::upgrade::available_ids(state);
353        if let Some(&u_idx) = available
354            .iter()
355            .min_by(|&&a, &&b| UPGRADES[a].cost.partial_cmp(&UPGRADES[b].cost).unwrap())
356        {
357            state.buy_upgrade(u_idx);
358        }
359    }
360
361    // Every ~15s, show a non-game panel for ~2s.
362    let phase = t % 300;
363    let panel_swap = if phase == 100 {
364        Some(Mode::Stats)
365    } else if phase == 140 {
366        Some(Mode::Achievements)
367    } else if phase == 180 {
368        Some(Mode::Upgrades)
369    } else if phase == 220 {
370        Some(Mode::Game)
371    } else {
372        None
373    };
374    if let Some(m) = panel_swap {
375        let _ = sim_msg_tx.send(SimMsg::DemoSetMode(m));
376    }
377
378    // Deadline: auto-quit when the user's requested duration elapses so the
379    // asciinema recording sees a clean exit.
380    if let Some(secs) = demo_seconds
381        && t >= (secs as u64) * (TICK_HZ as u64)
382    {
383        let _ = sim_msg_tx.send(SimMsg::DemoQuit);
384    }
385}
386
387// --- crossterm → InputEvent translation --------------------------------
388
389/// Normalize one crossterm event into our platform-neutral [`InputEvent`].
390/// Returns `None` for events we drop entirely (focus, paste, resize, key
391/// release/repeat, and unsupported mouse kinds).
392fn translate_crossterm(ev: Event) -> Option<InputEvent> {
393    match ev {
394        Event::Key(k) if k.kind == KeyEventKind::Press => {
395            let code = translate_key_code(k.code)?;
396            Some(InputEvent::KeyPress {
397                code,
398                mods: translate_mods(k.modifiers),
399            })
400        }
401        Event::Mouse(m) => translate_mouse(m),
402        _ => None,
403    }
404}
405
406fn translate_key_code(code: CtKeyCode) -> Option<InKeyCode> {
407    match code {
408        CtKeyCode::Char(c) => Some(InKeyCode::Char(c)),
409        CtKeyCode::Esc => Some(InKeyCode::Esc),
410        CtKeyCode::F(n) => Some(InKeyCode::F(n)),
411        _ => None,
412    }
413}
414
415fn translate_mods(mods: KeyModifiers) -> Modifiers {
416    Modifiers {
417        shift: mods.contains(KeyModifiers::SHIFT),
418        alt: mods.contains(KeyModifiers::ALT),
419        ctrl: mods.contains(KeyModifiers::CONTROL),
420    }
421}
422
423/// Narrow crossterm's mouse button to the subset the game cares about.
424/// Middle-click is intentionally dropped at the adapter boundary so it
425/// stays a no-op (matching pre-refactor behavior, where `handle_event`
426/// only matched `Down(Left)` / `Down(Right)`).
427fn translate_mouse_button(button: CtMouseButton) -> Option<InMouseButton> {
428    match button {
429        CtMouseButton::Left => Some(InMouseButton::Left),
430        CtMouseButton::Right => Some(InMouseButton::Right),
431        CtMouseButton::Middle => None,
432    }
433}
434
435fn translate_mouse(m: CtMouseEvent) -> Option<InputEvent> {
436    let mods = translate_mods(m.modifiers);
437    match m.kind {
438        MouseEventKind::Down(button) => Some(InputEvent::MouseDown {
439            col: m.column,
440            row: m.row,
441            button: translate_mouse_button(button)?,
442            mods,
443        }),
444        MouseEventKind::ScrollUp => Some(InputEvent::Wheel {
445            col: m.column,
446            row: m.row,
447            delta: WheelDelta::Up,
448        }),
449        MouseEventKind::ScrollDown => Some(InputEvent::Wheel {
450            col: m.column,
451            row: m.row,
452            delta: WheelDelta::Down,
453        }),
454        // K5: track mouse position for hover highlighting. Crossterm only
455        // emits Moved/Drag events when AnyMotion mouse mode is enabled
456        // (it is). Drag-with-left collapses to a plain Moved on the
457        // platform-neutral side; the renderer doesn't care.
458        MouseEventKind::Moved | MouseEventKind::Drag(CtMouseButton::Left) => {
459            Some(InputEvent::MouseMoved {
460                col: m.column,
461                row: m.row,
462            })
463        }
464        _ => None,
465    }
466}
467
468// --- Demo state --------------------------------------------------------
469
470/// Rich starting state for `--demo-for-recording`. Tuned so a viewer
471/// sees **numbers moving fast** (high FPS → counter spins visibly) and
472/// **many rings of hands** around the biscuit (heavy owned counts).
473/// Starting cuques is intentionally modest relative to FPS so the HUD
474/// counter grows by a visible fraction every frame instead of looking
475/// frozen.
476pub fn build_demo_state() -> GameState {
477    let mut s = GameState {
478        // Low relative to FPS so the counter clearly grows throughout
479        // the clip, and cheap enough to buy early tiers often.
480        cuques: 500_000.0,
481        lifetime_cuques: 500_000_000.0, // unlocks all tiers via the visibility gate
482        total_clicks: 500,
483        total_play_ticks: 3600 * TICK_HZ as u64, // pretend we've been at this an hour
484        prestige: 3,
485        golden_caught: 7,
486        // Default is a random 20-80s wait; force 0 so the first demo golden
487        // (a Buff, per the cycle in demo_driver_tick) spawns on tick 1 —
488        // the purple powerup lands well within the first few seconds of the clip.
489        golden_cooldown: 0,
490        best_fps: 50_000.0,
491        ..GameState::default()
492    };
493    // Seed counts/flags BY CATALOG INDEX rather than by hardcoded id strings,
494    // so a future rename/reorder/removal of a fingerer or upgrade can never
495    // silently degrade the demo (the live id at that slot is always used).
496    //
497    // Per-tier owned counts ramp down 40→10 across the first 8 fingerers.
498    // The per-type cap in `ui/hands.rs` is 40, so anything beyond that is
499    // visually identical — 8 types owned = thick crust of hands.
500    const DEMO_FINGERER_COUNTS: &[u32] = &[40, 40, 35, 30, 25, 20, 15, 10];
501    for (idx, &count) in DEMO_FINGERER_COUNTS.iter().enumerate() {
502        if let Some(f) = FINGERERS.get(idx)
503            && count > 0
504        {
505            s.fingerers_state.entry(f.id.to_string()).or_default().count = count;
506        }
507    }
508    // Take the first 10 upgrades from the catalog (deterministic regardless
509    // of how UPGRADES is reordered) — gives a spread of click + per-tier
510    // multipliers so the sidebar shows (xN) on several tiers.
511    for u in UPGRADES.iter().take(10) {
512        s.upgrades_earned.insert(u.id.to_string());
513    }
514    // First 6 achievements for visual variety in that panel.
515    for a in ACHIEVEMENTS.iter().take(6) {
516        s.achievements_earned.insert(a.id.to_string());
517    }
518    s
519}