Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3fc1096500
|
||
|
|
a5f2aab005 | ||
|
|
d37d327a02 | ||
|
|
71e5450a84 | ||
|
|
26a2567c91 | ||
|
|
25147ad58e | ||
|
|
c4fb345197 | ||
|
|
4385a661b3
|
||
|
|
a6967b4d1b
|
||
|
|
a394df23e7
|
||
|
|
d5336a2a92
|
||
|
|
8e1a1c91e2 | ||
|
|
9023af7e7e | ||
|
|
12d1ec0aab
|
||
|
|
9c5b033c0a | ||
|
|
bc1ebcaaaa | ||
|
|
3dff722535 | ||
|
|
7ee2e71bc7
|
||
|
|
86434aaa2b
|
@@ -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
|
|
||||||
@@ -1,21 +1,21 @@
|
|||||||
[target.wasm32-unknown-unknown]
|
[target.wasm32-unknown-unknown]
|
||||||
rustflags = [
|
rustflags = [
|
||||||
"-C",
|
"-C",
|
||||||
"link-arg=--import-undefined",
|
"link-arg=--import-undefined",
|
||||||
"-C",
|
"-C",
|
||||||
"link-arg=--export=tdkpin_storage_crate_version",
|
"link-arg=--export=tdkpin_storage_crate_version",
|
||||||
"-C",
|
"-C",
|
||||||
"link-arg=--export=tdkpin_browser_storage_clear",
|
"link-arg=--export=tdkpin_browser_storage_clear",
|
||||||
"-C",
|
"-C",
|
||||||
"link-arg=--export=tdkpin_browser_storage_push",
|
"link-arg=--export=tdkpin_browser_storage_push",
|
||||||
"-C",
|
"-C",
|
||||||
"link-arg=--export=tdkpin_browser_storage_finish",
|
"link-arg=--export=tdkpin_browser_storage_finish",
|
||||||
"-C",
|
"-C",
|
||||||
"link-arg=--export=tdkpin_browser_storage_save_revision",
|
"link-arg=--export=tdkpin_browser_storage_save_revision",
|
||||||
"-C",
|
"-C",
|
||||||
"link-arg=--export=tdkpin_browser_storage_save_length",
|
"link-arg=--export=tdkpin_browser_storage_save_length",
|
||||||
"-C",
|
"-C",
|
||||||
"link-arg=--export=tdkpin_browser_storage_save_byte",
|
"link-arg=--export=tdkpin_browser_storage_save_byte",
|
||||||
"-C",
|
"-C",
|
||||||
"link-arg=--export=tdkpin_browser_storage_save_ack",
|
"link-arg=--export=tdkpin_browser_storage_save_ack",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -5,6 +5,10 @@
|
|||||||
Automatically commit changes once a full feature, bugfix, refactor, or other
|
Automatically commit changes once a full feature, bugfix, refactor, or other
|
||||||
coherent unit of work is finished. Do not wait for the user to ask for a commit.
|
coherent unit of work is finished. Do not wait for the user to ask for a commit.
|
||||||
|
|
||||||
|
Use `divergence` as the Conventional Commit scope for every change that
|
||||||
|
deliberately differs from original-game behavior to fix an original bug, for
|
||||||
|
example `fix(divergence): reject occupied wheel slots`.
|
||||||
|
|
||||||
## Versioning Policy
|
## Versioning Policy
|
||||||
|
|
||||||
Only update the version, when the user explicitly asks for it.
|
Only update the version, when the user explicitly asks for it.
|
||||||
|
|||||||
@@ -8,6 +8,49 @@ 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
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Seed new SQLite high-score databases that did not exist before opening with
|
||||||
|
the ten distributed demo scores.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Deliberately diverge from the original game's multiball contact sentinel so a
|
||||||
|
reserve ball that fills a wheel hole leaves it permanently occupied; the
|
||||||
|
surviving ball can no longer enter and score that visibly filled slot again.
|
||||||
|
- Match capture-driven multiball collapse timing so the surviving slot keeps
|
||||||
|
double scoring through the current callback, then returns to normal scoring on
|
||||||
|
the next callback and the returning claw remains paused until that collapse;
|
||||||
|
completing the fifth wheel lock still ends multiball scoring immediately.
|
||||||
|
- Derive wheel-lock awards from the original live contact words so two balls
|
||||||
|
settling into different slots at once receive the same accumulated award as
|
||||||
|
the original game.
|
||||||
|
- Stage an effect-seven reserve-ball request until ball 1's full substep batch
|
||||||
|
has returned, preventing the new slot from participating in collision and
|
||||||
|
capture rules before the original creates it.
|
||||||
|
- Bound high-score server resource usage with 1 KiB route body limits,
|
||||||
|
single-operation database concurrency gating, 503 shedding, and offloading
|
||||||
|
SQLite operations to blocking worker threads.
|
||||||
|
- Use a relative path for the web build's high-score API endpoint so deployments
|
||||||
|
under nested URL subpaths route requests correctly.
|
||||||
|
- Implement bounded exponential backoff with jitter, initial delay and maximum
|
||||||
|
delay bounds, and `Retry-After` header support for failed web high-score
|
||||||
|
submissions.
|
||||||
|
|
||||||
## [1.1.0] - 2026-08-29
|
## [1.1.0] - 2026-08-29
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
Generated
+1
-1
@@ -551,7 +551,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tdkpin-rs"
|
name = "tdkpin-rs"
|
||||||
version = "1.1.0"
|
version = "1.2.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"directories",
|
"directories",
|
||||||
"futures-util",
|
"futures-util",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "tdkpin-rs"
|
name = "tdkpin-rs"
|
||||||
version = "1.1.0"
|
version = "1.2.1"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
|||||||
@@ -9,11 +9,12 @@ axum = "0.8"
|
|||||||
rusqlite = { version = "0.40", features = ["bundled"] }
|
rusqlite = { version = "0.40", features = ["bundled"] }
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
tokio = { version = "1", features = ["macros", "net", "rt-multi-thread"] }
|
tokio = { version = "1", features = ["macros", "net", "rt-multi-thread", "sync"] }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
http-body-util = "0.1"
|
http-body-util = "0.1"
|
||||||
tempfile = "3"
|
tempfile = "3"
|
||||||
|
tokio = { version = "1", features = ["time"] }
|
||||||
tower = { version = "0.5", features = ["util"] }
|
tower = { version = "0.5", features = ["util"] }
|
||||||
|
|
||||||
[lints.clippy]
|
[lints.clippy]
|
||||||
@@ -23,3 +24,22 @@ unwrap_used = "warn"
|
|||||||
|
|
||||||
[lints.rust]
|
[lints.rust]
|
||||||
unsafe_code = "forbid"
|
unsafe_code = "forbid"
|
||||||
|
|
||||||
|
[profile.release]
|
||||||
|
debug = true
|
||||||
|
strip = false
|
||||||
|
debug-assertions = true
|
||||||
|
overflow-checks = true
|
||||||
|
lto = false
|
||||||
|
panic = "unwind"
|
||||||
|
incremental = true
|
||||||
|
|
||||||
|
[profile.production]
|
||||||
|
inherits = "release"
|
||||||
|
debug = false
|
||||||
|
strip = true
|
||||||
|
debug-assertions = false
|
||||||
|
overflow-checks = false
|
||||||
|
lto = true
|
||||||
|
incremental = false
|
||||||
|
codegen-units = 1
|
||||||
|
|||||||
@@ -11,9 +11,12 @@ POST /api/highscores
|
|||||||
The POST body is JSON with a 21-character maximum name and a `u32` score. The
|
The POST body is JSON with a 21-character maximum name and a `u32` score. The
|
||||||
response is the canonical top-ten JSON array.
|
response is the canonical top-ten JSON array.
|
||||||
|
|
||||||
Submissions are anonymous and intentionally trust the browser's score. Add
|
A newly created database starts with the original distributed demo table. An
|
||||||
rate limiting, moderation, or server-side run verification if the table needs
|
existing database, including an existing empty database, is left unchanged.
|
||||||
to resist forged scores.
|
|
||||||
|
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:
|
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
|
cargo run --manifest-path highscore-server/Cargo.toml
|
||||||
```
|
```
|
||||||
|
|
||||||
Place [nginx.conf.example](nginx.conf.example) inside the public site's
|
Copy the rate and connection zone declarations from
|
||||||
existing `server` block. The browser client expects the API at
|
[nginx.conf.example](./nginx.conf.example) into the existing `http` block, then
|
||||||
`/api/highscores` on the same origin as the game.
|
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
|
The crate inherits the parent [`rustfmt.toml`](../rustfmt.toml); run
|
||||||
`just fmt-highscore-server` when formatting it directly.
|
`just fmt-highscore-server` when formatting it directly.
|
||||||
|
|||||||
@@ -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 {
|
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_pass http://127.0.0.1:3000;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
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.
|
# Optional health check for local monitoring.
|
||||||
|
|||||||
@@ -6,16 +6,31 @@ use std::{
|
|||||||
use axum::{
|
use axum::{
|
||||||
Json,
|
Json,
|
||||||
Router,
|
Router,
|
||||||
extract::State,
|
extract::{DefaultBodyLimit, State},
|
||||||
http::StatusCode,
|
http::StatusCode,
|
||||||
response::{IntoResponse, Response},
|
response::{IntoResponse, Response},
|
||||||
routing::get,
|
routing::get,
|
||||||
};
|
};
|
||||||
use rusqlite::{Connection, params, types::Type};
|
use rusqlite::{Connection, params, types::Type};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use tokio::{sync::Semaphore, task};
|
||||||
|
|
||||||
const MAX_HIGH_SCORES: usize = 10;
|
const MAX_HIGH_SCORES: usize = 10;
|
||||||
const MAX_NAME_CHARS: usize = 21;
|
const MAX_NAME_CHARS: usize = 21;
|
||||||
|
const MAX_SUBMISSION_BODY_BYTES: usize = 1024;
|
||||||
|
const MAX_CONCURRENT_DATABASE_OPERATIONS: usize = 1;
|
||||||
|
const DEFAULT_HIGH_SCORES: &[(&str, u32)] = &[
|
||||||
|
("Paul", 6_537_392),
|
||||||
|
("Paul Schulze", 2_979_000),
|
||||||
|
("Kalle", 2_393_464),
|
||||||
|
("Paul Schulze", 2_328_000),
|
||||||
|
("Martina Sommer", 2_326_000),
|
||||||
|
("Martina Sommer", 1_093_000),
|
||||||
|
("Sommer Martina", 1_027_000),
|
||||||
|
("No Name", 1_000_000),
|
||||||
|
("TDK Pinball Player", 923_000),
|
||||||
|
("Martina Sommer", 905_000),
|
||||||
|
];
|
||||||
|
|
||||||
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
|
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
|
||||||
pub struct HighScore {
|
pub struct HighScore {
|
||||||
@@ -46,7 +61,9 @@ impl HighScoreStore {
|
|||||||
/// Returns the SQLite error raised while opening or initializing the
|
/// Returns the SQLite error raised while opening or initializing the
|
||||||
/// database.
|
/// database.
|
||||||
pub fn open(path: impl AsRef<Path>) -> Result<Self, rusqlite::Error> {
|
pub fn open(path: impl AsRef<Path>) -> Result<Self, rusqlite::Error> {
|
||||||
Self::from_connection(Connection::open(path)?)
|
let path = path.as_ref();
|
||||||
|
let seed_defaults = !path.exists();
|
||||||
|
Self::from_connection(Connection::open(path)?, seed_defaults)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Create an in-memory high-score store for tests or short-lived runs.
|
/// Create an in-memory high-score store for tests or short-lived runs.
|
||||||
@@ -55,10 +72,13 @@ impl HighScoreStore {
|
|||||||
///
|
///
|
||||||
/// Returns the SQLite error raised while initializing the database.
|
/// Returns the SQLite error raised while initializing the database.
|
||||||
pub fn open_in_memory() -> Result<Self, rusqlite::Error> {
|
pub fn open_in_memory() -> Result<Self, rusqlite::Error> {
|
||||||
Self::from_connection(Connection::open_in_memory()?)
|
Self::from_connection(Connection::open_in_memory()?, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn from_connection(connection: Connection) -> Result<Self, rusqlite::Error> {
|
fn from_connection(
|
||||||
|
mut connection: Connection,
|
||||||
|
seed_defaults: bool,
|
||||||
|
) -> Result<Self, rusqlite::Error> {
|
||||||
connection.execute_batch(
|
connection.execute_batch(
|
||||||
"CREATE TABLE IF NOT EXISTS high_scores (
|
"CREATE TABLE IF NOT EXISTS high_scores (
|
||||||
id INTEGER PRIMARY KEY,
|
id INTEGER PRIMARY KEY,
|
||||||
@@ -68,6 +88,16 @@ impl HighScoreStore {
|
|||||||
CREATE INDEX IF NOT EXISTS high_scores_order
|
CREATE INDEX IF NOT EXISTS high_scores_order
|
||||||
ON high_scores (score DESC, id ASC);",
|
ON high_scores (score DESC, id ASC);",
|
||||||
)?;
|
)?;
|
||||||
|
if seed_defaults {
|
||||||
|
let transaction = connection.transaction()?;
|
||||||
|
for &(name, score) in DEFAULT_HIGH_SCORES {
|
||||||
|
transaction.execute(
|
||||||
|
"INSERT INTO high_scores (name, score) VALUES (?1, ?2)",
|
||||||
|
params![name, i64::from(score)],
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
transaction.commit()?;
|
||||||
|
}
|
||||||
Ok(Self(Arc::new(Mutex::new(connection))))
|
Ok(Self(Arc::new(Mutex::new(connection))))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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)]
|
#[derive(Debug, Deserialize)]
|
||||||
struct SubmitRequest {
|
struct SubmitRequest {
|
||||||
name: String,
|
name: String,
|
||||||
@@ -146,6 +220,19 @@ fn invalid_request(message: &'static str) -> Response {
|
|||||||
.into_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> {
|
fn validate_request(request: &SubmitRequest) -> Result<HighScore, &'static str> {
|
||||||
let name = request.name.trim();
|
let name = request.name.trim();
|
||||||
if name.is_empty() {
|
if name.is_empty() {
|
||||||
@@ -167,39 +254,47 @@ async fn health() -> &'static str {
|
|||||||
"ok"
|
"ok"
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn list_high_scores(State(store): State<HighScoreStore>) -> Response {
|
async fn list_high_scores(State(state): State<AppState>) -> Response {
|
||||||
match store.list() {
|
match run_database_operation(&state, HighScoreStore::list).await {
|
||||||
Ok(scores) => Json(scores).into_response(),
|
Ok(scores) => Json(scores).into_response(),
|
||||||
Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
|
Err(error) => database_error_response(&error),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn submit_high_score(
|
async fn submit_high_score(
|
||||||
State(store): State<HighScoreStore>,
|
State(state): State<AppState>,
|
||||||
Json(request): Json<SubmitRequest>,
|
Json(request): Json<SubmitRequest>,
|
||||||
) -> Response {
|
) -> Response {
|
||||||
let entry = match validate_request(&request) {
|
let entry = match validate_request(&request) {
|
||||||
Ok(entry) => entry,
|
Ok(entry) => entry,
|
||||||
Err(message) => return invalid_request(message),
|
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(),
|
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 {
|
pub fn router(store: HighScoreStore) -> Router {
|
||||||
|
router_with_state(AppState::new(store))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn router_with_state(state: AppState) -> Router {
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/healthz", get(health))
|
.route("/healthz", get(health))
|
||||||
.route(
|
.route(
|
||||||
"/api/highscores",
|
"/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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
|
use std::{sync::mpsc, time::Duration};
|
||||||
|
|
||||||
use axum::{
|
use axum::{
|
||||||
body::Body,
|
body::Body,
|
||||||
http::{Request, StatusCode},
|
http::{Request, StatusCode},
|
||||||
@@ -229,6 +324,44 @@ mod tests {
|
|||||||
.status()
|
.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> {
|
async fn list(app: &Router) -> Vec<HighScore> {
|
||||||
let response = app
|
let response = app
|
||||||
.clone()
|
.clone()
|
||||||
@@ -253,6 +386,7 @@ mod tests {
|
|||||||
async fn api_persists_and_keeps_the_top_ten() {
|
async fn api_persists_and_keeps_the_top_ten() {
|
||||||
let directory = tempdir().expect("temporary directory should exist");
|
let directory = tempdir().expect("temporary directory should exist");
|
||||||
let database = directory.path().join("highscores.sqlite3");
|
let database = directory.path().join("highscores.sqlite3");
|
||||||
|
std::fs::File::create(&database).expect("database file should exist");
|
||||||
let store = HighScoreStore::open(&database).expect("database should open");
|
let store = HighScoreStore::open(&database).expect("database should open");
|
||||||
let app = router(store);
|
let app = router(store);
|
||||||
|
|
||||||
@@ -272,6 +406,34 @@ mod tests {
|
|||||||
assert_eq!(list(&reopened).await, scores);
|
assert_eq!(list(&reopened).await, scores);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn new_database_starts_with_the_original_demo_scores() {
|
||||||
|
let directory = tempdir().expect("temporary directory should exist");
|
||||||
|
let database = directory.path().join("highscores.sqlite3");
|
||||||
|
let store = HighScoreStore::open(&database).expect("database should open");
|
||||||
|
|
||||||
|
let scores = store.list().expect("scores should list");
|
||||||
|
let expected = DEFAULT_HIGH_SCORES
|
||||||
|
.iter()
|
||||||
|
.map(|&(name, score)| HighScore {
|
||||||
|
name: name.to_owned(),
|
||||||
|
score,
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
assert_eq!(scores, expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn existing_database_is_not_seeded() {
|
||||||
|
let directory = tempdir().expect("temporary directory should exist");
|
||||||
|
let database = directory.path().join("highscores.sqlite3");
|
||||||
|
std::fs::File::create(&database).expect("database file should exist");
|
||||||
|
|
||||||
|
let store = HighScoreStore::open(&database).expect("database should open");
|
||||||
|
|
||||||
|
assert!(store.list().expect("scores should list").is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn api_rejects_invalid_names() {
|
async fn api_rejects_invalid_names() {
|
||||||
let app = router(HighScoreStore::open_in_memory().expect("database should open"));
|
let app = router(HighScoreStore::open_in_memory().expect("database should open"));
|
||||||
@@ -284,6 +446,134 @@ mod tests {
|
|||||||
assert_eq!(submit(&app, "ok\nno", 10).await, StatusCode::BAD_REQUEST);
|
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]
|
#[tokio::test]
|
||||||
async fn health_endpoint_is_available_for_nginx() {
|
async fn health_endpoint_is_available_for_nginx() {
|
||||||
let app = router(HighScoreStore::open_in_memory().expect("database should open"));
|
let app = router(HighScoreStore::open_in_memory().expect("database should open"));
|
||||||
|
|||||||
+4
-1
@@ -9,7 +9,10 @@ build:
|
|||||||
build-release:
|
build-release:
|
||||||
cargo build --release
|
cargo build --release
|
||||||
|
|
||||||
build-production:
|
build-production-highscore-server:
|
||||||
|
cargo build --manifest-path highscore-server/Cargo.toml --profile production
|
||||||
|
|
||||||
|
build-production: build-production-highscore-server
|
||||||
cargo build --profile production
|
cargo build --profile production
|
||||||
|
|
||||||
web-build:
|
web-build:
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
+831
-40
@@ -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,27 +615,46 @@ 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);
|
||||||
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 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 {
|
if had_secondary_ball {
|
||||||
self.score_mode = ScoreMode::Multiball;
|
self.score_mode = ScoreMode::Multiball;
|
||||||
}
|
}
|
||||||
for _ in 0..substeps {
|
if !primary_held {
|
||||||
if self.fixed_update(events) || self.finished || self.claw.ball_suspended {
|
for _ in 0..substeps {
|
||||||
break;
|
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() {
|
if had_secondary_ball && !self.finished {
|
||||||
self.score_mode = ScoreMode::Normal;
|
if self.secondary_ball.is_some() && !self.claw_holds_slot(ClawBallSlot::Secondary) {
|
||||||
}
|
|
||||||
if had_secondary_ball && !self.finished && !self.claw.ball_suspended {
|
|
||||||
if self.secondary_ball.is_some() {
|
|
||||||
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) {
|
||||||
@@ -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] =
|
self.pending_flipper_edges[0] =
|
||||||
@@ -743,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 {
|
||||||
@@ -762,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);
|
||||||
self.ball.velocity = primary_slot.to_velocity_per_second();
|
if !primary_held {
|
||||||
|
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);
|
||||||
secondary.velocity = secondary_slot.to_velocity_per_second();
|
if !secondary_held {
|
||||||
} else {
|
secondary.velocity = secondary_slot.to_velocity_per_second();
|
||||||
|
}
|
||||||
|
} else if !primary_held {
|
||||||
self.ball.velocity = velocity.to_velocity_per_second();
|
self.ball.velocity = velocity.to_velocity_per_second();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -816,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,
|
||||||
@@ -860,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();
|
||||||
}
|
}
|
||||||
@@ -928,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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -992,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);
|
||||||
@@ -1211,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)]
|
||||||
@@ -1251,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)
|
||||||
@@ -1540,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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1579,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,
|
||||||
@@ -1604,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);
|
||||||
@@ -1621,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
|
||||||
@@ -1663,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;
|
||||||
@@ -1731,7 +1820,17 @@ impl Game {
|
|||||||
events: &mut Vec<Event>,
|
events: &mut Vec<Event>,
|
||||||
) -> BallAction {
|
) -> BallAction {
|
||||||
self.wheel_holes[index] = true;
|
self.wheel_holes[index] = true;
|
||||||
let filled = self.wheel_holes.iter().filter(|filled| **filled).count();
|
// Deliberate original-game divergence: a multiball capture normally
|
||||||
|
// leaves owner 2 here, which lets the surviving single ball capture
|
||||||
|
// the visibly occupied hole once more. Occupied wheel holes remain
|
||||||
|
// permanent in the clone regardless of which ball completed them.
|
||||||
|
self.record_contacts[129 + index] = 99;
|
||||||
|
// 1000:967a derives the award from all live type-three contact words,
|
||||||
|
// including another ball that is still settling into a lock hole.
|
||||||
|
let filled = self.record_contacts[129..=133]
|
||||||
|
.iter()
|
||||||
|
.filter(|contact| **contact != 0)
|
||||||
|
.count();
|
||||||
let shift = u32::try_from(filled.min(5)).unwrap_or(5);
|
let shift = u32::try_from(filled.min(5)).unwrap_or(5);
|
||||||
let award = 5_000_u32 << shift;
|
let award = 5_000_u32 << shift;
|
||||||
let player = &mut self.players[self.current_player];
|
let player = &mut self.players[self.current_player];
|
||||||
@@ -1746,6 +1845,7 @@ impl Game {
|
|||||||
if ball_count == 1 {
|
if ball_count == 1 {
|
||||||
self.panel_frame = Some(0);
|
self.panel_frame = Some(0);
|
||||||
}
|
}
|
||||||
|
self.score_mode = ScoreMode::Normal;
|
||||||
}
|
}
|
||||||
events.push(Event::Lock);
|
events.push(Event::Lock);
|
||||||
if ball_count == 1 {
|
if ball_count == 1 {
|
||||||
@@ -2025,8 +2125,54 @@ 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 {
|
||||||
CLAW_TERMINAL_FRAMES[usize::from(self.random.below(4))]
|
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) {
|
fn apply_flipper_kicks(&mut self) {
|
||||||
@@ -2035,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),
|
||||||
@@ -2043,8 +2191,12 @@ impl Game {
|
|||||||
if delta == 0 {
|
if delta == 0 {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
apply_flipper_response_to_ball(&mut self.ball, delta, side);
|
if !primary_held {
|
||||||
if let Some(secondary) = &mut self.secondary_ball {
|
apply_flipper_response_to_ball(&mut self.ball, delta, side);
|
||||||
|
}
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2060,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;
|
||||||
@@ -2070,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>,
|
||||||
) {
|
) {
|
||||||
@@ -2081,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;
|
||||||
@@ -2118,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));
|
||||||
@@ -2153,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;
|
||||||
@@ -3420,15 +3602,69 @@ mod tests {
|
|||||||
spin: Real48::ZERO,
|
spin: Real48::ZERO,
|
||||||
capture_age: 300,
|
capture_age: 300,
|
||||||
});
|
});
|
||||||
|
game.score_mode = ScoreMode::Multiball;
|
||||||
let mut events = Vec::new();
|
let mut events = Vec::new();
|
||||||
|
|
||||||
game.advance_secondary_ball(&mut events);
|
game.advance_secondary_ball(&mut events);
|
||||||
|
|
||||||
assert!(game.secondary_ball.is_none());
|
assert!(game.secondary_ball.is_none());
|
||||||
|
assert_eq!(game.score_mode, ScoreMode::Multiball);
|
||||||
assert_eq!(game.ball.capture_age, 17);
|
assert_eq!(game.ball.capture_age, 17);
|
||||||
assert_eq!(game.record_contacts[usize::from(sensor.id)], 2);
|
assert_eq!(game.record_contacts[usize::from(sensor.id)], 99);
|
||||||
assert!(game.wheel_holes[0]);
|
assert!(game.wheel_holes[0]);
|
||||||
assert!(events.contains(&Event::Lock));
|
assert!(events.contains(&Event::Lock));
|
||||||
|
|
||||||
|
game.timer_tick(0.0, 0, &mut events);
|
||||||
|
assert_eq!(game.score_mode, ScoreMode::Normal);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn captured_ball_one_keeps_multiball_scoring_for_ball_two_slot_pass() {
|
||||||
|
let sensor = LOCK_HOLES[0];
|
||||||
|
let mut game = Game::new_with_seed(1, 7);
|
||||||
|
game.ball.in_launcher = false;
|
||||||
|
game.ball.position = sensor.center;
|
||||||
|
game.ball.velocity = Vec2::ZERO;
|
||||||
|
game.ball.capture_age = 300;
|
||||||
|
game.record_contacts[usize::from(sensor.id)] = 1;
|
||||||
|
game.secondary_ball = Some(Ball {
|
||||||
|
position: EFFECT_SENSOR.center,
|
||||||
|
velocity: Vec2::ZERO,
|
||||||
|
in_launcher: false,
|
||||||
|
spin: Real48::ZERO,
|
||||||
|
capture_age: 0,
|
||||||
|
});
|
||||||
|
game.target_effect = 1;
|
||||||
|
game.object_active[usize::from(EFFECT_SENSOR.id)] = true;
|
||||||
|
game.score_mode = ScoreMode::Multiball;
|
||||||
|
let mut events = Vec::new();
|
||||||
|
|
||||||
|
game.timer_tick(0.01, 1, &mut events);
|
||||||
|
|
||||||
|
assert!(game.secondary_ball.is_none());
|
||||||
|
assert_eq!(game.player().score, 1_000);
|
||||||
|
assert_eq!(game.player().secondary_score, 20_000);
|
||||||
|
assert_eq!(game.score_mode, ScoreMode::Multiball);
|
||||||
|
|
||||||
|
game.timer_tick(0.0, 0, &mut events);
|
||||||
|
assert_eq!(game.score_mode, ScoreMode::Normal);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn active_multiball_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]
|
#[test]
|
||||||
@@ -3460,10 +3696,35 @@ mod tests {
|
|||||||
assert_eq!(game.ball.position, survivor.position);
|
assert_eq!(game.ball.position, survivor.position);
|
||||||
assert_eq!(game.ball.velocity, survivor.velocity);
|
assert_eq!(game.ball.velocity, survivor.velocity);
|
||||||
assert_eq!(game.ball.capture_age, survivor.capture_age);
|
assert_eq!(game.ball.capture_age, survivor.capture_age);
|
||||||
assert_eq!(game.record_contacts[usize::from(sensor.id)], 2);
|
assert_eq!(game.record_contacts[usize::from(sensor.id)], 99);
|
||||||
assert!(game.wheel_holes[0]);
|
assert!(game.wheel_holes[0]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn occupied_multiball_wheel_hole_rejects_the_surviving_ball() {
|
||||||
|
let sensor = LOCK_HOLES[0];
|
||||||
|
let mut game = Game::new(1);
|
||||||
|
game.ball.in_launcher = false;
|
||||||
|
game.ball.position = sensor.center;
|
||||||
|
game.ball.velocity = Vec2::ZERO;
|
||||||
|
game.ball.capture_age = 300;
|
||||||
|
game.record_contacts[usize::from(sensor.id)] = 99;
|
||||||
|
game.wheel_holes[0] = true;
|
||||||
|
let mut events = Vec::new();
|
||||||
|
|
||||||
|
let action = game.check_sensor_objects(
|
||||||
|
MilliVec::from_position(sensor.center),
|
||||||
|
MilliVec::default(),
|
||||||
|
&mut events,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(action, BallAction::Keep);
|
||||||
|
assert_eq!(game.record_contacts[usize::from(sensor.id)], 99);
|
||||||
|
assert_eq!(game.player().secondary_score, 0);
|
||||||
|
assert!(!events.contains(&Event::Lock));
|
||||||
|
assert!(!game.ball.in_launcher);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn center_drain_advances_to_a_fresh_ball() {
|
fn center_drain_advances_to_a_fresh_ball() {
|
||||||
let mut game = Game::new(1);
|
let mut game = Game::new(1);
|
||||||
@@ -4257,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() {
|
||||||
@@ -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]
|
#[test]
|
||||||
fn fifth_multiball_lock_defers_panel_and_caps_survivor_reaward() {
|
fn fifth_multiball_lock_defers_panel_and_caps_survivor_reaward() {
|
||||||
let mut game = Game::new(1);
|
let mut game = Game::new(1);
|
||||||
@@ -4546,6 +5336,7 @@ mod tests {
|
|||||||
assert_eq!(game.player().secondary_score, 0);
|
assert_eq!(game.player().secondary_score, 0);
|
||||||
assert_eq!(game.player().score_multiplier, 2);
|
assert_eq!(game.player().score_multiplier, 2);
|
||||||
assert_eq!(game.panel_frame, None);
|
assert_eq!(game.panel_frame, None);
|
||||||
|
assert_eq!(game.score_mode, ScoreMode::Normal);
|
||||||
|
|
||||||
let mut reset_by_special_hole = game.clone();
|
let mut reset_by_special_hole = game.clone();
|
||||||
reset_by_special_hole.reset_ball_to_launcher();
|
reset_by_special_hole.reset_ball_to_launcher();
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -4,12 +4,16 @@
|
|||||||
const storageKey = "tdkpin.save.v1";
|
const storageKey = "tdkpin.save.v1";
|
||||||
const encoder = new TextEncoder();
|
const encoder = new TextEncoder();
|
||||||
const decoder = new TextDecoder();
|
const decoder = new TextDecoder();
|
||||||
const highScoreApi = "/api/highscores";
|
const highScoreApi = "api/highscores";
|
||||||
|
const highScoreRetryInitialDelayMs = 250;
|
||||||
|
const highScoreRetryMaxDelayMs = 30_000;
|
||||||
let lastRevision = 0;
|
let lastRevision = 0;
|
||||||
let lastHighScoreRevision = 0;
|
let lastHighScoreRevision = 0;
|
||||||
let highScoreRequestInFlight = false;
|
let highScoreRequestInFlight = false;
|
||||||
let highScoreGeneration = 0;
|
let highScoreGeneration = 0;
|
||||||
let highScoreSubmissionWarningShown = false;
|
let highScoreSubmissionWarningShown = false;
|
||||||
|
let highScoreRetryDelayMs = highScoreRetryInitialDelayMs;
|
||||||
|
let highScoreRetryAt = 0;
|
||||||
|
|
||||||
function browserStorage() {
|
function browserStorage() {
|
||||||
try {
|
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() {
|
async function flushHighScoreSubmission() {
|
||||||
if (highScoreRequestInFlight) {
|
if (highScoreRequestInFlight) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (Date.now() < highScoreRetryAt) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const revision = wasm_exports.tdkpin_browser_high_score_revision();
|
const revision = wasm_exports.tdkpin_browser_high_score_revision();
|
||||||
if (revision === lastHighScoreRevision) {
|
if (revision === lastHighScoreRevision) {
|
||||||
return;
|
return;
|
||||||
@@ -102,6 +146,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
highScoreRequestInFlight = true;
|
highScoreRequestInFlight = true;
|
||||||
|
let retryAfterMs = null;
|
||||||
try {
|
try {
|
||||||
const response = await fetch(highScoreApi, {
|
const response = await fetch(highScoreApi, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -110,6 +155,7 @@
|
|||||||
keepalive: true,
|
keepalive: true,
|
||||||
});
|
});
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
|
retryAfterMs = retryAfterDelay(response);
|
||||||
throw new Error(`high-score submission failed (${response.status})`);
|
throw new Error(`high-score submission failed (${response.status})`);
|
||||||
}
|
}
|
||||||
const json = await response.text();
|
const json = await response.text();
|
||||||
@@ -118,7 +164,9 @@
|
|||||||
wasm_exports.tdkpin_browser_high_score_ack(revision);
|
wasm_exports.tdkpin_browser_high_score_ack(revision);
|
||||||
lastHighScoreRevision = revision;
|
lastHighScoreRevision = revision;
|
||||||
highScoreSubmissionWarningShown = false;
|
highScoreSubmissionWarningShown = false;
|
||||||
|
resetHighScoreRetry();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
scheduleHighScoreRetry(retryAfterMs);
|
||||||
if (!highScoreSubmissionWarningShown) {
|
if (!highScoreSubmissionWarningShown) {
|
||||||
console.warn("shared high-score submission failed; will retry", error);
|
console.warn("shared high-score submission failed; will retry", error);
|
||||||
highScoreSubmissionWarningShown = true;
|
highScoreSubmissionWarningShown = true;
|
||||||
|
|||||||
Binary file not shown.
Reference in New Issue
Block a user