19 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
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
ddidderr 12d1ec0aab Merge branch 'tomerge' 2026-08-29 20:11:13 +02:00
ddidderr 9c5b033c0a fix(web): use relative path for high-score API endpoint
Change the high-score endpoint URL in storage.js from "/api/highscores" to
"./api/highscores". When serving the web build from a subpath rather than the
domain root, an absolute path sends fetch requests to the domain root instead
of the nested application path. Using a relative URL ensures requests resolve
relative to the active document path while still working when hosted at root.

Test Plan:
- `node --check tdkpin-rs/web/storage.js` -- passed
- `npx prettier --check tdkpin-rs/web/storage.js` -- passed
- `just test` -- passed (138 game tests, 3 highscore-server tests)
- `just clippy` -- passed
- `git diff --cached --check` -- passed
2026-08-29 20:08:15 +02:00
ddidderr bc1ebcaaaa fix(web): back off high-score retries
The browser previously retried every failed shared high-score submission on the
50 ms polling interval. With server-side rate limiting and database load
shedding, immediate 429 and 503 responses could create a tight retry loop.
Add a bounded exponential delay with jitter, honor Retry-After on transient
responses, and reset the delay after a successful submission. Keep the pending
revision retryable without allowing overlapping requests.

Test Plan:
- `node --check tdkpin-rs/web/storage.js` -- passed
- `prettier --check tdkpin-rs/web/storage.js` -- passed
- Node VM retry timing harness covering Retry-After, backoff, and reset -- passed
- `git diff --cached --check` -- passed
2026-08-29 19:49:49 +02:00
ddidderr 3dff722535 fix(highscores): bound service resource usage
The high-score endpoints previously accepted unbounded request bodies and ran
SQLite work directly in async handlers, allowing oversized input or database
contention to consume server resources. Add a 1 KiB route body limit, admit
only one database operation at a time, shed excess requests with a clear 503,
and run accepted SQLite work on blocking threads while retaining admission
until that work finishes. Extend the Nginx example with matching request,
connection, body, and proxy time limits, and cover the limits, health
availability, contention, and cancellation behavior with tests.

