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::tree::coord::TreeCoord;
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(
151                    f,
152                    &current,
153                    ui.mode,
154                    ui.zoom_idx,
155                    debug,
156                    ui.last_mouse_pos,
157                    &mut ui.tree_render,
158                    ui.prestige_confirm_pending,
159                );
160            })?;
161
162            // Hand fresh geometry to the sim. Ordering is preserved by mpsc,
163            // so the sim always uses the most recently drawn layout.
164            let _ = action_tx.send(Action::UpdateGeometry {
165                biscuit: layout.biscuit_rect,
166                powerups_paused: ui.mode == Mode::Tree,
167            });
168
169            if event::poll(Duration::from_millis(INPUT_POLL_MS))? {
170                let ctx = InputContext::from_layout(&layout, &current, debug);
171                loop {
172                    let ev = event::read()?;
173                    if let Some(input_ev) = translate_crossterm(ev) {
174                        actions.clear();
175                        input::process_input_event(input_ev, &mut ui, &ctx, &mut actions);
176                        for a in actions.drain(..) {
177                            let _ = action_tx.send(a);
178                        }
179                    }
180                    if !event::poll(Duration::ZERO)? {
181                        break;
182                    }
183                }
184            }
185        }
186
187        // Tell sim to wind down, wait for it to flush state to disk.
188        shutdown.store(true, Ordering::Relaxed);
189        drop(action_tx);
190        sim_handle.join().expect("sim thread panicked");
191        Ok(())
192    }
193}
194
195// --- Sim thread ---------------------------------------------------------
196
197fn sim_loop(
198    mut state: GameState,
199    snapshot: Arc<ArcSwap<GameState>>,
200    actions: mpsc::Receiver<Action>,
201    sim_msg_tx: mpsc::Sender<SimMsg>,
202    shutdown: Arc<AtomicBool>,
203    demo_seconds: Option<u32>,
204    persistence: Persistence,
205) {
206    let tick_dt = Duration::from_micros(1_000_000 / TICK_HZ as u64);
207    let mut next_tick = Instant::now() + tick_dt;
208    let mut ticks_since_save: u64 = 0;
209    let mut demo_ticks: u64 = 0;
210    let mut demo_golden_spawns: u32 = 0;
211    let mut geom = SimGeometry::default();
212
213    loop {
214        if shutdown.load(Ordering::Relaxed) {
215            break;
216        }
217
218        // Block until the next tick deadline OR an action arrives — whichever
219        // comes first. No busy spin, no tick drift.
220        let timeout = next_tick.saturating_duration_since(Instant::now());
221        match actions.recv_timeout(timeout) {
222            Ok(action) => sim::apply_action(&mut state, action, &mut geom),
223            Err(mpsc::RecvTimeoutError::Timeout) => {}
224            Err(mpsc::RecvTimeoutError::Disconnected) => break,
225        }
226
227        // Run every tick we're behind on. If we've fallen absurdly far
228        // behind (laptop sleep), snap forward rather than grind through
229        // thousands of catch-up ticks.
230        let mut catchup = 0u32;
231        while Instant::now() >= next_tick {
232            sim::sim_tick(&mut state, &geom);
233            // Native-only post-tick concerns: demo-recorder autopilot and
234            // periodic save scheduling. Both are wrapped around the
235            // platform-agnostic `sim::sim_tick` call above.
236            if demo_seconds.is_some() {
237                demo_driver_tick(
238                    &mut state,
239                    &geom,
240                    demo_seconds,
241                    &mut demo_ticks,
242                    &mut demo_golden_spawns,
243                    &sim_msg_tx,
244                );
245            } else {
246                ticks_since_save += 1;
247                if ticks_since_save >= SAVE_INTERVAL_TICKS {
248                    ticks_since_save = 0;
249                    let _ = persistence.save(&state);
250                }
251            }
252            next_tick += tick_dt;
253            catchup += 1;
254            if catchup >= MAX_TICK_CATCHUP && Instant::now() > next_tick {
255                next_tick = Instant::now() + tick_dt;
256                break;
257            }
258        }
259
260        // Publish the new snapshot. Cheap clone (few small HashMaps + a
261        // short Vec of particles); Arc swap is lock-free.
262        snapshot.store(Arc::new(state.clone()));
263    }
264
265    // Graceful shutdown: one last achievement sweep and a final save. Demo
266    // mode runs on ephemeral state and never touches disk.
267    if demo_seconds.is_none() {
268        state.tick_achievements();
269        let _ = persistence.save(&state);
270    }
271}
272
273/// Demo-mode autopilot, running on the sim thread. Mutates state directly
274/// for clicks/buys; sends `SimMsg` back to main for panel swaps and the
275/// final quit signal since `mode` lives on the render thread.
276fn demo_driver_tick(
277    state: &mut GameState,
278    geom: &SimGeometry,
279    demo_seconds: Option<u32>,
280    demo_ticks: &mut u64,
281    demo_golden_spawns: &mut u32,
282    sim_msg_tx: &mpsc::Sender<SimMsg>,
283) {
284    *demo_ticks += 1;
285    let t = *demo_ticks;
286    let mut rng = rand::rng();
287
288    // ~1.5 clicks/s. Real play is faster; on camera it'd smear.
289    if t.is_multiple_of(13) {
290        let r = geom.biscuit;
291        if r.width > 0 && r.height > 0 {
292            state.click((r.x + r.width / 2, r.y + r.height / 2), r);
293        }
294    }
295
296    // Keep the screen busy with powerups: deterministically cycle
297    // Buff → Frenzy → Lucky on a tight cadence so the clip definitely
298    // catches each variant. Skip GreenCoin in the cycle — it's the
299    // rarest variant in real play, but the demo is short and the green
300    // catch shows up just fine via the natural Vec-based spawn path
301    // when its cooldown rolls in. We push directly so we don't have to
302    // race the cooldowns in `maybe_spawn_powerups`.
303    if state.powerups.is_empty() && (*demo_ticks).is_multiple_of(DEMO_GOLDEN_COOLDOWN as u64) {
304        let kind = match *demo_golden_spawns % 3 {
305            0 => PowerupKind::Buff,
306            1 => PowerupKind::Frenzy,
307            _ => PowerupKind::Lucky,
308        };
309        *demo_golden_spawns += 1;
310        let r = geom.biscuit;
311        if r.width >= 8 && r.height >= 5 {
312            let spawn_id = state.mint_spawn_id();
313            state.powerups.push(Powerup {
314                kind,
315                spawn_id,
316                frac_x: 0.5,
317                frac_y: 0.4,
318                life_ticks: kind.lifetime_ticks(),
319            });
320        }
321    }
322
323    // Auto-catch any powerup that's been on screen long enough so the
324    // marker reads on camera before disappearing. Iterate by spawn_id
325    // so a swap_remove inside the loop doesn't trip us up.
326    let to_catch: Vec<u64> = state
327        .powerups
328        .iter()
329        .filter(|p| p.life_ticks + 20 < p.kind.lifetime_ticks())
330        .map(|p| p.spawn_id)
331        .collect();
332    for id in to_catch {
333        state.catch_powerup(id);
334    }
335
336    // Every ~4s, buy 1-2 of a random affordable fingerer.
337    if t.is_multiple_of(80) {
338        let candidates: Vec<usize> = (0..fingerer::count())
339            .filter(|&i| state.can_buy(i))
340            .collect();
341        if !candidates.is_empty() {
342            let idx = candidates[rng.random_range(0..candidates.len())];
343            state.buy_n(idx, rng.random_range(1..=2));
344        }
345    }
346
347    // Every ~8s, try to buy a reachable affordable tree node. Walk the
348    // king-neighbor ring around every owned lot and pick the cheapest
349    // hit; gives the demo a steady visible tree-spread without needing
350    // to plan a route.
351    if t.is_multiple_of(160) {
352        let mut best: Option<(TreeCoord, crate::bignum::Mag)> = None;
353        for &owned in &state.tree.bought {
354            for n in crate::game::tree::node::neighbors_of(owned) {
355                if state.tree.bought.contains(&n) {
356                    continue;
357                }
358                if !crate::game::tree::node::edge_exists(owned, n) {
359                    continue;
360                }
361                if let Some(spec) = crate::game::tree::node::node_at(n.x, n.y)
362                    && state.affordable_cuques() >= spec.cost
363                {
364                    let cost = spec.cost;
365                    if best.map(|(_, c)| cost < c).unwrap_or(true) {
366                        best = Some((n, cost));
367                    }
368                }
369            }
370        }
371        // First buy: origin (no neighbors to walk).
372        if state.tree.bought.is_empty() {
373            if let Some(spec) = crate::game::tree::node::node_at(0, 0)
374                && state.affordable_cuques() >= spec.cost
375            {
376                state.buy_tree_node(TreeCoord::ORIGIN);
377            }
378        } else if let Some((lot, _)) = best {
379            state.buy_tree_node(lot);
380        }
381    }
382
383    // Every ~15s, show a non-game panel for ~2s.
384    let phase = t % 300;
385    let panel_swap = if phase == 100 {
386        Some(Mode::Stats)
387    } else if phase == 140 {
388        Some(Mode::Achievements)
389    } else if phase == 180 {
390        Some(Mode::Tree)
391    } else if phase == 220 {
392        Some(Mode::Game)
393    } else {
394        None
395    };
396    if let Some(m) = panel_swap {
397        let _ = sim_msg_tx.send(SimMsg::DemoSetMode(m));
398    }
399
400    // Deadline: auto-quit when the user's requested duration elapses so the
401    // asciinema recording sees a clean exit.
402    if let Some(secs) = demo_seconds
403        && t >= (secs as u64) * (TICK_HZ as u64)
404    {
405        let _ = sim_msg_tx.send(SimMsg::DemoQuit);
406    }
407}
408
409// --- crossterm → InputEvent translation --------------------------------
410
411/// Normalize one crossterm event into our platform-neutral [`InputEvent`].
412/// Returns `None` for events we drop entirely (focus, paste, resize, key
413/// release/repeat, and unsupported mouse kinds).
414fn translate_crossterm(ev: Event) -> Option<InputEvent> {
415    match ev {
416        Event::Key(k) if k.kind == KeyEventKind::Press => {
417            let code = translate_key_code(k.code)?;
418            Some(InputEvent::KeyPress {
419                code,
420                mods: translate_mods(k.modifiers),
421            })
422        }
423        Event::Mouse(m) => translate_mouse(m),
424        _ => None,
425    }
426}
427
428fn translate_key_code(code: CtKeyCode) -> Option<InKeyCode> {
429    match code {
430        CtKeyCode::Char(c) => Some(InKeyCode::Char(c)),
431        CtKeyCode::Esc => Some(InKeyCode::Esc),
432        CtKeyCode::F(n) => Some(InKeyCode::F(n)),
433        CtKeyCode::Up => Some(InKeyCode::Up),
434        CtKeyCode::Down => Some(InKeyCode::Down),
435        CtKeyCode::Left => Some(InKeyCode::Left),
436        CtKeyCode::Right => Some(InKeyCode::Right),
437        CtKeyCode::Enter => Some(InKeyCode::Enter),
438        _ => None,
439    }
440}
441
442fn translate_mods(mods: KeyModifiers) -> Modifiers {
443    Modifiers {
444        shift: mods.contains(KeyModifiers::SHIFT),
445        alt: mods.contains(KeyModifiers::ALT),
446        ctrl: mods.contains(KeyModifiers::CONTROL),
447    }
448}
449
450/// Narrow crossterm's mouse button to the subset the game cares about.
451/// Middle-click is intentionally dropped at the adapter boundary so it
452/// stays a no-op (matching pre-refactor behavior, where `handle_event`
453/// only matched `Down(Left)` / `Down(Right)`).
454fn translate_mouse_button(button: CtMouseButton) -> Option<InMouseButton> {
455    match button {
456        CtMouseButton::Left => Some(InMouseButton::Left),
457        CtMouseButton::Right => Some(InMouseButton::Right),
458        CtMouseButton::Middle => None,
459    }
460}
461
462fn translate_mouse(m: CtMouseEvent) -> Option<InputEvent> {
463    let mods = translate_mods(m.modifiers);
464    match m.kind {
465        MouseEventKind::Down(button) => Some(InputEvent::MouseDown {
466            col: m.column,
467            row: m.row,
468            button: translate_mouse_button(button)?,
469            mods,
470        }),
471        MouseEventKind::Up(button) => Some(InputEvent::MouseUp {
472            col: m.column,
473            row: m.row,
474            button: translate_mouse_button(button)?,
475        }),
476        MouseEventKind::ScrollUp => Some(InputEvent::Wheel {
477            col: m.column,
478            row: m.row,
479            delta: WheelDelta::Up,
480        }),
481        MouseEventKind::ScrollDown => Some(InputEvent::Wheel {
482            col: m.column,
483            row: m.row,
484            delta: WheelDelta::Down,
485        }),
486        // K5: track mouse position for hover highlighting. Crossterm only
487        // emits Moved/Drag events when AnyMotion mouse mode is enabled
488        // (it is). Drag-with-left collapses to a plain Moved on the
489        // platform-neutral side; the renderer doesn't care.
490        MouseEventKind::Moved | MouseEventKind::Drag(CtMouseButton::Left) => {
491            Some(InputEvent::MouseMoved {
492                col: m.column,
493                row: m.row,
494            })
495        }
496        _ => None,
497    }
498}
499
500// --- Demo state --------------------------------------------------------
501
502/// Rich starting state for `--demo-for-recording`. Tuned so a viewer
503/// sees **numbers moving fast** (high FPS → counter spins visibly) and
504/// **many rings of hands** around the biscuit (heavy owned counts).
505/// Starting cuques is intentionally modest relative to FPS so the HUD
506/// counter grows by a visible fraction every frame instead of looking
507/// frozen.
508pub fn build_demo_state() -> GameState {
509    let mut s = GameState {
510        // Low relative to FPS so the counter clearly grows throughout
511        // the clip, and cheap enough to buy early tiers often.
512        cuques: crate::bignum::Mag::from_f64(500_000.0),
513        lifetime_cuques: crate::bignum::Mag::from_f64(500_000_000.0),
514        total_clicks: 500,
515        total_play_ticks: 3600 * TICK_HZ as u64,
516        prestige: 3,
517        golden_caught: 7,
518        powerup_cooldowns: [0; powerup::N_KINDS],
519        best_fps: crate::bignum::Mag::from_f64(50_000.0),
520        ..GameState::default()
521    };
522    // Seed counts/flags BY CATALOG INDEX rather than by hardcoded id strings,
523    // so a future rename/reorder/removal of a fingerer or upgrade can never
524    // silently degrade the demo (the live id at that slot is always used).
525    //
526    // Per-tier owned counts ramp down 40→10 across the first 8 fingerers.
527    // The per-type cap in `ui/hands.rs` is 40, so anything beyond that is
528    // visually identical — 8 types owned = thick crust of hands.
529    const DEMO_FINGERER_COUNTS: &[u32] = &[40, 40, 35, 30, 25, 20, 15, 10];
530    for (idx, &count) in DEMO_FINGERER_COUNTS.iter().enumerate() {
531        if let Some(f) = FINGERERS.get(idx)
532            && count > 0
533        {
534            s.fingerers_state.entry(f.id.to_string()).or_default().count = count;
535        }
536    }
537    // Seed a small starter cluster of tree nodes around the origin so the
538    // demo HUD has the same "look how stacked I am" vibe the old upgrade
539    // grant gave. We do this through the live state's buy path (which
540    // folds the aggregate) but bypass the cost gate by zero-ing the cost
541    // — demo build_state is rich-by-construction, not by progression.
542    for lot in [
543        TreeCoord::ORIGIN,
544        TreeCoord::new(1, 0),
545        TreeCoord::new(0, 1),
546        TreeCoord::new(-1, 0),
547        TreeCoord::new(0, -1),
548        TreeCoord::new(1, 1),
549        TreeCoord::new(-1, -1),
550    ] {
551        if let Some(spec) = crate::game::tree::node::node_at(lot.x, lot.y) {
552            // Connectivity guard relaxed for demo seeding — we want a
553            // tight cluster regardless of edge rolls. Manually add to
554            // `bought` + fold the aggregate.
555            s.tree.bought.insert(lot);
556            s.tree_aggregate.fold_in_node(&spec);
557        }
558    }
559    // First 6 achievements for visual variety in that panel.
560    for a in ACHIEVEMENTS.iter().take(6) {
561        s.achievements_earned.insert(a.id.to_string());
562    }
563    s
564}