Skip to main content

jugar_web/
demo.rs

1//! Demo mode and game mode management.
2//!
3//! This module implements the features from the Pong Improvements Demo Specification:
4//! - A. Demo Mode (AI vs AI attract mode)
5//! - B. Speed Toggle (1x to 1000x)
6//! - C. Game Modes (Demo/1P/2P)
7//! - Safety: Photosensitivity warning for high speeds
8
9use serde::{Deserialize, Serialize};
10
11/// Speed multiplier for physics simulation.
12///
13/// Higher speeds showcase SIMD/GPU acceleration by running multiple
14/// physics updates per render frame.
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
16pub enum SpeedMultiplier {
17    /// Normal speed: 60 physics updates/sec
18    #[default]
19    Normal = 1,
20    /// 5x speed: 300 physics updates/sec
21    Fast5x = 5,
22    /// 10x speed: 600 physics updates/sec
23    Fast10x = 10,
24    /// 50x speed: 3,000 physics updates/sec
25    Fast50x = 50,
26    /// 100x speed: 6,000 physics updates/sec
27    Fast100x = 100,
28    /// 1000x speed: 60,000 physics updates/sec
29    Fast1000x = 1000,
30}
31
32impl SpeedMultiplier {
33    /// Returns the numeric multiplier value.
34    #[must_use]
35    pub const fn value(self) -> u32 {
36        match self {
37            Self::Normal => 1,
38            Self::Fast5x => 5,
39            Self::Fast10x => 10,
40            Self::Fast50x => 50,
41            Self::Fast100x => 100,
42            Self::Fast1000x => 1000,
43        }
44    }
45
46    /// Returns true if this speed requires a photosensitivity warning.
47    ///
48    /// Speeds above 10x may cause high-frequency visual flashing.
49    #[must_use]
50    pub const fn requires_warning(self) -> bool {
51        matches!(self, Self::Fast50x | Self::Fast100x | Self::Fast1000x)
52    }
53
54    /// Returns the display label for this speed.
55    #[must_use]
56    pub const fn label(self) -> &'static str {
57        match self {
58            Self::Normal => "1x",
59            Self::Fast5x => "5x",
60            Self::Fast10x => "10x",
61            Self::Fast50x => "50x",
62            Self::Fast100x => "100x",
63            Self::Fast1000x => "1000x",
64        }
65    }
66
67    /// Cycles to the next speed multiplier.
68    #[must_use]
69    pub const fn next(self) -> Self {
70        match self {
71            Self::Normal => Self::Fast5x,
72            Self::Fast5x => Self::Fast10x,
73            Self::Fast10x => Self::Fast50x,
74            Self::Fast50x => Self::Fast100x,
75            Self::Fast100x => Self::Fast1000x,
76            Self::Fast1000x => Self::Normal,
77        }
78    }
79
80    /// Creates from keyboard shortcut (1-6).
81    #[must_use]
82    pub const fn from_key(key: u8) -> Option<Self> {
83        match key {
84            1 => Some(Self::Normal),
85            2 => Some(Self::Fast5x),
86            3 => Some(Self::Fast10x),
87            4 => Some(Self::Fast50x),
88            5 => Some(Self::Fast100x),
89            6 => Some(Self::Fast1000x),
90            _ => None,
91        }
92    }
93
94    /// Returns all speed multipliers in order.
95    #[must_use]
96    pub const fn all() -> [Self; 6] {
97        [
98            Self::Normal,
99            Self::Fast5x,
100            Self::Fast10x,
101            Self::Fast50x,
102            Self::Fast100x,
103            Self::Fast1000x,
104        ]
105    }
106}
107
108/// Game mode selection.
109///
110/// Determines which paddles are controlled by humans vs AI.
111#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
112pub enum GameMode {
113    /// Demo mode: AI vs AI (attract mode)
114    Demo,
115    /// Single player: Human (left, W/S keys) vs AI (right) - DEFAULT
116    #[default]
117    SinglePlayer,
118    /// Two player: Human (left, W/S) vs Human (right, Up/Down arrows)
119    TwoPlayer,
120}
121
122impl GameMode {
123    /// Returns the enum variant name (Demo, SinglePlayer, TwoPlayer).
124    /// Used for API/debug output.
125    #[must_use]
126    pub const fn name(self) -> &'static str {
127        match self {
128            Self::Demo => "Demo",
129            Self::SinglePlayer => "SinglePlayer",
130            Self::TwoPlayer => "TwoPlayer",
131        }
132    }
133
134    /// Returns the display label for this mode.
135    #[must_use]
136    pub const fn label(self) -> &'static str {
137        match self {
138            Self::Demo => "Demo",
139            Self::SinglePlayer => "1 Player",
140            Self::TwoPlayer => "2 Player",
141        }
142    }
143
144    /// Returns the short label for buttons.
145    #[must_use]
146    pub const fn short_label(self) -> &'static str {
147        match self {
148            Self::Demo => "Demo",
149            Self::SinglePlayer => "1P",
150            Self::TwoPlayer => "2P",
151        }
152    }
153
154    /// Returns true if left paddle is AI-controlled.
155    /// In Demo and SinglePlayer modes, the left paddle is AI.
156    /// SinglePlayer: AI (left) vs Human (right/arrows)
157    #[must_use]
158    pub const fn left_is_ai(self) -> bool {
159        matches!(self, Self::Demo | Self::SinglePlayer)
160    }
161
162    /// Returns true if right paddle is AI-controlled.
163    /// Only in Demo mode is the right paddle AI.
164    /// SinglePlayer: AI (left) vs Human (right/arrows)
165    #[must_use]
166    pub const fn right_is_ai(self) -> bool {
167        matches!(self, Self::Demo)
168    }
169
170    /// Returns the label for the left paddle based on game mode.
171    /// Shows controller type and keys if human-controlled.
172    /// In 1P mode: AI on left, human on right
173    /// In 2P mode: P2 on left (W/S), P1 on right (arrows)
174    #[must_use]
175    pub const fn left_paddle_label(self) -> &'static str {
176        match self {
177            Self::Demo | Self::SinglePlayer => "AI",
178            Self::TwoPlayer => "P2 [W/S]",
179        }
180    }
181
182    /// Returns the label for the right paddle based on game mode.
183    /// Shows controller type and keys if human-controlled.
184    /// In 1P mode: human on right (arrow keys)
185    /// In 2P mode: P1 on right (arrow keys)
186    #[must_use]
187    pub const fn right_paddle_label(self) -> &'static str {
188        match self {
189            Self::Demo => "AI",
190            Self::SinglePlayer | Self::TwoPlayer => "P1 [^/v]",
191        }
192    }
193
194    /// Cycles to the next game mode.
195    #[must_use]
196    pub const fn next(self) -> Self {
197        match self {
198            Self::Demo => Self::SinglePlayer,
199            Self::SinglePlayer => Self::TwoPlayer,
200            Self::TwoPlayer => Self::Demo,
201        }
202    }
203
204    /// Returns all game modes in order.
205    #[must_use]
206    pub const fn all() -> [Self; 3] {
207        [Self::Demo, Self::SinglePlayer, Self::TwoPlayer]
208    }
209}
210
211/// Demo mode state tracking.
212///
213/// Manages auto-demo engagement and transition to player control.
214#[derive(Debug, Clone)]
215pub struct DemoState {
216    /// Time since last user input (seconds)
217    idle_time: f64,
218    /// Threshold for auto-engage (seconds)
219    auto_engage_threshold: f64,
220    /// Whether demo was auto-engaged (vs manual)
221    auto_engaged: bool,
222    /// Current difficulty cycle time
223    difficulty_cycle_time: f64,
224    /// Difficulty cycle period (seconds)
225    difficulty_cycle_period: f64,
226    /// Current left AI difficulty (for demo mode)
227    left_ai_difficulty: u8,
228    /// Current right AI difficulty (for demo mode)
229    right_ai_difficulty: u8,
230}
231
232impl Default for DemoState {
233    fn default() -> Self {
234        Self {
235            idle_time: 0.0,
236            auto_engage_threshold: 10.0, // 10 seconds per spec
237            auto_engaged: false,
238            difficulty_cycle_time: 0.0,
239            difficulty_cycle_period: 60.0, // 60 seconds per spec
240            left_ai_difficulty: 7,         // Per spec: challenging
241            right_ai_difficulty: 5,        // Per spec: slightly easier
242        }
243    }
244}
245
246impl DemoState {
247    /// Creates a new demo state with custom thresholds.
248    #[must_use]
249    pub fn new(auto_engage_threshold: f64, difficulty_cycle_period: f64) -> Self {
250        Self {
251            auto_engage_threshold,
252            difficulty_cycle_period,
253            ..Default::default()
254        }
255    }
256
257    /// Records user input, resetting idle timer.
258    #[allow(clippy::missing_const_for_fn)] // const fn with mutable ref not yet stable
259    pub fn record_input(&mut self) {
260        self.idle_time = 0.0;
261        self.auto_engaged = false;
262    }
263
264    /// Updates idle time and returns true if demo should auto-engage.
265    pub fn update(&mut self, dt: f64, has_input: bool) -> bool {
266        if has_input {
267            self.record_input();
268            return false;
269        }
270
271        self.idle_time += dt;
272
273        // Check if we should auto-engage
274        if !self.auto_engaged && self.idle_time >= self.auto_engage_threshold {
275            self.auto_engaged = true;
276            return true;
277        }
278
279        false
280    }
281
282    /// Updates difficulty cycling for demo mode.
283    pub fn update_difficulty_cycle(&mut self, dt: f64) {
284        self.difficulty_cycle_time += dt;
285
286        // Cycle difficulties every period
287        if self.difficulty_cycle_time >= self.difficulty_cycle_period {
288            self.difficulty_cycle_time = 0.0;
289            // Swap difficulties for variety
290            core::mem::swap(&mut self.left_ai_difficulty, &mut self.right_ai_difficulty);
291        }
292    }
293
294    /// Returns true if demo was auto-engaged (vs manually selected).
295    #[must_use]
296    pub const fn is_auto_engaged(&self) -> bool {
297        self.auto_engaged
298    }
299
300    /// Returns the current left AI difficulty for demo mode.
301    #[must_use]
302    pub const fn left_difficulty(&self) -> u8 {
303        self.left_ai_difficulty
304    }
305
306    /// Returns the current right AI difficulty for demo mode.
307    #[must_use]
308    pub const fn right_difficulty(&self) -> u8 {
309        self.right_ai_difficulty
310    }
311
312    /// Returns idle time in seconds.
313    #[must_use]
314    pub const fn idle_time(&self) -> f64 {
315        self.idle_time
316    }
317
318    /// Resets the demo state.
319    #[allow(clippy::missing_const_for_fn)] // const fn with mutable ref not yet stable
320    pub fn reset(&mut self) {
321        self.idle_time = 0.0;
322        self.auto_engaged = false;
323        self.difficulty_cycle_time = 0.0;
324    }
325}
326
327/// Performance statistics for display.
328#[derive(Debug, Clone, Default, Serialize, Deserialize)]
329pub struct PerformanceStats {
330    /// Current physics updates per second
331    pub physics_updates_per_sec: u32,
332    /// Current render FPS
333    pub render_fps: f64,
334    /// Physics backend name
335    pub backend_name: String,
336    /// Current speed multiplier
337    pub speed_multiplier: u32,
338}
339
340impl PerformanceStats {
341    /// Creates new stats with the given values.
342    #[must_use]
343    pub fn new(physics_ups: u32, render_fps: f64, backend: &str, speed: u32) -> Self {
344        Self {
345            physics_updates_per_sec: physics_ups,
346            render_fps,
347            backend_name: backend.to_string(),
348            speed_multiplier: speed,
349        }
350    }
351
352    /// Formats the stats for display.
353    #[must_use]
354    pub fn format_display(&self) -> String {
355        format!(
356            "Backend: {} | Physics: {}/s | Render: {:.0} FPS",
357            self.backend_name, self.physics_updates_per_sec, self.render_fps
358        )
359    }
360}
361
362/// Attribution information for footer display.
363#[derive(Debug, Clone)]
364pub struct Attribution {
365    /// Engine name and version
366    pub engine_version: String,
367    /// GitHub repository URL
368    pub github_url: String,
369    /// Organization website URL
370    pub org_url: String,
371    /// AI model filename
372    pub model_filename: String,
373    /// AI model size in bytes
374    pub model_size: u32,
375}
376
377impl Default for Attribution {
378    fn default() -> Self {
379        Self {
380            engine_version: "Jugar Engine v0.1.0".to_string(),
381            github_url: "https://github.com/paiml/jugar".to_string(),
382            org_url: "https://paiml.com".to_string(),
383            model_filename: "pong-ai-v1.apr".to_string(),
384            model_size: 491,
385        }
386    }
387}
388
389impl Attribution {
390    /// Returns the GitHub link label.
391    #[must_use]
392    pub fn github_label(&self) -> String {
393        "github.com/paiml/jugar".to_string()
394    }
395
396    /// Returns the organization link label.
397    #[must_use]
398    pub fn org_label(&self) -> String {
399        "paiml.com".to_string()
400    }
401
402    /// Returns the model download label.
403    #[must_use]
404    pub fn model_label(&self) -> String {
405        format!("{} ({} bytes)", self.model_filename, self.model_size)
406    }
407}
408
409#[cfg(test)]
410mod tests {
411    use super::*;
412
413    // ==================== SpeedMultiplier Tests ====================
414
415    #[test]
416    fn test_speed_multiplier_values() {
417        assert_eq!(SpeedMultiplier::Normal.value(), 1);
418        assert_eq!(SpeedMultiplier::Fast5x.value(), 5);
419        assert_eq!(SpeedMultiplier::Fast10x.value(), 10);
420        assert_eq!(SpeedMultiplier::Fast50x.value(), 50);
421        assert_eq!(SpeedMultiplier::Fast100x.value(), 100);
422        assert_eq!(SpeedMultiplier::Fast1000x.value(), 1000);
423    }
424
425    #[test]
426    fn test_speed_multiplier_warning_threshold() {
427        // Speeds <= 10x should NOT require warning
428        assert!(!SpeedMultiplier::Normal.requires_warning());
429        assert!(!SpeedMultiplier::Fast5x.requires_warning());
430        assert!(!SpeedMultiplier::Fast10x.requires_warning());
431
432        // Speeds > 10x MUST require warning (Safety Condition #1)
433        assert!(SpeedMultiplier::Fast50x.requires_warning());
434        assert!(SpeedMultiplier::Fast100x.requires_warning());
435        assert!(SpeedMultiplier::Fast1000x.requires_warning());
436    }
437
438    #[test]
439    fn test_speed_multiplier_labels() {
440        assert_eq!(SpeedMultiplier::Normal.label(), "1x");
441        assert_eq!(SpeedMultiplier::Fast5x.label(), "5x");
442        assert_eq!(SpeedMultiplier::Fast1000x.label(), "1000x");
443    }
444
445    #[test]
446    fn test_speed_multiplier_cycling() {
447        let speed = SpeedMultiplier::Normal;
448        assert_eq!(speed.next(), SpeedMultiplier::Fast5x);
449        assert_eq!(speed.next().next(), SpeedMultiplier::Fast10x);
450        // Full cycle back to normal
451        assert_eq!(SpeedMultiplier::Fast1000x.next(), SpeedMultiplier::Normal);
452    }
453
454    #[test]
455    fn test_speed_multiplier_from_key() {
456        assert_eq!(SpeedMultiplier::from_key(1), Some(SpeedMultiplier::Normal));
457        assert_eq!(SpeedMultiplier::from_key(2), Some(SpeedMultiplier::Fast5x));
458        assert_eq!(
459            SpeedMultiplier::from_key(6),
460            Some(SpeedMultiplier::Fast1000x)
461        );
462        assert_eq!(SpeedMultiplier::from_key(0), None);
463        assert_eq!(SpeedMultiplier::from_key(7), None);
464    }
465
466    #[test]
467    fn test_speed_multiplier_all() {
468        let all = SpeedMultiplier::all();
469        assert_eq!(all.len(), 6);
470        assert_eq!(all[0], SpeedMultiplier::Normal);
471        assert_eq!(all[5], SpeedMultiplier::Fast1000x);
472    }
473
474    // ==================== GameMode Tests ====================
475
476    #[test]
477    fn test_game_mode_default_is_single_player() {
478        // SinglePlayer is DEFAULT so users can play immediately with W/S keys
479        assert_eq!(GameMode::default(), GameMode::SinglePlayer);
480    }
481
482    #[test]
483    fn test_game_mode_labels() {
484        assert_eq!(GameMode::Demo.label(), "Demo");
485        assert_eq!(GameMode::SinglePlayer.label(), "1 Player");
486        assert_eq!(GameMode::TwoPlayer.label(), "2 Player");
487    }
488
489    #[test]
490    fn test_game_mode_short_labels() {
491        assert_eq!(GameMode::Demo.short_label(), "Demo");
492        assert_eq!(GameMode::SinglePlayer.short_label(), "1P");
493        assert_eq!(GameMode::TwoPlayer.short_label(), "2P");
494    }
495
496    #[test]
497    fn test_game_mode_ai_control() {
498        // Demo: both AI
499        assert!(GameMode::Demo.left_is_ai());
500        assert!(GameMode::Demo.right_is_ai());
501
502        // 1 Player: AI left, Human right (arrow keys)
503        // Player controls RIGHT paddle with arrow keys
504        assert!(GameMode::SinglePlayer.left_is_ai());
505        assert!(!GameMode::SinglePlayer.right_is_ai());
506
507        // 2 Player: both human (P2 left W/S, P1 right arrows)
508        assert!(!GameMode::TwoPlayer.left_is_ai());
509        assert!(!GameMode::TwoPlayer.right_is_ai());
510    }
511
512    #[test]
513    fn test_game_mode_cycling() {
514        assert_eq!(GameMode::Demo.next(), GameMode::SinglePlayer);
515        assert_eq!(GameMode::SinglePlayer.next(), GameMode::TwoPlayer);
516        assert_eq!(GameMode::TwoPlayer.next(), GameMode::Demo);
517    }
518
519    #[test]
520    fn test_game_mode_paddle_labels() {
521        // Demo: both AI
522        assert_eq!(GameMode::Demo.left_paddle_label(), "AI");
523        assert_eq!(GameMode::Demo.right_paddle_label(), "AI");
524
525        // SinglePlayer: AI left, P1 right (human uses arrow keys)
526        assert_eq!(GameMode::SinglePlayer.left_paddle_label(), "AI");
527        assert_eq!(GameMode::SinglePlayer.right_paddle_label(), "P1 [^/v]");
528
529        // TwoPlayer: P2 left (W/S), P1 right (arrows)
530        assert_eq!(GameMode::TwoPlayer.left_paddle_label(), "P2 [W/S]");
531        assert_eq!(GameMode::TwoPlayer.right_paddle_label(), "P1 [^/v]");
532    }
533
534    // ==================== DemoState Tests ====================
535
536    #[test]
537    #[allow(clippy::float_cmp)]
538    fn test_demo_state_default_threshold() {
539        let state = DemoState::default();
540        assert_eq!(state.auto_engage_threshold, 10.0); // 10 seconds per spec
541    }
542
543    #[test]
544    fn test_demo_state_auto_engage_after_timeout() {
545        let mut state = DemoState::default();
546
547        // Simulate 9 seconds - should NOT auto-engage
548        let should_engage = state.update(9.0, false);
549        assert!(!should_engage);
550        assert!(!state.is_auto_engaged());
551
552        // Simulate 1 more second (total 10) - SHOULD auto-engage
553        let should_engage = state.update(1.0, false);
554        assert!(should_engage);
555        assert!(state.is_auto_engaged());
556    }
557
558    #[test]
559    fn test_demo_state_input_resets_idle() {
560        let mut state = DemoState::default();
561
562        // Simulate 8 seconds idle
563        let _ = state.update(8.0, false);
564        assert!(state.idle_time() > 7.0);
565
566        // User input should reset
567        let _ = state.update(0.016, true);
568        assert!(state.idle_time() < 0.1);
569        assert!(!state.is_auto_engaged());
570    }
571
572    #[test]
573    fn test_demo_state_difficulty_defaults() {
574        let state = DemoState::default();
575        assert_eq!(state.left_difficulty(), 7); // Per spec: challenging
576        assert_eq!(state.right_difficulty(), 5); // Per spec: slightly easier
577    }
578
579    #[test]
580    fn test_demo_state_difficulty_cycling() {
581        let mut state = DemoState::default();
582        let initial_left = state.left_difficulty();
583        let initial_right = state.right_difficulty();
584
585        // Simulate 60 seconds
586        state.update_difficulty_cycle(60.0);
587
588        // Difficulties should swap
589        assert_eq!(state.left_difficulty(), initial_right);
590        assert_eq!(state.right_difficulty(), initial_left);
591    }
592
593    #[test]
594    fn test_demo_state_reset() {
595        let mut state = DemoState::default();
596        let _ = state.update(15.0, false); // Auto-engage
597        assert!(state.is_auto_engaged());
598
599        state.reset();
600        assert!(!state.is_auto_engaged());
601        assert!(state.idle_time() < 0.1);
602    }
603
604    // ==================== PerformanceStats Tests ====================
605
606    #[test]
607    fn test_performance_stats_format() {
608        let stats = PerformanceStats::new(60000, 60.0, "WASM-SIMD", 1000);
609        let display = stats.format_display();
610
611        assert!(display.contains("WASM-SIMD"));
612        assert!(display.contains("60000/s"));
613        assert!(display.contains("60 FPS"));
614    }
615
616    // ==================== Attribution Tests ====================
617
618    #[test]
619    fn test_attribution_defaults() {
620        let attr = Attribution::default();
621        assert!(attr.engine_version.contains("Jugar"));
622        assert!(attr.github_url.contains("paiml/jugar"));
623        assert!(attr.org_url.contains("paiml.com"));
624        assert_eq!(attr.model_filename, "pong-ai-v1.apr");
625        assert_eq!(attr.model_size, 491);
626    }
627
628    #[test]
629    fn test_attribution_labels() {
630        let attr = Attribution::default();
631        assert!(attr.github_label().contains("github.com"));
632        assert!(attr.org_label().contains("paiml.com"));
633        assert!(attr.model_label().contains("491 bytes"));
634    }
635
636    // ==================== Coverage Gap Tests ====================
637
638    #[test]
639    fn test_demo_state_record_input_direct() {
640        let mut state = DemoState {
641            idle_time: 5.0,
642            auto_engaged: true,
643            ..Default::default()
644        };
645
646        state.record_input();
647
648        assert!(state.idle_time() < 0.001);
649        assert!(!state.is_auto_engaged());
650    }
651
652    #[test]
653    fn test_demo_state_partial_difficulty_cycle() {
654        let mut state = DemoState::default();
655        let initial_left = state.left_difficulty();
656        let initial_right = state.right_difficulty();
657
658        // Update with less than one period (60s default)
659        state.update_difficulty_cycle(30.0); // Half period
660
661        // Should NOT swap yet
662        assert_eq!(state.left_difficulty(), initial_left);
663        assert_eq!(state.right_difficulty(), initial_right);
664
665        // Update with more time to complete the period
666        state.update_difficulty_cycle(30.0); // Complete the period
667
668        // Now should swap
669        assert_eq!(state.left_difficulty(), initial_right);
670        assert_eq!(state.right_difficulty(), initial_left);
671    }
672
673    #[test]
674    fn test_demo_state_new_accessors() {
675        let state = DemoState::new(10.0, 30.0);
676
677        assert!(state.idle_time() < 0.001);
678        assert_eq!(state.left_difficulty(), 7); // Per spec: challenging
679        assert_eq!(state.right_difficulty(), 5); // Per spec: slightly easier
680    }
681}