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::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, &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 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}