Test Plan:
- `just --justfile tdkpin-rs/justfile test` -- passed (144 tests)
- `just --justfile tdkpin-rs/justfile clippy` -- passed
- `cargo +nightly fmt --manifest-path tdkpin-rs/highscore-server/Cargo.toml -- --check` -- passed
- `rumdl check --flavor commonmark tdkpin-rs/highscore-server/README.md` -- passed
- `git diff --cached --check` -- passed
2026-08-29 19:36:53 +02:00
ddidderr 7ee2e71bc7 fix: relative api/highscores path 2026-08-29 18:46:10 +02:00
ddidderr 86434aaa2b fix: remote unused github workflow 2026-08-29 18:07:54 +02:00
16 changed files with 1316 additions and 114 deletions
-27
View File
@@ -1,27 +0,0 @@
name: Build TDK Pinball
on:
push:
pull_request:
jobs:
build:
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
runs-on: ${{ matrix.os }}
defaults:
run:
working-directory: tdkpin-rs
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
components: clippy, rustfmt
- name: Install Linux development libraries
if: runner.os == 'Linux'
run: sudo apt-get update && sudo apt-get install -y libasound2-dev libgl1-mesa-dev libxi-dev
- run: cargo check --all-targets
- run: cargo test --all-targets
- run: cargo clippy --all-targets -- -D warnings
+4
View File
@@ -5,6 +5,10 @@
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.
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
Only update the version, when the user explicitly asks for it.
+43
View File
@@ -8,6 +8,49 @@ and this project adheres to
## [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
### 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
### Added
+1 -1
View File
@@ -551,7 +551,7 @@ dependencies = [
[[package]]
name = "tdkpin-rs"
version = "1.1.0"
version = "1.2.1"
dependencies = [
"directories",
"futures-util",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "tdkpin-rs"
version = "1.1.0"
version = "1.2.1"
edition = "2024"
[dependencies]
+21 -1
View File
@@ -9,11 +9,12 @@ axum = "0.8"
rusqlite = { version = "0.40", features = ["bundled"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["macros", "net", "rt-multi-thread"] }
tokio = { version = "1", features = ["macros", "net", "rt-multi-thread", "sync"] }
[dev-dependencies]
http-body-util = "0.1"
tempfile = "3"
tokio = { version = "1", features = ["time"] }
tower = { version = "0.5", features = ["util"] }
[lints.clippy]
@@ -23,3 +24,22 @@ unwrap_used = "warn"
[lints.rust]
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
+12 -6
View File
@@ -11,9 +11,12 @@ POST /api/highscores
The POST body is JSON with a 21-character maximum name and a `u32` score. The
response is the canonical top-ten JSON array.
Submissions are anonymous and intentionally trust the browser's score. Add
rate limiting, moderation, or server-side run verification if the table needs
to resist forged scores.
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 rate
limiting, moderation, or server-side run verification if the table needs to
resist forged scores.
Run it from `tdkpin-rs` with:
@@ -23,9 +26,12 @@ TDKPIN_HIGHSCORE_DB=/var/lib/tdkpin/highscores.sqlite3 \
cargo run --manifest-path highscore-server/Cargo.toml
```
Place [nginx.conf.example](nginx.conf.example) inside the public site's
existing `server` block. The browser client expects the API at
`/api/highscores` on the same origin as the game.
Copy the rate and connection zone declarations from
[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
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
origin as the game.
The crate inherits the parent [`rustfmt.toml`](../rustfmt.toml); run
`just fmt-highscore-server` when formatting it directly.
+22 -1
View File
@@ -1,10 +1,31 @@
# Add this block inside the nginx server block that serves the game.
# Add these directives inside the existing nginx http block. The server-wide
# zones bound aggregate traffic, while the address-keyed zones prevent one
# client from consuming the whole allowance.
limit_req_zone $binary_remote_addr zone=tdkpin_highscore_client_rate:10m rate=5r/s;
limit_req_zone $server_name zone=tdkpin_highscore_global_rate:1m rate=50r/s;
limit_conn_zone $binary_remote_addr zone=tdkpin_highscore_client_connections:10m;
limit_conn_zone $server_name zone=tdkpin_highscore_global_connections:1m;
# Add these blocks inside the server block that serves the game.
location /api/highscores {
limit_req zone=tdkpin_highscore_client_rate burst=10 nodelay;
limit_req zone=tdkpin_highscore_global_rate burst=25 nodelay;
limit_req_status 429;
limit_conn tdkpin_highscore_client_connections 10;
limit_conn tdkpin_highscore_global_connections 100;
limit_conn_status 429;
client_max_body_size 1k;
client_body_timeout 5s;
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_connect_timeout 2s;
proxy_send_timeout 5s;
proxy_read_timeout 5s;
proxy_next_upstream off;
}
# Optional health check for local monitoring.
+302 -12
View File
@@ -6,16 +6,31 @@ use std::{
use axum::{
Json,
Router,
extract::State,
extract::{DefaultBodyLimit, State},
http::StatusCode,
response::{IntoResponse, Response},
routing::get,
};
use rusqlite::{Connection, params, types::Type};
use serde::{Deserialize, Serialize};
use tokio::{sync::Semaphore, task};
const MAX_HIGH_SCORES: usize = 10;
const MAX_NAME_CHARS: usize = 21;
const MAX_SUBMISSION_BODY_BYTES: usize = 1024;
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)]
pub struct HighScore {
@@ -46,7 +61,9 @@ impl HighScoreStore {
/// Returns the SQLite error raised while opening or initializing the
/// database.
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.
@@ -55,10 +72,13 @@ impl HighScoreStore {
///
/// Returns the SQLite error raised while initializing the database.
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(
"CREATE TABLE IF NOT EXISTS high_scores (
id INTEGER PRIMARY KEY,
@@ -68,6 +88,16 @@ impl HighScoreStore {
CREATE INDEX IF NOT EXISTS high_scores_order
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))))
}
@@ -127,6 +157,50 @@ impl HighScoreStore {
}
}
#[derive(Clone)]
struct AppState {
store: HighScoreStore,
database_slots: Arc<Semaphore>,
}
impl AppState {
fn new(store: HighScoreStore) -> Self {
Self {
store,
database_slots: Arc::new(Semaphore::new(MAX_CONCURRENT_DATABASE_OPERATIONS)),
}
}
}
#[derive(Debug)]
enum DatabaseRequestError {
Busy,
Failed,
}
async fn run_database_operation<T, F>(
state: &AppState,
operation: F,
) -> Result<T, DatabaseRequestError>
where
T: Send + 'static,
F: FnOnce(&HighScoreStore) -> Result<T, StoreError> + Send + 'static,
{
let permit = state
.database_slots
.clone()
.try_acquire_owned()
.map_err(|_| DatabaseRequestError::Busy)?;
let store = state.store.clone();
task::spawn_blocking(move || {
let _permit = permit;
operation(&store)
})
.await
.map_err(|_| DatabaseRequestError::Failed)?
.map_err(|_| DatabaseRequestError::Failed)
}
#[derive(Debug, Deserialize)]
struct SubmitRequest {
name: String,
@@ -146,6 +220,19 @@ fn invalid_request(message: &'static str) -> Response {
.into_response()
}
fn database_error_response(error: &DatabaseRequestError) -> Response {
match error {
DatabaseRequestError::Busy => (
StatusCode::SERVICE_UNAVAILABLE,
Json(ErrorResponse {
error: "service is busy",
}),
)
.into_response(),
DatabaseRequestError::Failed => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
}
}
fn validate_request(request: &SubmitRequest) -> Result<HighScore, &'static str> {
let name = request.name.trim();
if name.is_empty() {
@@ -167,39 +254,47 @@ async fn health() -> &'static str {
"ok"
}
async fn list_high_scores(State(store): State<HighScoreStore>) -> Response {
match store.list() {
async fn list_high_scores(State(state): State<AppState>) -> Response {
match run_database_operation(&state, HighScoreStore::list).await {
Ok(scores) => Json(scores).into_response(),
Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
Err(error) => database_error_response(&error),
}
}
async fn submit_high_score(
State(store): State<HighScoreStore>,
State(state): State<AppState>,
Json(request): Json<SubmitRequest>,
) -> Response {
let entry = match validate_request(&request) {
Ok(entry) => entry,
Err(message) => return invalid_request(message),
};
match store.submit(&entry) {
match run_database_operation(&state, move |store| store.submit(&entry)).await {
Ok(scores) => (StatusCode::CREATED, Json(scores)).into_response(),
Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
Err(error) => database_error_response(&error),
}
}
pub fn router(store: HighScoreStore) -> Router {
router_with_state(AppState::new(store))
}
fn router_with_state(state: AppState) -> Router {
Router::new()
.route("/healthz", get(health))
.route(
"/api/highscores",
get(list_high_scores).post(submit_high_score),
get(list_high_scores)
.post(submit_high_score)
.layer(DefaultBodyLimit::max(MAX_SUBMISSION_BODY_BYTES)),
)
.with_state(store)
.with_state(state)
}
#[cfg(test)]
mod tests {
use std::{sync::mpsc, time::Duration};
use axum::{
body::Body,
http::{Request, StatusCode},
@@ -229,6 +324,44 @@ mod tests {
.status()
}
async fn request(app: &Router, request: Request<Body>) -> StatusCode {
app.clone()
.oneshot(request)
.await
.expect("router should respond")
.status()
}
fn hold_database_lock(
store: HighScoreStore,
) -> (mpsc::SyncSender<()>, std::thread::JoinHandle<()>) {
let (locked_sender, locked_receiver) = mpsc::sync_channel(0);
let (release_sender, release_receiver) = mpsc::sync_channel(0);
let lock_thread = std::thread::spawn(move || {
let _connection = store.0.lock().expect("database lock should succeed");
locked_sender
.send(())
.expect("test should observe the held database lock");
release_receiver
.recv()
.expect("test should release the database lock");
});
locked_receiver
.recv()
.expect("database lock thread should start");
(release_sender, lock_thread)
}
async fn wait_until_database_is_busy(database_slots: &Semaphore) {
tokio::time::timeout(Duration::from_secs(1), async {
while database_slots.available_permits() != 0 {
tokio::task::yield_now().await;
}
})
.await
.expect("first database request should acquire admission");
}
async fn list(app: &Router) -> Vec<HighScore> {
let response = app
.clone()
@@ -253,6 +386,7 @@ mod tests {
async fn api_persists_and_keeps_the_top_ten() {
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");
let app = router(store);
@@ -272,6 +406,34 @@ mod tests {
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]
async fn api_rejects_invalid_names() {
let app = router(HighScoreStore::open_in_memory().expect("database should open"));
@@ -284,6 +446,134 @@ mod tests {
assert_eq!(submit(&app, "ok\nno", 10).await, StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn api_rejects_oversized_submission_bodies() {
let app = router(HighScoreStore::open_in_memory().expect("database should open"));
let body = serde_json::to_vec(&serde_json::json!({
"name": "Player",
"score": 10,
"padding": "x".repeat(MAX_SUBMISSION_BODY_BYTES),
}))
.expect("request JSON should encode");
let status = request(
&app,
Request::post("/api/highscores")
.header("content-type", "application/json")
.body(Body::from(body))
.expect("request should build"),
)
.await;
assert_eq!(status, StatusCode::PAYLOAD_TOO_LARGE);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
async fn busy_database_load_sheds_api_without_blocking_health() {
let store = HighScoreStore::open_in_memory().expect("database should open");
let (release_sender, lock_thread) = hold_database_lock(store.clone());
let state = AppState::new(store);
let database_slots = state.database_slots.clone();
let app = router_with_state(state);
let accepted_app = app.clone();
let accepted = tokio::spawn(async move { submit(&accepted_app, "Player", 10).await });
wait_until_database_is_busy(&database_slots).await;
assert_eq!(
tokio::time::timeout(
Duration::from_millis(250),
request(
&app,
Request::get("/healthz")
.body(Body::empty())
.expect("request should build"),
),
)
.await
.expect("health request should not wait for the database"),
StatusCode::OK
);
assert_eq!(
submit(&app, "Other Player", 20).await,
StatusCode::SERVICE_UNAVAILABLE
);
assert_eq!(
request(
&app,
Request::get("/api/highscores")
.body(Body::empty())
.expect("request should build"),
)
.await,
StatusCode::SERVICE_UNAVAILABLE
);
assert_eq!(
request(
&app,
Request::builder()
.method("HEAD")
.uri("/api/highscores")
.body(Body::empty())
.expect("request should build"),
)
.await,
StatusCode::SERVICE_UNAVAILABLE
);
release_sender
.send(())
.expect("database lock should be released");
assert_eq!(
accepted.await.expect("accepted request should complete"),
StatusCode::CREATED
);
lock_thread
.join()
.expect("database lock thread should stop");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
async fn canceled_request_holds_admission_until_blocking_work_stops() {
let store = HighScoreStore::open_in_memory().expect("database should open");
let (release_sender, lock_thread) = hold_database_lock(store.clone());
let state = AppState::new(store);
let database_slots = state.database_slots.clone();
let app = router_with_state(state);
let canceled_app = app.clone();
let canceled = tokio::spawn(async move { submit(&canceled_app, "Player", 10).await });
wait_until_database_is_busy(&database_slots).await;
canceled.abort();
assert!(
canceled
.await
.expect_err("request should be canceled")
.is_cancelled()
);
assert_eq!(database_slots.available_permits(), 0);
assert_eq!(
submit(&app, "Other Player", 20).await,
StatusCode::SERVICE_UNAVAILABLE
);
release_sender
.send(())
.expect("database lock should be released");
lock_thread
.join()
.expect("database lock thread should stop");
tokio::time::timeout(Duration::from_secs(1), async {
while database_slots.available_permits() == 0 {
tokio::task::yield_now().await;
}
})
.await
.expect("completed blocking work should release admission");
assert_eq!(submit(&app, "Other Player", 20).await, StatusCode::CREATED);
}
#[tokio::test]
async fn health_endpoint_is_available_for_nginx() {
let app = router(HighScoreStore::open_in_memory().expect("database should open"));
+4 -1
View File
@@ -9,7 +9,10 @@ build:
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
web-build:
+4 -2
View File
@@ -901,7 +901,7 @@ impl App {
);
}
}
if !game.claw.ball_suspended {
if !game.primary_ball_suspended() {
draw_texture(
&self.assets.ball,
game.ball.position.x - 8.0,
@@ -909,7 +909,9 @@ impl App {
WHITE,
);
}
if let Some(ball) = game.secondary_ball {
if let Some(ball) = game.secondary_ball
&& !game.secondary_ball_suspended()
{
draw_texture(
&self.assets.ball,
ball.position.x - 8.0,
+823 -32
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_SUBSTEPS: [u8; 5] = [5, 4, 3, 2, 1];
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;
#[allow(clippy::cast_possible_wrap)]
@@ -129,12 +130,32 @@ pub enum ClawSpriteBank {
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)]
pub struct Claw {
pub active: bool,
pub frame: u8,
pub bank: ClawSpriteBank,
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,
frame_accumulator: f32,
}
@@ -146,6 +167,8 @@ impl Default for Claw {
frame: 10,
bank: ClawSpriteBank::Opening,
ball_suspended: false,
captured_slot: None,
capture_entry: None,
target_frame: 10,
frame_accumulator: 0.0,
}
@@ -592,27 +615,46 @@ impl Game {
self.update_target_rotation(events);
}
let panel_active = self.update_panel_completion(events);
if !self.claw.ball_suspended && !panel_active {
// A held claw ball still counts as the second live ball until the
// other slot disappears or the claw releases it.
if self.secondary_ball.is_none()
&& self.claw.captured_slot != Some(ClawBallSlot::Secondary)
&& self.score_mode == ScoreMode::Multiball
{
self.score_mode = ScoreMode::Normal;
}
if !panel_active {
let had_secondary_ball = self.secondary_ball.is_some();
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 {
self.score_mode = ScoreMode::Multiball;
}
if !primary_held {
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_holds_slot(ClawBallSlot::Primary) {
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 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 {
if self.advance_secondary_ball(events) {
if self.advance_secondary_ball_slot(events, !primary_held) {
break;
}
}
} else {
} else if !self.claw_holds_slot(ClawBallSlot::Primary)
&& !self.claw_holds_slot(ClawBallSlot::Secondary)
{
self.secondary_ball = Some(self.ball);
for _ in 0..substeps {
if self.advance_secondary_ball_slot(events, false) {
@@ -626,6 +668,14 @@ impl Game {
}
}
}
if let Some(spawned) = spawned_secondary {
debug_assert!(self.secondary_ball.is_none());
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] =
@@ -743,6 +793,8 @@ impl Game {
}
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 {
let slot_impulse = match nudge {
Nudge::Left | Nudge::Right => MilliVec {
@@ -762,12 +814,16 @@ impl Game {
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.y = primary_slot.y.wrapping_add(slot_impulse.y);
if !primary_held {
self.ball.velocity = primary_slot.to_velocity_per_second();
}
let mut secondary_slot = MilliVec::from_velocity_per_second(secondary.velocity);
secondary_slot.x = secondary_slot.x.wrapping_add(slot_impulse.x);
secondary_slot.y = secondary_slot.y.wrapping_add(slot_impulse.y);
if !secondary_held {
secondary.velocity = secondary_slot.to_velocity_per_second();
} else {
}
} else if !primary_held {
self.ball.velocity = velocity.to_velocity_per_second();
}
@@ -816,8 +872,13 @@ impl Game {
);
let mut best_collision = best_static
.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;
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 response = ball_collision_response(
old_position,
@@ -860,6 +921,8 @@ impl Game {
if object_id == 175
&& let (Some(secondary), Some(transferred)) =
(&mut self.secondary_ball, transferred_secondary_velocity)
&& !secondary_held
&& !primary_held
{
secondary.velocity = transferred.to_velocity_per_second();
}
@@ -928,6 +991,7 @@ impl Game {
.secondary_ball
.take()
.expect("a multiball capture must leave the other slot active");
self.promote_claw_secondary_to_primary();
return true;
}
}
@@ -992,12 +1056,12 @@ impl Game {
&mut capture_candidate,
events,
) {
CaptureStep::Complete if ball_count == 1 => {
let terminal_frame = self.next_claw_terminal_frame();
self.begin_claw_capture_after_hold(ball, terminal_frame, events);
CaptureStep::Complete => {
let terminal_frame = self
.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;
}
CaptureStep::Complete => action = BallAction::Remove,
CaptureStep::Outside | CaptureStep::Holding => {}
}
*velocity = MilliVec::from_velocity_per_second(ball.velocity);
@@ -1211,8 +1275,9 @@ impl Game {
)
}
#[cfg(test)]
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)]
@@ -1251,6 +1316,8 @@ impl Game {
let mut transferred_primary_velocity = None;
let other_position = MilliVec::from_position(primary_position);
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_add(21_000)
&& predicted.y >= other_position.y.wrapping_sub(21_000)
@@ -1540,13 +1607,10 @@ impl Game {
) {
CaptureStep::Holding | CaptureStep::Outside => {}
CaptureStep::Complete => {
if ball_count == 1 {
let terminal_frame = self.next_claw_terminal_frame();
self.begin_claw_capture_after_hold(ball, terminal_frame, events);
let terminal_frame = self
.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;
} else {
action = BallAction::Remove;
}
}
}
}
@@ -1579,7 +1643,11 @@ impl Game {
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(
&mut self,
ball: &mut Ball,
@@ -1604,6 +1672,12 @@ impl Game {
|| predicted_position.y < center.y.wrapping_sub(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;
}
let dx = center.x.wrapping_sub(current_position.x);
@@ -1621,6 +1695,13 @@ impl Game {
&& contact_allowed
&& !(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 {
let mut velocity = MilliVec::from_velocity_per_second(ball.velocity);
let stationary = center.x.wrapping_sub(previous_position.x).wrapping_abs() < 500
@@ -1663,13 +1744,21 @@ impl Game {
return CaptureStep::Complete;
}
ball.capture_age = 0;
self.record_contacts[contact_index] = if record_id == 148 { 2 } else { 99 };
if ball_count > 1 {
self.record_contacts[contact_index] = 2;
}
self.record_contacts[contact_index] = if record_id == 89 && ball_count > 1 {
// Keep ownership with the held slot so its later release can
// clear the claw contact before a fresh entry.
ball_number
} else if record_id == 148 || ball_count > 1 {
2
} else {
99
};
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 {
self.record_contacts[contact_index] = 0;
ball.capture_age = 0;
@@ -1731,7 +1820,17 @@ impl Game {
events: &mut Vec<Event>,
) -> BallAction {
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 award = 5_000_u32 << shift;
let player = &mut self.players[self.current_player];
@@ -1746,6 +1845,7 @@ impl Game {
if ball_count == 1 {
self.panel_frame = Some(0);
}
self.score_mode = ScoreMode::Normal;
}
events.push(Event::Lock);
if ball_count == 1 {
@@ -2025,9 +2125,55 @@ impl Game {
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))]
}
}
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) {
let pending = self.pending_flipper_edges;
@@ -2035,6 +2181,8 @@ impl Game {
if self.ball.in_launcher || self.tilted {
return;
}
let primary_held = self.claw_holds_slot(ClawBallSlot::Primary);
let secondary_held = self.claw_holds_slot(ClawBallSlot::Secondary);
for (delta, side) in [
(i32::from(pending[0]), FlipperSide::Left),
@@ -2043,8 +2191,12 @@ impl Game {
if delta == 0 {
continue;
}
if !primary_held {
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);
}
}
@@ -2060,6 +2212,8 @@ impl Game {
self.claw.target_frame = terminal_frame;
self.claw.bank = ClawSpriteBank::Closing;
self.claw.ball_suspended = true;
self.claw.captured_slot = Some(ClawBallSlot::Primary);
self.claw.capture_entry = None;
self.claw.frame_accumulator = 0.0;
self.ball.velocity = Vec2::ZERO;
self.ball.spin = Real48::ZERO;
@@ -2070,6 +2224,7 @@ impl Game {
fn begin_claw_capture_after_hold(
&mut self,
ball: &mut Ball,
ball_number: u16,
terminal_frame: u8,
events: &mut Vec<Event>,
) {
@@ -2081,6 +2236,7 @@ impl Game {
self.claw.target_frame = terminal_frame;
self.claw.bank = ClawSpriteBank::Closing;
self.claw.ball_suspended = true;
self.claw.captured_slot = Some(ClawBallSlot::from_ball_number(ball_number));
self.claw.frame_accumulator = 0.0;
ball.velocity = Vec2::ZERO;
ball.spin = Real48::ZERO;
@@ -2118,7 +2274,24 @@ impl Game {
let release_frame = self.claw.frame;
self.claw.target_frame = 10;
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;
events.push(Event::ClawRelease);
events.push(Event::Sound(2016));
@@ -2153,6 +2326,15 @@ impl Game {
}
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() {
self.ball = remaining_ball;
self.score_mode = ScoreMode::Normal;
@@ -3420,15 +3602,69 @@ mod tests {
spin: Real48::ZERO,
capture_age: 300,
});
game.score_mode = ScoreMode::Multiball;
let mut events = Vec::new();
game.advance_secondary_ball(&mut events);
assert!(game.secondary_ball.is_none());
assert_eq!(game.score_mode, ScoreMode::Multiball);
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!(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_advances_the_returning_claw_while_both_balls_live() {
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.claw.ball_suspended = true;
game.claw.captured_slot = Some(ClawBallSlot::Primary);
game.score_mode = ScoreMode::Multiball;
let mut events = Vec::new();
game.update_claw(CLAW_FRAME_SECONDS, &mut events);
assert_eq!(game.claw.frame, 2);
}
#[test]
@@ -3460,10 +3696,35 @@ mod tests {
assert_eq!(game.ball.position, survivor.position);
assert_eq!(game.ball.velocity, survivor.velocity);
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]);
}
#[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]
fn center_drain_advances_to_a_fresh_ball() {
let mut game = Game::new(1);
@@ -4257,6 +4518,518 @@ mod tests {
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]
fn claw_frame_cadence_follows_the_five_original_timer_choices() {
for (detail, frame_seconds) in DETAIL_TIMER_SECONDS.into_iter().enumerate() {
@@ -4529,6 +5302,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]
fn fifth_multiball_lock_defers_panel_and_caps_survivor_reaward() {
let mut game = Game::new(1);
@@ -4546,6 +5336,7 @@ mod tests {
assert_eq!(game.player().secondary_score, 0);
assert_eq!(game.player().score_multiplier, 2);
assert_eq!(game.panel_frame, None);
assert_eq!(game.score_mode, ScoreMode::Normal);
let mut reset_by_special_hole = game.clone();
reset_by_special_hole.reset_ball_to_launcher();
+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
from a `file:` URL.
For a strict Content Security Policy, allow `script-src 'self'
'wasm-unsafe-eval'` for the browser's WebAssembly compilation and
`connect-src 'self'` for the local WASM and optional high-score API requests.
For a strict Content Security Policy, allow
`script-src 'self' 'wasm-unsafe-eval'` for the browser's WebAssembly compilation
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
and submits the shared top-ten table there. The small Axum service and an nginx
+49 -1
View File
@@ -4,12 +4,16 @@
const storageKey = "tdkpin.save.v1";
const encoder = new TextEncoder();
const decoder = new TextDecoder();
const highScoreApi = "/api/highscores";
const highScoreApi = "api/highscores";
const highScoreRetryInitialDelayMs = 250;
const highScoreRetryMaxDelayMs = 30_000;
let lastRevision = 0;
let lastHighScoreRevision = 0;
let highScoreRequestInFlight = false;
let highScoreGeneration = 0;
let highScoreSubmissionWarningShown = false;
let highScoreRetryDelayMs = highScoreRetryInitialDelayMs;
let highScoreRetryAt = 0;
function browserStorage() {
try {
@@ -86,10 +90,50 @@
}
}
function retryAfterDelay(response) {
if (response.status !== 429 && response.status !== 503) {
return null;
}
const value = response.headers.get("Retry-After")?.trim();
if (!value) {
return null;
}
const seconds = Number(value);
if (Number.isFinite(seconds) && seconds >= 0) {
const delay = seconds * 1000;
return Number.isFinite(delay) ? delay : null;
}
const timestamp = Date.parse(value);
return Number.isFinite(timestamp)
? Math.max(0, timestamp - Date.now())
: null;
}
function scheduleHighScoreRetry(retryAfterMs) {
const jitteredDelay = highScoreRetryDelayMs * (0.5 + Math.random());
const delay =
retryAfterMs === null ? jitteredDelay : retryAfterMs + jitteredDelay;
highScoreRetryAt = Date.now() + delay;
highScoreRetryDelayMs = Math.min(
highScoreRetryDelayMs * 2,
highScoreRetryMaxDelayMs,
);
}
function resetHighScoreRetry() {
highScoreRetryDelayMs = highScoreRetryInitialDelayMs;
highScoreRetryAt = 0;
}
async function flushHighScoreSubmission() {
if (highScoreRequestInFlight) {
return;
}
if (Date.now() < highScoreRetryAt) {
return;
}
const revision = wasm_exports.tdkpin_browser_high_score_revision();
if (revision === lastHighScoreRevision) {
return;
@@ -102,6 +146,7 @@
}
highScoreRequestInFlight = true;
let retryAfterMs = null;
try {
const response = await fetch(highScoreApi, {
method: "POST",
@@ -110,6 +155,7 @@
keepalive: true,
});
if (!response.ok) {
retryAfterMs = retryAfterDelay(response);
throw new Error(`high-score submission failed (${response.status})`);
}
const json = await response.text();
@@ -118,7 +164,9 @@
wasm_exports.tdkpin_browser_high_score_ack(revision);
lastHighScoreRevision = revision;
highScoreSubmissionWarningShown = false;
resetHighScoreRetry();
} catch (error) {
scheduleHighScoreRetry(retryAfterMs);
if (!highScoreSubmissionWarningShown) {
console.warn("shared high-score submission failed; will retry", error);
highScoreSubmissionWarningShown = true;
Binary file not shown.