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