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.
This commit is contained in:
ddidderr committed 2026-10-10 11:14:40 +02:00
1 parent 1a216dc795
commit 3a00be5661
6 files changed
+89 -30

No files matched your search

+3 -2
View File
@@ -11,8 +11,9 @@ and this project adheres to
### Added ### Added
- Crazy mode: Ctrl+Shift+0 drops ten extra balls onto the playfield. They bounce - 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 and score off walls, bumpers, and flippers and replace the main ball when it
drains, and keep the game's scores off the online high-score table. 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 - 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 to existing databases on startup and treats submissions from older clients as
normal scores. normal scores.
+3 -3
View File
@@ -81,9 +81,9 @@ for long end-to-end validation runs.
Crazy mode is a modern addition. Its balls bounce off walls, bumpers, and 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 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 the main ball drains, a crazy ball takes its place before the turn ends. A
from games that used crazy mode are not submitted to the online high-score player who used crazy mode still enters the high-score table, with their entry
table. labelled `CRAZY`.
The five original speed choices remain available in settings. Physics now uses 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 the original invariant 100 Hz millipixel substep, batched as 5/4/3/2/1 steps on
+4 -1
View File
@@ -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; - `real48.rs`: bit-exact Borland six-byte arithmetic, conversion, and square root;
- `flipper_physics.rs`: reconstructed moving-flipper geometry and response; - `flipper_physics.rs`: reconstructed moving-flipper geometry and response;
- `table.rs`: source-traceable collision objects recovered from the Win16 table; - `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.
+17 -6
View File
@@ -222,6 +222,7 @@ pub struct App {
last_help_click: f64, last_help_click: f64,
name: String, name: String,
pending_score: u32, pending_score: u32,
pending_crazy: bool,
attract: AttractAnimation, attract: AttractAnimation,
/// Ctrl+Shift+0 holds Shift, whose release would otherwise nudge. /// Ctrl+Shift+0 holds Shift, whose release would otherwise nudge.
crazy_shift_nudge_suppressed: bool, crazy_shift_nudge_suppressed: bool,
@@ -252,6 +253,7 @@ impl App {
last_help_click: -1.0, last_help_click: -1.0,
name: String::new(), name: String::new(),
pending_score: 0, pending_score: 0,
pending_crazy: false,
attract: AttractAnimation::default(), attract: AttractAnimation::default(),
crazy_shift_nudge_suppressed: false, crazy_shift_nudge_suppressed: false,
} }
@@ -438,16 +440,17 @@ impl App {
}); });
let mut high_score_candidate = None; let mut high_score_candidate = None;
for event in events { for event in events {
if let Event::HighScoreCandidate(score) = event { if let Event::HighScoreCandidate { score, crazy } = event {
high_score_candidate = Some(score); high_score_candidate = Some((score, crazy));
} }
self.play_event(event); 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) && qualifies_high_score(&self.saved.high_scores, score)
{ {
self.pending_score = score; self.pending_score = score;
self.pending_crazy = crazy;
"TDK Pinball Player".clone_into(&mut self.name); "TDK Pinball Player".clone_into(&mut self.name);
self.screen = Screen::NameEntry; self.screen = Screen::NameEntry;
return; return;
@@ -582,12 +585,11 @@ impl App {
let entry = HighScore { let entry = HighScore {
name: self.name.clone(), name: self.name.clone(),
score: self.pending_score, score: self.pending_score,
crazy: self.pending_crazy,
}; };
insert_high_score(&mut self.saved.high_scores, entry.clone()); insert_high_score(&mut self.saved.high_scores, entry.clone());
#[cfg(target_arch = "wasm32")] #[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(); self.save();
if self.game.as_ref().is_some_and(|game| game.finished) { if self.game.as_ref().is_some_and(|game| game.finished) {
self.take_game_and_preserve_random_seed(); self.take_game_and_preserve_random_seed();
@@ -1248,6 +1250,15 @@ impl App {
let score = entry.score.to_string(); let score = entry.score.to_string();
let width = measure_text(&score, None, 16, 1.0).width; let width = measure_text(&score, None, 16, 1.0).width;
draw_text(&score, 460.0 - width, y, 16.0, BLACK); 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),
);
}
} }
} }
+44 -18
View File
@@ -88,7 +88,7 @@ pub enum Event {
Nudge, Nudge,
Tilt, Tilt,
Drain, Drain,
HighScoreCandidate(u32), HighScoreCandidate { score: u32, crazy: bool },
StopSound, StopSound,
Sound(u16), Sound(u16),
} }
@@ -111,7 +111,7 @@ impl Event {
| Self::Nudge | Self::Nudge
| Self::Tilt | Self::Tilt
| Self::Drain | Self::Drain
| Self::HighScoreCandidate(_) | Self::HighScoreCandidate { .. }
| Self::StopSound => None, | Self::StopSound => None,
} }
} }
@@ -208,9 +208,18 @@ pub struct Player {
pub bumper_value: u32, pub bumper_value: u32,
pub diamond_segments: u8, pub diamond_segments: u8,
pub media_level: u8, pub media_level: u8,
crazy_mode: CrazyMode,
rules: RuleState, 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 { impl Default for Player {
fn default() -> Self { fn default() -> Self {
Self { Self {
@@ -222,6 +231,7 @@ impl Default for Player {
bumper_value: 1_000, bumper_value: 1_000,
diamond_segments: 0, diamond_segments: 0,
media_level: 0, media_level: 0,
crazy_mode: CrazyMode::Unused,
rules: RuleState::default(), rules: RuleState::default(),
} }
} }
@@ -428,7 +438,6 @@ pub struct Game {
special_hole_gate: SpecialHoleGate, special_hole_gate: SpecialHoleGate,
score_mode: ScoreMode, score_mode: ScoreMode,
ball_double: BallDoubleState, ball_double: BallDoubleState,
crazy_mode: CrazyMode,
} }
impl Game { impl Game {
@@ -475,7 +484,6 @@ impl Game {
special_hole_gate: SpecialHoleGate::Enabled, special_hole_gate: SpecialHoleGate::Enabled,
score_mode: ScoreMode::Normal, score_mode: ScoreMode::Normal,
ball_double: BallDoubleState::Inactive, ball_double: BallDoubleState::Inactive,
crazy_mode: CrazyMode::Unused,
} }
} }
@@ -513,18 +521,12 @@ impl Game {
true 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. /// Crazy mode: drop ten extra balls onto the playfield.
pub fn add_crazy_balls(&mut self) -> bool { pub fn add_crazy_balls(&mut self) -> bool {
if self.finished { if self.finished {
return false; return false;
} }
self.crazy_mode = CrazyMode::Used; self.players[self.current_player].crazy_mode = CrazyMode::Used;
self.crazy_balls self.crazy_balls
.extend(CRAZY_BALL_POSITIONS.into_iter().map(|position| Ball { .extend(CRAZY_BALL_POSITIONS.into_iter().map(|position| Ball {
position, position,
@@ -2504,14 +2506,17 @@ impl Game {
player.balls = player.balls.saturating_sub(1); player.balls = player.balls.saturating_sub(1);
} }
let high_score_candidate = 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(); self.save_current_rule_state();
events.push(Event::Drain); events.push(Event::Drain);
if !self.tilted { if !self.tilted {
events.push(Event::Sound(2008)); events.push(Event::Sound(2008));
} }
if let Some(score) = high_score_candidate { if let Some(candidate) = high_score_candidate {
events.push(Event::HighScoreCandidate(score)); events.push(candidate);
self.flipper_release_latch[0] |= self.flipper_inputs.left_raised; self.flipper_release_latch[0] |= self.flipper_inputs.left_raised;
self.flipper_release_latch[1] |= self.flipper_inputs.right_raised; self.flipper_release_latch[1] |= self.flipper_inputs.right_raised;
self.flipper_inputs = Flippers::default(); self.flipper_inputs = Flippers::default();
@@ -2749,7 +2754,7 @@ mod tests {
fn crazy_mode_adds_ten_balls_that_fall_and_score() { fn crazy_mode_adds_ten_balls_that_fall_and_score() {
let mut game = Game::new_with_seed(1, 7); let mut game = Game::new_with_seed(1, 7);
assert!(game.add_crazy_balls()); assert!(game.add_crazy_balls());
assert!(game.crazy_mode_used()); assert!(game.player().crazy_mode_used());
assert_eq!(game.crazy_balls.len(), 10); assert_eq!(game.crazy_balls.len(), 10);
let start: Vec<_> = game.crazy_balls.iter().map(|ball| ball.position).collect(); let start: Vec<_> = game.crazy_balls.iter().map(|ball| ball.position).collect();
for _ in 0..20 { for _ in 0..20 {
@@ -2787,13 +2792,28 @@ mod tests {
assert!(game.ball.in_launcher); 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] #[test]
fn finished_game_rejects_crazy_mode() { fn finished_game_rejects_crazy_mode() {
let mut game = Game::new_with_seed(1, 7); let mut game = Game::new_with_seed(1, 7);
game.finished = true; game.finished = true;
assert!(!game.add_crazy_balls()); assert!(!game.add_crazy_balls());
assert!(game.crazy_balls.is_empty()); assert!(game.crazy_balls.is_empty());
assert!(!game.crazy_mode_used()); assert!(!game.player().crazy_mode_used());
} }
#[test] #[test]
@@ -4032,7 +4052,10 @@ mod tests {
let mut events = Vec::new(); let mut events = Vec::new();
game.drain(&mut events); 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_eq!(game.current_player, 1);
assert!(!game.finished); assert!(!game.finished);
@@ -4040,7 +4063,10 @@ mod tests {
game.players[1].balls = 1; game.players[1].balls = 1;
game.players[1].score = 654_321; game.players[1].score = 654_321;
game.drain(&mut events); game.drain(&mut events);
assert!(events.contains(&Event::HighScoreCandidate(654_321))); assert!(events.contains(&Event::HighScoreCandidate {
score: 654_321,
crazy: false,
}));
assert!(game.finished); assert!(game.finished);
} }
+18
View File
@@ -62,6 +62,10 @@ impl Default for Settings {
pub struct HighScore { pub struct HighScore {
pub name: String, pub name: String,
pub score: u32, 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)] #[derive(Clone, Debug, Deserialize, Serialize)]
@@ -244,6 +248,7 @@ pub fn parse_original_high_scores(bytes: &[u8]) -> Vec<HighScore> {
HighScore { HighScore {
name: String::from_utf8_lossy(&name_bytes[..length]).into_owned(), name: String::from_utf8_lossy(&name_bytes[..length]).into_owned(),
score, score,
crazy: false,
} }
}) })
.collect() .collect()
@@ -335,6 +340,7 @@ mod tests {
HighScore { HighScore {
name: "TEST".to_owned(), name: "TEST".to_owned(),
score: 6_537_393, score: 6_537_393,
crazy: false,
}, },
); );
assert_eq!(scores.len(), 10); assert_eq!(scores.len(), 10);
@@ -349,12 +355,23 @@ mod tests {
HighScore { HighScore {
name: "WRAPPED".to_owned(), name: "WRAPPED".to_owned(),
score: u32::MAX, score: u32::MAX,
crazy: false,
}, },
); );
assert!(!scores.iter().any(|entry| entry.name == "WRAPPED")); assert!(!scores.iter().any(|entry| entry.name == "WRAPPED"));
assert!(!qualifies_high_score(&scores, u32::MAX)); 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] #[test]
fn original_dialog_allows_an_accepted_empty_name() { fn original_dialog_allows_an_accepted_empty_name() {
let mut scores = parse_original_high_scores(ORIGINAL_HIGHSCORES); let mut scores = parse_original_high_scores(ORIGINAL_HIGHSCORES);
@@ -363,6 +380,7 @@ mod tests {
HighScore { HighScore {
name: String::new(), name: String::new(),
score: 6_537_393, score: 6_537_393,
crazy: false,
}, },
); );