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:
1 parent
1a216dc795
commit
3a00be5661
6 files changed
+89
-30
No files matched your search
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user