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        // 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, &current, 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, &current, 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 goldens: tighter cooldown than normal.
287    if state.golden.is_none() && state.golden_cooldown == 0 {
288        state.golden_cooldown = DEMO_GOLDEN_COOLDOWN;
289    }
290
291    // Force the variant on freshly-spawned goldens so the clip deterministically
292    // cycles Buff → Frenzy → Lucky. Buff comes first so a viewer definitely
293    // sees the purple powerup.
294    if let Some(g) = &mut state.golden
295        && g.life_ticks == golden::GOLDEN_LIFE_TICKS
296    {
297        g.variant = match *demo_golden_spawns % 3 {
298            0 => GoldenVariant::Buff,
299            1 => GoldenVariant::Frenzy,
300            _ => GoldenVariant::Lucky,
301        };
302        *demo_golden_spawns += 1;
303    }
304
305    // Auto-catch whatever golden is on screen after a brief "reaction
306    // time" so the marker is actually visible before disappearing.
307    if let Some(g) = &state.golden
308        && g.life_ticks + 20 < golden::GOLDEN_LIFE_TICKS
309    {
310        state.catch_golden();
311    }
312
313    // Every ~4s, buy 1-2 of a random affordable fingerer.
314    if t.is_multiple_of(80) {
315        let candidates: Vec<usize> = (0..fingerer::count())
316            .filter(|&i| state.can_buy(i))
317            .collect();
318        if !candidates.is_empty() {
319            let idx = candidates[rng.random_range(0..candidates.len())];
320            state.buy_n(idx, rng.random_range(1..=2));
321        }
322    }
323
324    // Every ~8s, buy the cheapest available upgrade.
325    if t.is_multiple_of(160) {
326        let available = crate::game::upgrade::available_ids(state);
327        if let Some(&u_idx) = available
328            .iter()
329            .min_by(|&&a, &&b| UPGRADES[a].cost.partial_cmp(&UPGRADES[b].cost).unwrap())
330        {
331            state.buy_upgrade(u_idx);
332        }
333    }
334
335    // Every ~15s, show a non-game panel for ~2s.
336    let phase = t % 300;
337    let panel_swap = if phase == 100 {
338        Some(Mode::Stats)
339    } else if phase == 140 {
340        Some(Mode::Achievements)
341    } else if phase == 180 {
342        Some(Mode::Upgrades)
343    } else if phase == 220 {
344        Some(Mode::Game)
345    } else {
346        None
347    };
348    if let Some(m) = panel_swap {
349        let _ = sim_msg_tx.send(SimMsg::DemoSetMode(m));
350    }
351
352    // Deadline: auto-quit when the user's requested duration elapses so the
353    // asciinema recording sees a clean exit.
354    if let Some(secs) = demo_seconds
355        && t >= (secs as u64) * (TICK_HZ as u64)
356    {
357        let _ = sim_msg_tx.send(SimMsg::DemoQuit);
358    }
359}
360
361// --- crossterm → InputEvent translation --------------------------------
362
363/// Normalize one crossterm event into our platform-neutral [`InputEvent`].
364/// Returns `None` for events we drop entirely (focus, paste, resize, key
365/// release/repeat, and unsupported mouse kinds).
366fn translate_crossterm(ev: Event) -> Option<InputEvent> {
367    match ev {
368        Event::Key(k) if k.kind == KeyEventKind::Press => {
369            let code = translate_key_code(k.code)?;
370            Some(InputEvent::KeyPress {
371                code,
372                mods: translate_mods(k.modifiers),
373            })
374        }
375        Event::Mouse(m) => translate_mouse(m),
376        _ => None,
377    }
378}
379
380fn translate_key_code(code: CtKeyCode) -> Option<InKeyCode> {
381    match code {
382        CtKeyCode::Char(c) => Some(InKeyCode::Char(c)),
383        CtKeyCode::Esc => Some(InKeyCode::Esc),
384        CtKeyCode::F(n) => Some(InKeyCode::F(n)),
385        _ => None,
386    }
387}
388
389fn translate_mods(mods: KeyModifiers) -> Modifiers {
390    Modifiers {
391        shift: mods.contains(KeyModifiers::SHIFT),
392        alt: mods.contains(KeyModifiers::ALT),
393        ctrl: mods.contains(KeyModifiers::CONTROL),
394    }
395}
396
397/// Narrow crossterm's mouse button to the subset the game cares about.
398/// Middle-click is intentionally dropped at the adapter boundary so it
399/// stays a no-op (matching pre-refactor behavior, where `handle_event`
400/// only matched `Down(Left)` / `Down(Right)`).
401fn translate_mouse_button(button: CtMouseButton) -> Option<InMouseButton> {
402    match button {
403        CtMouseButton::Left => Some(InMouseButton::Left),
404        CtMouseButton::Right => Some(InMouseButton::Right),
405        CtMouseButton::Middle => None,
406    }
407}
408
409fn translate_mouse(m: CtMouseEvent) -> Option<InputEvent> {
410    let mods = translate_mods(m.modifiers);
411    match m.kind {
412        MouseEventKind::Down(button) => Some(InputEvent::MouseDown {
413            col: m.column,
414            row: m.row,
415            button: translate_mouse_button(button)?,
416            mods,
417        }),
418        MouseEventKind::ScrollUp => Some(InputEvent::Wheel {
419            col: m.column,
420            row: m.row,
421            delta: WheelDelta::Up,
422        }),
423        MouseEventKind::ScrollDown => Some(InputEvent::Wheel {
424            col: m.column,
425            row: m.row,
426            delta: WheelDelta::Down,
427        }),
428        // K5: track mouse position for hover highlighting. Crossterm only
429        // emits Moved/Drag events when AnyMotion mouse mode is enabled
430        // (it is). Drag-with-left collapses to a plain Moved on the
431        // platform-neutral side; the renderer doesn't care.
432        MouseEventKind::Moved | MouseEventKind::Drag(CtMouseButton::Left) => {
433            Some(InputEvent::MouseMoved {
434                col: m.column,
435                row: m.row,
436            })
437        }
438        _ => None,
439    }
440}
441
442// --- Demo state --------------------------------------------------------
443
444/// Rich starting state for `--demo-for-recording`. Tuned so a viewer
445/// sees **numbers moving fast** (high FPS → counter spins visibly) and
446/// **many rings of hands** around the biscuit (heavy owned counts).
447/// Starting cuques is intentionally modest relative to FPS so the HUD
448/// counter grows by a visible fraction every frame instead of looking
449/// frozen.
450pub fn build_demo_state() -> GameState {
451    let mut s = GameState {
452        // Low relative to FPS so the counter clearly grows throughout
453        // the clip, and cheap enough to buy early tiers often.
454        cuques: 500_000.0,
455        lifetime_cuques: 500_000_000.0, // unlocks all tiers via the visibility gate
456        total_clicks: 500,
457        total_play_ticks: 3600 * TICK_HZ as u64, // pretend we've been at this an hour
458        prestige: 3,
459        golden_caught: 7,
460        // Default is a random 20-80s wait; force 0 so the first demo golden
461        // (a Buff, per the cycle in demo_driver_tick) spawns on tick 1 —
462        // the purple powerup lands well within the first few seconds of the clip.
463        golden_cooldown: 0,
464        best_fps: 50_000.0,
465        ..GameState::default()
466    };
467    // Seed counts/flags BY CATALOG INDEX rather than by hardcoded id strings,
468    // so a future rename/reorder/removal of a fingerer or upgrade can never
469    // silently degrade the demo (the live id at that slot is always used).
470    //
471    // Per-tier owned counts ramp down 40→10 across the first 8 fingerers.
472    // The per-type cap in `ui/hands.rs` is 40, so anything beyond that is
473    // visually identical — 8 types owned = thick crust of hands.
474    const DEMO_FINGERER_COUNTS: &[u32] = &[40, 40, 35, 30, 25, 20, 15, 10];
475    for (idx, &count) in DEMO_FINGERER_COUNTS.iter().enumerate() {
476        if let Some(f) = FINGERERS.get(idx)
477            && count > 0
478        {
479            s.fingerers_state.entry(f.id.to_string()).or_default().count = count;
480        }
481    }
482    // Take the first 10 upgrades from the catalog (deterministic regardless
483    // of how UPGRADES is reordered) — gives a spread of click + per-tier
484    // multipliers so the sidebar shows (xN) on several tiers.
485    for u in UPGRADES.iter().take(10) {
486        s.upgrades_earned.insert(u.id.to_string());
487    }
488    // First 6 achievements for visual variety in that panel.
489    for a in ACHIEVEMENTS.iter().take(6) {
490        s.achievements_earned.insert(a.id.to_string());
491    }
492    s
493}