From 3a00be566139cfba06b3941fb2f8dbbbf4cd4b15 Mon Sep 17 00:00:00 2001 From: ddidderr Date: Sat, 10 Oct 2026 11:14:40 +0200 Subject: [PATCH] feat: label crazy-mode high scores instead of hiding them Previously a game that used crazy mode kept its local high score but skipped the online submission entirely. The requirement changed: crazy scores should count like any other score, but be recognisable as crazy. Changes: - Crazy-mode use is now tracked per player (Player::crazy_mode_used) rather than per game. Balls dropped during one player's turn cannot outlive that turn, so only the player who pressed the key is marked; the other players' scores stay normal. - Event::HighScoreCandidate carries { score, crazy } so the app knows which kind of score the name-entry dialog is about to record. - HighScore gains `crazy: bool` with #[serde(default)]. Save files and server responses from before this change load as normal scores. - The high-score table draws a small red "CRAZY" tag in the empty column right of the score. That column is free regardless of name or score length, so the tag never overlaps text. - The browser build submits every score again, flag included. The companion server change stores it; an older server ignores the field. Test Plan: - cargo test - crazy_scores_are_flagged_on_the_high_score_candidate - high_scores_without_a_crazy_flag_load_as_normal_scores - Manual: use crazy mode, finish with a qualifying score, and check the table (F9) shows the entry with the red CRAZY tag; a normal game's entry has none. --- tdkpin-rs/CHANGELOG.md | 5 +-- tdkpin-rs/README.md | 6 ++-- tdkpin-rs/RECONSTRUCTION.md | 5 ++- tdkpin-rs/src/app.rs | 23 +++++++++---- tdkpin-rs/src/game.rs | 62 +++++++++++++++++++++++++----------- tdkpin-rs/src/persistence.rs | 18 +++++++++++ 6 files changed, 89 insertions(+), 30 deletions(-) diff --git a/tdkpin-rs/CHANGELOG.md b/tdkpin-rs/CHANGELOG.md index ed7ea5f..8aed67b 100644 --- a/tdkpin-rs/CHANGELOG.md +++ b/tdkpin-rs/CHANGELOG.md @@ -11,8 +11,9 @@ and this project adheres to ### Added - Crazy mode: Ctrl+Shift+0 drops ten extra balls onto the playfield. They bounce - and score off walls, bumpers, and flippers, replace the main ball when it - drains, and keep the game's scores off the online high-score table. + and score off walls, bumpers, and flippers and replace the main ball when it + drains. High-score entries of players who used it show a red `CRAZY` label in + the table and are shared like any other score. - The high-score service stores a crazy-mode flag per entry; it adds the column to existing databases on startup and treats submissions from older clients as normal scores. diff --git a/tdkpin-rs/README.md b/tdkpin-rs/README.md index 7dbfbcc..5d5c291 100644 --- a/tdkpin-rs/README.md +++ b/tdkpin-rs/README.md @@ -81,9 +81,9 @@ for long end-to-end validation runs. Crazy mode is a modern addition. Its balls bounce off walls, bumpers, and flippers and score through them, but skip sensors, holes, and the claw. When -the main ball drains, a crazy ball takes its place before the turn ends. Scores -from games that used crazy mode are not submitted to the online high-score -table. +the main ball drains, a crazy ball takes its place before the turn ends. A +player who used crazy mode still enters the high-score table, with their entry +labelled `CRAZY`. The five original speed choices remain available in settings. Physics now uses the original invariant 100 Hz millipixel substep, batched as 5/4/3/2/1 steps on diff --git a/tdkpin-rs/RECONSTRUCTION.md b/tdkpin-rs/RECONSTRUCTION.md index 26e4be5..b6ec48a 100644 --- a/tdkpin-rs/RECONSTRUCTION.md +++ b/tdkpin-rs/RECONSTRUCTION.md @@ -95,4 +95,7 @@ decoded, build-ready subset; it does not replace that evidence archive. - `real48.rs`: bit-exact Borland six-byte arithmetic, conversion, and square root; - `flipper_physics.rs`: reconstructed moving-flipper geometry and response; - `table.rs`: source-traceable collision objects recovered from the Win16 table; -- `persistence.rs`: platform paths, settings, high scores, and legacy import. +- `persistence.rs`: platform paths, settings, high scores (with the optional + crazy-mode label), and legacy import; +- `highscore-server/`: shared SQLite top-ten service; it migrates databases + from before crazy mode by adding the `crazy` column. diff --git a/tdkpin-rs/src/app.rs b/tdkpin-rs/src/app.rs index 0b39dff..1a94f0a 100644 --- a/tdkpin-rs/src/app.rs +++ b/tdkpin-rs/src/app.rs @@ -222,6 +222,7 @@ pub struct App { last_help_click: f64, name: String, pending_score: u32, + pending_crazy: bool, attract: AttractAnimation, /// Ctrl+Shift+0 holds Shift, whose release would otherwise nudge. crazy_shift_nudge_suppressed: bool, @@ -252,6 +253,7 @@ impl App { last_help_click: -1.0, name: String::new(), pending_score: 0, + pending_crazy: false, attract: AttractAnimation::default(), crazy_shift_nudge_suppressed: false, } @@ -438,16 +440,17 @@ impl App { }); let mut high_score_candidate = None; for event in events { - if let Event::HighScoreCandidate(score) = event { - high_score_candidate = Some(score); + if let Event::HighScoreCandidate { score, crazy } = event { + high_score_candidate = Some((score, crazy)); } self.play_event(event); } - if let Some(score) = high_score_candidate + if let Some((score, crazy)) = high_score_candidate && qualifies_high_score(&self.saved.high_scores, score) { self.pending_score = score; + self.pending_crazy = crazy; "TDK Pinball Player".clone_into(&mut self.name); self.screen = Screen::NameEntry; return; @@ -582,12 +585,11 @@ impl App { let entry = HighScore { name: self.name.clone(), score: self.pending_score, + crazy: self.pending_crazy, }; insert_high_score(&mut self.saved.high_scores, entry.clone()); #[cfg(target_arch = "wasm32")] - if !self.game.as_ref().is_some_and(Game::crazy_mode_used) { - self.persistence.submit_high_score(&entry); - } + self.persistence.submit_high_score(&entry); self.save(); if self.game.as_ref().is_some_and(|game| game.finished) { self.take_game_and_preserve_random_seed(); @@ -1248,6 +1250,15 @@ impl App { let score = entry.score.to_string(); let width = measure_text(&score, None, 16, 1.0).width; draw_text(&score, 460.0 - width, y, 16.0, BLACK); + if entry.crazy { + draw_text( + "CRAZY", + 464.0, + y - 1.0, + 11.0, + Color::from_rgba(192, 0, 0, 255), + ); + } } } diff --git a/tdkpin-rs/src/game.rs b/tdkpin-rs/src/game.rs index 7dd1723..02ef9df 100644 --- a/tdkpin-rs/src/game.rs +++ b/tdkpin-rs/src/game.rs @@ -88,7 +88,7 @@ pub enum Event { Nudge, Tilt, Drain, - HighScoreCandidate(u32), + HighScoreCandidate { score: u32, crazy: bool }, StopSound, Sound(u16), } @@ -111,7 +111,7 @@ impl Event { | Self::Nudge | Self::Tilt | Self::Drain - | Self::HighScoreCandidate(_) + | Self::HighScoreCandidate { .. } | Self::StopSound => None, } } @@ -208,9 +208,18 @@ pub struct Player { pub bumper_value: u32, pub diamond_segments: u8, pub media_level: u8, + crazy_mode: CrazyMode, rules: RuleState, } +impl Player { + /// Whether this player dropped crazy-mode balls during the game. Their + /// high score is labelled accordingly. + pub fn crazy_mode_used(&self) -> bool { + self.crazy_mode == CrazyMode::Used + } +} + impl Default for Player { fn default() -> Self { Self { @@ -222,6 +231,7 @@ impl Default for Player { bumper_value: 1_000, diamond_segments: 0, media_level: 0, + crazy_mode: CrazyMode::Unused, rules: RuleState::default(), } } @@ -428,7 +438,6 @@ pub struct Game { special_hole_gate: SpecialHoleGate, score_mode: ScoreMode, ball_double: BallDoubleState, - crazy_mode: CrazyMode, } impl Game { @@ -475,7 +484,6 @@ impl Game { special_hole_gate: SpecialHoleGate::Enabled, score_mode: ScoreMode::Normal, ball_double: BallDoubleState::Inactive, - crazy_mode: CrazyMode::Unused, } } @@ -513,18 +521,12 @@ impl Game { true } - /// Whether crazy mode was used, so its scores stay off the online table. - #[cfg(any(target_arch = "wasm32", test))] - pub fn crazy_mode_used(&self) -> bool { - self.crazy_mode == CrazyMode::Used - } - /// Crazy mode: drop ten extra balls onto the playfield. pub fn add_crazy_balls(&mut self) -> bool { if self.finished { return false; } - self.crazy_mode = CrazyMode::Used; + self.players[self.current_player].crazy_mode = CrazyMode::Used; self.crazy_balls .extend(CRAZY_BALL_POSITIONS.into_iter().map(|position| Ball { position, @@ -2504,14 +2506,17 @@ impl Game { player.balls = player.balls.saturating_sub(1); } let high_score_candidate = - (player.balls == 0 && player.extra_balls == 0).then_some(player.score); + (player.balls == 0 && player.extra_balls == 0).then_some(Event::HighScoreCandidate { + score: player.score, + crazy: player.crazy_mode_used(), + }); self.save_current_rule_state(); events.push(Event::Drain); if !self.tilted { events.push(Event::Sound(2008)); } - if let Some(score) = high_score_candidate { - events.push(Event::HighScoreCandidate(score)); + if let Some(candidate) = high_score_candidate { + events.push(candidate); self.flipper_release_latch[0] |= self.flipper_inputs.left_raised; self.flipper_release_latch[1] |= self.flipper_inputs.right_raised; self.flipper_inputs = Flippers::default(); @@ -2749,7 +2754,7 @@ mod tests { fn crazy_mode_adds_ten_balls_that_fall_and_score() { let mut game = Game::new_with_seed(1, 7); assert!(game.add_crazy_balls()); - assert!(game.crazy_mode_used()); + assert!(game.player().crazy_mode_used()); assert_eq!(game.crazy_balls.len(), 10); let start: Vec<_> = game.crazy_balls.iter().map(|ball| ball.position).collect(); for _ in 0..20 { @@ -2787,13 +2792,28 @@ mod tests { assert!(game.ball.in_launcher); } + #[test] + fn crazy_scores_are_flagged_on_the_high_score_candidate() { + let mut game = Game::new_with_seed(1, 7); + game.add_crazy_balls(); + game.crazy_balls.clear(); + game.players[0].balls = 1; + game.players[0].score = 1_234; + let mut events = Vec::new(); + game.drain(&mut events); + assert!(events.contains(&Event::HighScoreCandidate { + score: 1_234, + crazy: true, + })); + } + #[test] fn finished_game_rejects_crazy_mode() { let mut game = Game::new_with_seed(1, 7); game.finished = true; assert!(!game.add_crazy_balls()); assert!(game.crazy_balls.is_empty()); - assert!(!game.crazy_mode_used()); + assert!(!game.player().crazy_mode_used()); } #[test] @@ -4032,7 +4052,10 @@ mod tests { let mut events = Vec::new(); game.drain(&mut events); - assert!(events.contains(&Event::HighScoreCandidate(123_456))); + assert!(events.contains(&Event::HighScoreCandidate { + score: 123_456, + crazy: false, + })); assert_eq!(game.current_player, 1); assert!(!game.finished); @@ -4040,7 +4063,10 @@ mod tests { game.players[1].balls = 1; game.players[1].score = 654_321; game.drain(&mut events); - assert!(events.contains(&Event::HighScoreCandidate(654_321))); + assert!(events.contains(&Event::HighScoreCandidate { + score: 654_321, + crazy: false, + })); assert!(game.finished); } diff --git a/tdkpin-rs/src/persistence.rs b/tdkpin-rs/src/persistence.rs index 2cf375a..8793d72 100644 --- a/tdkpin-rs/src/persistence.rs +++ b/tdkpin-rs/src/persistence.rs @@ -62,6 +62,10 @@ impl Default for Settings { pub struct HighScore { pub name: String, pub score: u32, + /// Set when the score was reached with crazy-mode balls. Saved tables + /// and server responses from before crazy mode omit it. + #[serde(default)] + pub crazy: bool, } #[derive(Clone, Debug, Deserialize, Serialize)] @@ -244,6 +248,7 @@ pub fn parse_original_high_scores(bytes: &[u8]) -> Vec { HighScore { name: String::from_utf8_lossy(&name_bytes[..length]).into_owned(), score, + crazy: false, } }) .collect() @@ -335,6 +340,7 @@ mod tests { HighScore { name: "TEST".to_owned(), score: 6_537_393, + crazy: false, }, ); assert_eq!(scores.len(), 10); @@ -349,12 +355,23 @@ mod tests { HighScore { name: "WRAPPED".to_owned(), score: u32::MAX, + crazy: false, }, ); assert!(!scores.iter().any(|entry| entry.name == "WRAPPED")); assert!(!qualifies_high_score(&scores, u32::MAX)); } + #[test] + fn high_scores_without_a_crazy_flag_load_as_normal_scores() { + let entry: HighScore = serde_json::from_str(r#"{"name":"Paul","score":42}"#) + .expect("pre-crazy-mode entries must still load"); + assert!(!entry.crazy); + let crazy: HighScore = serde_json::from_str(r#"{"name":"Paul","score":42,"crazy":true}"#) + .expect("crazy entries load"); + assert!(crazy.crazy); + } + #[test] fn original_dialog_allows_an_accepted_empty_name() { let mut scores = parse_original_high_scores(ORIGINAL_HIGHSCORES); @@ -363,6 +380,7 @@ mod tests { HighScore { name: String::new(), score: 6_537_393, + crazy: false, }, );