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
- 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.
+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
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
+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;
- `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.
+17 -6
View File
@@ -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),
);
}
}
}
+44 -18
View File
@@ -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);
}
+18
View File
@@ -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> {
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,
},
);