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
|
||||
|
||||
- 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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
Reference in new issue
Block a user