7 Commits
Author SHA1 Message Date
ddidderr c4fb345197 chore(release): prepare v1.2.0
Bump the package version to 1.2.0 for the high-score demo database seeding, service hardening, web retry bounds, and multiball parity and divergence fixes. Update CHANGELOG.md and refresh the tracked web WASM artifact.

Test Plan:
- `just test` -- passed (142 game tests and 8 service tests)
- `just clippy` -- passed
- `just build-production` -- passed
- `just web-build` -- passed
- `cargo +nightly fmt --all -- --check` -- passed
- `rumdl check --flavor commonmark tdkpin-rs/CHANGELOG.md` -- passed
- `cargo metadata --locked --format-version 1 --no-deps` -- passed
- `git diff --cached --check` -- passed
2026-08-31 20:48:59 +02:00
ddidderr 4385a661b3 web 2026-08-31 20:46:20 +02:00
ddidderr a6967b4d1b fix(divergence): reject occupied multiball wheel slots
The original leaves contact owner 2 after either ball fills a wheel hole during
multiball. Once play collapses to one ball, that sentinel permits one more
capture and award in the visibly occupied hole before becoming permanent.

Deliberately replace the completed wheel contact with the permanent 99 sentinel
for both ball slots. This keeps visual occupancy, collision admission, and
scoring consistent: a filled hole cannot consume or reward another ball. The
special reserve hole and claw retain their original contact behavior.

Document `divergence` as the required Conventional Commit scope for future fixes
that intentionally differ from original-game behavior.

Test Plan:
- `just test` -- passed (142 game tests and 8 service tests)
- `just clippy` -- passed
- `just build-production` -- passed
- `cargo +nightly fmt --all -- --check` -- passed
- `rumdl check --flavor commonmark CHANGELOG.md AGENTS.md` -- passed
- `git diff --cached --check` -- passed
2026-08-31 19:54:11 +02:00
ddidderr a394df23e7 fix(multiball): match capture collapse timing
Capture removal and ordinary drains do not clear the original game's shared
multiball flag at the same point. The clone treated both transitions alike,
which could end double scoring before ball two's remaining slot pass or leave
it active after ball two entered a capture hole. It also failed to pause the
returning claw until collapse and to clear the flag on a fifth lock.

Collapse capture-driven multiball state at the next timer callback, retain the
immediate drain behavior, and end the mode explicitly when all five lock
contacts complete. Calculate lock awards from the live contact words so a
second ball still settling in another hole is counted. Stage effect-seven slot
creation until the primary substep batch returns, matching the timer's spawn
request ordering.

The original leaves a multiball capture contact as value 2, so a later single
ball may enter that same wheel slot once more and convert it to the permanent
99 sentinel. This possibly unintended original-game quirk remains for binary
parity.

Test Plan:
- `just test` -- passed (141 game tests and 8 service tests)
- `just clippy` -- passed
- `just build-production` -- passed
- `cargo +nightly fmt --all -- --check` -- passed
- `rumdl check --flavor commonmark CHANGELOG.md` -- passed
- `git diff --cached --check` -- passed
2026-08-31 19:54:10 +02:00
ddidderr d5336a2a92 build: production profile for highscore-server 2026-08-29 20:50:41 +02:00
ddidderr 8e1a1c91e2 feat(highscores): seed new server databases with demo scores
New SQLite high-score databases were previously created empty, so the first
shared table had no original entries. Seed only database paths that did not
exist before opening with the ten distributed demo scores, while leaving
existing files and in-memory stores unchanged. Add coverage for both first
creation and existing empty files, and document the behavior.

Test Plan:
- `just test-highscore-server` -- passed (8 tests)
- `just clippy-highscore-server` -- passed
- `cargo +nightly fmt --manifest-path highscore-server/Cargo.toml -- --check` -- passed
- `git diff --cached --check` -- passed
2026-08-29 20:44:05 +02:00
ddidderr 9023af7e7e fix(web): define high-score retry bounds
The browser storage plugin initializes and updates its high-score retry
backoff, but the constants supplying its initial and maximum delays were
lost during the merge that combined the retry and relative-endpoint fixes.
Define the intended 250 ms initial delay and 30 s cap so the plugin can load
and retain bounded retry behavior after transient submission failures.

