6 Commits
Author SHA1 Message Date
ddidderr 3fc1096500 fmt: README.md files and .cargo/config.toml 2026-09-01 05:27:10 +02:00
ddidderr a5f2aab005 chore(release): prepare v1.2.1
Bump the package version to 1.2.1 for multiball Greifarm capture preservation and scoping fixes. Update CHANGELOG.md and refresh the tracked web WASM artifact.

Test Plan:
- `just test` -- passed (152 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 CHANGELOG.md` -- passed
- `cargo metadata --locked --format-version 1 --no-deps` -- passed
- `git diff --cached --check` -- passed
2026-09-01 05:22:59 +02:00
ddidderr d37d327a02 test(divergence): cover converging claw approaches
A follow-up review questioned whether both multiball slots could enter the
Greifarm deep zone before either established capture ownership. The zone is
less than 16 pixels wide while ball contact begins at 17 pixels, so two
non-overlapping balls cannot fit there together. Document that invariant and
exercise the real sequential timer from cardinal approaches at two distances
and speeds, asserting that every multiball-latched capture retains its entry
owner.

Test Plan:
- broader diagnostic sweep -- passed (574 captures, no owner mismatch)
- `just --justfile tdkpin-rs/justfile test` -- passed (152 game, 8 service tests)
- `just --justfile tdkpin-rs/justfile clippy` -- passed
- `cargo +nightly fmt --manifest-path tdkpin-rs/Cargo.toml --all -- --check` -- passed
- `git diff --cached --check` -- passed
2026-09-01 04:16:05 +02:00
ddidderr 71e5450a84 fix(divergence): scope claw latch to captured slot
The Greifarm entry restriction was still shared across both multiball slots.
An unrelated upper-playfield ball could clear the entering ball's latch during
its own record-89 scan, while a shallow aborted entry could retain a stale
choice. Store the latch owner with the capture-time restriction, permit only
that slot to clear it when leaving the deep capture zone, and migrate the owner
when a held secondary ball becomes primary.

Test Plan:
- `just --justfile tdkpin-rs/justfile test` -- passed (151 game, 8 service tests)
- `just --justfile tdkpin-rs/justfile clippy` -- passed
- `just --justfile tdkpin-rs/justfile build-production` -- passed
- `just --justfile tdkpin-rs/justfile web-build` -- passed
- `cargo +nightly fmt --manifest-path tdkpin-rs/Cargo.toml --all -- --check` -- passed
- `rumdl check --flavor commonmark tdkpin-rs/CHANGELOG.md tdkpin-rs/AGENTS.md` -- passed
- `git diff --cached --check` -- passed
2026-08-31 23:27:15 +02:00
ddidderr 26a2567c91 fix(divergence): latch multiball claw capture state
A multiball Greifarm capture could lose its two-ball restriction while the
ball was still being pulled: capture age remains zero during nonstationary
pulling, so a later callback with one remaining ball rewrote the decision.
Represent the capture decision as an explicit optional latch and clear it only
when that entry exits or the claw state resets. When a held secondary ball is
promoted after the primary drains or is captured, migrate record 89 ownership
to ball one as well, so the released ball can clear its contact normally.

Test Plan:
- `just --justfile tdkpin-rs/justfile test` -- passed (149 game/service tests)
- `just --justfile tdkpin-rs/justfile clippy` -- passed
- `just --justfile tdkpin-rs/justfile build-production` -- passed
- `just --justfile tdkpin-rs/justfile web-build` -- passed
- `cargo +nightly fmt --manifest-path tdkpin-rs/Cargo.toml --all -- --check` -- passed
- `rumdl check --flavor commonmark tdkpin-rs/CHANGELOG.md tdkpin-rs/AGENTS.md` -- passed
- `git diff --cached --check` -- passed
2026-08-31 23:11:00 +02:00
ddidderr 25147ad58e fix(divergence): preserve multiball Greifarm captures
The original port consumed a ball as soon as record 89 completed during
multiball, which skipped the Greifarm animation and lost the ball permanently.
Keep the captured slot through the full animation instead, latch whether the
capture began with two live balls, and exclude the launcher release (frame 18)
for that capture. The surviving slot continues physics, scoring, nudging, and
flipper responses while the held slot is suspended; drain and promotion paths
also preserve the held ball. A later Greifarm entry makes a fresh ball-count
decision. Update slot-aware rendering and refresh the browser WASM artifact.

Test Plan:
- `just --justfile tdkpin-rs/justfile test` -- passed (149 game/service tests)
- `just --justfile tdkpin-rs/justfile clippy` -- passed
- `just --justfile tdkpin-rs/justfile build-production` -- passed
- `just --justfile tdkpin-rs/justfile web-build` -- passed
- `cargo +nightly fmt --manifest-path tdkpin-rs/Cargo.toml --all -- --check` -- passed
- `rumdl check --flavor commonmark tdkpin-rs/CHANGELOG.md tdkpin-rs/AGENTS.md` -- passed
- `git diff --cached --check` -- passed
2026-08-31 22:15:39 +02:00
9 changed files with 761 additions and 81 deletions
+12
View File
@@ -8,6 +8,18 @@ and this project adheres to
## [Unreleased] ## [Unreleased]
## [1.2.1] - 2026-09-01
### Fixed
- Keep a ball captured by the Greifarm during multiball instead of consuming it;
latch the two-ball capture rule at each entry, exclude the launcher release
while that capture is active, and let later single-ball re-entries use all
four original release choices.
- Preserve that entry decision through the complete pull phase, scope it to the
entering slot until that ball exits the capture zone, and migrate Greifarm
state when a held secondary ball becomes the primary ball.
## [1.2.0] - 2026-08-31 ## [1.2.0] - 2026-08-31
### Added ### Added
+1 -1
View File
@@ -551,7 +551,7 @@ dependencies = [
[[package]] [[package]]
name = "tdkpin-rs" name = "tdkpin-rs"
version = "1.2.0" version = "1.2.1"
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.2.0" version = "1.2.1"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
+4 -4
View File
@@ -14,9 +14,9 @@ response is the canonical top-ten JSON array.
A newly created database starts with the original distributed demo table. An A newly created database starts with the original distributed demo table. An
existing database, including an existing empty database, is left unchanged. 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
rate limiting, moderation, or server-side run verification if the table needs limiting, moderation, or server-side run verification if the table needs to
to resist forged scores. resist forged scores.
Run it from `tdkpin-rs` with: Run it from `tdkpin-rs` with:
@@ -27,7 +27,7 @@ cargo run --manifest-path highscore-server/Cargo.toml
``` ```
Copy the rate and connection zone declarations from Copy the rate and connection zone declarations from
[nginx.conf.example](nginx.conf.example) into the existing `http` block, then [nginx.conf.example](./nginx.conf.example) into the existing `http` block, then
place its two `location` blocks inside the public site's `server` block. The place its two `location` blocks inside the public site's `server` block. The
example bounds per-client and aggregate API traffic, request bodies, and proxy example bounds per-client and aggregate API traffic, request bodies, and proxy
waits. The browser client expects the API at `/api/highscores` on the same waits. The browser client expects the API at `/api/highscores` on the same
+4 -2
View File
@@ -901,7 +901,7 @@ impl App {
); );
} }
} }
if !game.claw.ball_suspended { if !game.primary_ball_suspended() {
draw_texture( draw_texture(
&self.assets.ball, &self.assets.ball,
game.ball.position.x - 8.0, game.ball.position.x - 8.0,
@@ -909,7 +909,9 @@ impl App {
WHITE, WHITE,
); );
} }
if let Some(ball) = game.secondary_ball { if let Some(ball) = game.secondary_ball
&& !game.secondary_ball_suspended()
{
draw_texture( draw_texture(
&self.assets.ball, &self.assets.ball,
ball.position.x - 8.0, ball.position.x - 8.0,
+703 -38
View File
@@ -46,6 +46,7 @@ const CLAW_FRAME_SECONDS: f32 = 0.030;
const DETAIL_TIMER_SECONDS: [f32; 5] = [0.050, 0.040, 0.030, 0.020, 0.010]; const DETAIL_TIMER_SECONDS: [f32; 5] = [0.050, 0.040, 0.030, 0.020, 0.010];
const DETAIL_SUBSTEPS: [u8; 5] = [5, 4, 3, 2, 1]; const DETAIL_SUBSTEPS: [u8; 5] = [5, 4, 3, 2, 1];
const CLAW_TERMINAL_FRAMES: [u8; 4] = [1, 6, 7, 18]; const CLAW_TERMINAL_FRAMES: [u8; 4] = [1, 6, 7, 18];
const CLAW_TABLE_TERMINAL_FRAMES: [u8; 3] = [1, 6, 7];
const ORIGINAL_BALL_SPEED_PER_SECOND: f32 = 380.0; const ORIGINAL_BALL_SPEED_PER_SECOND: f32 = 380.0;
#[allow(clippy::cast_possible_wrap)] #[allow(clippy::cast_possible_wrap)]
@@ -129,12 +130,32 @@ pub enum ClawSpriteBank {
Opening, Opening,
} }
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum ClawBallSlot {
Primary,
Secondary,
}
impl ClawBallSlot {
const fn from_ball_number(ball_number: u16) -> Self {
if ball_number == 2 {
Self::Secondary
} else {
Self::Primary
}
}
}
#[derive(Clone, Copy, Debug)] #[derive(Clone, Copy, Debug)]
pub struct Claw { pub struct Claw {
pub active: bool, pub active: bool,
pub frame: u8, pub frame: u8,
pub bank: ClawSpriteBank, pub bank: ClawSpriteBank,
pub ball_suspended: bool, pub ball_suspended: bool,
captured_slot: Option<ClawBallSlot>,
// The deep zone is less than 16 px wide while ball contact is 17 px, so
// only one physically non-overlapping ball can own a pending entry.
capture_entry: Option<(ClawBallSlot, bool)>,
target_frame: u8, target_frame: u8,
frame_accumulator: f32, frame_accumulator: f32,
} }
@@ -146,6 +167,8 @@ impl Default for Claw {
frame: 10, frame: 10,
bank: ClawSpriteBank::Opening, bank: ClawSpriteBank::Opening,
ball_suspended: false, ball_suspended: false,
captured_slot: None,
capture_entry: None,
target_frame: 10, target_frame: 10,
frame_accumulator: 0.0, frame_accumulator: 0.0,
} }
@@ -592,19 +615,23 @@ 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 // A held claw ball still counts as the second live ball until the
// the next physics pass. Capture removal does not clear multiball // other slot disappears or the claw releases it.
// scoring until that collapse, so the surviving slot retains double if self.secondary_ball.is_none()
// scoring for the remainder of the callback in which it was captured. && self.claw.captured_slot != Some(ClawBallSlot::Secondary)
if self.secondary_ball.is_none() && self.score_mode == ScoreMode::Multiball { && self.score_mode == ScoreMode::Multiball
{
self.score_mode = ScoreMode::Normal; self.score_mode = ScoreMode::Normal;
} }
if !self.claw.ball_suspended && !panel_active { if !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; let mut spawned_secondary = None;
let primary_held = self.claw_holds_slot(ClawBallSlot::Primary);
let secondary_held = self.claw_holds_slot(ClawBallSlot::Secondary);
if had_secondary_ball { if had_secondary_ball {
self.score_mode = ScoreMode::Multiball; self.score_mode = ScoreMode::Multiball;
} }
if !primary_held {
for _ in 0..substeps { for _ in 0..substeps {
let stop = self.fixed_update(events); let stop = self.fixed_update(events);
// Effect seven publishes its spawn request during record 149, // Effect seven publishes its spawn request during record 149,
@@ -613,18 +640,21 @@ impl Game {
if !had_secondary_ball && spawned_secondary.is_none() { if !had_secondary_ball && spawned_secondary.is_none() {
spawned_secondary = self.secondary_ball.take(); spawned_secondary = self.secondary_ball.take();
} }
if stop || self.finished || self.claw.ball_suspended { if stop || self.finished || self.claw_holds_slot(ClawBallSlot::Primary) {
break; break;
} }
} }
if had_secondary_ball && !self.finished && !self.claw.ball_suspended { }
if self.secondary_ball.is_some() { if had_secondary_ball && !self.finished {
if self.secondary_ball.is_some() && !self.claw_holds_slot(ClawBallSlot::Secondary) {
for _ in 0..substeps { for _ in 0..substeps {
if self.advance_secondary_ball(events) { if self.advance_secondary_ball_slot(events, !primary_held) {
break; break;
} }
} }
} else { } else if !self.claw_holds_slot(ClawBallSlot::Primary)
&& !self.claw_holds_slot(ClawBallSlot::Secondary)
{
self.secondary_ball = Some(self.ball); self.secondary_ball = Some(self.ball);
for _ in 0..substeps { for _ in 0..substeps {
if self.advance_secondary_ball_slot(events, false) { if self.advance_secondary_ball_slot(events, false) {
@@ -642,6 +672,10 @@ impl Game {
debug_assert!(self.secondary_ball.is_none()); debug_assert!(self.secondary_ball.is_none());
self.secondary_ball = Some(spawned); self.secondary_ball = Some(spawned);
} }
// The two slots are mutually exclusive while the claw is holding
// a ball.
debug_assert!(!(primary_held && secondary_held));
} }
self.pending_flipper_edges[0] = self.pending_flipper_edges[0] =
@@ -759,6 +793,8 @@ impl Game {
} }
Nudge::None => return, Nudge::None => return,
} }
let primary_held = self.claw_holds_slot(ClawBallSlot::Primary);
let secondary_held = self.claw_holds_slot(ClawBallSlot::Secondary);
if let Some(secondary) = &mut self.secondary_ball { if let Some(secondary) = &mut self.secondary_ball {
let slot_impulse = match nudge { let slot_impulse = match nudge {
Nudge::Left | Nudge::Right => MilliVec { Nudge::Left | Nudge::Right => MilliVec {
@@ -778,12 +814,16 @@ impl Game {
let mut primary_slot = MilliVec::from_velocity_per_second(self.ball.velocity); let mut primary_slot = MilliVec::from_velocity_per_second(self.ball.velocity);
primary_slot.x = primary_slot.x.wrapping_add(slot_impulse.x); primary_slot.x = primary_slot.x.wrapping_add(slot_impulse.x);
primary_slot.y = primary_slot.y.wrapping_add(slot_impulse.y); primary_slot.y = primary_slot.y.wrapping_add(slot_impulse.y);
if !primary_held {
self.ball.velocity = primary_slot.to_velocity_per_second(); self.ball.velocity = primary_slot.to_velocity_per_second();
}
let mut secondary_slot = MilliVec::from_velocity_per_second(secondary.velocity); let mut secondary_slot = MilliVec::from_velocity_per_second(secondary.velocity);
secondary_slot.x = secondary_slot.x.wrapping_add(slot_impulse.x); secondary_slot.x = secondary_slot.x.wrapping_add(slot_impulse.x);
secondary_slot.y = secondary_slot.y.wrapping_add(slot_impulse.y); secondary_slot.y = secondary_slot.y.wrapping_add(slot_impulse.y);
if !secondary_held {
secondary.velocity = secondary_slot.to_velocity_per_second(); secondary.velocity = secondary_slot.to_velocity_per_second();
} else { }
} else if !primary_held {
self.ball.velocity = velocity.to_velocity_per_second(); self.ball.velocity = velocity.to_velocity_per_second();
} }
@@ -832,8 +872,13 @@ impl Game {
); );
let mut best_collision = best_static let mut best_collision = best_static
.map(|(id, is_wall, candidate)| (id, is_wall, candidate.resolve(velocity, ball.spin))); .map(|(id, is_wall, candidate)| (id, is_wall, candidate.resolve(velocity, ball.spin)));
let primary_held = self.claw_holds_slot(ClawBallSlot::Primary);
let secondary_held = self.claw_holds_slot(ClawBallSlot::Secondary);
let mut transferred_secondary_velocity = None; let mut transferred_secondary_velocity = None;
if let Some(secondary) = initial_secondary { if let Some(secondary) = initial_secondary
&& !primary_held
&& !secondary_held
{
let other_position = MilliVec::from_position(secondary.position); let other_position = MilliVec::from_position(secondary.position);
let response = ball_collision_response( let response = ball_collision_response(
old_position, old_position,
@@ -876,6 +921,8 @@ impl Game {
if object_id == 175 if object_id == 175
&& let (Some(secondary), Some(transferred)) = && let (Some(secondary), Some(transferred)) =
(&mut self.secondary_ball, transferred_secondary_velocity) (&mut self.secondary_ball, transferred_secondary_velocity)
&& !secondary_held
&& !primary_held
{ {
secondary.velocity = transferred.to_velocity_per_second(); secondary.velocity = transferred.to_velocity_per_second();
} }
@@ -944,6 +991,7 @@ impl Game {
.secondary_ball .secondary_ball
.take() .take()
.expect("a multiball capture must leave the other slot active"); .expect("a multiball capture must leave the other slot active");
self.promote_claw_secondary_to_primary();
return true; return true;
} }
} }
@@ -1008,12 +1056,12 @@ impl Game {
&mut capture_candidate, &mut capture_candidate,
events, events,
) { ) {
CaptureStep::Complete if ball_count == 1 => { CaptureStep::Complete => {
let terminal_frame = self.next_claw_terminal_frame(); let terminal_frame = self
self.begin_claw_capture_after_hold(ball, terminal_frame, events); .next_claw_terminal_frame(self.claw_capture_restricted_for(ball_number));
self.begin_claw_capture_after_hold(ball, ball_number, terminal_frame, events);
action = BallAction::Suspend; action = BallAction::Suspend;
} }
CaptureStep::Complete => action = BallAction::Remove,
CaptureStep::Outside | CaptureStep::Holding => {} CaptureStep::Outside | CaptureStep::Holding => {}
} }
*velocity = MilliVec::from_velocity_per_second(ball.velocity); *velocity = MilliVec::from_velocity_per_second(ball.velocity);
@@ -1227,8 +1275,9 @@ impl Game {
) )
} }
#[cfg(test)]
fn advance_secondary_ball(&mut self, events: &mut Vec<Event>) -> bool { fn advance_secondary_ball(&mut self, events: &mut Vec<Event>) -> bool {
self.advance_secondary_ball_slot(events, true) self.advance_secondary_ball_slot(events, !self.claw_holds_slot(ClawBallSlot::Primary))
} }
#[allow(clippy::too_many_lines)] #[allow(clippy::too_many_lines)]
@@ -1267,6 +1316,8 @@ impl Game {
let mut transferred_primary_velocity = None; let mut transferred_primary_velocity = None;
let other_position = MilliVec::from_position(primary_position); let other_position = MilliVec::from_position(primary_position);
if primary_slot_active if primary_slot_active
&& !self.claw_holds_slot(ClawBallSlot::Primary)
&& !self.claw_holds_slot(ClawBallSlot::Secondary)
&& predicted.x >= other_position.x.wrapping_sub(21_000) && predicted.x >= other_position.x.wrapping_sub(21_000)
&& predicted.x <= other_position.x.wrapping_add(21_000) && predicted.x <= other_position.x.wrapping_add(21_000)
&& predicted.y >= other_position.y.wrapping_sub(21_000) && predicted.y >= other_position.y.wrapping_sub(21_000)
@@ -1556,13 +1607,10 @@ impl Game {
) { ) {
CaptureStep::Holding | CaptureStep::Outside => {} CaptureStep::Holding | CaptureStep::Outside => {}
CaptureStep::Complete => { CaptureStep::Complete => {
if ball_count == 1 { let terminal_frame = self
let terminal_frame = self.next_claw_terminal_frame(); .next_claw_terminal_frame(self.claw_capture_restricted_for(ball_number));
self.begin_claw_capture_after_hold(ball, terminal_frame, events); self.begin_claw_capture_after_hold(ball, ball_number, terminal_frame, events);
action = BallAction::Suspend; action = BallAction::Suspend;
} else {
action = BallAction::Remove;
}
} }
} }
} }
@@ -1595,7 +1643,11 @@ impl Game {
SensorScanResult { action } SensorScanResult { action }
} }
#[allow(clippy::cast_possible_truncation, clippy::too_many_arguments)] #[allow(
clippy::cast_possible_truncation,
clippy::too_many_arguments,
clippy::too_many_lines
)]
fn capture_record_step( fn capture_record_step(
&mut self, &mut self,
ball: &mut Ball, ball: &mut Ball,
@@ -1620,6 +1672,12 @@ impl Game {
|| predicted_position.y < center.y.wrapping_sub(broadphase_margin) || predicted_position.y < center.y.wrapping_sub(broadphase_margin)
|| predicted_position.y > center.y.wrapping_add(broadphase_margin) || predicted_position.y > center.y.wrapping_add(broadphase_margin)
{ {
if record_id == 89
&& ball.capture_age == 0
&& self.record_contacts[usize::from(record_id)] == 0
{
self.clear_claw_capture_entry_for(ball_number);
}
return CaptureStep::Outside; return CaptureStep::Outside;
} }
let dx = center.x.wrapping_sub(current_position.x); let dx = center.x.wrapping_sub(current_position.x);
@@ -1637,6 +1695,13 @@ impl Game {
&& contact_allowed && contact_allowed
&& !(record_id == 148 && self.special_hole_gate == SpecialHoleGate::Suppressed) && !(record_id == 148 && self.special_hole_gate == SpecialHoleGate::Suppressed)
{ {
if record_id == 89 && ball.capture_age == 0 && self.claw.capture_entry.is_none() {
// Latch the restriction when the ball first enters the deep
// capture zone, before the pull/hold phase can outlive the
// other active ball.
self.claw.capture_entry =
Some((ClawBallSlot::from_ball_number(ball_number), ball_count > 1));
}
if ball.capture_age < 300 { if ball.capture_age < 300 {
let mut velocity = MilliVec::from_velocity_per_second(ball.velocity); let mut velocity = MilliVec::from_velocity_per_second(ball.velocity);
let stationary = center.x.wrapping_sub(previous_position.x).wrapping_abs() < 500 let stationary = center.x.wrapping_sub(previous_position.x).wrapping_abs() < 500
@@ -1679,13 +1744,21 @@ impl Game {
return CaptureStep::Complete; return CaptureStep::Complete;
} }
ball.capture_age = 0; ball.capture_age = 0;
self.record_contacts[contact_index] = if record_id == 148 { 2 } else { 99 }; self.record_contacts[contact_index] = if record_id == 89 && ball_count > 1 {
if ball_count > 1 { // Keep ownership with the held slot so its later release can
self.record_contacts[contact_index] = 2; // clear the claw contact before a fresh entry.
} ball_number
} else if record_id == 148 || ball_count > 1 {
2
} else {
99
};
return CaptureStep::Complete; return CaptureStep::Complete;
} }
if record_id == 89 && surface_distance >= -11_000 {
self.clear_claw_capture_entry_for(ball_number);
}
if surface_distance > -11_000 && contact == ball_number { if surface_distance > -11_000 && contact == ball_number {
self.record_contacts[contact_index] = 0; self.record_contacts[contact_index] = 0;
ball.capture_age = 0; ball.capture_age = 0;
@@ -2052,9 +2125,55 @@ impl Game {
self.target_effect.min(7) self.target_effect.min(7)
} }
fn next_claw_terminal_frame(&mut self) -> u8 { fn next_claw_terminal_frame(&mut self, restricted: bool) -> u8 {
if restricted {
// Frame 18 sends the released ball down the shooter lane. During
// a capture that began with two live balls, keep it on the table.
CLAW_TABLE_TERMINAL_FRAMES[usize::from(self.random.below(3))]
} else {
CLAW_TERMINAL_FRAMES[usize::from(self.random.below(4))] CLAW_TERMINAL_FRAMES[usize::from(self.random.below(4))]
} }
}
fn claw_holds_slot(&self, slot: ClawBallSlot) -> bool {
self.claw.ball_suspended && self.claw.captured_slot == Some(slot)
}
fn claw_capture_restricted_for(&self, ball_number: u16) -> bool {
self.claw.capture_entry == Some((ClawBallSlot::from_ball_number(ball_number), true))
}
fn clear_claw_capture_entry_for(&mut self, ball_number: u16) {
let slot = ClawBallSlot::from_ball_number(ball_number);
if self
.claw
.capture_entry
.is_some_and(|(owner, _)| owner == slot)
{
self.claw.capture_entry = None;
}
}
fn promote_claw_secondary_to_primary(&mut self) {
if self.claw.captured_slot == Some(ClawBallSlot::Secondary) {
self.claw.captured_slot = Some(ClawBallSlot::Primary);
if let Some((ClawBallSlot::Secondary, restricted)) = self.claw.capture_entry {
self.claw.capture_entry = Some((ClawBallSlot::Primary, restricted));
}
if self.record_contacts[89] == 2 {
self.record_contacts[89] = 1;
}
self.score_mode = ScoreMode::Normal;
}
}
pub fn primary_ball_suspended(&self) -> bool {
self.claw_holds_slot(ClawBallSlot::Primary)
}
pub fn secondary_ball_suspended(&self) -> bool {
self.claw_holds_slot(ClawBallSlot::Secondary)
}
fn apply_flipper_kicks(&mut self) { fn apply_flipper_kicks(&mut self) {
let pending = self.pending_flipper_edges; let pending = self.pending_flipper_edges;
@@ -2062,6 +2181,8 @@ impl Game {
if self.ball.in_launcher || self.tilted { if self.ball.in_launcher || self.tilted {
return; return;
} }
let primary_held = self.claw_holds_slot(ClawBallSlot::Primary);
let secondary_held = self.claw_holds_slot(ClawBallSlot::Secondary);
for (delta, side) in [ for (delta, side) in [
(i32::from(pending[0]), FlipperSide::Left), (i32::from(pending[0]), FlipperSide::Left),
@@ -2070,8 +2191,12 @@ impl Game {
if delta == 0 { if delta == 0 {
continue; continue;
} }
if !primary_held {
apply_flipper_response_to_ball(&mut self.ball, delta, side); apply_flipper_response_to_ball(&mut self.ball, delta, side);
if let Some(secondary) = &mut self.secondary_ball { }
if let Some(secondary) = &mut self.secondary_ball
&& !secondary_held
{
apply_flipper_response_to_ball(secondary, delta, side); apply_flipper_response_to_ball(secondary, delta, side);
} }
} }
@@ -2087,6 +2212,8 @@ impl Game {
self.claw.target_frame = terminal_frame; self.claw.target_frame = terminal_frame;
self.claw.bank = ClawSpriteBank::Closing; self.claw.bank = ClawSpriteBank::Closing;
self.claw.ball_suspended = true; self.claw.ball_suspended = true;
self.claw.captured_slot = Some(ClawBallSlot::Primary);
self.claw.capture_entry = None;
self.claw.frame_accumulator = 0.0; self.claw.frame_accumulator = 0.0;
self.ball.velocity = Vec2::ZERO; self.ball.velocity = Vec2::ZERO;
self.ball.spin = Real48::ZERO; self.ball.spin = Real48::ZERO;
@@ -2097,6 +2224,7 @@ impl Game {
fn begin_claw_capture_after_hold( fn begin_claw_capture_after_hold(
&mut self, &mut self,
ball: &mut Ball, ball: &mut Ball,
ball_number: u16,
terminal_frame: u8, terminal_frame: u8,
events: &mut Vec<Event>, events: &mut Vec<Event>,
) { ) {
@@ -2108,6 +2236,7 @@ impl Game {
self.claw.target_frame = terminal_frame; self.claw.target_frame = terminal_frame;
self.claw.bank = ClawSpriteBank::Closing; self.claw.bank = ClawSpriteBank::Closing;
self.claw.ball_suspended = true; self.claw.ball_suspended = true;
self.claw.captured_slot = Some(ClawBallSlot::from_ball_number(ball_number));
self.claw.frame_accumulator = 0.0; self.claw.frame_accumulator = 0.0;
ball.velocity = Vec2::ZERO; ball.velocity = Vec2::ZERO;
ball.spin = Real48::ZERO; ball.spin = Real48::ZERO;
@@ -2115,7 +2244,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 || self.score_mode == ScoreMode::Multiball { if !self.claw.active {
return; return;
} }
self.claw.frame_accumulator += dt; self.claw.frame_accumulator += dt;
@@ -2145,7 +2274,24 @@ impl Game {
let release_frame = self.claw.frame; let release_frame = self.claw.frame;
self.claw.target_frame = 10; self.claw.target_frame = 10;
self.claw.bank = ClawSpriteBank::Opening; self.claw.bank = ClawSpriteBank::Opening;
(self.ball.position, self.ball.velocity) = claw_release(release_frame); let release = claw_release(release_frame);
match self.claw.captured_slot {
Some(ClawBallSlot::Primary) => {
(self.ball.position, self.ball.velocity) = release;
}
Some(ClawBallSlot::Secondary) => {
if let Some(ball) = &mut self.secondary_ball {
(ball.position, ball.velocity) = release;
} else {
(self.ball.position, self.ball.velocity) = release;
self.promote_claw_secondary_to_primary();
}
}
None => {
(self.ball.position, self.ball.velocity) = release;
self.claw.captured_slot = Some(ClawBallSlot::Primary);
}
}
self.claw.ball_suspended = false; self.claw.ball_suspended = false;
events.push(Event::ClawRelease); events.push(Event::ClawRelease);
events.push(Event::Sound(2016)); events.push(Event::Sound(2016));
@@ -2180,6 +2326,15 @@ impl Game {
} }
fn drain(&mut self, events: &mut Vec<Event>) { fn drain(&mut self, events: &mut Vec<Event>) {
if self.claw_holds_slot(ClawBallSlot::Secondary) {
self.ball = self
.secondary_ball
.take()
.expect("the claw-held secondary ball must remain in slot two");
self.promote_claw_secondary_to_primary();
events.push(Event::Drain);
return;
}
if let Some(remaining_ball) = self.secondary_ball.take() { if let Some(remaining_ball) = self.secondary_ball.take() {
self.ball = remaining_ball; self.ball = remaining_ball;
self.score_mode = ScoreMode::Normal; self.score_mode = ScoreMode::Normal;
@@ -3496,20 +3651,18 @@ mod tests {
} }
#[test] #[test]
fn active_multiball_pauses_the_returning_claw_until_slot_collapse() { fn active_multiball_advances_the_returning_claw_while_both_balls_live() {
let mut game = Game::new(1); let mut game = Game::new(1);
game.claw.active = true; game.claw.active = true;
game.claw.frame = 1; game.claw.frame = 1;
game.claw.target_frame = 10; game.claw.target_frame = 10;
game.claw.bank = ClawSpriteBank::Opening; game.claw.bank = ClawSpriteBank::Opening;
game.claw.frame_accumulator = 0.0; game.claw.frame_accumulator = 0.0;
game.claw.ball_suspended = true;
game.claw.captured_slot = Some(ClawBallSlot::Primary);
game.score_mode = ScoreMode::Multiball; game.score_mode = ScoreMode::Multiball;
let mut events = Vec::new(); 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); game.update_claw(CLAW_FRAME_SECONDS, &mut events);
assert_eq!(game.claw.frame, 2); assert_eq!(game.claw.frame, 2);
} }
@@ -4365,6 +4518,518 @@ mod tests {
assert!(!game.claw.active); assert!(!game.claw.active);
} }
#[test]
fn multiball_claw_capture_keeps_the_ball_and_uses_only_table_releases() {
let mut game = Game::new_with_seed(1, 7);
game.ball.in_launcher = false;
game.ball.position = CLAW_TRIGGER_CENTER;
game.ball.velocity = Vec2::ZERO;
game.secondary_ball = Some(Ball {
position: vec2(210.0, 240.0),
velocity: vec2(12.0, -34.0),
in_launcher: false,
spin: Real48::ZERO,
capture_age: 0,
});
game.object_active.fill(false);
game.score_mode = ScoreMode::Multiball;
let claw_position = MilliVec::from_position(CLAW_TRIGGER_CENTER);
let mut events = Vec::new();
assert_eq!(
game.check_sensor_objects(claw_position, MilliVec::default(), &mut events),
BallAction::Keep
);
assert_eq!(game.claw.capture_entry, Some((ClawBallSlot::Primary, true)));
assert_eq!(
game.check_sensor_objects(claw_position, MilliVec::default(), &mut events),
BallAction::Suspend
);
assert!(game.claw.active);
assert!(game.primary_ball_suspended());
assert!(!game.secondary_ball_suspended());
assert!(matches!(game.claw.target_frame, 1 | 6 | 7));
assert_eq!(game.record_contacts[89], 1);
let survivor_before = game.secondary_ball.expect("survivor must remain").position;
game.timer_tick(CLAW_FRAME_SECONDS, 1, &mut events);
assert_ne!(
game.secondary_ball.expect("survivor must remain").position,
survivor_before
);
while game.claw.active {
game.update_claw(CLAW_FRAME_SECONDS, &mut events);
}
assert!(events.contains(&Event::ClawRelease));
assert!(!game.primary_ball_suspended());
assert!(game.secondary_ball.is_some());
game.ball.position = CLAW_TRIGGER_CENTER + vec2(20.0, 0.0);
game.ball.velocity = Vec2::ZERO;
game.ball.capture_age = 0;
assert_eq!(
game.check_sensor_objects(
MilliVec::from_position(game.ball.position),
MilliVec::default(),
&mut events,
),
BallAction::Keep
);
assert_eq!(game.record_contacts[89], 0);
}
#[test]
fn multiball_claw_restriction_is_latched_before_the_other_ball_drains() {
let mut game = Game::new_with_seed(1, 7);
game.ball.in_launcher = false;
game.ball.position = CLAW_TRIGGER_CENTER;
game.ball.velocity = vec2(100.0, 0.0);
game.secondary_ball = Some(Ball {
position: vec2(210.0, 240.0),
velocity: Vec2::ZERO,
in_launcher: false,
spin: Real48::ZERO,
capture_age: 0,
});
game.object_active.fill(false);
game.score_mode = ScoreMode::Multiball;
let claw_position = MilliVec::from_position(CLAW_TRIGGER_CENTER);
let mut events = Vec::new();
assert_eq!(
game.check_sensor_objects(claw_position, MilliVec::default(), &mut events),
BallAction::Keep
);
game.secondary_ball = None;
game.score_mode = ScoreMode::Normal;
game.ball.velocity = vec2(100.0, 0.0);
assert_eq!(
game.check_sensor_objects(claw_position, MilliVec::default(), &mut events),
BallAction::Keep
);
assert_eq!(game.claw.capture_entry, Some((ClawBallSlot::Primary, true)));
game.ball.velocity = Vec2::ZERO;
game.ball.capture_age = 300;
assert_eq!(
game.check_sensor_objects(claw_position, MilliVec::default(), &mut events),
BallAction::Suspend
);
assert_eq!(game.claw.capture_entry, Some((ClawBallSlot::Primary, true)));
assert!(matches!(game.claw.target_frame, 1 | 6 | 7));
}
#[test]
fn other_multiball_slot_cannot_clear_the_claw_entry_latch() {
let mut game = Game::new_with_seed(1, 7);
game.ball.in_launcher = false;
game.ball.position = CLAW_TRIGGER_CENTER;
game.ball.velocity = vec2(100.0, 0.0);
game.secondary_ball = Some(Ball {
position: vec2(210.0, 100.0),
velocity: Vec2::ZERO,
in_launcher: false,
spin: Real48::ZERO,
capture_age: 0,
});
game.object_active.fill(false);
game.score_mode = ScoreMode::Multiball;
let mut events = Vec::new();
assert_eq!(
game.check_sensor_objects(
MilliVec::from_position(CLAW_TRIGGER_CENTER),
MilliVec::default(),
&mut events,
),
BallAction::Keep
);
assert_eq!(game.claw.capture_entry, Some((ClawBallSlot::Primary, true)));
let mut secondary = game.secondary_ball.take().expect("secondary ball");
let secondary_position = MilliVec::from_position(secondary.position);
assert_eq!(
game.check_sensor_objects_for_ball(
&mut secondary,
2,
2,
secondary_position,
MilliVec::default(),
&mut events,
)
.action,
BallAction::Keep
);
game.secondary_ball = Some(secondary);
assert_eq!(game.claw.capture_entry, Some((ClawBallSlot::Primary, true)));
}
#[test]
fn shallow_claw_exit_clears_only_the_owning_entry_latch() {
let mut game = Game::new_with_seed(1, 7);
game.ball.in_launcher = false;
game.ball.position = CLAW_TRIGGER_CENTER;
game.ball.velocity = vec2(100.0, 0.0);
game.secondary_ball = Some(Ball {
position: vec2(210.0, 240.0),
velocity: Vec2::ZERO,
in_launcher: false,
spin: Real48::ZERO,
capture_age: 0,
});
game.object_active.fill(false);
game.score_mode = ScoreMode::Multiball;
let mut events = Vec::new();
assert_eq!(
game.check_sensor_objects(
MilliVec::from_position(CLAW_TRIGGER_CENTER),
MilliVec::default(),
&mut events,
),
BallAction::Keep
);
assert_eq!(game.claw.capture_entry, Some((ClawBallSlot::Primary, true)));
game.ball.position = CLAW_TRIGGER_CENTER + vec2(20.0, 0.0);
game.ball.velocity = Vec2::ZERO;
assert_eq!(
game.check_sensor_objects(
MilliVec::from_position(game.ball.position),
MilliVec::default(),
&mut events,
),
BallAction::Keep
);
assert_eq!(game.claw.capture_entry, None);
game.secondary_ball = None;
game.score_mode = ScoreMode::Normal;
game.ball.position = CLAW_TRIGGER_CENTER;
game.ball.velocity = vec2(100.0, 0.0);
assert_eq!(
game.check_sensor_objects(
MilliVec::from_position(CLAW_TRIGGER_CENTER),
MilliVec::default(),
&mut events,
),
BallAction::Keep
);
assert_eq!(
game.claw.capture_entry,
Some((ClawBallSlot::Primary, false))
);
}
#[test]
fn nonoverlapping_two_ball_claw_approaches_keep_capture_ownership() {
// Exercise the sequential solver from non-overlapping cardinal
// approaches around the sub-8 px deep zone. Ball contact begins at
// 17 px, so a second ball must be resolved before both can settle.
let capture_center = CLAW_TRIGGER_CENTER - vec2(0.0, 2.0);
let directions = [
vec2(1.0, 0.0),
vec2(0.0, 1.0),
vec2(-1.0, 0.0),
vec2(0.0, -1.0),
];
let radii = [10.0, 18.0];
let speeds = [100.0, 300.0];
let mut captures = 0;
for first_direction in directions {
for second_direction in directions {
for first_radius in radii {
for second_radius in radii {
let first_position = capture_center + first_direction * first_radius;
let second_position = capture_center + second_direction * second_radius;
if first_position.distance(second_position) < 17.0 {
continue;
}
for first_speed in speeds {
for second_speed in speeds {
let mut game = Game::new_with_seed(1, 7);
game.ball = Ball {
position: first_position,
velocity: (capture_center - first_position).normalize()
* first_speed,
in_launcher: false,
spin: Real48::ZERO,
capture_age: 0,
};
game.secondary_ball = Some(Ball {
position: second_position,
velocity: (capture_center - second_position).normalize()
* second_speed,
in_launcher: false,
spin: Real48::ZERO,
capture_age: 0,
});
game.object_active.fill(false);
game.score_mode = ScoreMode::Multiball;
let mut events = Vec::new();
for _ in 0..100 {
game.timer_tick(0.01, 1, &mut events);
if game.claw.active {
let Some((owner, true)) = game.claw.capture_entry else {
break;
};
captures += 1;
assert_eq!(
Some(owner),
game.claw.captured_slot,
"positions {first_position:?}/{second_position:?}, speeds {first_speed}/{second_speed}"
);
break;
}
if game.secondary_ball.is_none() || game.finished {
break;
}
}
}
}
}
}
}
}
assert!(captures > 0, "the sweep must exercise claw captures");
}
#[test]
fn secondary_multiball_ball_can_be_held_by_the_claw_without_hiding_primary() {
let mut game = Game::new_with_seed(1, 7);
game.ball.in_launcher = false;
game.ball.position = vec2(210.0, 240.0);
game.ball.velocity = Vec2::ZERO;
game.secondary_ball = Some(Ball {
position: CLAW_TRIGGER_CENTER,
velocity: Vec2::ZERO,
in_launcher: false,
spin: Real48::ZERO,
capture_age: 0,
});
game.object_active.fill(false);
game.score_mode = ScoreMode::Multiball;
let claw_position = MilliVec::from_position(CLAW_TRIGGER_CENTER);
let mut events = Vec::new();
let mut secondary = game.secondary_ball.take().expect("secondary ball");
assert_eq!(
game.check_sensor_objects_for_ball(
&mut secondary,
2,
2,
claw_position,
MilliVec::default(),
&mut events,
)
.action,
BallAction::Keep
);
game.secondary_ball = Some(secondary);
let mut secondary = game.secondary_ball.take().expect("secondary ball");
assert_eq!(
game.check_sensor_objects_for_ball(
&mut secondary,
2,
2,
claw_position,
MilliVec::default(),
&mut events,
)
.action,
BallAction::Suspend
);
game.secondary_ball = Some(secondary);
assert!(!game.primary_ball_suspended());
assert!(game.secondary_ball_suspended());
assert!(matches!(game.claw.target_frame, 1 | 6 | 7));
let primary_before = game.ball.position;
game.timer_tick(CLAW_FRAME_SECONDS, 1, &mut events);
assert_ne!(game.ball.position, primary_before);
}
#[test]
fn held_claw_ball_survives_the_other_ball_draining() {
let mut game = Game::new_with_seed(1, 7);
game.ball.in_launcher = false;
game.ball.position = CLAW_TRIGGER_CENTER;
game.ball.velocity = Vec2::ZERO;
game.secondary_ball = Some(Ball {
position: vec2(340.0, 100.0),
velocity: vec2(1.0, 0.0),
in_launcher: false,
spin: Real48::ZERO,
capture_age: 0,
});
game.object_active.fill(false);
game.score_mode = ScoreMode::Multiball;
let claw_position = MilliVec::from_position(CLAW_TRIGGER_CENTER);
let mut events = Vec::new();
assert_eq!(
game.check_sensor_objects(claw_position, MilliVec::default(), &mut events),
BallAction::Keep
);
assert_eq!(
game.check_sensor_objects(claw_position, MilliVec::default(), &mut events),
BallAction::Suspend
);
let balls_before = game.player().balls;
game.timer_tick(CLAW_FRAME_SECONDS, 1, &mut events);
assert!(game.secondary_ball.is_none());
assert_eq!(game.player().balls, balls_before);
assert_eq!(game.score_mode, ScoreMode::Normal);
assert!(game.claw.ball_suspended);
while game.claw.ball_suspended {
game.update_claw(CLAW_FRAME_SECONDS, &mut events);
}
assert!(events.contains(&Event::ClawRelease));
assert!(!game.ball.in_launcher);
}
#[test]
fn held_secondary_claw_ball_is_promoted_if_primary_drains() {
let mut game = Game::new_with_seed(1, 7);
game.ball.in_launcher = false;
game.ball.position = vec2(340.0, 100.0);
game.ball.velocity = vec2(1.0, 0.0);
game.secondary_ball = Some(Ball {
position: CLAW_TRIGGER_CENTER,
velocity: Vec2::ZERO,
in_launcher: false,
spin: Real48::ZERO,
capture_age: 0,
});
game.object_active.fill(false);
game.score_mode = ScoreMode::Multiball;
let claw_position = MilliVec::from_position(CLAW_TRIGGER_CENTER);
let mut events = Vec::new();
let mut secondary = game.secondary_ball.take().expect("secondary ball");
assert_eq!(
game.check_sensor_objects_for_ball(
&mut secondary,
2,
2,
claw_position,
MilliVec::default(),
&mut events,
)
.action,
BallAction::Keep
);
game.secondary_ball = Some(secondary);
let mut secondary = game.secondary_ball.take().expect("secondary ball");
assert_eq!(
game.check_sensor_objects_for_ball(
&mut secondary,
2,
2,
claw_position,
MilliVec::default(),
&mut events,
)
.action,
BallAction::Suspend
);
game.secondary_ball = Some(secondary);
assert_eq!(game.record_contacts[89], 2);
assert_eq!(
game.claw.capture_entry,
Some((ClawBallSlot::Secondary, true))
);
game.timer_tick(CLAW_FRAME_SECONDS, 1, &mut events);
assert!(game.secondary_ball.is_none());
assert!(game.primary_ball_suspended());
assert_eq!(game.record_contacts[89], 1);
assert_eq!(game.claw.capture_entry, Some((ClawBallSlot::Primary, true)));
assert_eq!(game.score_mode, ScoreMode::Normal);
assert!(events.contains(&Event::Drain));
}
#[test]
fn unrestricted_claw_capture_retains_the_launcher_release_choice() {
let mut saw_launcher_release = false;
for seed in 0..64 {
let mut game = Game::new_with_seed(1, seed);
if game.next_claw_terminal_frame(false) == 18 {
saw_launcher_release = true;
break;
}
}
assert!(saw_launcher_release);
}
#[test]
fn claw_reentry_after_multiball_uses_the_current_ball_count() {
let mut game = Game::new_with_seed(1, 7);
game.ball.in_launcher = false;
game.ball.position = CLAW_TRIGGER_CENTER;
game.ball.velocity = Vec2::ZERO;
game.secondary_ball = Some(Ball {
position: vec2(210.0, 240.0),
velocity: Vec2::ZERO,
in_launcher: false,
spin: Real48::ZERO,
capture_age: 0,
});
game.object_active.fill(false);
game.score_mode = ScoreMode::Multiball;
let claw_position = MilliVec::from_position(CLAW_TRIGGER_CENTER);
let mut events = Vec::new();
assert_eq!(
game.check_sensor_objects(claw_position, MilliVec::default(), &mut events),
BallAction::Keep
);
assert_eq!(
game.check_sensor_objects(claw_position, MilliVec::default(), &mut events),
BallAction::Suspend
);
assert_eq!(game.claw.capture_entry, Some((ClawBallSlot::Primary, true)));
game.secondary_ball = None;
game.score_mode = ScoreMode::Normal;
while game.claw.active {
game.update_claw(CLAW_FRAME_SECONDS, &mut events);
}
let unrestricted_seed = (0..64)
.find(|seed| {
let mut candidate = Game::new_with_seed(1, *seed);
candidate.next_claw_terminal_frame(false) == 18
})
.expect("a seed must select the launcher release");
game.random = BorlandRandom::new(unrestricted_seed);
game.ball.position = CLAW_TRIGGER_CENTER;
game.ball.velocity = Vec2::ZERO;
game.ball.capture_age = 0;
game.record_contacts[89] = 0;
assert_eq!(
game.check_sensor_objects(claw_position, MilliVec::default(), &mut events),
BallAction::Keep
);
assert_eq!(
game.claw.capture_entry,
Some((ClawBallSlot::Primary, false))
);
assert_eq!(
game.check_sensor_objects(claw_position, MilliVec::default(), &mut events),
BallAction::Suspend
);
assert_eq!(game.claw.target_frame, 18);
}
#[test] #[test]
fn claw_frame_cadence_follows_the_five_original_timer_choices() { fn claw_frame_cadence_follows_the_five_original_timer_choices() {
for (detail, frame_seconds) in DETAIL_TIMER_SECONDS.into_iter().enumerate() { for (detail, frame_seconds) in DETAIL_TIMER_SECONDS.into_iter().enumerate() {
+4 -3
View File
@@ -16,9 +16,10 @@ bootstrap is local as well, so the page does not require inline or third-party
JavaScript. The web page must be served over HTTP rather than opened directly JavaScript. The web page must be served over HTTP rather than opened directly
from a `file:` URL. from a `file:` URL.
For a strict Content Security Policy, allow `script-src 'self' For a strict Content Security Policy, allow
'wasm-unsafe-eval'` for the browser's WebAssembly compilation and `script-src 'self' 'wasm-unsafe-eval'` for the browser's WebAssembly compilation
`connect-src 'self'` for the local WASM and optional high-score API requests. and `connect-src 'self'` for the local WASM and optional high-score API
requests.
When the same-origin `/api/highscores` endpoint is available, the browser loads When the same-origin `/api/highscores` endpoint is available, the browser loads
and submits the shared top-ten table there. The small Axum service and an nginx and submits the shared top-ten table there. The small Axum service and an nginx
Binary file not shown.