facett-demo/src/attract.rs
//! **Attract mode** — the demo's AUTO-DEMO / kiosk loop (TASK #82).
//!
//! When the demo sits untouched for [`IDLE_ARM_SECS`], it **arms** and auto-advances
//! through a fixed [`TOUR`] of the showcase tabs — System Map → Code Vault → Scene →
//! OSM 3D → OSM 2D → Graphs — dwelling [`DWELL_SECS`] on each and **looping forever**.
//! Any real user interaction (pointer move / click / key / a tab click) **cancels** it
//! immediately and resets the idle timer; it **re-arms** after another full idle window.
//! A subtle on-screen indicator says so while it runs.
//!
//! The clock is driven purely by the caller's per-frame `dt` (from `ctx.input(stable_dt)`)
//! and an `interacted` flag (also from `ctx.input`) — **no `Date::now`, no wall-clock**,
//! so it is wasm-safe AND runs on the SAME shared `DemoState::draw_app` path natively.
//! [`AttractMode::update`] is a pure state machine (deterministic in its inputs), so a
//! headless robot test drives the exact same code path the live UI runs.
/// Seconds of no user interaction before the auto-demo arms — both from load and to
/// **re-arm** after the user takes control.
pub const IDLE_ARM_SECS: f32 = 5.0;
/// Seconds the auto-demo dwells on each tab before advancing to the next.
pub const DWELL_SECS: f32 = 5.0;
/// The auto-demo tour, in order — deck tab **titles** (the `set_active_by_title` keys,
/// NOT the human labels). The loop advances through these and wraps forever:
/// System Map → Code Vault → Scene → OSM 3D → OSM 2D → Graphs.
pub const TOUR: &[&str] = &[
"System Map", // 🫧 System Map (fluid)
"codevault", // Wreck-It-Ralph Code Vault
"scene", // the declarative Scene shell
"OSM 3D", // extruded 3D city
"OSM 2D", // slippy 2D OSM map
"Graphs", // the graph navigator (3-D rotating cloud)
];
/// What the caller should do this frame after ticking the clock with
/// [`AttractMode::update`].
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum AttractAction {
/// Nothing to do this frame.
None,
/// Switch the deck to this tab title — the tour just armed or advanced.
Switch(&'static str),
}
/// The auto-demo state machine. Pure + `Copy`: it holds only the idle/dwell clocks and
/// the tour cursor, and every transition is a function of `(dt, interacted)`.
#[derive(Clone, Copy, Debug)]
pub struct AttractMode {
/// Seconds of accumulated idle (while NOT running) toward [`IDLE_ARM_SECS`].
idle_secs: f32,
/// Seconds dwelt on the current tour tab (while running) toward [`DWELL_SECS`].
dwell_secs: f32,
/// Whether the auto-demo is currently running (advancing tabs).
running: bool,
/// Cursor into [`TOUR`] of the tab currently shown (while running).
tour_idx: usize,
/// **Pinned**: the tour is off for the rest of the session and can never arm.
/// Set by an explicit "show me THIS tab" request (the `$FACETT_DEMO_TAB` /
/// `#<tab>` deep-link), which the kiosk tour must not steal back.
pinned: bool,
}
impl Default for AttractMode {
fn default() -> Self {
Self { idle_secs: 0.0, dwell_secs: 0.0, running: false, tour_idx: 0, pinned: false }
}
}
impl AttractMode {
/// A fresh, disarmed attract-mode (idle clock at zero).
#[must_use]
pub fn new() -> Self {
Self::default()
}
/// Whether the auto-demo is currently running (drives the on-screen indicator).
#[must_use]
pub fn running(&self) -> bool {
self.running
}
/// Seconds of idle accumulated so far (while disarmed) — observability.
#[must_use]
pub fn idle_secs(&self) -> f32 {
self.idle_secs
}
/// Seconds dwelt on the current tour tab (while running) — observability.
#[must_use]
pub fn dwell_secs(&self) -> f32 {
self.dwell_secs
}
/// The tour cursor (index into [`TOUR`]) of the tab currently shown.
#[must_use]
pub fn tour_index(&self) -> usize {
self.tour_idx
}
/// The tab title the tour is currently showing, or `None` when not running.
#[must_use]
pub fn current_tab(&self) -> Option<&'static str> {
self.running.then(|| TOUR[self.tour_idx % TOUR.len()])
}
/// **Pin** the deck: stop the tour and forbid it from ever arming again in this
/// session. One-way on purpose — it answers an explicit "open THIS tab" request,
/// and a request that a 5-second timer can undo is not a request.
///
/// Without this the deep-link is only advisory: [`IDLE_ARM_SECS`] is 5.0, so a
/// deep-linked tab is silently replaced by `TOUR[0]` five seconds after launch —
/// long before a human (or a screenshotter waiting on a cold start) can look at
/// it. Measured on oden: every attempt to show `GFX v2` came back showing
/// `System Map`, `OSM 2D` or `scene`, and jiggling the pointer to keep the idle
/// clock down is a race, not a fix.
pub fn pin(&mut self) {
self.pinned = true;
self.running = false;
self.idle_secs = 0.0;
self.dwell_secs = 0.0;
}
/// Whether the tour has been pinned off — observability (`state_json`).
#[must_use]
pub fn pinned(&self) -> bool {
self.pinned
}
/// Advance the clock by `dt` seconds given whether the user **interacted** this
/// frame, returning the [`AttractAction`] the caller applies to the deck. Pure and
/// deterministic (no wall-clock) so it is wasm-safe and headless-drivable.
///
/// - **interacted** → cancel any run, reset the idle timer (re-arms after a fresh
/// [`IDLE_ARM_SECS`] window);
/// - **idle ≥ [`IDLE_ARM_SECS`]** (disarmed) → **arm**: restart the tour at the top
/// (`System Map`) and switch to it;
/// - **dwell ≥ [`DWELL_SECS`]** (running) → advance to the next tour tab, wrapping.
pub fn update(&mut self, dt: f32, interacted: bool) -> AttractAction {
// A backgrounded/paused tab can report a multi-second stall in one frame; clamp
// so a single long frame can't jump several tabs at once.
let dt = dt.clamp(0.0, 1.0);
// PINNED: an explicit tab request outranks the kiosk tour, forever. Checked
// before the idle clock so no amount of idling can arm it.
if self.pinned {
self.running = false;
return AttractAction::None;
}
if interacted {
// Any real interaction cancels the auto-demo AND resets the idle timer; it
// re-arms only after another full idle window.
self.running = false;
self.idle_secs = 0.0;
self.dwell_secs = 0.0;
return AttractAction::None;
}
if !self.running {
self.idle_secs += dt;
if self.idle_secs >= IDLE_ARM_SECS {
// ARM: (re)start the tour from the top and show its first tab.
self.running = true;
self.tour_idx = 0;
self.dwell_secs = 0.0;
self.idle_secs = 0.0;
return AttractAction::Switch(TOUR[0]);
}
return AttractAction::None;
}
// Running: dwell on the current tab, then advance (looping) to the next.
self.dwell_secs += dt;
if self.dwell_secs >= DWELL_SECS {
self.dwell_secs = 0.0;
self.tour_idx = (self.tour_idx + 1) % TOUR.len();
return AttractAction::Switch(TOUR[self.tour_idx]);
}
AttractAction::None
}
/// Register an external **take-control** event — a programmatic tab switch (the
/// robot/control-channel equivalent of a human clicking a tab header). Cancels any
/// run and resets the idle timer, exactly like a real interaction; the auto-demo
/// re-arms only after a fresh [`IDLE_ARM_SECS`] window. The auto-demo's OWN advances
/// go straight to the deck and never call this, so they don't self-cancel.
pub fn interrupt(&mut self) {
self.running = false;
self.idle_secs = 0.0;
self.dwell_secs = 0.0;
}
/// The observable state as JSON (folded into `DemoState::state_json` under
/// `attract`), so a headless robot / agent reads the auto-demo status as DATA:
/// whether it runs, the clocks, the tour cursor + current tab, and the tour itself.
#[must_use]
pub fn state_json(&self) -> serde_json::Value {
serde_json::json!({
"running": self.running,
// Pinned off by an explicit tab request. Without this key a pinned deck and
// a merely-idle one look identical from outside (`running: false`,
// `idle_secs: 0.0`), so a robot cannot tell "the tour is disabled" from
// "the tour is about to arm".
"pinned": self.pinned,
"idle_secs": self.idle_secs,
"dwell_secs": self.dwell_secs,
"tour_index": self.tour_idx,
"current_tab": self.current_tab(),
"tour": TOUR,
"idle_arm_secs": IDLE_ARM_SECS,
"dwell_secs_each": DWELL_SECS,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Feed `secs` of idle (no interaction) in small steps, collecting every tab the
/// tour switched to. Small steps mirror real per-frame `dt` and stay under the
/// 1 s clamp in [`AttractMode::update`].
fn idle(a: &mut AttractMode, secs: f32) -> Vec<&'static str> {
let mut out = Vec::new();
let step = 0.25;
let n = (secs / step).round() as usize;
for _ in 0..n {
if let AttractAction::Switch(t) = a.update(step, false) {
out.push(t);
}
}
out
}
#[test]
fn arms_after_idle_and_advances_in_order_then_loops() {
let mut a = AttractMode::new();
assert!(!a.running(), "disarmed on construction");
// Just under the arm window: still disarmed, no switch.
assert!(idle(&mut a, 4.5).is_empty(), "no arm before the idle window");
assert!(!a.running());
// Cross the arm window → arms on System Map.
let sw = idle(&mut a, 1.0);
assert_eq!(sw, ["System Map"], "arms on the first tour tab");
assert!(a.running());
assert_eq!(a.current_tab(), Some("System Map"));
// Each further dwell window advances to the next tour tab, in order.
assert_eq!(idle(&mut a, 5.0), ["codevault"]);
assert_eq!(idle(&mut a, 5.0), ["scene"]);
assert_eq!(idle(&mut a, 5.0), ["OSM 3D"]);
assert_eq!(idle(&mut a, 5.0), ["OSM 2D"]);
assert_eq!(idle(&mut a, 5.0), ["Graphs"]);
// …and LOOPS back to the top.
assert_eq!(idle(&mut a, 5.0), ["System Map"], "the tour loops forever");
}
#[test]
fn interaction_cancels_and_re_arms() {
let mut a = AttractMode::new();
assert_eq!(idle(&mut a, 5.0), ["System Map"], "armed");
assert!(a.running());
// A real interaction stops it immediately and resets the idle timer.
assert_eq!(a.update(0.016, true), AttractAction::None);
assert!(!a.running(), "interaction cancels the auto-demo");
assert_eq!(a.current_tab(), None);
// It does NOT re-arm until a fresh full idle window elapses.
assert!(idle(&mut a, 4.5).is_empty(), "no premature re-arm");
assert!(!a.running());
assert_eq!(idle(&mut a, 1.0), ["System Map"], "re-arms from the top after idle");
assert!(a.running());
}
#[test]
fn a_long_stall_frame_advances_at_most_one_tab() {
let mut a = AttractMode::new();
assert_eq!(idle(&mut a, 5.0), ["System Map"]);
// A 10 s backgrounded frame is clamped to 1 s → cannot skip tabs.
assert_eq!(a.update(10.0, false), AttractAction::None);
assert_eq!(a.current_tab(), Some("System Map"));
}
}