Test Plan:
- `node --check tdkpin-rs/web/storage.js` -- passed
- Node VM top-level load harness -- passed
- `prettier --check tdkpin-rs/web/storage.js` -- passed
- `cargo +nightly fmt --all -- --check` -- passed
- `just test` -- passed (144 tests)
- `just clippy` -- passed
- `git diff --cached --check` -- passed
2026-08-29 20:18:53 +02:00
11 changed files with 258 additions and 14 deletions
+4
View File
@@ -5,6 +5,10 @@
Automatically commit changes once a full feature, bugfix, refactor, or other Automatically commit changes once a full feature, bugfix, refactor, or other
coherent unit of work is finished. Do not wait for the user to ask for a commit. coherent unit of work is finished. Do not wait for the user to ask for a commit.
Use `divergence` as the Conventional Commit scope for every change that
deliberately differs from original-game behavior to fix an original bug, for
example `fix(divergence): reject occupied wheel slots`.
## Versioning Policy ## Versioning Policy
Only update the version, when the user explicitly asks for it. Only update the version, when the user explicitly asks for it.
+31
View File
@@ -8,6 +8,37 @@ and this project adheres to
## [Unreleased] ## [Unreleased]
## [1.2.0] - 2026-08-31
### Added
- Seed new SQLite high-score databases that did not exist before opening with
the ten distributed demo scores.
### Fixed
- Deliberately diverge from the original game's multiball contact sentinel so a
reserve ball that fills a wheel hole leaves it permanently occupied; the
surviving ball can no longer enter and score that visibly filled slot again.
- Match capture-driven multiball collapse timing so the surviving slot keeps
double scoring through the current callback, then returns to normal scoring on
the next callback and the returning claw remains paused until that collapse;
completing the fifth wheel lock still ends multiball scoring immediately.
- Derive wheel-lock awards from the original live contact words so two balls
settling into different slots at once receive the same accumulated award as
the original game.
- Stage an effect-seven reserve-ball request until ball 1's full substep batch
has returned, preventing the new slot from participating in collision and
capture rules before the original creates it.
- Bound high-score server resource usage with 1 KiB route body limits,
single-operation database concurrency gating, 503 shedding, and offloading
SQLite operations to blocking worker threads.
- Use a relative path for the web build's high-score API endpoint so deployments
under nested URL subpaths route requests correctly.
- Implement bounded exponential backoff with jitter, initial delay and maximum
delay bounds, and `Retry-After` header support for failed web high-score
submissions.
## [1.1.0] - 2026-08-29 ## [1.1.0] - 2026-08-29
### Added ### Added
+1 -1
View File
@@ -551,7 +551,7 @@ dependencies = [
[[package]] [[package]]
name = "tdkpin-rs" name = "tdkpin-rs"
version = "1.1.0" version = "1.2.0"
dependencies = [ dependencies = [
"directories", "directories",
"futures-util", "futures-util",
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "tdkpin-rs" name = "tdkpin-rs"
version = "1.1.0" version = "1.2.0"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
+19
View File
@@ -24,3 +24,22 @@ unwrap_used = "warn"
[lints.rust] [lints.rust]
unsafe_code = "forbid" unsafe_code = "forbid"
[profile.release]
debug = true
strip = false
debug-assertions = true
overflow-checks = true
lto = false
panic = "unwind"
incremental = true
[profile.production]
inherits = "release"
debug = false
strip = true
debug-assertions = false
overflow-checks = false
lto = true
incremental = false
codegen-units = 1
+3
View File
@@ -11,6 +11,9 @@ POST /api/highscores
The POST body is JSON with a 21-character maximum name and a `u32` score. The The POST body is JSON with a 21-character maximum name and a `u32` score. The
response is the canonical top-ten JSON array. response is the canonical top-ten JSON array.
A newly created database starts with the original distributed demo table. An
existing database, including an existing empty database, is left unchanged.
Submissions are anonymous and intentionally trust the browser's score. Add Submissions are anonymous and intentionally trust the browser's score. Add
rate limiting, moderation, or server-side run verification if the table needs rate limiting, moderation, or server-side run verification if the table needs
to resist forged scores. to resist forged scores.
+59 -3
View File
@@ -19,6 +19,18 @@ const MAX_HIGH_SCORES: usize = 10;
const MAX_NAME_CHARS: usize = 21; const MAX_NAME_CHARS: usize = 21;
const MAX_SUBMISSION_BODY_BYTES: usize = 1024; const MAX_SUBMISSION_BODY_BYTES: usize = 1024;
const MAX_CONCURRENT_DATABASE_OPERATIONS: usize = 1; const MAX_CONCURRENT_DATABASE_OPERATIONS: usize = 1;
const DEFAULT_HIGH_SCORES: &[(&str, u32)] = &[
("Paul", 6_537_392),
("Paul Schulze", 2_979_000),
("Kalle", 2_393_464),
("Paul Schulze", 2_328_000),
("Martina Sommer", 2_326_000),
("Martina Sommer", 1_093_000),
("Sommer Martina", 1_027_000),
("No Name", 1_000_000),
("TDK Pinball Player", 923_000),
("Martina Sommer", 905_000),
];
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
pub struct HighScore { pub struct HighScore {
@@ -49,7 +61,9 @@ impl HighScoreStore {
/// Returns the SQLite error raised while opening or initializing the /// Returns the SQLite error raised while opening or initializing the
/// database. /// database.
pub fn open(path: impl AsRef<Path>) -> Result<Self, rusqlite::Error> { pub fn open(path: impl AsRef<Path>) -> Result<Self, rusqlite::Error> {
Self::from_connection(Connection::open(path)?) let path = path.as_ref();
let seed_defaults = !path.exists();
Self::from_connection(Connection::open(path)?, seed_defaults)
} }
/// Create an in-memory high-score store for tests or short-lived runs. /// Create an in-memory high-score store for tests or short-lived runs.
@@ -58,10 +72,13 @@ impl HighScoreStore {
/// ///
/// Returns the SQLite error raised while initializing the database. /// Returns the SQLite error raised while initializing the database.
pub fn open_in_memory() -> Result<Self, rusqlite::Error> { pub fn open_in_memory() -> Result<Self, rusqlite::Error> {
Self::from_connection(Connection::open_in_memory()?) Self::from_connection(Connection::open_in_memory()?, false)
} }
fn from_connection(connection: Connection) -> Result<Self, rusqlite::Error> { fn from_connection(
mut connection: Connection,
seed_defaults: bool,
) -> Result<Self, rusqlite::Error> {
connection.execute_batch( connection.execute_batch(
"CREATE TABLE IF NOT EXISTS high_scores ( "CREATE TABLE IF NOT EXISTS high_scores (
id INTEGER PRIMARY KEY, id INTEGER PRIMARY KEY,
@@ -71,6 +88,16 @@ impl HighScoreStore {
CREATE INDEX IF NOT EXISTS high_scores_order CREATE INDEX IF NOT EXISTS high_scores_order
ON high_scores (score DESC, id ASC);", ON high_scores (score DESC, id ASC);",
)?; )?;
if seed_defaults {
let transaction = connection.transaction()?;
for &(name, score) in DEFAULT_HIGH_SCORES {
transaction.execute(
"INSERT INTO high_scores (name, score) VALUES (?1, ?2)",
params![name, i64::from(score)],
)?;
}
transaction.commit()?;
}
Ok(Self(Arc::new(Mutex::new(connection)))) Ok(Self(Arc::new(Mutex::new(connection))))
} }
@@ -359,6 +386,7 @@ mod tests {
async fn api_persists_and_keeps_the_top_ten() { async fn api_persists_and_keeps_the_top_ten() {
let directory = tempdir().expect("temporary directory should exist"); let directory = tempdir().expect("temporary directory should exist");
let database = directory.path().join("highscores.sqlite3"); let database = directory.path().join("highscores.sqlite3");
std::fs::File::create(&database).expect("database file should exist");
let store = HighScoreStore::open(&database).expect("database should open"); let store = HighScoreStore::open(&database).expect("database should open");
let app = router(store); let app = router(store);
@@ -378,6 +406,34 @@ mod tests {
assert_eq!(list(&reopened).await, scores); assert_eq!(list(&reopened).await, scores);
} }
#[test]
fn new_database_starts_with_the_original_demo_scores() {
let directory = tempdir().expect("temporary directory should exist");
let database = directory.path().join("highscores.sqlite3");
let store = HighScoreStore::open(&database).expect("database should open");
let scores = store.list().expect("scores should list");
let expected = DEFAULT_HIGH_SCORES
.iter()
.map(|&(name, score)| HighScore {
name: name.to_owned(),
score,
})
.collect::<Vec<_>>();
assert_eq!(scores, expected);
}
#[test]
fn existing_database_is_not_seeded() {
let directory = tempdir().expect("temporary directory should exist");
let database = directory.path().join("highscores.sqlite3");
std::fs::File::create(&database).expect("database file should exist");
let store = HighScoreStore::open(&database).expect("database should open");
assert!(store.list().expect("scores should list").is_empty());
}
#[tokio::test] #[tokio::test]
async fn api_rejects_invalid_names() { async fn api_rejects_invalid_names() {
let app = router(HighScoreStore::open_in_memory().expect("database should open")); let app = router(HighScoreStore::open_in_memory().expect("database should open"));
+4 -1
View File
@@ -9,7 +9,10 @@ build:
build-release: build-release:
cargo build --release cargo build --release
build-production: build-production-highscore-server:
cargo build --manifest-path highscore-server/Cargo.toml --profile production
build-production: build-production-highscore-server
cargo build --profile production cargo build --profile production
web-build: web-build:
+134 -8
View File
@@ -592,19 +592,31 @@ impl Game {
self.update_target_rotation(events); self.update_target_rotation(events);
} }
let panel_active = self.update_panel_completion(events); let panel_active = self.update_panel_completion(events);
// The original collapses an inactive two-ball slot immediately before
// the next physics pass. Capture removal does not clear multiball
// scoring until that collapse, so the surviving slot retains double
// scoring for the remainder of the callback in which it was captured.
if self.secondary_ball.is_none() && self.score_mode == ScoreMode::Multiball {
self.score_mode = ScoreMode::Normal;
}
if !self.claw.ball_suspended && !panel_active { if !self.claw.ball_suspended && !panel_active {
let had_secondary_ball = self.secondary_ball.is_some(); let had_secondary_ball = self.secondary_ball.is_some();
let mut spawned_secondary = None;
if had_secondary_ball { if had_secondary_ball {
self.score_mode = ScoreMode::Multiball; self.score_mode = ScoreMode::Multiball;
} }
for _ in 0..substeps { for _ in 0..substeps {
if self.fixed_update(events) || self.finished || self.claw.ball_suspended { let stop = self.fixed_update(events);
// Effect seven publishes its spawn request during record 149,
// but the original timer does not create slot two until the
// complete slot-one simulation pass has returned.
if !had_secondary_ball && spawned_secondary.is_none() {
spawned_secondary = self.secondary_ball.take();
}
if stop || self.finished || self.claw.ball_suspended {
break; break;
} }
} }
if had_secondary_ball && self.secondary_ball.is_none() {
self.score_mode = ScoreMode::Normal;
}
if had_secondary_ball && !self.finished && !self.claw.ball_suspended { if had_secondary_ball && !self.finished && !self.claw.ball_suspended {
if self.secondary_ball.is_some() { if self.secondary_ball.is_some() {
for _ in 0..substeps { for _ in 0..substeps {
@@ -626,6 +638,10 @@ impl Game {
} }
} }
} }
if let Some(spawned) = spawned_secondary {
debug_assert!(self.secondary_ball.is_none());
self.secondary_ball = Some(spawned);
}
} }
self.pending_flipper_edges[0] = self.pending_flipper_edges[0] =
@@ -1731,7 +1747,17 @@ impl Game {
events: &mut Vec<Event>, events: &mut Vec<Event>,
) -> BallAction { ) -> BallAction {
self.wheel_holes[index] = true; self.wheel_holes[index] = true;
let filled = self.wheel_holes.iter().filter(|filled| **filled).count(); // Deliberate original-game divergence: a multiball capture normally
// leaves owner 2 here, which lets the surviving single ball capture
// the visibly occupied hole once more. Occupied wheel holes remain
// permanent in the clone regardless of which ball completed them.
self.record_contacts[129 + index] = 99;
// 1000:967a derives the award from all live type-three contact words,
// including another ball that is still settling into a lock hole.
let filled = self.record_contacts[129..=133]
.iter()
.filter(|contact| **contact != 0)
.count();
let shift = u32::try_from(filled.min(5)).unwrap_or(5); let shift = u32::try_from(filled.min(5)).unwrap_or(5);
let award = 5_000_u32 << shift; let award = 5_000_u32 << shift;
let player = &mut self.players[self.current_player]; let player = &mut self.players[self.current_player];
@@ -1746,6 +1772,7 @@ impl Game {
if ball_count == 1 { if ball_count == 1 {
self.panel_frame = Some(0); self.panel_frame = Some(0);
} }
self.score_mode = ScoreMode::Normal;
} }
events.push(Event::Lock); events.push(Event::Lock);
if ball_count == 1 { if ball_count == 1 {
@@ -2088,7 +2115,7 @@ impl Game {
} }
fn update_claw(&mut self, dt: f32, events: &mut Vec<Event>) { fn update_claw(&mut self, dt: f32, events: &mut Vec<Event>) {
if !self.claw.active { if !self.claw.active || self.score_mode == ScoreMode::Multiball {
return; return;
} }
self.claw.frame_accumulator += dt; self.claw.frame_accumulator += dt;
@@ -3420,15 +3447,71 @@ mod tests {
spin: Real48::ZERO, spin: Real48::ZERO,
capture_age: 300, capture_age: 300,
}); });
game.score_mode = ScoreMode::Multiball;
let mut events = Vec::new(); let mut events = Vec::new();
game.advance_secondary_ball(&mut events); game.advance_secondary_ball(&mut events);
assert!(game.secondary_ball.is_none()); assert!(game.secondary_ball.is_none());
assert_eq!(game.score_mode, ScoreMode::Multiball);
assert_eq!(game.ball.capture_age, 17); assert_eq!(game.ball.capture_age, 17);
assert_eq!(game.record_contacts[usize::from(sensor.id)], 2); assert_eq!(game.record_contacts[usize::from(sensor.id)], 99);
assert!(game.wheel_holes[0]); assert!(game.wheel_holes[0]);
assert!(events.contains(&Event::Lock)); assert!(events.contains(&Event::Lock));
game.timer_tick(0.0, 0, &mut events);
assert_eq!(game.score_mode, ScoreMode::Normal);
}
#[test]
fn captured_ball_one_keeps_multiball_scoring_for_ball_two_slot_pass() {
let sensor = LOCK_HOLES[0];
let mut game = Game::new_with_seed(1, 7);
game.ball.in_launcher = false;
game.ball.position = sensor.center;
game.ball.velocity = Vec2::ZERO;
game.ball.capture_age = 300;
game.record_contacts[usize::from(sensor.id)] = 1;
game.secondary_ball = Some(Ball {
position: EFFECT_SENSOR.center,
velocity: Vec2::ZERO,
in_launcher: false,
spin: Real48::ZERO,
capture_age: 0,
});
game.target_effect = 1;
game.object_active[usize::from(EFFECT_SENSOR.id)] = true;
game.score_mode = ScoreMode::Multiball;
let mut events = Vec::new();
game.timer_tick(0.01, 1, &mut events);
assert!(game.secondary_ball.is_none());
assert_eq!(game.player().score, 1_000);
assert_eq!(game.player().secondary_score, 20_000);
assert_eq!(game.score_mode, ScoreMode::Multiball);
game.timer_tick(0.0, 0, &mut events);
assert_eq!(game.score_mode, ScoreMode::Normal);
}
#[test]
fn active_multiball_pauses_the_returning_claw_until_slot_collapse() {
let mut game = Game::new(1);
game.claw.active = true;
game.claw.frame = 1;
game.claw.target_frame = 10;
game.claw.bank = ClawSpriteBank::Opening;
game.claw.frame_accumulator = 0.0;
game.score_mode = ScoreMode::Multiball;
let mut events = Vec::new();
game.update_claw(CLAW_FRAME_SECONDS, &mut events);
assert_eq!(game.claw.frame, 1);
game.score_mode = ScoreMode::Normal;
game.update_claw(CLAW_FRAME_SECONDS, &mut events);
assert_eq!(game.claw.frame, 2);
} }
#[test] #[test]
@@ -3460,10 +3543,35 @@ mod tests {
assert_eq!(game.ball.position, survivor.position); assert_eq!(game.ball.position, survivor.position);
assert_eq!(game.ball.velocity, survivor.velocity); assert_eq!(game.ball.velocity, survivor.velocity);
assert_eq!(game.ball.capture_age, survivor.capture_age); assert_eq!(game.ball.capture_age, survivor.capture_age);
assert_eq!(game.record_contacts[usize::from(sensor.id)], 2); assert_eq!(game.record_contacts[usize::from(sensor.id)], 99);
assert!(game.wheel_holes[0]); assert!(game.wheel_holes[0]);
} }
#[test]
fn occupied_multiball_wheel_hole_rejects_the_surviving_ball() {
let sensor = LOCK_HOLES[0];
let mut game = Game::new(1);
game.ball.in_launcher = false;
game.ball.position = sensor.center;
game.ball.velocity = Vec2::ZERO;
game.ball.capture_age = 300;
game.record_contacts[usize::from(sensor.id)] = 99;
game.wheel_holes[0] = true;
let mut events = Vec::new();
let action = game.check_sensor_objects(
MilliVec::from_position(sensor.center),
MilliVec::default(),
&mut events,
);
assert_eq!(action, BallAction::Keep);
assert_eq!(game.record_contacts[usize::from(sensor.id)], 99);
assert_eq!(game.player().secondary_score, 0);
assert!(!events.contains(&Event::Lock));
assert!(!game.ball.in_launcher);
}
#[test] #[test]
fn center_drain_advances_to_a_fresh_ball() { fn center_drain_advances_to_a_fresh_ball() {
let mut game = Game::new(1); let mut game = Game::new(1);
@@ -4529,6 +4637,23 @@ mod tests {
); );
} }
#[test]
fn wheel_award_counts_another_ball_settling_in_a_different_hole() {
let mut game = Game::new(1);
game.record_contacts[129] = 1;
game.record_contacts[130] = 2;
let mut events = Vec::new();
assert_eq!(
game.complete_lock_hole(1, 2, &mut events),
BallAction::Remove
);
assert_eq!(game.player().secondary_score, 20_000);
assert!(!game.wheel_holes[0]);
assert!(game.wheel_holes[1]);
}
#[test] #[test]
fn fifth_multiball_lock_defers_panel_and_caps_survivor_reaward() { fn fifth_multiball_lock_defers_panel_and_caps_survivor_reaward() {
let mut game = Game::new(1); let mut game = Game::new(1);
@@ -4546,6 +4671,7 @@ mod tests {
assert_eq!(game.player().secondary_score, 0); assert_eq!(game.player().secondary_score, 0);
assert_eq!(game.player().score_multiplier, 2); assert_eq!(game.player().score_multiplier, 2);
assert_eq!(game.panel_frame, None); assert_eq!(game.panel_frame, None);
assert_eq!(game.score_mode, ScoreMode::Normal);
let mut reset_by_special_hole = game.clone(); let mut reset_by_special_hole = game.clone();
reset_by_special_hole.reset_ball_to_launcher(); reset_by_special_hole.reset_ball_to_launcher();
+2
View File
@@ -5,6 +5,8 @@
const encoder = new TextEncoder(); const encoder = new TextEncoder();
const decoder = new TextDecoder(); const decoder = new TextDecoder();
const highScoreApi = "api/highscores"; const highScoreApi = "api/highscores";
const highScoreRetryInitialDelayMs = 250;
const highScoreRetryMaxDelayMs = 30_000;
let lastRevision = 0; let lastRevision = 0;
let lastHighScoreRevision = 0; let lastHighScoreRevision = 0;
let highScoreRequestInFlight = false; let highScoreRequestInFlight = false;
Binary file not shown.