fix(scores): restore per-player high-score flow

The rewrite waited for the whole game to finish, selected only the maximum
player score, and sorted entries as ordinary unsigned integers. The original
checks each player immediately when their own last ball is lost and compares
scores by signed high word then unsigned low word.

Emit a candidate at each player elimination, prompt qualifying players with the
original `TDK Pinball Player` value, show the table, and then either resume the
next player or return to attract mode. Preserve source table order on import and
insert only strictly qualifying scores with the original word-wise comparator;
keep JSON as the host persistence representation.

Test Plan:
- `cargo test --all-targets` -- passed, 60 tests
- `cargo clippy --all-targets -- -D warnings` -- passed
- `rumdl check tdkpin-rs/CHANGELOG.md tdkpin-rs/RECONSTRUCTION.md` -- passed
- `git diff --cached --check` -- passed
This commit is contained in:
2026-08-23 17:00:12 +02:00
parent 64d41eb605
commit 3fa3e29ad3
5 changed files with 102 additions and 34 deletions
+25 -23
View File
@@ -1,7 +1,9 @@
use crate::{
assets::Assets,
game::{Controls, Event, Game, Nudge},
persistence::{HighScore, Language, Persistence, SavedData, insert_high_score},
persistence::{
HighScore, Language, Persistence, SavedData, insert_high_score, qualifies_high_score,
},
table::BUMPERS,
};
use macroquad::prelude::*;
@@ -191,10 +193,23 @@ impl App {
let events = self.game.as_mut().map_or_else(Vec::new, |game| {
game.update(get_frame_time(), self.saved.settings.speed, controls)
});
let mut high_score_candidate = None;
for event in events {
if let Event::HighScoreCandidate(score) = event {
high_score_candidate = Some(score);
}
self.play_event(event);
}
if let Some(score) = high_score_candidate
&& qualifies_high_score(&self.saved.high_scores, score)
{
self.pending_score = score;
"TDK Pinball Player".clone_into(&mut self.name);
self.screen = Screen::NameEntry;
return;
}
if self.game.as_ref().is_some_and(|game| game.finished) {
self.finish_game();
}
@@ -207,26 +222,9 @@ impl App {
}
fn finish_game(&mut self) {
self.pending_score = self
.game
.as_ref()
.and_then(|game| game.players.iter().map(|player| player.score).max())
.unwrap_or(0);
let qualifies = self.saved.high_scores.len() < 10
|| self
.saved
.high_scores
.last()
.is_some_and(|entry| self.pending_score > entry.score);
if qualifies {
self.name.clear();
self.screen = Screen::NameEntry;
} else {
self.game = None;
self.return_screen = Screen::Attract;
self.screen = Screen::HighScores;
}
self.assets.play(2020, self.saved.settings.sounds);
self.game = None;
self.return_screen = Screen::Attract;
self.screen = Screen::HighScores;
}
fn update_help(&mut self) {
@@ -315,8 +313,12 @@ impl App {
},
);
self.save();
self.game = None;
self.return_screen = Screen::Attract;
if self.game.as_ref().is_some_and(|game| game.finished) {
self.game = None;
self.return_screen = Screen::Attract;
} else {
self.return_screen = Screen::Playing;
}
self.screen = Screen::HighScores;
}
}
+28 -1
View File
@@ -67,6 +67,7 @@ pub enum Event {
Nudge,
Tilt,
Drain,
HighScoreCandidate(u32),
Sound(u16),
}
@@ -87,7 +88,8 @@ impl Event {
| Self::ExtraBall
| Self::Nudge
| Self::Tilt
| Self::Drain => None,
| Self::Drain
| Self::HighScoreCandidate(_) => None,
}
}
}
@@ -1190,9 +1192,14 @@ impl Game {
} else {
player.balls = player.balls.saturating_sub(1);
}
let high_score_candidate =
(player.balls == 0 && player.extra_balls == 0).then_some(player.score);
self.save_current_rule_state();
events.push(Event::Drain);
events.push(Event::Sound(2008));
if let Some(score) = high_score_candidate {
events.push(Event::HighScoreCandidate(score));
}
self.tilted = false;
self.tilt_counter = 0;
self.tilt_counter_accumulator = 0.0;
@@ -1634,6 +1641,26 @@ mod tests {
assert_eq!(game.multiball_state, MultiballState::Ready);
}
#[test]
fn each_player_reports_a_high_score_candidate_on_their_last_ball() {
let mut game = Game::new(2);
game.players[0].balls = 1;
game.players[0].score = 123_456;
let mut events = Vec::new();
game.drain(&mut events);
assert!(events.contains(&Event::HighScoreCandidate(123_456)));
assert_eq!(game.current_player, 1);
assert!(!game.finished);
events.clear();
game.players[1].balls = 1;
game.players[1].score = 654_321;
game.drain(&mut events);
assert!(events.contains(&Event::HighScoreCandidate(654_321)));
assert!(game.finished);
}
#[test]
fn fast_ball_cannot_tunnel_through_the_top_rail() {
let mut game = Game::new(1);
+44 -9
View File
@@ -1,6 +1,6 @@
use directories::ProjectDirs;
use serde::{Deserialize, Serialize};
use std::{cmp::Reverse, fs, io, path::PathBuf};
use std::{fs, io, path::PathBuf};
const ORIGINAL_HIGHSCORES: &[u8] = include_bytes!("../assets/original/HISCORES.DAT");
@@ -125,7 +125,7 @@ pub fn parse_original_high_scores(bytes: &[u8]) -> Vec<HighScore> {
let scores = &bytes[HEADER..HEADER + COUNT * 4];
let names = &bytes[HEADER + COUNT * 4..];
let mut result = (0..COUNT)
(0..COUNT)
.map(|index| {
let score_start = index * 4;
let score = u32::from_le_bytes([
@@ -145,17 +145,38 @@ pub fn parse_original_high_scores(bytes: &[u8]) -> Vec<HighScore> {
score,
}
})
.collect::<Vec<_>>();
result.sort_by_key(|entry| Reverse(entry.score));
result
.collect()
}
pub fn insert_high_score(scores: &mut Vec<HighScore>, entry: HighScore) {
scores.push(entry);
scores.sort_by_key(|entry| Reverse(entry.score));
let Some(index) = scores
.iter()
.position(|existing| score_is_greater(entry.score, existing.score))
.or_else(|| (scores.len() < 10).then_some(scores.len()))
else {
return;
};
scores.insert(index, entry);
scores.truncate(10);
}
pub fn qualifies_high_score(scores: &[HighScore], score: u32) -> bool {
scores.len() < 10
|| scores
.last()
.is_some_and(|entry| score_is_greater(score, entry.score))
}
fn score_is_greater(left: u32, right: u32) -> bool {
let [left_0, left_1, left_2, left_3] = left.to_le_bytes();
let [right_0, right_1, right_2, right_3] = right.to_le_bytes();
let left_high = i16::from_le_bytes([left_2, left_3]);
let right_high = i16::from_le_bytes([right_2, right_3]);
let left_low = u16::from_le_bytes([left_0, left_1]);
let right_low = u16::from_le_bytes([right_0, right_1]);
left_high > right_high || (left_high == right_high && left_low > right_low)
}
#[cfg(test)]
mod tests {
use super::*;
@@ -181,16 +202,30 @@ mod tests {
}
#[test]
fn new_score_is_inserted_and_table_stays_bounded() {
fn qualifying_score_is_inserted_and_table_stays_bounded() {
let mut scores = parse_original_high_scores(ORIGINAL_HIGHSCORES);
insert_high_score(
&mut scores,
HighScore {
name: "TEST".to_owned(),
score: u32::MAX,
score: 6_537_393,
},
);
assert_eq!(scores.len(), 10);
assert_eq!(scores[0].name, "TEST");
}
#[test]
fn comparison_uses_the_original_signed_high_word() {
let mut scores = parse_original_high_scores(ORIGINAL_HIGHSCORES);
insert_high_score(
&mut scores,
HighScore {
name: "WRAPPED".to_owned(),
score: u32::MAX,
},
);
assert!(!scores.iter().any(|entry| entry.name == "WRAPPED"));
assert!(!qualifies_high_score(&scores, u32::MAX));
}
}