Compare commits

...
8 Commits
Author SHA1 Message Date
ddidderr 27d40504cc fix(divergence): release the parked ball when the last ball in play leaves
Bug: with crazy-mode balls on the table and a ball parked in the special
hole, the parked ball stayed there after every other ball had drained.
It was reported after a tilt, which drains all balls quickly because the
flippers are dead.

Cause: the special respawn only fired when the drained ball was the very
last ball in Game::balls. In crazy mode a ball is often still waiting in
the launcher (crazy mode pressed before launching, or a ball that rolled
back onto the shooter stop). That waiting ball kept the list non-empty,
so each drain simply removed its ball and the parked ball never came out.
Reproduced deterministically: 30/30 seeds ended with only the launcher
ball left and the special hole still armed.

Fix:
- special_respawn_pending(leaving) now asks whether the leaving ball is
  the last one *in play*: every other ball waits in the launcher, and no
  effect-seven ball is about to be published. It no longer looks at the
  score mode; in the original, normal scoring there meant "no second
  ball", which this expresses directly.
- lose_ball: with other balls still in the list, the drained ball is
  removed and, if it was the last one in play, the parked ball is
  released onto the table (same position, velocity, contact clearing,
  and gate suppression as the original respawn). With no other ball it
  respawns in place as before, keeping the original's continuation of
  the drained ball's response.
- A ball that leaves because it came to rest on the shooter stop while
  the launcher was occupied also releases the parked ball if it was the
  last one in play.

Tilt is untouched: it still ends only with the player's ball, and the
released ball plays under it like the original respawned ball.

Test Plan:
- cargo test parked_ball_is_released_when_only_a_waiting_ball_remains:
  a tilted game with a launcher ball and one ball in play; draining it
  releases the parked ball without costing a ball. A second case with
  another ball still in play keeps the parked ball.
- The temporary reproduction (30 tilted crazy-mode games) now releases
  the parked ball in 30/30; a 40-game crazy-mode stress run still
  finishes every game.
2026-10-10 12:01:15 +02:00
ddidderr 158936577a fix(divergence): replace a full wheel immediately during multiball
Bug: filling the fifth wheel hole with several balls in play awarded the
transfer, multiplier, and extra ball, but did not run the wheel
replacement. The original deferred the 281-frame panel until a later
launcher reset, which with crazy-mode balls could be a long time, while
the full wheel kept every hole closed.

Requirement: a full wheel must be replaced with the same animation and
scoring as in single-ball play. Balls still in play keep moving, with
the wheel's outer border as a solid boundary and no hole captures until
the replacement is done.

Changes:
- complete_lock_hole starts the panel for a multiball fill too (if one
  is not already running). The single-ball path is unchanged and still
  restarts it unconditionally, as in the original. Scoring was already
  identical and is untouched.
- timer_tick only freezes physics during the panel while every ball
  waits in the launcher. That is always the case for the single-ball
  replacement (the locking ball was reset), so it is frozen exactly as
  before; during multiball the balls in play keep integrating.
- While the panel runs, check_lock_holes captures nothing and
  retain_wheel_rim adds a circle around the wheel hub (record 134's
  center, 48 px ball-center radius, enclosing the five hole posts) with
  the hub's material. It only repels balls moving towards it, so a ball
  already inside rolls out instead of being caged.
- Stress runs then showed balls trapped for good at the bottom of a hole
  pocket, cradled between the hub and a post, after another ball filled
  that hole or the panel closed it. The pocket bottom lies outside the
  hole's capture zone, so nothing ever moved them. A filled or closed
  hole now kicks such a ball (more than 4 px inside its rim) out of its
  center, radially away from the hub at 3 px/step, a path that clears
  both neighbouring posts. Balls arriving from outside are reflected by
  the rim before getting that deep.
- A lock or special-hole capture that removes the last remaining ball
  of a multiball callback now resets it to the launcher (or finishes it
  when tilted), exactly like the single-ball capture, instead of ending
  the player's ball.

Test Plan:
- cargo test (164 tests), including
  full_wheel_during_multiball_replaces_it_while_balls_play_on (rim
  bounce, no captures, balls move, wheel cleared after the panel),
  filled_or_closed_lock_hole_kicks_out_a_ball_resting_in_its_pocket
  (the exact trapped position seen in stress runs), and the updated
  fifth_multiball_lock_starts_the_panel_and_caps_survivor_reaward.
- A temporary release-mode stress run (not committed): 40 seeded
  two-player games with crazy mode, autoplay flippers, and nudges all
  finished; 62 wheel replacements happened during multiball; no
  invariant violations.
- Manual: in crazy mode fill the fifth wheel hole; the replacement
  animation plays while the other balls keep moving and bounce off the
  wheel instead of dropping into its holes.
2026-10-10 11:46:28 +02:00
ddidderr 17a7b3c328 fix(divergence): keep claw releases on the table during multiball
Bug: with several balls in play the claw could still drop its ball into
the shooter lane (terminal frame 18), sending it back to the launcher.

Cause: the restriction to table releases (frames 1, 6, 7) is latched
when the ball enters the claw's capture zone, from the ball count at
that moment. Balls added afterwards (crazy mode, a release-ball spawn)
did not affect a capture that had already chosen frame 18.

Fix: while the claw holds a ball and is heading for frame 18,
update_claw checks the live ball count every timer callback. With more
than one ball (the held one included) it re-targets the claw to a
random table frame using the same restricted draw as a multiball
capture. The claw animates one frame per callback, so it simply turns
around towards the new target.

Single-ball captures, and multiball captures that were already
restricted at entry, behave exactly as before; the extra random draw only
happens in the situation that used to misbehave.

Test Plan:
- cargo test claw_never_drops_into_the_shooter_lane_with_several_balls:
  a frame-18 capture gains a second ball mid-ride and must release at
  frame 1, 6, or 7, onto the table.
- A release-mode stress run (40 two-player crazy-mode games) counted 254
  claw releases and none into the shooter lane with several balls.
2026-10-10 11:46:00 +02:00
ddidderr 0e8121d027 fix(divergence): release the parked ball during multiball
Bug: with crazy-mode balls on the table, hitting the effect target while
"release ball" (effect seven) was lit did nothing. The ball parked in
the special hole stayed there.

Cause: the release kept the original two-slot guard "only when no
second ball exists" (balls.len() == 1), plus a check that the triggering
ball's slot number differs from the hole's contact word. Both made sense
when the released ball could only ever become slot two, but they block
the release whenever more balls are in play.

Fix:
- Effect seven now releases whenever the special hole actually holds a
  parked ball (special_hole_active: multiball Ready and contact set),
  at most once per callback. The slot-number check is gone; with one
  ball it never mattered, and with more balls it blocked slot two.
- publish_spawned_balls appends the released ball and makes it the
  owner of the special-hole contact under its real slot number, so it
  clears the contact itself as it rolls out. With one ball in play that
  number is 2, identical to the original's contact word.
- While multiball is Active, remove_ball renumbers (or clears) that
  owner when an earlier ball leaves, like the claw and lock-hole owners.
  In Ready the word is the "ball parked" marker and stays untouched.

The single-ball release path is unchanged; all existing special-hole and
effect-seven tests still pass.

Test Plan:
- cargo test release_ball_frees_the_parked_ball_during_multiball: three
  balls in play, release spawns a fourth at the hole, the owner follows
  a removal, and the released ball leaves the hole and clears it.
- Manual: in crazy mode, park a ball in the special hole (top left),
  light release ball, hit the effect target; a new ball rolls out.
2026-10-10 11:45:46 +02:00
ddidderr a8cc0fc5fc feat(divergence): make every ball identical and bind crazy mode to 0
Crazy mode first shipped as a side list of "crazy balls" with reduced
physics (walls, bumpers, flippers only) next to the original primary and
secondary slots. That is not what was wanted: a ball in the machine is a
ball, with no main/second/extra distinction. This replaces the two-slot
engine with one ordered list and moves the shortcut to a single key.

Ball model (src/game.rs)
- Game::balls: Vec<Ball> replaces `ball`, `secondary_ball`, and
  `crazy_balls`. The list is never empty while the game runs; a waiting
  ball is simply a ball with in_launcher set.
- advance_ball_slots runs one timer callback: every ball, in slot order,
  integrates up to `substeps` steps through the single integrator
  step_ball (record scan, ball-to-ball contact, rule dispatch, drain) and
  stops after its first collision, exactly like the original slot pass.
- BallPass carries the ball's current index plus the slot number and
  ball count from the start of the callback. The original keeps slot two
  numbered 2 for the rest of a callback even after slot one drained, and
  type-three contact records publish that number as their owner; keeping
  it per pass preserves that.
- Effect seven's ball goes to `spawned_balls` and joins after all slot
  passes, as the original publishes slot two only after slot one's pass.
- lose_ball removes a drained ball while others remain (Drain event,
  normal scoring once one ball is left) and only the last ball takes the
  special respawn or ends the player's ball. remove_ball keeps the claw's
  captured/entry indices and the claw and lock-hole contact owners
  pointing at the same physical ball when an earlier slot leaves.
- The claw stores a ball index instead of a Primary/Secondary enum.
- Nudge, flipper kicks, multiball scoring, and the "no third ball from
  effect seven" guard are expressed over the list. With one or two balls
  the original order, random draws, and ownership rules are unchanged;
  all existing parity and multiball tests pass after a mechanical port
  (test-only shims fixed_update, advance_secondary_ball, drain,
  secondary/set_secondary/take_secondary keep them readable).
- Crazy mode adds ten ordinary balls (capped at MAX_BALLS = 64 because
  contact records reserve 99 as a sentinel) and marks the player.

Deliberate differences from the original (also in PARITY_AUDIT.md),
mostly because there is no longer a special slot one:
- A ball resting on the shooter stop returns to an empty launcher from
  any slot; the original deleted a slot-two ball there. If the launcher
  is occupied the ball leaves the table without costing a ball.
- Flipper kicks, record countdowns, and target rotation keep running for
  balls in play while another ball waits in the launcher.
- Ball-to-ball contact skips only the claw-held ball; every ball's hit
  is published as last_collision_id.
- Stress runs with 10-64 balls showed balls trapped forever: two balls
  settle into one lock hole, one fills it (contact 99), and the rim then
  reflects the other from the inside. A filled hole's rim now only
  repels balls moving towards its center. Lock-hole owners of a removed
  ball are cleared/renumbered like the claw owner.

Input (src/app.rs)
- Crazy mode is the bare main-row 0 key. It overlaps no original or
  modern binding and has no browser default action, so the WASM build
  receives it. The Ctrl+Shift+0 combo and its Shift-release nudge
  suppression are gone.
- Rendering draws every ball not held by the claw.

Simulator: snapshots report `balls: [...]` instead of `ball` and
`secondary_ball`; autoplay flips for any ball near a flipper.

Test Plan:
- cargo test (160 tests), including new ones:
  a_ball_moves_the_same_in_any_slot (same ball in slot one vs. behind a
  waiting ball follows the identical trajectory),
  crazy_mode_adds_ten_ordinary_balls_and_marks_the_player,
  the_turn_ends_only_when_the_last_ball_drains,
  shooter_stop_rearms_a_returning_ball_from_any_slot,
  crazy_mode_is_capped_and_rejected_after_the_game.
- A temporary release-mode stress test (not committed) ran 12 seeded
  two-player games with crazy mode, autoplay flippers, and nudges to
  completion, and 4 seeds at the 64-ball cap for 30k frames, asserting
  finite state, a non-empty ball list, and valid claw indices.
- Manual: during a game press 0 (also in the browser build); ten balls
  appear, each can be captured by the claw, lock holes, and targets, and
  the turn ends only after the last ball drains.
2026-10-10 11:15:11 +02:00
ddidderr 3a00be5661 feat: label crazy-mode high scores instead of hiding them
Previously a game that used crazy mode kept its local high score but
skipped the online submission entirely. The requirement changed: crazy
scores should count like any other score, but be recognisable as crazy.

Changes:
- Crazy-mode use is now tracked per player (Player::crazy_mode_used)
  rather than per game. Balls dropped during one player's turn cannot
  outlive that turn, so only the player who pressed the key is marked;
  the other players' scores stay normal.
- Event::HighScoreCandidate carries { score, crazy } so the app knows
  which kind of score the name-entry dialog is about to record.
- HighScore gains `crazy: bool` with #[serde(default)]. Save files and
  server responses from before this change load as normal scores.
- The high-score table draws a small red "CRAZY" tag in the empty
  column right of the score. That column is free regardless of name or
  score length, so the tag never overlaps text.
- The browser build submits every score again, flag included. The
  companion server change stores it; an older server ignores the field.

Test Plan:
- cargo test
  - crazy_scores_are_flagged_on_the_high_score_candidate
  - high_scores_without_a_crazy_flag_load_as_normal_scores
- Manual: use crazy mode, finish with a qualifying score, and check the
  table (F9) shows the entry with the red CRAZY tag; a normal game's
  entry has none.
2026-10-10 11:14:40 +02:00
ddidderr 1a216dc795 feat(highscore-server): store a crazy-mode flag per score
The game is getting a crazy mode that drops extra balls, and scores
reached with it should still enter the shared table but be visibly
labelled. The service therefore has to persist and return that label.

API:
- POST /api/highscores accepts an optional "crazy" boolean. Missing
  means false, so cached clients from before crazy mode keep working.
- GET responses include "crazy" on every entry. Older clients ignore
  unknown fields (serde default behaviour), so the new response shape is
  safe for them too.

Database:
- New databases get a `crazy INTEGER NOT NULL DEFAULT 0 CHECK (crazy IN
  (0, 1))` column.
- Existing databases are migrated on open: if pragma_table_info shows no
  `crazy` column, ALTER TABLE adds it with the same definition. SQLite
  fills existing rows with the default, so every historic score stays a
  normal score. No data is rewritten and the migration is idempotent.

Ordering and the top-ten pruning are unchanged; the flag is not part of
the ranking.

Test Plan:
- cargo test --manifest-path highscore-server/Cargo.toml
  - pre_crazy_mode_database_is_migrated_in_place: builds the old schema
    with a row, opens it, submits a crazy score, checks both rows come
    back with the right flag, and reopens the migrated file.
  - api_accepts_crazy_flag_and_defaults_it_for_old_clients: one request
    without the field, one with "crazy": true.
- Deploy: restart the service against the production database and check
  GET /api/highscores lists the old entries with "crazy": false.
2026-10-10 11:13:37 +02:00
ddidderr 139e641005 feat: add Ctrl+Shift+0 crazy mode with ten extra balls
Pressing Ctrl+Shift+0 during a game drops ten balls at fixed points
spread over the open playfield (CRAZY_BALL_POSITIONS): two near the top
bumpers, a row in the middle, and four above the slingshots. Repeated
presses add another ten each time.

The original engine is a faithful two-slot reconstruction (primary and
secondary ball) whose claw, lock-hole, special-hole, and effect-seven
rules all assume at most two balls. Rather than generalising that
engine, crazy balls live in a separate Game::crazy_balls list with a
reduced step:

- gravity, speed clamp, and the same static record ranges as the main
  scan (walls, passive circles, bumpers, flippers);
- wall and bumper rules, so they score, light banks, and kick normally;
- flipper kicks and nudge impulses;
- no sensors, holes, magnets, claw, or ball-to-ball collisions.

A crazy ball that leaves the board, hits the drain, or comes to rest on
the shooter stop is removed. When the primary ball drains (and no
secondary or special respawn takes priority), a crazy ball is promoted
into the primary slot, so the turn only ends once every ball is gone.
The promoted ball then runs the full original rules.

Because the mode is effectively a cheat, Game records that it was used
and the browser build skips the online high-score submission for that
game; the local table still records the score.

The App suppresses the nudge that releasing Shift would otherwise fire
after the combo. Ctrl also raises the left flipper while held, which
is left as is. Browsers may reserve Ctrl+Shift+0 for themselves.

A new `crazy` simulation scenario starts a game with crazy balls.

Test Plan:
- cargo test: new tests check every drop point is clear of all static
  records, that the balls move, score, and eventually drain without
  costing a ball, that a primary drain promotes a crazy ball, and that a
  finished game rejects the mode.
- Manual: start a game, press Ctrl+Shift+0, release Shift, and confirm
  ten balls appear and no nudge/tilt sound plays.
2026-10-10 10:48:50 +02:00
10 changed files with 1743 additions and 968 deletions

No files matched your search

+39
View File
@@ -8,6 +8,45 @@ and this project adheres to
## [Unreleased]
### Added
- Crazy mode: pressing the main-row `0` key during a game drops ten more balls
onto the playfield, up to 64 balls in play. High-score entries reached with
crazy mode show a red `CRAZY` label in the table.
- The high-score service stores a crazy-mode flag per entry; it adds the column
to existing databases on startup and treats submissions from older clients as
normal scores.
### Changed
- Replace the separate primary and secondary ball slots with one ordered list
of identical balls. Every ball runs the same physics, sensors, holes, claw,
ball-to-ball contact, and drain rules; the player's ball ends only when the
last ball drains. One- and two-ball behavior keeps the recovered slot order.
- A ball resting on the shooter stop returns to an empty launcher from any slot,
and flippers keep kicking balls in play while another ball waits there.
- The simulator trace now reports every ball in a `balls` array.
### Fixed
- Free a ball that was settling into a lock hole when another ball filled it,
and release a lock hole whose settling ball left the table.
- The release-ball effect frees the ball parked in the special hole even when
several balls are already in play; the released ball keeps ownership of the
hole until it rolls out.
- The claw never drops a ball into the shooter lane while more than one ball is
in play, including balls that arrive after the capture began.
- Filling the fifth wheel hole during multiball replaces the wheel at once with
the normal animation and scoring instead of waiting for multiball to end.
Balls in play keep moving, the holes stay closed, and the wheel's outer rim
acts as a solid boundary until the replacement finishes. Locking the last
remaining ball returns it to the launcher as in single-ball play.
- A closed or filled lock hole kicks out a ball that came to rest in its pocket,
so it can no longer stay trapped next to the wheel hub.
- Release the ball parked in the special hole when the last ball in play
leaves, even if another ball is waiting in the launcher (for example after
pressing crazy mode before launching).
## [1.2.1] - 2026-09-01
### Fixed
+41
View File
@@ -66,6 +66,47 @@ ledger in `../original/C_RECONSTRUCTION_FINAL_AUDIT.md`.
on those hosts is delegated to the repository's three-OS CI matrix. The
semantic core is platform-independent and has no target-specific branches.
## Modern additions and their divergences
Crazy mode (main-row `0`) adds up to 64 live balls. To make every ball behave
identically, the two original timer slots became one ordered ball list. One-
and two-ball play keeps the recovered slot order, slot-numbered contact
ownership, deferred effect-seven spawn, survivor identity, nudge draws, and
multiball scoring, and all multiball tests above still pass. These deliberate
differences follow from treating every ball alike:
- A ball resting on the shooter stop returns to the launcher from any slot (the
original deleted a slot-two ball there) unless another ball already waits.
- Flipper kicks, record countdowns, and target rotation continue for balls in
play while another ball waits in the launcher.
- Ball-to-ball contact ignores only the claw-held ball, and every ball's
collision is published as the last collision record.
- When a ball leaves, claw and lock-hole contact owners move with their ball,
and a lock hole the leaving ball was settling into is freed.
- The rim of a filled lock hole only repels balls from the outside, so a ball
that was settling into it when another ball filled it rolls back out.
- The release-ball effect (effect seven) frees a parked special-hole ball
whenever one is parked, not only when exactly one ball is in play. The
released ball owns the hole's contact under its own slot number.
- The claw re-targets a shooter-lane release (terminal frame 18) to a table
release whenever more than one ball is in play during its ride, not only
when the capture began in multiball.
- A full wheel during multiball starts the 281-frame replacement immediately
(the original deferred it to the next launcher reset). The table only freezes
while every ball waits in the launcher; otherwise balls keep moving, the lock
holes do not capture, and the wheel's outer rim (record 134's center, 48 px
ball contact radius) repels balls from outside.
- A ball resting inside a filled or closed lock hole's pocket is kicked out
from the hole center, radially away from the hub. A capture that removes
the last ball of a multiball callback resets it to the launcher like a
single-ball capture instead of ending the ball.
- The special-hole respawn triggers when the last ball *in play* drains; balls
waiting in the launcher do not count. The original checked for exactly one
ball and normal scoring, which is the same thing when only one or two balls
exist.
- High-score entries carry a `crazy` flag that both local storage and the
shared service persist; older entries default to normal scores.
## Reproducible final gates
```sh
+8 -1
View File
@@ -60,7 +60,7 @@ cargo run -- --simulate claw-6 --at 0.15 \
Use `--step N` instead of `--at SECONDS` to reproduce one exact update. The
available scenarios are `loading`, `attract`, `highscores`, `name-entry`,
`effect-7`, `autoplay`, `launcher`, `flippers`, `panel`, `targets`, `claw-1`, `claw-6`,
`claw-7`, and `claw-18`; `--seed N` fixes random choices.
`claw-7`, `claw-18`, and `crazy`; `--seed N` fixes random choices.
`autoplay` charges each ball and operates the flippers from live ball position
for long end-to-end validation runs.
@@ -77,6 +77,13 @@ for long end-to-end validation runs.
| Settings | `TDKPIN.INI` before launch | F10 opens the portable settings screen |
| High scores | Automatic when a player finishes | F9 opens the portable table viewer |
| Sound | F12 | - |
| Crazy mode: drop 10 more balls | - | `0` (main row) |
Crazy mode is a modern addition. Each press drops ten balls at fixed points on
the playfield, up to 64 balls in play. They are ordinary balls: every ball runs
the same physics, sensors, holes, claw, and scoring, and the player's ball ends
only when the last one drains. A player who used crazy mode still enters the
high-score table, with their entry labelled `CRAZY`.
The five original speed choices remain available in settings. Physics now uses
the original invariant 100 Hz millipixel substep, batched as 5/4/3/2/1 steps on
+21 -1
View File
@@ -88,9 +88,29 @@ decoded, build-ready subset; it does not replace that evidence archive.
- `app.rs`: screen flow, controls, rendering, audio dispatch, and overlays;
- `assets.rs`: compile-time original asset embedding;
- `game.rs`: fixed-step game state, recovered walls, rules, and scoring;
- ball model: `Game::balls` is one ordered list of identical balls. Each
timer callback (`advance_ball_slots`) runs every ball's substep batch in
slot order through the single integrator `step_ball`: record scan,
ball-to-ball contact, rule dispatch, and drain handling. A ball keeps the
slot number it started the callback with, which the type-three contact
records publish as their owner. With one or two balls this is the
original slot-one/slot-two timer; more balls extend the same order;
- ball lifetime: `lose_ball` takes a drained ball off the table while
others remain and ends the player's ball only for the last one;
`remove_ball` keeps the claw and contact-record owners pointing at the
same physical ball; effect seven's ball joins after all slot passes;
- wheel replacement: the 281-frame panel freezes the table only while every
ball waits in the launcher; during multiball the lock holes close, the
wheel rim (`retain_wheel_rim`) repels balls, and closed holes kick out a
ball resting in their pocket (`kick_out_of_lock_hole`);
- crazy mode (a modern addition): `add_crazy_balls` drops ten ordinary balls
and marks the current player, whose high score is labelled;
- `geometry.rs`: segment/circle collision primitives;
- `original_physics.rs`: millipixel integration and recovered type-2 responses;
- `real48.rs`: bit-exact Borland six-byte arithmetic, conversion, and square root;
- `flipper_physics.rs`: reconstructed moving-flipper geometry and response;
- `table.rs`: source-traceable collision objects recovered from the Win16 table;
- `persistence.rs`: platform paths, settings, high scores, and legacy import.
- `persistence.rs`: platform paths, settings, high scores (with the optional
crazy-mode label), and legacy import;
- `highscore-server/`: shared SQLite top-ten service; it migrates databases
from before crazy mode by adding the `crazy` column.
+6 -2
View File
@@ -8,8 +8,12 @@ GET /api/highscores
POST /api/highscores
```
The POST body is JSON with a 21-character maximum name and a `u32` score. The
response is the canonical top-ten JSON array.
The POST body is JSON with a 21-character maximum name, a `u32` score, and an
optional `crazy` boolean (default `false`) marking a crazy-mode score. The
response is the canonical top-ten JSON array, each entry carrying `crazy`.
Databases created before crazy mode gain the `crazy` column automatically on
startup; existing rows become normal scores.
A newly created database starts with the original distributed demo table. An
existing database, including an existing empty database, is left unchanged.
+106 -4
View File
@@ -36,6 +36,10 @@ const DEFAULT_HIGH_SCORES: &[(&str, u32)] = &[
pub struct HighScore {
pub name: String,
pub score: u32,
/// The score was reached in crazy mode. Clients that predate crazy mode
/// neither send nor read it.
#[serde(default)]
pub crazy: bool,
}
#[derive(Debug)]
@@ -53,6 +57,21 @@ impl From<rusqlite::Error> for StoreError {
#[derive(Clone)]
pub struct HighScoreStore(Arc<Mutex<Connection>>);
/// Databases created before crazy mode lack the `crazy` column. Adding it
/// with a default keeps every existing row as a normal score.
fn add_crazy_column(connection: &Connection) -> Result<(), rusqlite::Error> {
let has_column = connection
.prepare("SELECT 1 FROM pragma_table_info('high_scores') WHERE name = 'crazy'")?
.exists([])?;
if !has_column {
connection.execute_batch(
"ALTER TABLE high_scores
ADD COLUMN crazy INTEGER NOT NULL DEFAULT 0 CHECK (crazy IN (0, 1));",
)?;
}
Ok(())
}
impl HighScoreStore {
/// Open or create a SQLite-backed high-score store.
///
@@ -83,11 +102,13 @@ impl HighScoreStore {
"CREATE TABLE IF NOT EXISTS high_scores (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
score INTEGER NOT NULL CHECK (score >= 0 AND score <= 4294967295)
score INTEGER NOT NULL CHECK (score >= 0 AND score <= 4294967295),
crazy INTEGER NOT NULL DEFAULT 0 CHECK (crazy IN (0, 1))
);
CREATE INDEX IF NOT EXISTS high_scores_order
ON high_scores (score DESC, id ASC);",
)?;
add_crazy_column(&connection)?;
if seed_defaults {
let transaction = connection.transaction()?;
for &(name, score) in DEFAULT_HIGH_SCORES {
@@ -109,7 +130,7 @@ impl HighScoreStore {
pub fn list(&self) -> Result<Vec<HighScore>, StoreError> {
let connection = self.0.lock().map_err(|_| StoreError::Poisoned)?;
let mut statement = connection.prepare(
"SELECT name, score
"SELECT name, score, crazy
FROM high_scores
ORDER BY score DESC, id ASC
LIMIT ?1",
@@ -123,6 +144,7 @@ impl HighScoreStore {
Ok(HighScore {
name: row.get(0)?,
score,
crazy: row.get(2)?,
})
})?
.collect::<Result<Vec<_>, _>>()?;
@@ -139,8 +161,8 @@ impl HighScoreStore {
let mut connection = self.0.lock().map_err(|_| StoreError::Poisoned)?;
let transaction = connection.transaction()?;
transaction.execute(
"INSERT INTO high_scores (name, score) VALUES (?1, ?2)",
params![entry.name, i64::from(entry.score)],
"INSERT INTO high_scores (name, score, crazy) VALUES (?1, ?2, ?3)",
params![entry.name, i64::from(entry.score), entry.crazy],
)?;
transaction.execute(
"DELETE FROM high_scores
@@ -205,6 +227,8 @@ where
struct SubmitRequest {
name: String,
score: u32,
#[serde(default)]
crazy: bool,
}
#[derive(Debug, Serialize)]
@@ -247,6 +271,7 @@ fn validate_request(request: &SubmitRequest) -> Result<HighScore, &'static str>
Ok(HighScore {
name: name.to_owned(),
score: request.score,
crazy: request.crazy,
})
}
@@ -418,11 +443,88 @@ mod tests {
.map(|&(name, score)| HighScore {
name: name.to_owned(),
score,
crazy: false,
})
.collect::<Vec<_>>();
assert_eq!(scores, expected);
}
#[test]
fn pre_crazy_mode_database_is_migrated_in_place() {
let directory = tempdir().expect("temporary directory should exist");
let database = directory.path().join("highscores.sqlite3");
{
let connection = Connection::open(&database).expect("database should open");
connection
.execute_batch(
"CREATE TABLE high_scores (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
score INTEGER NOT NULL CHECK (score >= 0 AND score <= 4294967295)
);
INSERT INTO high_scores (name, score) VALUES ('Old', 500);",
)
.expect("legacy schema should be created");
}
let store = HighScoreStore::open(&database).expect("legacy database should open");
let scores = store
.submit(&HighScore {
name: "New".to_owned(),
score: 900,
crazy: true,
})
.expect("submission should succeed");
assert_eq!(
scores,
[
HighScore {
name: "New".to_owned(),
score: 900,
crazy: true,
},
HighScore {
name: "Old".to_owned(),
score: 500,
crazy: false,
},
]
);
drop(store);
HighScoreStore::open(&database).expect("migrated database should reopen");
}
#[tokio::test]
async fn api_accepts_crazy_flag_and_defaults_it_for_old_clients() {
let app = router(HighScoreStore::open_in_memory().expect("database should open"));
assert_eq!(submit(&app, "Old client", 10).await, StatusCode::CREATED);
let status = request(
&app,
Request::post("/api/highscores")
.header("content-type", "application/json")
.body(Body::from(
serde_json::to_vec(&serde_json::json!({
"name": "Crazy",
"score": 20,
"crazy": true,
}))
.expect("request JSON should encode"),
))
.expect("request should build"),
)
.await;
assert_eq!(status, StatusCode::CREATED);
let scores = list(&app).await;
assert_eq!(
scores
.iter()
.map(|entry| (entry.name.as_str(), entry.crazy))
.collect::<Vec<_>>(),
[("Crazy", true), ("Old client", false)]
);
}
#[test]
fn existing_database_is_not_seeded() {
let directory = tempdir().expect("temporary directory should exist");
+27 -15
View File
@@ -20,6 +20,9 @@ const WIDTH: f32 = 640.0;
const HEIGHT: f32 = 460.0;
const DETAIL_TIMER_SECONDS: [f32; 5] = [0.050, 0.040, 0.030, 0.020, 0.010];
const ADD_PLAYER_KEYS: [KeyCode; 3] = [KeyCode::KpAdd, KeyCode::RightBracket, KeyCode::Equal];
/// Modern addition: drops ten crazy-mode balls. A bare main-row `0` is free
/// in the original key map and has no browser default action.
const CRAZY_MODE_KEY: KeyCode = KeyCode::Key0;
const TARGET_POSITIONS: [[(f32, f32); 5]; 6] = [
[
(9.0, 41.0),
@@ -222,6 +225,7 @@ pub struct App {
last_help_click: f64,
name: String,
pending_score: u32,
pending_crazy: bool,
attract: AttractAnimation,
}
@@ -250,6 +254,7 @@ impl App {
last_help_click: -1.0,
name: String::new(),
pending_score: 0,
pending_crazy: false,
attract: AttractAnimation::default(),
}
}
@@ -398,6 +403,9 @@ impl App {
|| is_key_down(KeyCode::Enter)
|| is_key_down(KeyCode::D)
|| is_key_down(KeyCode::Right);
if is_key_pressed(CRAZY_MODE_KEY) && self.game.as_mut().is_some_and(Game::add_crazy_balls) {
self.assets.play(2007, self.sounds_enabled);
}
let nudge = if is_key_released(KeyCode::LeftShift) {
Nudge::Left
} else if is_key_released(KeyCode::RightShift) || is_key_released(KeyCode::Kp3) {
@@ -418,16 +426,17 @@ impl App {
});
let mut high_score_candidate = None;
for event in events {
if let Event::HighScoreCandidate(score) = event {
high_score_candidate = Some(score);
if let Event::HighScoreCandidate { score, crazy } = event {
high_score_candidate = Some((score, crazy));
}
self.play_event(event);
}
if let Some(score) = high_score_candidate
if let Some((score, crazy)) = high_score_candidate
&& qualifies_high_score(&self.saved.high_scores, score)
{
self.pending_score = score;
self.pending_crazy = crazy;
"TDK Pinball Player".clone_into(&mut self.name);
self.screen = Screen::NameEntry;
return;
@@ -562,6 +571,7 @@ impl App {
let entry = HighScore {
name: self.name.clone(),
score: self.pending_score,
crazy: self.pending_crazy,
};
insert_high_score(&mut self.saved.high_scores, entry.clone());
#[cfg(target_arch = "wasm32")]
@@ -859,7 +869,7 @@ impl App {
if game.special_hole_active() {
self.draw_active_table_region(9, 14, 17, 17);
}
if game.ball.in_launcher {
if game.balls.iter().any(|ball| ball.in_launcher) {
draw_texture_ex(
&self.assets.plunger,
314.0,
@@ -901,17 +911,10 @@ impl App {
);
}
}
if !game.primary_ball_suspended() {
draw_texture(
&self.assets.ball,
game.ball.position.x - 8.0,
game.ball.position.y - 8.0,
WHITE,
);
}
if let Some(ball) = game.secondary_ball
&& !game.secondary_ball_suspended()
{
for (index, ball) in game.balls.iter().enumerate() {
if game.ball_suspended(index) {
continue;
}
draw_texture(
&self.assets.ball,
ball.position.x - 8.0,
@@ -1218,6 +1221,15 @@ impl App {
let score = entry.score.to_string();
let width = measure_text(&score, None, 16, 1.0).width;
draw_text(&score, 460.0 - width, y, 16.0, BLACK);
if entry.crazy {
draw_text(
"CRAZY",
464.0,
y - 1.0,
11.0,
Color::from_rgba(192, 0, 0, 255),
);
}
}
}
+1432 -906
View File
@@ -47,6 +47,18 @@ const DETAIL_TIMER_SECONDS: [f32; 5] = [0.050, 0.040, 0.030, 0.020, 0.010];
const DETAIL_SUBSTEPS: [u8; 5] = [5, 4, 3, 2, 1];
const CLAW_TERMINAL_FRAMES: [u8; 4] = [1, 6, 7, 18];
const CLAW_TABLE_TERMINAL_FRAMES: [u8; 3] = [1, 6, 7];
/// Claw terminal frame that drops the released ball into the shooter lane.
const CLAW_SHOOTER_LANE_FRAME: u8 = 18;
/// Wheel hub record 134; its center is also the center of the lock wheel.
const WHEEL_HUB_ID: u8 = 134;
/// Ball-center contact radius of the wheel's outer rim (about 40 px of
/// artwork plus the 8 px ball radius), so it encloses the five hole posts.
const WHEEL_RIM_CONTACT_RADIUS: f32 = 48.0;
/// How far (in millipixels) a ball must sit inside a closed lock hole's rim
/// before the hole kicks it out. Balls arriving from outside are reflected by
/// the rim before they get this deep.
const CLOSED_HOLE_KICKOUT_DEPTH_MILLI: i32 = 4_000;
const CLOSED_HOLE_KICKOUT_SPEED_MILLI: f32 = 3_000.0;
const ORIGINAL_BALL_SPEED_PER_SECOND: f32 = 380.0;
#[allow(clippy::cast_possible_wrap)]
@@ -88,7 +100,7 @@ pub enum Event {
Nudge,
Tilt,
Drain,
HighScoreCandidate(u32),
HighScoreCandidate { score: u32, crazy: bool },
StopSound,
Sound(u16),
}
@@ -111,7 +123,7 @@ impl Event {
| Self::Nudge
| Self::Tilt
| Self::Drain
| Self::HighScoreCandidate(_)
| Self::HighScoreCandidate { .. }
| Self::StopSound => None,
}
}
@@ -130,32 +142,18 @@ pub enum ClawSpriteBank {
Opening,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum ClawBallSlot {
Primary,
Secondary,
}
impl ClawBallSlot {
const fn from_ball_number(ball_number: u16) -> Self {
if ball_number == 2 {
Self::Secondary
} else {
Self::Primary
}
}
}
#[derive(Clone, Copy, Debug)]
pub struct Claw {
pub active: bool,
pub frame: u8,
pub bank: ClawSpriteBank,
pub ball_suspended: bool,
captured_slot: Option<ClawBallSlot>,
/// Index into `Game::balls` of the captured ball. Kept in step with the
/// list when an earlier ball leaves the table.
captured_slot: Option<usize>,
// 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)>,
capture_entry: Option<(usize, bool)>,
target_frame: u8,
frame_accumulator: f32,
}
@@ -208,9 +206,18 @@ pub struct Player {
pub bumper_value: u32,
pub diamond_segments: u8,
pub media_level: u8,
crazy_mode: CrazyMode,
rules: RuleState,
}
impl Player {
/// Whether this player dropped crazy-mode balls during the game. Their
/// high score is labelled accordingly.
pub fn crazy_mode_used(&self) -> bool {
self.crazy_mode == CrazyMode::Used
}
}
impl Default for Player {
fn default() -> Self {
Self {
@@ -222,6 +229,7 @@ impl Default for Player {
bumper_value: 1_000,
diamond_segments: 0,
media_level: 0,
crazy_mode: CrazyMode::Unused,
rules: RuleState::default(),
}
}
@@ -264,6 +272,13 @@ enum BallDoubleState {
Active,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
enum CrazyMode {
#[default]
Unused,
Used,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
enum SpecialHoleGate {
#[default]
@@ -287,6 +302,29 @@ enum BallAction {
Finish,
}
/// Identity of one ball for the duration of its slot pass in a timer callback.
#[derive(Clone, Copy, Debug)]
struct BallPass {
/// Current position of the ball in `Game::balls`.
index: usize,
/// One-based slot number the ball held when the callback started. The
/// original publishes it as the owner in type-three contact records.
number: u16,
/// Number of live balls when the callback started.
count: u16,
}
/// Result of one physics substep for one ball.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum BallStep {
/// Keep integrating this ball in the current batch.
Continue,
/// The ball is still at its index but its batch ends here.
Stop,
/// The ball left `Game::balls`; later balls moved down one index.
Removed,
}
#[derive(Clone, Copy, Debug)]
struct SensorScanResult {
action: BallAction,
@@ -365,12 +403,38 @@ impl Default for Ball {
}
}
/// Upper bound on balls in play. Type-three contact records store a ball's
/// one-based slot number and reserve 99 as a sentinel, so slots stay below it.
pub const MAX_BALLS: usize = 64;
/// Drop points for crazy mode, spread over open playfield between the
/// bumpers, slingshots, and lanes.
pub const CRAZY_BALL_POSITIONS: [Vec2; 10] = [
vec2(170.0, 112.0),
vec2(250.0, 140.0),
vec2(120.0, 160.0),
vec2(90.0, 215.0),
vec2(250.0, 215.0),
vec2(110.0, 260.0),
vec2(215.0, 260.0),
vec2(160.0, 300.0),
vec2(120.0, 340.0),
vec2(195.0, 335.0),
];
#[derive(Clone, Debug)]
pub struct Game {
pub players: Vec<Player>,
pub current_player: usize,
pub ball: Ball,
pub secondary_ball: Option<Ball>,
/// Every ball in the machine, in timer slot order. All balls run the
/// same physics and rules; the order only decides who moves first in a
/// callback and which slot number owns a contact record. The list holds
/// at least one ball (possibly waiting in the launcher) until the game
/// finishes.
pub balls: Vec<Ball>,
/// Balls created during the current callback. The original publishes
/// effect seven's ball only after every slot pass has returned.
spawned_balls: Vec<Ball>,
pub wheel_holes: [bool; 5],
pub top_targets: [bool; 3],
pub tilted: bool,
@@ -414,8 +478,8 @@ impl Game {
Self {
players: vec![Player::default(); player_count.clamp(1, 4)],
current_player: 0,
ball: Ball::default(),
secondary_ball: None,
balls: vec![Ball::default()],
spawned_balls: Vec::new(),
wheel_holes: [false; 5],
top_targets: [false; 3],
tilted: false,
@@ -484,11 +548,43 @@ impl Game {
true
}
/// Crazy mode: drop ten more balls onto the playfield. They are ordinary
/// balls in every respect; the current player's score is marked as a
/// crazy-mode score. Returns false when nothing was added.
pub fn add_crazy_balls(&mut self) -> bool {
if self.finished || self.balls.len() >= MAX_BALLS {
return false;
}
self.players[self.current_player].crazy_mode = CrazyMode::Used;
let room = MAX_BALLS - self.balls.len();
self.balls.extend(
CRAZY_BALL_POSITIONS
.into_iter()
.take(room)
.map(|position| Ball {
position,
velocity: Vec2::ZERO,
in_launcher: false,
..Ball::default()
}),
);
true
}
/// Whether the claw currently holds the ball at `index` off the table.
pub fn ball_suspended(&self, index: usize) -> bool {
self.claw_holds(index)
}
fn launcher_ball(&self) -> Option<usize> {
self.balls.iter().position(|ball| ball.in_launcher)
}
#[cfg(not(target_arch = "wasm32"))]
pub(crate) fn begin_claw_scenario(&mut self, terminal_frame: u8) -> Vec<Event> {
self.ball.in_launcher = false;
self.ball.position = CLAW_TRIGGER_CENTER;
self.ball.velocity = Vec2::ZERO;
self.balls[0].in_launcher = false;
self.balls[0].position = CLAW_TRIGGER_CENTER;
self.balls[0].velocity = Vec2::ZERO;
let mut events = Vec::new();
self.begin_claw_capture(terminal_frame, &mut events);
events
@@ -506,8 +602,8 @@ impl Game {
#[cfg(not(target_arch = "wasm32"))]
pub(crate) fn begin_target_scenario(&mut self) {
self.ball.in_launcher = false;
self.ball.position = vec2(170.0, 230.0);
self.balls[0].in_launcher = false;
self.balls[0].position = vec2(170.0, 230.0);
self.players[0].bumper_value = 6_000;
self.wheel_holes = [true, false, true, false, false];
self.target_rotation_state = Some(0);
@@ -515,7 +611,7 @@ impl Game {
self.object_active[109] = false;
}
fn fire_launcher(&mut self) {
fn fire_launcher(&mut self, index: usize) {
self.launcher_velocity_milli = self
.launcher_velocity_milli
.wrapping_sub(LAUNCHER_RELEASE_IMPULSE_MILLI);
@@ -527,8 +623,9 @@ impl Game {
launch_velocity =
launch_velocity.wrapping_sub(i32::from(self.random.below(3_800) / 40));
}
self.ball.in_launcher = false;
self.ball.velocity = MilliVec {
let ball = &mut self.balls[index];
ball.in_launcher = false;
ball.velocity = MilliVec {
x: 0,
y: launch_velocity,
}
@@ -566,7 +663,7 @@ impl Game {
// The original key handlers keep accepting the launcher key while
// tilted; Tilt disables flippers and scoring, but must not strand a
// ball that is still waiting in the shooter lane.
if self.ball.in_launcher {
if let Some(launcher_ball) = self.launcher_ball() {
if controls.launch_down {
if self.launcher_was_down {
self.launcher_hold_seconds += frame_time.min(0.05);
@@ -587,7 +684,7 @@ impl Game {
self.launcher_charge =
f32::from(i16::try_from(charge_milli).unwrap_or(3_800)) / f32::from(3_800_i16);
} else if self.launcher_was_down {
self.fire_launcher();
self.fire_launcher(launcher_ball);
events.push(Event::Launch);
if !self.tilted {
events.push(Event::Sound(2002));
@@ -610,72 +707,20 @@ impl Game {
fn timer_tick(&mut self, elapsed: f32, substeps: u8, events: &mut Vec<Event>) {
self.update_claw(elapsed, events);
if !self.ball.in_launcher {
if self.balls.iter().any(|ball| !ball.in_launcher) {
self.update_record_countdowns();
self.update_target_rotation(events);
}
let panel_active = self.update_panel_completion(events);
// 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
{
// A held claw ball stays in the list, so it still counts as a live
// ball until the claw releases it or the others drain.
if self.balls.len() < 2 {
self.score_mode = ScoreMode::Normal;
}
if !panel_active {
let had_secondary_ball = self.secondary_ball.is_some();
let mut spawned_secondary = None;
let primary_held = self.claw_holds_slot(ClawBallSlot::Primary);
let secondary_held = self.claw_holds_slot(ClawBallSlot::Secondary);
if had_secondary_ball {
self.score_mode = ScoreMode::Multiball;
}
if !primary_held {
for _ in 0..substeps {
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.finished {
if self.secondary_ball.is_some() && !self.claw_holds_slot(ClawBallSlot::Secondary) {
for _ in 0..substeps {
if self.advance_secondary_ball_slot(events, !primary_held) {
break;
}
}
} else if !self.claw_holds_slot(ClawBallSlot::Primary)
&& !self.claw_holds_slot(ClawBallSlot::Secondary)
{
self.secondary_ball = Some(self.ball);
for _ in 0..substeps {
if self.advance_secondary_ball_slot(events, false) {
break;
}
}
if let Some(survivor) = self.secondary_ball.take() {
self.ball = survivor;
} else {
self.drain(events);
}
}
}
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));
// The single-ball wheel replacement runs with its ball parked in the
// launcher and freezes the table; balls still in play keep moving.
if !panel_active || self.balls.iter().any(|ball| !ball.in_launcher) {
self.advance_ball_slots(substeps, events);
}
self.pending_flipper_edges[0] =
@@ -703,6 +748,69 @@ impl Game {
}
}
/// Runs one timer callback's physics batch for every ball, in slot
/// order. Each ball integrates up to `substeps` steps and stops after its
/// first collision, exactly like the original single-slot integrator.
fn advance_ball_slots(&mut self, substeps: u8, events: &mut Vec<Event>) {
let ball_count = self.balls.len();
if ball_count > 1 {
self.score_mode = ScoreMode::Multiball;
}
let count = u16::try_from(ball_count).unwrap_or(u16::MAX);
let mut index = 0;
for number in 1..=count {
if self.finished || index >= self.balls.len() {
break;
}
if self.claw_holds(index) {
index += 1;
continue;
}
// A ball keeps the slot number it started the callback with,
// even when an earlier slot drained during this callback.
let pass = BallPass {
index,
number,
count,
};
let mut removed = false;
for _ in 0..substeps {
match self.step_ball(pass, events) {
BallStep::Continue => {}
BallStep::Stop => break,
BallStep::Removed => {
removed = true;
break;
}
}
if self.finished || self.claw_holds(index) {
break;
}
}
if !removed {
index += 1;
}
}
// Effect seven publishes its spawn request during a slot pass, but
// the new ball only joins once every slot pass has returned.
self.publish_spawned_balls();
}
/// Adds balls released from the special hole to the end of the slot
/// order. The released ball owns the special-hole contact until it rolls
/// out; with one ball already in play that is slot two, as in the
/// original.
fn publish_spawned_balls(&mut self) {
for ball in std::mem::take(&mut self.spawned_balls) {
if self.balls.len() >= MAX_BALLS {
break;
}
self.record_contacts[usize::from(SPECIAL_HOLE_SENSOR.id)] =
u16::try_from(self.balls.len() + 1).unwrap_or(2);
self.balls.push(ball);
}
}
fn update_record_countdowns(&mut self) {
for countdown in &mut self.record_countdowns[1..] {
if *countdown != 0 {
@@ -767,7 +875,7 @@ impl Game {
fn apply_nudge(&mut self, nudge: Nudge, events: &mut Vec<Event>) {
const SCALAR: i32 = 15;
events.push(Event::Sound(2019));
let mut velocity = MilliVec::from_velocity_per_second(self.ball.velocity);
let mut velocity = MilliVec::from_velocity_per_second(self.balls[0].velocity);
match nudge {
Nudge::Left | Nudge::Right => {
let amount = (50 - i32::from(self.random.below(20))) * SCALAR;
@@ -793,9 +901,8 @@ impl Game {
}
Nudge::None => return,
}
let primary_held = self.claw_holds_slot(ClawBallSlot::Primary);
let secondary_held = self.claw_holds_slot(ClawBallSlot::Secondary);
if let Some(secondary) = &mut self.secondary_ball {
let held = self.held_ball();
if self.balls.len() > 1 {
let slot_impulse = match nudge {
Nudge::Left | Nudge::Right => MilliVec {
x: if nudge == Nudge::Left { -600 } else { 600 },
@@ -811,20 +918,17 @@ impl Game {
}
Nudge::None => MilliVec::default(),
};
let mut primary_slot = MilliVec::from_velocity_per_second(self.ball.velocity);
primary_slot.x = primary_slot.x.wrapping_add(slot_impulse.x);
primary_slot.y = primary_slot.y.wrapping_add(slot_impulse.y);
if !primary_held {
self.ball.velocity = primary_slot.to_velocity_per_second();
for (index, ball) in self.balls.iter_mut().enumerate() {
if held == Some(index) {
continue;
}
let mut slot = MilliVec::from_velocity_per_second(ball.velocity);
slot.x = slot.x.wrapping_add(slot_impulse.x);
slot.y = slot.y.wrapping_add(slot_impulse.y);
ball.velocity = slot.to_velocity_per_second();
}
let mut secondary_slot = MilliVec::from_velocity_per_second(secondary.velocity);
secondary_slot.x = secondary_slot.x.wrapping_add(slot_impulse.x);
secondary_slot.y = secondary_slot.y.wrapping_add(slot_impulse.y);
if !secondary_held {
secondary.velocity = secondary_slot.to_velocity_per_second();
}
} else if !primary_held {
self.ball.velocity = velocity.to_velocity_per_second();
} else if held != Some(0) {
self.balls[0].velocity = velocity.to_velocity_per_second();
}
self.tilt_counter = self.tilt_counter.wrapping_add(25);
@@ -842,70 +946,76 @@ impl Game {
}
}
/// Integrates one physics substep for the ball described by `pass`.
///
/// This is the single per-ball integrator: every ball, whichever slot it
/// occupies, runs the same record scan, ball-to-ball contact, rule
/// dispatch, and drain handling.
#[allow(clippy::too_many_lines)]
fn fixed_update(&mut self, events: &mut Vec<Event>) -> bool {
if self.ball.in_launcher {
self.ball.position = LAUNCHER_POSITION;
self.ball.velocity = Vec2::ZERO;
return true;
fn step_ball(&mut self, pass: BallPass, events: &mut Vec<Event>) -> BallStep {
let index = pass.index;
let mut ball = self.balls[index];
if ball.in_launcher {
ball.position = LAUNCHER_POSITION;
ball.velocity = Vec2::ZERO;
self.balls[index] = ball;
return BallStep::Stop;
}
let old_position = MilliVec::from_position(self.ball.position);
let initial_secondary = self.secondary_ball;
let ball_count = if initial_secondary.is_some() { 2 } else { 1 };
let mut ball = self.ball;
let old_position = MilliVec::from_position(ball.position);
let mut velocity = MilliVec::from_velocity_per_second(ball.velocity);
velocity.y += GRAVITY_MILLI_PER_STEP;
velocity.clamp_speed(MAXIMUM_SPEED_MILLI_PER_STEP);
let speed_before_collision = milli_distance(velocity);
if predicted_outside_board(old_position.add(velocity)) {
self.drain(events);
return true;
return self.lose_ball(index, events);
}
let (best_static, scan, predicted) = self.scan_ordered_records_for_ball(
&mut ball,
1,
ball_count,
pass,
old_position,
&mut velocity,
events,
);
let mut best_collision = best_static
.map(|(id, is_wall, candidate)| (id, is_wall, candidate.resolve(velocity, ball.spin)));
let primary_held = self.claw_holds_slot(ClawBallSlot::Primary);
let secondary_held = self.claw_holds_slot(ClawBallSlot::Secondary);
let mut transferred_secondary_velocity = None;
if let Some(secondary) = initial_secondary
&& !primary_held
&& !secondary_held
{
let other_position = MilliVec::from_position(secondary.position);
let response = ball_collision_response(
old_position,
velocity,
ball.spin,
other_position,
MilliVec::from_velocity_per_second(secondary.velocity),
secondary.capture_age <= 0,
);
if predicted.x >= other_position.x.wrapping_sub(21_000)
&& predicted.x <= other_position.x.wrapping_add(21_000)
&& predicted.y >= other_position.y.wrapping_sub(21_000)
&& predicted.y <= other_position.y.wrapping_add(21_000)
&& let Some(response) = response
&& best_collision.is_none()
{
transferred_secondary_velocity = Some(response.other_velocity);
best_collision = Some((
175,
false,
CollisionResponse {
surface_distance: response.surface_distance,
velocity: response.moving_velocity,
spin: response.moving_spin,
auxiliary_fired: false,
},
));
// Ball-to-ball contact only competes when no static record was hit.
// Objects 174 and 175 are the original's moving circle records for
// slot one and every later slot.
let mut transferred_velocity = None;
if best_collision.is_none() {
for (other_index, other) in self.balls.iter().enumerate() {
if other_index == index || self.claw_holds(other_index) {
continue;
}
let other_position = MilliVec::from_position(other.position);
if predicted.x >= other_position.x.wrapping_sub(21_000)
&& predicted.x <= other_position.x.wrapping_add(21_000)
&& predicted.y >= other_position.y.wrapping_sub(21_000)
&& predicted.y <= other_position.y.wrapping_add(21_000)
&& let Some(response) = ball_collision_response(
old_position,
velocity,
ball.spin,
other_position,
MilliVec::from_velocity_per_second(other.velocity),
other.capture_age <= 0,
)
{
transferred_velocity = Some((other_index, response.other_velocity));
best_collision = Some((
if other_index == 0 { 174 } else { 175 },
false,
CollisionResponse {
surface_distance: response.surface_distance,
velocity: response.moving_velocity,
spin: response.moving_spin,
auxiliary_fired: false,
},
));
break;
}
}
}
let collided = best_collision.is_some();
@@ -913,90 +1023,130 @@ impl Game {
ball.capture_age = 0;
}
let mut auxiliary_fired = false;
let (hit_wall, hit_circle) = if let Some((object_id, is_wall, response)) = best_collision {
let mut hit = None;
if let Some((object_id, is_wall, response)) = best_collision {
velocity = response.velocity;
ball.spin = response.spin;
auxiliary_fired = response.auxiliary_fired;
self.last_collision_id = Some(object_id);
if object_id == 175
&& let (Some(secondary), Some(transferred)) =
(&mut self.secondary_ball, transferred_secondary_velocity)
&& !secondary_held
&& !primary_held
{
secondary.velocity = transferred.to_velocity_per_second();
}
if is_wall {
(Some(object_id), None)
} else {
(None, Some(object_id))
}
} else {
(None, None)
};
if hit_wall == Some(25) && speed_before_collision >= 1_000 {
velocity.x = 0;
hit = Some((object_id, is_wall));
}
if hit_wall == Some(25) && speed_before_collision < 1_000 {
if let Some((other_index, transferred)) = transferred_velocity {
self.balls[other_index].velocity = transferred.to_velocity_per_second();
}
if hit == Some((25, true)) {
velocity.x = 0;
velocity.y = 0;
if speed_before_collision < 1_000 {
velocity.y = 0;
}
}
velocity.clamp_speed(MAXIMUM_SPEED_MILLI_PER_STEP);
ball.position = old_position.add(velocity).to_position();
ball.velocity = velocity.to_velocity_per_second();
self.ball = ball;
self.balls[index] = ball;
if let Some(wall_id) = hit_wall {
if wall_id == 2 {
let special_respawn = self.special_respawn_pending();
let response_spin = self.ball.spin;
self.drain(events);
if special_respawn {
let origin = MilliVec::from_position(self.ball.position);
self.ball.position = origin.add(velocity).to_position();
self.ball.velocity = velocity.to_velocity_per_second();
self.ball.spin = response_spin;
match hit {
Some((2, true)) => {
// The original continues the drained ball's response on the
// ball respawned in its slot.
let respawn_in_place = self.balls.len() == 1 && self.special_respawn_pending(index);
let response_spin = ball.spin;
let step = self.lose_ball(index, events);
if respawn_in_place {
let respawned = &mut self.balls[index];
let origin = MilliVec::from_position(respawned.position);
respawned.position = origin.add(velocity).to_position();
respawned.velocity = velocity.to_velocity_per_second();
respawned.spin = response_spin;
}
return true;
return step;
}
if wall_id == 25 && speed_before_collision < 1_000 {
self.ball = Ball::default();
self.launcher_charge = 0.0;
self.launcher_was_down = false;
return true;
Some((25, true)) if speed_before_collision < 1_000 => {
return self.return_ball_to_launcher(index);
}
self.apply_wall_rule(wall_id, events);
if auxiliary_fired && matches!(wall_id, 55 | 74 | 107) {
events.push(Event::Sound(2019));
Some((wall_id, true)) => {
self.apply_wall_rule(wall_id, events);
if auxiliary_fired && matches!(wall_id, 55 | 74 | 107) {
events.push(Event::Sound(2019));
}
}
}
if let Some(circle_id) = hit_circle {
self.apply_bumper_rule(circle_id, auxiliary_fired, events);
Some((circle_id, false)) => self.apply_bumper_rule(circle_id, auxiliary_fired, events),
None => {}
}
match scan.action {
BallAction::Keep => {}
BallAction::Suspend => return true,
BallAction::Suspend => return BallStep::Stop,
BallAction::Reset => {
self.reset_ball_to_launcher();
return true;
}
BallAction::Finish => {
self.drain(events);
return true;
self.reset_ball_to_launcher(index);
return BallStep::Stop;
}
BallAction::Finish => return self.lose_ball(index, events),
BallAction::Remove => {
self.ball = self
.secondary_ball
.take()
.expect("a multiball capture must leave the other slot active");
self.promote_claw_secondary_to_primary();
return true;
// A capture during multiball takes the ball off the table
// without a drain. If the other balls already left during
// this callback it was the last ball, which returns to the
// launcher exactly like a single-ball capture.
if self.balls.len() == 1 {
if self.tilted {
return self.lose_ball(index, events);
}
self.reset_ball_to_launcher(index);
return BallStep::Stop;
}
self.remove_ball(index);
return BallStep::Removed;
}
}
collided
if collided {
BallStep::Stop
} else {
BallStep::Continue
}
}
/// Test shim: one substep of slot one, as the original slot-one
/// integrator. Returns whether its batch would stop.
#[cfg(test)]
fn fixed_update(&mut self, events: &mut Vec<Event>) -> bool {
let count = u16::try_from(self.balls.len()).unwrap_or(u16::MAX);
let pass = BallPass {
index: 0,
number: 1,
count,
};
self.step_ball(pass, events) != BallStep::Continue
}
/// While the wheel is being replaced its holes are closed and the
/// wheel's outer rim repels balls arriving from outside.
fn retain_wheel_rim(
old_position: MilliVec,
predicted: MilliVec,
velocity: MilliVec,
best: &mut Option<(u8, bool, StaticCollisionCandidate)>,
) {
let Some(hub) = PASSIVE_CIRCLES
.iter()
.find(|circle| circle.id == WHEEL_HUB_ID)
else {
return;
};
if let Some(candidate) = circle_collision_candidate_at(
old_position,
predicted,
velocity,
hub.center,
WHEEL_RIM_CONTACT_RADIUS,
CollisionMaterial::circle(
f64::from(hub.normal_rebound),
f64::from(hub.tangent_coupling),
0.0,
),
) {
retain_first_collision(best, (WHEEL_HUB_ID, false, candidate));
}
}
fn retain_static_range(
@@ -1017,12 +1167,11 @@ impl Game {
}
}
#[allow(clippy::too_many_arguments, clippy::too_many_lines)]
#[allow(clippy::too_many_lines)]
fn scan_ordered_records_for_ball(
&mut self,
ball: &mut Ball,
ball_number: u16,
ball_count: u16,
pass: BallPass,
old_position: MilliVec,
velocity: &mut MilliVec,
events: &mut Vec<Event>,
@@ -1046,8 +1195,7 @@ impl Game {
ball.velocity = velocity.to_velocity_per_second();
match self.capture_record_step(
ball,
ball_number,
ball_count,
pass,
89,
CLAW_TRIGGER_CENTER,
CLAW_TRIGGER_RADIUS,
@@ -1057,9 +1205,9 @@ impl Game {
events,
) {
CaptureStep::Complete => {
let terminal_frame = self
.next_claw_terminal_frame(self.claw_capture_restricted_for(ball_number));
self.begin_claw_capture_after_hold(ball, ball_number, terminal_frame, events);
let terminal_frame =
self.next_claw_terminal_frame(self.claw_capture_restricted_for(pass.index));
self.begin_claw_capture_after_hold(ball, pass.index, terminal_frame, events);
action = BallAction::Suspend;
}
CaptureStep::Outside | CaptureStep::Holding => {}
@@ -1072,8 +1220,7 @@ impl Game {
ball.velocity = velocity.to_velocity_per_second();
if let Some(completed_action) = self.check_lock_holes(
ball,
ball_number,
ball_count,
pass,
old_position,
predicted,
&mut capture_candidate,
@@ -1084,6 +1231,9 @@ impl Game {
*velocity = MilliVec::from_velocity_per_second(ball.velocity);
}
if self.panel_frame.is_some() {
Self::retain_wheel_rim(old_position, predicted, *velocity, &mut best);
}
self.retain_static_range(old_position, predicted, *velocity, 134..=139, &mut best);
if old_position.y < 250_000 {
ball.velocity = velocity.to_velocity_per_second();
@@ -1103,8 +1253,7 @@ impl Game {
}
if let Some(completed_action) = self.check_special_hole(
ball,
ball_number,
ball_count,
pass,
old_position,
predicted,
&mut capture_candidate,
@@ -1115,7 +1264,6 @@ impl Game {
*velocity = MilliVec::from_velocity_per_second(ball.velocity);
let effect_triggered = self.check_effect_sensor(
ball,
ball_number,
old_position,
movement_from_prediction(old_position, predicted),
events,
@@ -1275,124 +1423,18 @@ impl Game {
)
}
/// Test shim: one substep of slot two during a two-ball callback.
#[cfg(test)]
fn advance_secondary_ball(&mut self, events: &mut Vec<Event>) -> bool {
self.advance_secondary_ball_slot(events, !self.claw_holds_slot(ClawBallSlot::Primary))
}
#[allow(clippy::too_many_lines)]
fn advance_secondary_ball_slot(
&mut self,
events: &mut Vec<Event>,
primary_slot_active: bool,
) -> bool {
let Some(mut ball) = self.secondary_ball.take() else {
if self.balls.len() < 2 {
return true;
}
let pass = BallPass {
index: 1,
number: 2,
count: 2,
};
let primary_position = self.ball.position;
let primary_velocity = self.ball.velocity;
let primary_capture_age = self.ball.capture_age;
let old_position = MilliVec::from_position(ball.position);
let mut velocity = MilliVec::from_velocity_per_second(ball.velocity);
velocity.y += GRAVITY_MILLI_PER_STEP;
velocity.clamp_speed(MAXIMUM_SPEED_MILLI_PER_STEP);
let speed_before_collision = milli_distance(velocity);
if predicted_outside_board(old_position.add(velocity)) {
self.score_mode = ScoreMode::Normal;
events.push(Event::Drain);
return true;
}
let (best_static, scan, predicted) = self.scan_ordered_records_for_ball(
&mut ball,
2,
2,
old_position,
&mut velocity,
events,
);
let mut best_collision = best_static
.map(|(id, is_wall, candidate)| (id, is_wall, candidate.resolve(velocity, ball.spin)));
let mut transferred_primary_velocity = None;
let other_position = MilliVec::from_position(primary_position);
if primary_slot_active
&& !self.claw_holds_slot(ClawBallSlot::Primary)
&& !self.claw_holds_slot(ClawBallSlot::Secondary)
&& predicted.x >= other_position.x.wrapping_sub(21_000)
&& predicted.x <= other_position.x.wrapping_add(21_000)
&& predicted.y >= other_position.y.wrapping_sub(21_000)
&& predicted.y <= other_position.y.wrapping_add(21_000)
&& let Some(response) = ball_collision_response(
old_position,
velocity,
ball.spin,
other_position,
MilliVec::from_velocity_per_second(primary_velocity),
primary_capture_age <= 0,
)
&& best_collision.is_none()
{
transferred_primary_velocity = Some(response.other_velocity);
best_collision = Some((
174,
false,
CollisionResponse {
surface_distance: response.surface_distance,
velocity: response.moving_velocity,
spin: response.moving_spin,
auxiliary_fired: false,
},
));
}
let mut hit = None;
let collided = best_collision.is_some();
if collided {
ball.capture_age = 0;
}
let mut auxiliary_fired = false;
if let Some((object_id, is_wall, response)) = best_collision {
velocity = response.velocity;
ball.spin = response.spin;
auxiliary_fired = response.auxiliary_fired;
hit = Some((object_id, is_wall));
if object_id == 174
&& let Some(transferred) = transferred_primary_velocity
{
self.ball.velocity = transferred.to_velocity_per_second();
}
}
if hit == Some((25, true)) && speed_before_collision >= 1_000 {
velocity.x = 0;
}
if hit == Some((25, true)) && speed_before_collision < 1_000 {
velocity.x = 0;
velocity.y = 0;
}
velocity.clamp_speed(MAXIMUM_SPEED_MILLI_PER_STEP);
ball.position = old_position.add(velocity).to_position();
ball.velocity = velocity.to_velocity_per_second();
if hit == Some((2, true)) {
self.score_mode = ScoreMode::Normal;
events.push(Event::Drain);
return true;
}
if let Some((object_id, true)) = hit {
if object_id == 25 && speed_before_collision < 1_000 {
self.score_mode = ScoreMode::Normal;
return true;
}
self.apply_wall_rule(object_id, events);
if auxiliary_fired && matches!(object_id, 55 | 74 | 107) {
events.push(Event::Sound(2019));
}
} else if let Some((object_id, false)) = hit {
self.apply_bumper_rule(object_id, auxiliary_fired, events);
}
match scan.action {
BallAction::Keep | BallAction::Suspend => self.secondary_ball = Some(ball),
BallAction::Reset | BallAction::Remove | BallAction::Finish => return true,
}
collided || scan.action == BallAction::Suspend
self.step_ball(pass, events) != BallStep::Continue
}
fn apply_bumper_rule(&mut self, object_id: u8, auxiliary_fired: bool, events: &mut Vec<Event>) {
@@ -1534,30 +1576,30 @@ impl Game {
movement_velocity: MilliVec,
events: &mut Vec<Event>,
) -> BallAction {
let ball_count = if self.secondary_ball.is_some() { 2 } else { 1 };
let mut ball = self.ball;
let count = u16::try_from(self.balls.len()).unwrap_or(u16::MAX);
let pass = BallPass {
index: 0,
number: 1,
count,
};
let mut ball = self.balls[0];
let scan = self.check_sensor_objects_for_ball(
&mut ball,
1,
ball_count,
pass,
old_position,
movement_velocity,
events,
);
match scan.action {
BallAction::Keep | BallAction::Suspend => self.ball = ball,
BallAction::Reset => self.reset_ball_to_launcher(),
BallAction::Keep | BallAction::Suspend => self.balls[0] = ball,
BallAction::Reset => self.reset_ball_to_launcher(0),
BallAction::Finish => {
self.ball = ball;
self.drain(events);
}
BallAction::Remove => {
self.ball = self
.secondary_ball
.take()
.expect("a multiball capture must leave the other slot active");
self.balls[0] = ball;
self.lose_ball(0, events);
}
BallAction::Remove => self.remove_ball(0),
}
self.publish_spawned_balls();
scan.action
}
@@ -1565,8 +1607,7 @@ impl Game {
fn check_sensor_objects_for_ball(
&mut self,
ball: &mut Ball,
ball_number: u16,
ball_count: u16,
pass: BallPass,
old_position: MilliVec,
movement_velocity: MilliVec,
events: &mut Vec<Event>,
@@ -1595,8 +1636,7 @@ impl Game {
if !self.claw.active {
match self.capture_record_step(
ball,
ball_number,
ball_count,
pass,
89,
CLAW_TRIGGER_CENTER,
CLAW_TRIGGER_RADIUS,
@@ -1607,9 +1647,9 @@ impl Game {
) {
CaptureStep::Holding | CaptureStep::Outside => {}
CaptureStep::Complete => {
let terminal_frame = self
.next_claw_terminal_frame(self.claw_capture_restricted_for(ball_number));
self.begin_claw_capture_after_hold(ball, ball_number, terminal_frame, events);
let terminal_frame =
self.next_claw_terminal_frame(self.claw_capture_restricted_for(pass.index));
self.begin_claw_capture_after_hold(ball, pass.index, terminal_frame, events);
action = BallAction::Suspend;
}
}
@@ -1617,8 +1657,7 @@ impl Game {
if let Some(completed_action) = self.check_lock_holes(
ball,
ball_number,
ball_count,
pass,
old_position,
predicted_position,
&mut capture_candidate,
@@ -1629,8 +1668,7 @@ impl Game {
self.check_target_sensors(ball, old_position, movement_velocity, 140..=147, events);
if let Some(completed_action) = self.check_special_hole(
ball,
ball_number,
ball_count,
pass,
old_position,
predicted_position,
&mut capture_candidate,
@@ -1638,7 +1676,7 @@ impl Game {
) {
action = completed_action;
}
self.check_effect_sensor(ball, ball_number, old_position, movement_velocity, events);
self.check_effect_sensor(ball, old_position, movement_velocity, events);
self.check_target_sensors(ball, old_position, movement_velocity, 150..=152, events);
SensorScanResult { action }
}
@@ -1651,8 +1689,7 @@ impl Game {
fn capture_record_step(
&mut self,
ball: &mut Ball,
ball_number: u16,
ball_count: u16,
pass: BallPass,
record_id: u8,
center: Vec2,
radius: f32,
@@ -1661,6 +1698,11 @@ impl Game {
best_capture: &mut Option<(u8, StaticCollisionCandidate)>,
events: &mut Vec<Event>,
) -> CaptureStep {
let BallPass {
index: ball_index,
number: ball_number,
count: ball_count,
} = pass;
let current_position = MilliVec::from_position(ball.position);
let center_view = center;
let center = MilliVec::from_position(center_view);
@@ -1676,7 +1718,7 @@ impl Game {
&& ball.capture_age == 0
&& self.record_contacts[usize::from(record_id)] == 0
{
self.clear_claw_capture_entry_for(ball_number);
self.clear_claw_capture_entry_for(ball_index);
}
return CaptureStep::Outside;
}
@@ -1690,6 +1732,13 @@ impl Game {
let contact = self.record_contacts[contact_index];
let contact_allowed =
contact == 0 || (contact != 99 && ball_count == 1) || contact == ball_number;
if (129..=133).contains(&record_id)
&& contact == 99
&& surface_distance < -CLOSED_HOLE_KICKOUT_DEPTH_MILLI
{
kick_out_of_lock_hole(ball, center_view);
return CaptureStep::Outside;
}
if surface_distance < -11_000
&& contact_allowed
@@ -1699,8 +1748,7 @@ impl Game {
// 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));
self.claw.capture_entry = Some((ball_index, ball_count > 1));
}
if ball.capture_age < 300 {
let mut velocity = MilliVec::from_velocity_per_second(ball.velocity);
@@ -1757,16 +1805,26 @@ impl Game {
}
if record_id == 89 && surface_distance >= -11_000 {
self.clear_claw_capture_entry_for(ball_number);
self.clear_claw_capture_entry_for(ball_index);
}
if surface_distance > -11_000 && contact == ball_number {
self.record_contacts[contact_index] = 0;
ball.capture_age = 0;
}
// Deliberate original-game divergence: a ball that was settling into
// a lock hole when another ball filled it would bounce off the rim
// from the inside forever. With many balls that is common, so the
// rim of a filled hole only repels balls moving towards its center.
let velocity = MilliVec::from_velocity_per_second(ball.velocity);
let leaving_filled_hole = (129..=133).contains(&record_id)
&& contact == 99
&& i64::from(velocity.x) * i64::from(current_position.x.wrapping_sub(center.x))
+ i64::from(velocity.y) * i64::from(current_position.y.wrapping_sub(center.y))
> 0;
if surface_distance <= milli_distance(velocity)
&& surface_distance >= -11_000
&& contact != 0
&& !leaving_filled_hole
&& let Some(candidate) = capture_collision_candidate(
current_position,
velocity,
@@ -1785,18 +1843,34 @@ impl Game {
fn check_lock_holes(
&mut self,
ball: &mut Ball,
ball_number: u16,
ball_count: u16,
pass: BallPass,
old_position: MilliVec,
predicted_position: MilliVec,
best_capture: &mut Option<(u8, StaticCollisionCandidate)>,
events: &mut Vec<Event>,
) -> Option<BallAction> {
if self.panel_frame.is_some() {
// The wheel is being replaced: no hole captures, and a ball that
// was resting in a hole's pocket is kicked back onto the table.
for sensor in LOCK_HOLES {
let center = MilliVec::from_position(sensor.center);
let current = MilliVec::from_position(ball.position);
let offset = MilliVec {
x: center.x.wrapping_sub(current.x),
y: center.y.wrapping_sub(current.y).wrapping_sub(2_000),
};
let radius = MilliVec::from_position(vec2(sensor.radius, 0.0)).x;
if milli_distance(offset) - radius < -CLOSED_HOLE_KICKOUT_DEPTH_MILLI {
kick_out_of_lock_hole(ball, sensor.center);
break;
}
}
return None;
}
for (index, sensor) in LOCK_HOLES.into_iter().enumerate() {
match self.capture_record_step(
ball,
ball_number,
ball_count,
pass,
sensor.id,
sensor.center,
sensor.radius,
@@ -1808,7 +1882,7 @@ impl Game {
CaptureStep::Outside | CaptureStep::Holding => continue,
CaptureStep::Complete => {}
}
return Some(self.complete_lock_hole(index, ball_count, events));
return Some(self.complete_lock_hole(index, pass.count, events));
}
None
}
@@ -1842,7 +1916,10 @@ impl Game {
player.secondary_score = 0;
player.score_multiplier = player.score_multiplier.wrapping_add(1);
player.extra_balls = player.extra_balls.wrapping_add(1);
if ball_count == 1 {
// Deliberate original-game divergence: the original deferred the
// wheel replacement until multiball ended. It now starts at once,
// and balls still in play run against the closed wheel rim.
if ball_count == 1 || self.panel_frame.is_none() {
self.panel_frame = Some(0);
}
self.score_mode = ScoreMode::Normal;
@@ -1863,8 +1940,7 @@ impl Game {
fn check_special_hole(
&mut self,
ball: &mut Ball,
ball_number: u16,
ball_count: u16,
pass: BallPass,
old_position: MilliVec,
predicted_position: MilliVec,
best_capture: &mut Option<(u8, StaticCollisionCandidate)>,
@@ -1872,8 +1948,7 @@ impl Game {
) -> Option<BallAction> {
match self.capture_record_step(
ball,
ball_number,
ball_count,
pass,
SPECIAL_HOLE_SENSOR.id,
SPECIAL_HOLE_SENSOR.center,
SPECIAL_HOLE_SENSOR.radius,
@@ -1887,7 +1962,7 @@ impl Game {
}
self.multiball_state = MultiballState::Ready;
events.push(Event::Wheel);
Some(if ball_count == 1 {
Some(if pass.count == 1 {
if self.tilted {
BallAction::Finish
} else {
@@ -1901,7 +1976,6 @@ impl Game {
fn check_effect_sensor(
&mut self,
ball: &mut Ball,
ball_number: u16,
old_position: MilliVec,
movement_velocity: MilliVec,
events: &mut Vec<Event>,
@@ -1942,16 +2016,10 @@ impl Game {
self.add_score(transfer, events);
self.players[self.current_player].secondary_score = 0;
}
7 if self.record_contacts[usize::from(SPECIAL_HOLE_SENSOR.id)] != 0
&& self.record_contacts[usize::from(SPECIAL_HOLE_SENSOR.id)]
!= ball_number
&& self.secondary_ball.is_none()
&& matches!(
self.multiball_state,
MultiballState::Ready | MultiballState::Active
) =>
{
self.secondary_ball = Some(Ball {
// Release the ball parked in the special hole, however
// many balls are already in play.
7 if self.special_hole_active() && self.spawned_balls.is_empty() => {
self.spawned_balls.push(Ball {
position: vec2(17.0, 23.0),
velocity: MilliVec { x: 0, y: 3_040 }.to_velocity_per_second(),
in_launcher: false,
@@ -2036,7 +2104,7 @@ impl Game {
ball.velocity = velocity.to_velocity_per_second();
}
fn reset_ball_to_launcher(&mut self) {
fn reset_ball_to_launcher(&mut self, index: usize) {
if self.record_contacts[129..=133]
.iter()
.all(|contact| *contact != 0)
@@ -2045,7 +2113,7 @@ impl Game {
}
self.tilted = false;
self.tilt_counter = 0;
self.ball = Ball::default();
self.balls[index] = Ball::default();
self.launcher_charge = 0.0;
self.launcher_was_down = false;
self.launcher_hold_seconds = 0.0;
@@ -2135,55 +2203,95 @@ impl Game {
}
}
fn claw_holds_slot(&self, slot: ClawBallSlot) -> bool {
self.claw.ball_suspended && self.claw.captured_slot == Some(slot)
fn held_ball(&self) -> Option<usize> {
self.claw
.ball_suspended
.then_some(self.claw.captured_slot)
.flatten()
}
fn claw_capture_restricted_for(&self, ball_number: u16) -> bool {
self.claw.capture_entry == Some((ClawBallSlot::from_ball_number(ball_number), true))
fn claw_holds(&self, index: usize) -> bool {
self.held_ball() == Some(index)
}
fn clear_claw_capture_entry_for(&mut self, ball_number: u16) {
let slot = ClawBallSlot::from_ball_number(ball_number);
fn claw_capture_restricted_for(&self, index: usize) -> bool {
self.claw.capture_entry == Some((index, true))
}
fn clear_claw_capture_entry_for(&mut self, index: usize) {
if self
.claw
.capture_entry
.is_some_and(|(owner, _)| owner == slot)
.is_some_and(|(owner, _)| owner == index)
{
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));
/// Takes the ball at `index` out of play without any drain rules and
/// keeps every index-based reference pointing at the same physical ball.
fn remove_ball(&mut self, index: usize) {
self.balls.remove(index);
let shift = |slot: Option<usize>| match slot {
Some(slot) if slot == index => None,
Some(slot) if slot > index => Some(slot - 1),
slot => slot,
};
self.claw.captured_slot = shift(self.claw.captured_slot);
self.claw.capture_entry = self
.claw
.capture_entry
.and_then(|(owner, restricted)| shift(Some(owner)).map(|owner| (owner, restricted)));
// The claw and lock-hole contacts name their owner by slot number;
// later slots move down one number with their ball. A lock hole the
// removed ball was still settling into is free again.
let removed_number = index + 1;
let claw_contact = &mut self.record_contacts[89];
if *claw_contact != 99 && usize::from(*claw_contact) > removed_number {
*claw_contact -= 1;
}
// While multiball is active the special-hole contact names the
// released ball rather than a parked one.
let special_owner = (self.multiball_state == MultiballState::Active)
.then_some(usize::from(SPECIAL_HOLE_SENSOR.id));
for contact_index in (129..=133).chain(special_owner) {
let contact = &mut self.record_contacts[contact_index];
if *contact == 99 || *contact == 0 {
continue;
}
if self.record_contacts[89] == 2 {
self.record_contacts[89] = 1;
match usize::from(*contact).cmp(&removed_number) {
std::cmp::Ordering::Equal => *contact = 0,
std::cmp::Ordering::Greater => *contact -= 1,
std::cmp::Ordering::Less => {}
}
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)
/// A ball came to rest on the shooter stop. It waits in the launcher
/// unless another ball already occupies it.
fn return_ball_to_launcher(&mut self, index: usize) -> BallStep {
if self.launcher_ball().is_some_and(|waiting| waiting != index) {
let special_respawn = self.special_respawn_pending(index);
self.take_ball_out_of_play(index);
if special_respawn {
let released = self.release_parked_ball();
self.balls.push(released);
}
return BallStep::Removed;
}
self.balls[index] = Ball::default();
self.launcher_charge = 0.0;
self.launcher_was_down = false;
BallStep::Stop
}
fn apply_flipper_kicks(&mut self) {
let pending = self.pending_flipper_edges;
self.pending_flipper_edges.fill(0);
if self.ball.in_launcher || self.tilted {
if self.tilted {
return;
}
let primary_held = self.claw_holds_slot(ClawBallSlot::Primary);
let secondary_held = self.claw_holds_slot(ClawBallSlot::Secondary);
let held = self.held_ball();
for (delta, side) in [
(i32::from(pending[0]), FlipperSide::Left),
(i32::from(pending[1]), FlipperSide::Right),
@@ -2191,13 +2299,10 @@ impl Game {
if delta == 0 {
continue;
}
if !primary_held {
apply_flipper_response_to_ball(&mut self.ball, delta, side);
}
if let Some(secondary) = &mut self.secondary_ball
&& !secondary_held
{
apply_flipper_response_to_ball(secondary, delta, side);
for (index, ball) in self.balls.iter_mut().enumerate() {
if !ball.in_launcher && held != Some(index) {
apply_flipper_response_to_ball(ball, delta, side);
}
}
}
}
@@ -2212,11 +2317,11 @@ impl Game {
self.claw.target_frame = terminal_frame;
self.claw.bank = ClawSpriteBank::Closing;
self.claw.ball_suspended = true;
self.claw.captured_slot = Some(ClawBallSlot::Primary);
self.claw.captured_slot = Some(0);
self.claw.capture_entry = None;
self.claw.frame_accumulator = 0.0;
self.ball.velocity = Vec2::ZERO;
self.ball.spin = Real48::ZERO;
self.balls[0].velocity = Vec2::ZERO;
self.balls[0].spin = Real48::ZERO;
events.push(Event::ClawCapture);
events.push(Event::Sound(2015));
}
@@ -2224,7 +2329,7 @@ impl Game {
fn begin_claw_capture_after_hold(
&mut self,
ball: &mut Ball,
ball_number: u16,
index: usize,
terminal_frame: u8,
events: &mut Vec<Event>,
) {
@@ -2236,7 +2341,7 @@ impl Game {
self.claw.target_frame = terminal_frame;
self.claw.bank = ClawSpriteBank::Closing;
self.claw.ball_suspended = true;
self.claw.captured_slot = Some(ClawBallSlot::from_ball_number(ball_number));
self.claw.captured_slot = Some(index);
self.claw.frame_accumulator = 0.0;
ball.velocity = Vec2::ZERO;
ball.spin = Real48::ZERO;
@@ -2248,6 +2353,14 @@ impl Game {
return;
}
self.claw.frame_accumulator += dt;
// Never send a ball down the shooter lane while other balls are in
// play, even when more balls arrived after the capture began.
if self.claw.ball_suspended
&& self.claw.target_frame == CLAW_SHOOTER_LANE_FRAME
&& self.balls.len() > 1
{
self.claw.target_frame = self.next_claw_terminal_frame(true);
}
while self.claw.active && self.claw.frame_accumulator >= self.claw_frame_seconds {
self.claw.frame_accumulator -= self.claw_frame_seconds;
self.advance_claw(events);
@@ -2275,23 +2388,12 @@ impl Game {
self.claw.target_frame = 10;
self.claw.bank = ClawSpriteBank::Opening;
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);
}
}
let index = match self.claw.captured_slot {
Some(index) if index < self.balls.len() => index,
_ => 0,
};
self.claw.captured_slot = Some(index);
(self.balls[index].position, self.balls[index].velocity) = release;
self.claw.ball_suspended = false;
events.push(Event::ClawRelease);
events.push(Event::Sound(2016));
@@ -2325,35 +2427,61 @@ impl Game {
}
}
fn drain(&mut self, events: &mut Vec<Event>) {
if self.claw_holds_slot(ClawBallSlot::Secondary) {
self.ball = self
.secondary_ball
.take()
.expect("the claw-held secondary ball must remain in slot two");
self.promote_claw_secondary_to_primary();
/// The ball at `index` drained. While other balls remain it simply
/// leaves the table; the last ball either takes the special respawn or
/// ends the player's ball.
fn lose_ball(&mut self, index: usize, events: &mut Vec<Event>) -> BallStep {
let special_respawn = self.special_respawn_pending(index);
if self.balls.len() > 1 {
self.take_ball_out_of_play(index);
events.push(Event::Drain);
return;
if special_respawn {
// Only balls waiting in the launcher are left, so the parked
// ball is released just as for a single drained ball.
let released = self.release_parked_ball();
self.balls.push(released);
}
return BallStep::Removed;
}
if let Some(remaining_ball) = self.secondary_ball.take() {
self.ball = remaining_ball;
if special_respawn {
self.balls[index] = self.release_parked_ball();
return BallStep::Stop;
}
self.end_ball(events);
BallStep::Stop
}
/// Removes a ball that left play while others remain.
fn take_ball_out_of_play(&mut self, index: usize) {
self.remove_ball(index);
if self.balls.len() == 1 {
self.score_mode = ScoreMode::Normal;
events.push(Event::Drain);
return;
}
if self.special_respawn_pending() {
self.ball = Ball {
position: vec2(17.0, 23.0),
velocity: MilliVec { x: 0, y: 3_040 }.to_velocity_per_second(),
in_launcher: false,
spin: Real48::ZERO,
capture_age: 0,
};
self.multiball_state = MultiballState::Unavailable;
self.record_contacts[148] = 0;
self.special_hole_gate = SpecialHoleGate::Suppressed;
return;
}
/// Empties the special hole and returns its ball, rolling out of the
/// hole, ready to be put into play.
fn release_parked_ball(&mut self) -> Ball {
self.multiball_state = MultiballState::Unavailable;
self.record_contacts[usize::from(SPECIAL_HOLE_SENSOR.id)] = 0;
self.special_hole_gate = SpecialHoleGate::Suppressed;
Ball {
position: vec2(17.0, 23.0),
velocity: MilliVec { x: 0, y: 3_040 }.to_velocity_per_second(),
in_launcher: false,
spin: Real48::ZERO,
capture_age: 0,
}
}
/// Test shim: slot one drains.
#[cfg(test)]
fn drain(&mut self, events: &mut Vec<Event>) {
self.lose_ball(0, events);
}
fn end_ball(&mut self, events: &mut Vec<Event>) {
self.spawned_balls.clear();
self.ball_double = BallDoubleState::Inactive;
self.multiball_state = MultiballState::Unavailable;
self.special_hole_gate = SpecialHoleGate::Enabled;
@@ -2364,21 +2492,23 @@ impl Game {
player.balls = player.balls.saturating_sub(1);
}
let high_score_candidate =
(player.balls == 0 && player.extra_balls == 0).then_some(player.score);
(player.balls == 0 && player.extra_balls == 0).then_some(Event::HighScoreCandidate {
score: player.score,
crazy: player.crazy_mode_used(),
});
self.save_current_rule_state();
events.push(Event::Drain);
if !self.tilted {
events.push(Event::Sound(2008));
}
if let Some(score) = high_score_candidate {
events.push(Event::HighScoreCandidate(score));
if let Some(candidate) = high_score_candidate {
events.push(candidate);
self.flipper_release_latch[0] |= self.flipper_inputs.left_raised;
self.flipper_release_latch[1] |= self.flipper_inputs.right_raised;
self.flipper_inputs = Flippers::default();
}
self.tilted = false;
self.tilt_counter = 0;
self.secondary_ball = None;
self.score_mode = ScoreMode::Normal;
self.launcher_charge = 0.0;
self.launcher_was_down = false;
@@ -2391,7 +2521,7 @@ impl Game {
if self.players[next].balls > 0 || self.players[next].extra_balls > 0 {
self.current_player = next;
self.load_current_rule_state();
self.ball = Ball::default();
self.balls = vec![Ball::default()];
return;
}
next = (next + 1) % self.players.len();
@@ -2399,9 +2529,16 @@ impl Game {
self.finished = true;
}
fn special_respawn_pending(&self) -> bool {
self.secondary_ball.is_none()
&& self.score_mode == ScoreMode::Normal
/// Whether the ball at `leaving` is the last one in play while a ball is
/// parked in the special hole. Balls waiting in the launcher are not in
/// play; a ball effect seven is about to publish is.
fn special_respawn_pending(&self, leaving: usize) -> bool {
self.spawned_balls.is_empty()
&& self
.balls
.iter()
.enumerate()
.all(|(index, ball)| index == leaving || ball.in_launcher)
&& self.record_contacts[usize::from(SPECIAL_HOLE_SENSOR.id)] != 0
&& matches!(
self.multiball_state,
@@ -2514,6 +2651,28 @@ fn claw_release(frame: u8) -> (Vec2, Vec2) {
(position, direction * ORIGINAL_BALL_SPEED_PER_SECOND)
}
/// Deliberate original-game divergence: with several balls, one can come to
/// rest at the bottom of a lock hole's pocket, between the wheel hub and a
/// post, when another ball fills that hole or the wheel is being replaced.
/// The closed hole sends it out from its center, radially away from the hub,
/// which clears both neighbouring posts.
#[allow(clippy::cast_possible_truncation)]
fn kick_out_of_lock_hole(ball: &mut Ball, hole_center: Vec2) {
let hub = PASSIVE_CIRCLES
.iter()
.find(|circle| circle.id == WHEEL_HUB_ID)
.map_or(vec2(124.0, 76.0), |circle| circle.center);
let direction = (hole_center - hub).normalize_or_zero();
ball.position = hole_center;
ball.velocity = MilliVec {
x: (direction.x * CLOSED_HOLE_KICKOUT_SPEED_MILLI).round() as i32,
y: (direction.y * CLOSED_HOLE_KICKOUT_SPEED_MILLI).round() as i32,
}
.to_velocity_per_second();
ball.spin = Real48::ZERO;
ball.capture_age = 0;
}
fn apply_flipper_response_to_ball(ball: &mut Ball, delta: i32, side: FlipperSide) {
let position = MilliVec::from_position(ball.position);
let velocity = MilliVec::from_velocity_per_second(ball.velocity);
@@ -2530,6 +2689,33 @@ fn apply_flipper_response_to_ball(ball: &mut Ball, delta: i32, side: FlipperSide
}
}
#[cfg(test)]
impl BallPass {
fn slot(number: u16, count: u16) -> Self {
Self {
index: usize::from(number) - 1,
number,
count,
}
}
}
#[cfg(test)]
impl Game {
fn secondary(&self) -> Option<Ball> {
self.balls.get(1).copied()
}
fn set_secondary(&mut self, ball: Option<Ball>) {
self.balls.truncate(1);
self.balls.extend(ball);
}
fn take_secondary(&mut self) -> Option<Ball> {
(self.balls.len() > 1).then(|| self.balls.remove(1))
}
}
#[allow(clippy::too_many_lines)]
#[cfg(test)]
mod tests {
@@ -2555,9 +2741,9 @@ mod tests {
events: &mut Vec<Event>,
) {
for _ in 0..=60 {
game.ball.in_launcher = false;
game.ball.position = center;
game.ball.velocity = Vec2::ZERO;
game.balls[0].in_launcher = false;
game.balls[0].position = center;
game.balls[0].velocity = Vec2::ZERO;
game.check_sensor_objects(MilliVec::from_position(center), MilliVec::default(), events);
}
}
@@ -2570,14 +2756,13 @@ mod tests {
previous_position: MilliVec,
events: &mut Vec<Event>,
) -> CaptureStep {
let mut ball = game.ball;
let mut ball = game.balls[0];
let mut capture_candidate = None;
let predicted_position =
previous_position.add(MilliVec::from_velocity_per_second(ball.velocity));
let result = game.capture_record_step(
&mut ball,
1,
if game.secondary_ball.is_some() { 2 } else { 1 },
BallPass::slot(1, if game.secondary().is_some() { 2 } else { 1 }),
record_id,
center,
radius,
@@ -2586,10 +2771,327 @@ mod tests {
&mut capture_candidate,
events,
);
game.ball = ball;
game.balls[0] = ball;
result
}
#[test]
fn crazy_balls_start_clear_of_every_static_record() {
let game = Game::new_with_seed(1, 7);
for position in CRAZY_BALL_POSITIONS {
let origin = MilliVec::from_position(position);
for (x, y) in [(3_000, 0), (-3_000, 0), (0, 3_000), (0, -3_000)] {
assert!(
game.find_static_collision_candidate(origin, MilliVec { x, y })
.is_none(),
"{position:?} touches a record moving ({x}, {y})"
);
}
}
}
#[test]
fn crazy_mode_adds_ten_ordinary_balls_and_marks_the_player() {
let mut game = Game::new_with_seed(1, 7);
assert!(game.add_crazy_balls());
assert!(game.player().crazy_mode_used());
assert_eq!(game.balls.len(), 11);
assert!(game.balls[0].in_launcher);
let start: Vec<_> = game.balls.iter().map(|ball| ball.position).collect();
for _ in 0..20 {
game.update(0.03, 3, Controls::default());
}
assert!(game.balls[0].in_launcher);
assert!(
game.balls[1..]
.iter()
.zip(&start[1..])
.all(|(ball, start)| ball.position != *start)
);
for _ in 0..3_000 {
game.update(0.03, 3, Controls::default());
}
// Every dropped ball drained; the waiting ball never counted as lost.
assert_eq!(game.balls.len(), 1);
assert!(game.balls[0].in_launcher);
assert!(game.player().score > 0);
assert_eq!(game.player().balls, 3);
}
#[test]
fn a_ball_moves_the_same_in_any_slot() {
let ball = Ball {
position: vec2(120.0, 160.0),
velocity: vec2(40.0, -120.0),
in_launcher: false,
..Ball::default()
};
let mut alone = Game::new_with_seed(1, 7);
alone.balls = vec![ball];
let mut behind_launcher = Game::new_with_seed(1, 7);
behind_launcher.balls = vec![Ball::default(), ball];
for _ in 0..150 {
alone.update(0.03, 3, Controls::default());
behind_launcher.update(0.03, 3, Controls::default());
if alone.balls[0].in_launcher || behind_launcher.balls.len() < 2 {
break;
}
assert_eq!(alone.balls[0].position, behind_launcher.balls[1].position);
assert_eq!(alone.balls[0].velocity, behind_launcher.balls[1].velocity);
}
}
#[test]
fn the_turn_ends_only_when_the_last_ball_drains() {
let mut game = Game::new_with_seed(1, 7);
game.balls[0].in_launcher = false;
game.add_crazy_balls();
let mut events = Vec::new();
game.drain(&mut events);
assert_eq!(game.balls.len(), 10);
assert_eq!(game.balls[0].position, CRAZY_BALL_POSITIONS[0]);
assert_eq!(game.player().balls, 3);
game.balls.truncate(1);
game.drain(&mut events);
assert_eq!(game.player().balls, 2);
assert_eq!(game.balls.len(), 1);
assert!(game.balls[0].in_launcher);
}
#[test]
fn crazy_scores_are_flagged_on_the_high_score_candidate() {
let mut game = Game::new_with_seed(1, 7);
game.add_crazy_balls();
game.balls.truncate(1);
game.players[0].balls = 1;
game.players[0].score = 1_234;
let mut events = Vec::new();
game.drain(&mut events);
assert!(events.contains(&Event::HighScoreCandidate {
score: 1_234,
crazy: true,
}));
}
#[test]
fn crazy_mode_is_capped_and_rejected_after_the_game() {
let mut game = Game::new_with_seed(1, 7);
while game.add_crazy_balls() {}
assert_eq!(game.balls.len(), MAX_BALLS);
let mut finished = Game::new_with_seed(1, 7);
finished.finished = true;
assert!(!finished.add_crazy_balls());
assert_eq!(finished.balls.len(), 1);
assert!(!finished.player().crazy_mode_used());
}
#[test]
fn release_ball_frees_the_parked_ball_during_multiball() {
let mut game = Game::new_with_seed(1, 7);
game.multiball_state = MultiballState::Ready;
game.record_contacts[148] = 2;
game.target_effect = 7;
game.object_active[usize::from(EFFECT_SENSOR.id)] = true;
game.balls[0].in_launcher = false;
game.balls[0].position = EFFECT_SENSOR.center;
for position in [vec2(250.0, 215.0), vec2(160.0, 300.0)] {
game.balls.push(Ball {
position,
in_launcher: false,
..Ball::default()
});
}
let mut events = Vec::new();
game.check_sensor_objects(
MilliVec::from_position(EFFECT_SENSOR.center),
MilliVec::default(),
&mut events,
);
assert_eq!(game.balls.len(), 4);
assert_eq!(game.balls[3].position, vec2(17.0, 23.0));
assert_eq!(game.multiball_state, MultiballState::Active);
assert!(!game.special_hole_active());
// The released ball owns the hole, and keeps owning it when an
// earlier ball leaves the table.
assert_eq!(game.record_contacts[148], 4);
game.remove_ball(1);
assert_eq!(game.record_contacts[148], 3);
for _ in 0..30 {
game.update(0.03, 3, Controls::default());
}
assert_eq!(game.record_contacts[148], 0);
assert!(
game.balls
.iter()
.all(|ball| ball.position.distance(vec2(17.0, 23.0)) > 12.0)
);
}
#[test]
fn claw_never_drops_into_the_shooter_lane_with_several_balls() {
let mut game = Game::new_with_seed(1, 7);
game.begin_claw_scenario(CLAW_SHOOTER_LANE_FRAME);
assert_eq!(game.claw.target_frame, CLAW_SHOOTER_LANE_FRAME);
// A second ball arrives while the claw is already on its way.
game.balls.push(Ball {
position: vec2(160.0, 300.0),
in_launcher: false,
..Ball::default()
});
let mut events = Vec::new();
for _ in 0..80 {
game.timer_tick(CLAW_FRAME_SECONDS, 1, &mut events);
if events.contains(&Event::ClawRelease) {
break;
}
}
assert!(events.contains(&Event::ClawRelease));
assert!(CLAW_TABLE_TERMINAL_FRAMES.contains(&game.claw.frame));
assert!(!game.balls[0].in_launcher);
assert!(game.balls[0].position.x < 300.0);
}
#[test]
fn full_wheel_during_multiball_replaces_it_while_balls_play_on() {
let mut game = Game::new_with_seed(1, 7);
game.wheel_holes = [true, true, true, true, false];
game.record_contacts[129..=132].fill(99);
game.players[0].secondary_score = 150_000;
let mut events = Vec::new();
assert_eq!(
game.complete_lock_hole(4, 2, &mut events),
BallAction::Remove
);
assert_eq!(game.panel_frame, Some(0));
assert_eq!(game.player().secondary_score, 0);
assert_eq!(game.player().score_multiplier, 2);
// A ball heading for the wheel bounces off its outer rim.
let hub = vec2(124.0, 76.0);
game.balls[0] = Ball {
position: hub + vec2(52.0, 0.0),
velocity: MilliVec { x: -3_000, y: 0 }.to_velocity_per_second(),
in_launcher: false,
..Ball::default()
};
let mut hit_rim = false;
for _ in 0..4 {
game.fixed_update(&mut events);
hit_rim |= game.last_collision_id == Some(WHEEL_HUB_ID);
}
assert!(hit_rim);
assert!(game.balls[0].velocity.x > 0.0);
// The holes do not capture while the wheel is replaced.
game.balls[0] = Ball {
position: LOCK_HOLES[0].center,
in_launcher: false,
..Ball::default()
};
for _ in 0..20 {
game.fixed_update(&mut events);
}
assert!(
game.record_contacts[129..=133]
.iter()
.all(|contact| *contact != 2)
);
assert!(!game.balls[0].in_launcher);
// Balls keep moving during the animation, and it completes.
let mut panel = Game::new_with_seed(1, 7);
panel.panel_frame = Some(0);
panel.wheel_holes.fill(true);
panel.balls[0] = Ball {
position: vec2(160.0, 300.0),
in_launcher: false,
..Ball::default()
};
let start = panel.balls[0].position;
panel.timer_tick(0.03, 3, &mut events);
assert_ne!(panel.balls[0].position, start);
for _ in 0..300 {
panel.timer_tick(0.03, 3, &mut events);
}
assert_eq!(panel.panel_frame, None);
assert_eq!(panel.wheel_holes, [false; 5]);
}
#[test]
fn filled_or_closed_lock_hole_kicks_out_a_ball_resting_in_its_pocket() {
// Found by multiball stress runs: the bottom of hole 131's pocket,
// cradled between the wheel hub and post 137.
let pocket = vec2(140.85, 65.2);
let hub = vec2(124.0, 76.0);
for closed_by_panel in [false, true] {
let mut game = Game::new_with_seed(1, 7);
if closed_by_panel {
game.panel_frame = Some(0);
} else {
game.record_contacts[131] = 99;
game.wheel_holes[2] = true;
}
game.balls[0] = Ball {
position: pocket,
in_launcher: false,
..Ball::default()
};
for _ in 0..40 {
game.update(0.03, 3, Controls::default());
}
assert!(
game.balls[0].position.distance(hub) > WHEEL_RIM_CONTACT_RADIUS,
"ball still in the wheel at {:?}",
game.balls[0].position
);
}
}
#[test]
fn parked_ball_is_released_when_only_a_waiting_ball_remains() {
let mut game = Game::new_with_seed(1, 7);
game.multiball_state = MultiballState::Ready;
game.record_contacts[148] = 2;
game.tilted = true;
// Ball one waits in the launcher; ball two is the last one in play.
game.balls.push(Ball {
position: vec2(160.0, 300.0),
in_launcher: false,
..Ball::default()
});
let mut events = Vec::new();
assert_eq!(game.lose_ball(1, &mut events), BallStep::Removed);
assert_eq!(game.balls.len(), 2);
assert!(game.balls[0].in_launcher);
assert_eq!(game.balls[1].position, vec2(17.0, 23.0));
assert!(!game.balls[1].in_launcher);
assert_eq!(game.record_contacts[148], 0);
assert_eq!(game.multiball_state, MultiballState::Unavailable);
assert_eq!(game.special_hole_gate, SpecialHoleGate::Suppressed);
assert_eq!(game.player().balls, 3);
assert!(game.tilted);
// With another ball still in play the parked ball stays put.
let mut busy = Game::new_with_seed(1, 7);
busy.multiball_state = MultiballState::Ready;
busy.record_contacts[148] = 2;
for position in [vec2(160.0, 300.0), vec2(120.0, 160.0)] {
busy.balls.push(Ball {
position,
in_launcher: false,
..Ball::default()
});
}
busy.lose_ball(1, &mut events);
assert_eq!(busy.balls.len(), 2);
assert_eq!(busy.record_contacts[148], 2);
assert!(busy.special_hole_active());
}
#[test]
fn player_count_is_bounded() {
assert_eq!(Game::new(0).players.len(), 1);
@@ -2616,8 +3118,8 @@ mod tests {
fn ball_starts_in_the_recovered_shooter_lane() {
let game = Game::new(1);
assert_eq!(game.ball.position, Vec2::new(325.0, 413.0));
assert!(game.ball.in_launcher);
assert_eq!(game.balls[0].position, Vec2::new(325.0, 413.0));
assert!(game.balls[0].in_launcher);
}
#[test]
@@ -2630,9 +3132,9 @@ mod tests {
let mut game = Game::new(1);
game.object_active.fill(false);
game.object_active[usize::from(wall.id)] = true;
game.ball.in_launcher = false;
game.ball.position = midpoint - normal;
game.ball.velocity =
game.balls[0].in_launcher = false;
game.balls[0].position = midpoint - normal;
game.balls[0].velocity =
MilliVec::from_velocity_per_second(normal * 200.0).to_velocity_per_second();
game.fixed_update(&mut Vec::new());
if game.last_collision_id != Some(wall.id) {
@@ -2645,9 +3147,9 @@ mod tests {
let mut game = Game::new(1);
game.object_active.fill(false);
game.object_active[usize::from(circle.id)] = true;
game.ball.in_launcher = false;
game.ball.position = circle.center + vec2(circle.contact_radius + 1.0, 0.0);
game.ball.velocity = MilliVec { x: -2_000, y: 0 }.to_velocity_per_second();
game.balls[0].in_launcher = false;
game.balls[0].position = circle.center + vec2(circle.contact_radius + 1.0, 0.0);
game.balls[0].velocity = MilliVec { x: -2_000, y: 0 }.to_velocity_per_second();
game.fixed_update(&mut Vec::new());
if game.last_collision_id != Some(circle.id) {
missed_circles.push(circle.id);
@@ -2674,18 +3176,18 @@ mod tests {
(5, 0.010, 1),
] {
let mut game = Game::new(1);
game.ball.in_launcher = false;
game.ball.position = vec2(200.0, 250.0);
game.ball.velocity = Vec2::ZERO;
game.balls[0].in_launcher = false;
game.balls[0].position = vec2(200.0, 250.0);
game.balls[0].velocity = Vec2::ZERO;
game.object_active.fill(false);
game.update(interval - 0.001, detail, Controls::default());
assert_eq!(game.ball.position, vec2(200.0, 250.0));
assert_eq!(game.ball.velocity, Vec2::ZERO);
assert_eq!(game.balls[0].position, vec2(200.0, 250.0));
assert_eq!(game.balls[0].velocity, Vec2::ZERO);
game.update(0.001_1, detail, Controls::default());
let velocity = MilliVec::from_velocity_per_second(game.ball.velocity);
let position = MilliVec::from_position(game.ball.position);
let velocity = MilliVec::from_velocity_per_second(game.balls[0].velocity);
let position = MilliVec::from_position(game.balls[0].position);
assert_eq!(velocity.y, 15 * substeps);
assert_eq!(position.y, 250_000 + 15 * substeps * (substeps + 1) / 2);
}
@@ -2694,9 +3196,9 @@ mod tests {
#[test]
fn detail_batch_stops_after_the_first_collision_like_integrate_one_ball() {
let mut game = Game::new(1);
game.ball.in_launcher = false;
game.ball.position = vec2(270.0, 30.0);
game.ball.velocity = vec2(0.0, -380.0);
game.balls[0].in_launcher = false;
game.balls[0].position = vec2(270.0, 30.0);
game.balls[0].velocity = vec2(0.0, -380.0);
let mut expected = game.clone();
let mut expected_events = Vec::new();
let mut stopped = false;
@@ -2711,17 +3213,17 @@ mod tests {
let mut events = Vec::new();
game.timer_tick(0.050, 5, &mut events);
assert_eq!(game.ball.position, expected.ball.position);
assert_eq!(game.ball.velocity, expected.ball.velocity);
assert_eq!(game.balls[0].position, expected.balls[0].position);
assert_eq!(game.balls[0].velocity, expected.balls[0].velocity);
assert_eq!(events, expected_events);
}
#[test]
fn effect_seven_spawn_waits_until_the_next_timer_callback() {
let mut game = Game::new_with_seed(1, 7);
game.ball.in_launcher = false;
game.ball.position = EFFECT_SENSOR.center;
game.ball.velocity = Vec2::ZERO;
game.balls[0].in_launcher = false;
game.balls[0].position = EFFECT_SENSOR.center;
game.balls[0].velocity = Vec2::ZERO;
game.object_active.fill(false);
game.object_active[usize::from(EFFECT_SENSOR.id)] = true;
game.multiball_state = MultiballState::Ready;
@@ -2731,7 +3233,7 @@ mod tests {
game.timer_tick(0.050, 5, &mut Vec::new());
let spawned = game
.secondary_ball
.secondary()
.expect("the effect-seven request must create slot two after simulation");
assert_eq!(spawned.position, vec2(17.0, 23.0));
assert_eq!(
@@ -2744,30 +3246,30 @@ mod tests {
fn survivor_keeps_ball_two_identity_for_the_rest_of_the_callback() {
let lock = LOCK_HOLES[0];
let mut game = Game::new_with_seed(1, 7);
game.ball.in_launcher = false;
game.ball.position = lock.center;
game.ball.velocity = Vec2::ZERO;
game.ball.capture_age = 300;
game.balls[0].in_launcher = false;
game.balls[0].position = lock.center;
game.balls[0].velocity = Vec2::ZERO;
game.balls[0].capture_age = 300;
game.record_contacts[usize::from(lock.id)] = 1;
game.secondary_ball = Some(Ball {
game.set_secondary(Some(Ball {
position: EFFECT_SENSOR.center,
velocity: Vec2::ZERO,
in_launcher: false,
spin: Real48::ZERO,
capture_age: 0,
});
}));
game.object_active[usize::from(EFFECT_SENSOR.id)] = true;
game.multiball_state = MultiballState::Ready;
game.target_effect = 7;
game.timer_tick(0.050, 5, &mut Vec::new());
assert!(game.secondary_ball.is_none());
assert!(game.secondary().is_none());
assert_eq!(game.target_effect, 0);
assert!(!game.effect_target_active());
assert_eq!(game.multiball_state, MultiballState::Ready);
assert!(game.wheel_holes[0]);
assert!(game.ball.position.y > EFFECT_SENSOR.center.y);
assert!(game.balls[0].position.y > EFFECT_SENSOR.center.y);
}
#[test]
@@ -2782,14 +3284,14 @@ mod tests {
},
);
assert!(game.ball.in_launcher);
assert!(game.balls[0].in_launcher);
assert!(game.launcher_charge > 0.0);
let events = game.update(1.0 / 60.0, 3, Controls::default());
assert!(events.contains(&Event::Launch));
assert!(!game.ball.in_launcher);
assert!(game.ball.velocity.y < 0.0);
assert!(!game.balls[0].in_launcher);
assert!(game.balls[0].velocity.y < 0.0);
}
#[test]
@@ -2801,15 +3303,15 @@ mod tests {
};
quick.update(0.0, 3, held);
quick.update(0.0, 3, Controls::default());
let quick_speed = -quick.ball.velocity.y;
let quick_speed = -quick.balls[0].velocity.y;
assert_eq!(
MilliVec::from_velocity_per_second(quick.ball.velocity),
MilliVec::from_velocity_per_second(quick.balls[0].velocity),
MilliVec { x: 0, y: -2_270 }
);
let mut charged = Game::new_with_seed(1, 7);
launch_ball(&mut charged, 60);
let charged_speed = -charged.ball.velocity.y;
let charged_speed = -charged.balls[0].velocity.y;
assert!(charged_speed > quick_speed + 80.0);
}
@@ -2843,7 +3345,7 @@ mod tests {
launch_ball(&mut first, 60);
launch_ball(&mut second, 60);
assert_ne!(first.ball.velocity, second.ball.velocity);
assert_ne!(first.balls[0].velocity, second.balls[0].velocity);
}
#[test]
@@ -2853,21 +3355,21 @@ mod tests {
launch_ball(&mut game, 60);
let mut entered_table = false;
let mut returned_to_launcher = false;
let mut minimum_y = game.ball.position.y;
let mut minimum_y = game.balls[0].position.y;
for _ in 0..480 {
game.update(1.0 / 120.0, detail, Controls::default());
minimum_y = minimum_y.min(game.ball.position.y);
if game.ball.position.x < 280.0 && game.ball.position.y > 30.0 {
minimum_y = minimum_y.min(game.balls[0].position.y);
if game.balls[0].position.x < 280.0 && game.balls[0].position.y > 30.0 {
entered_table = true;
}
returned_to_launcher |= game.ball.in_launcher && !entered_table;
returned_to_launcher |= game.balls[0].in_launcher && !entered_table;
}
assert!(
entered_table,
"detail {detail}: the shooter curve should guide the ball onto the table; position={:?}, velocity={:?}, minimum_y={minimum_y}",
game.ball.position, game.ball.velocity
game.balls[0].position, game.balls[0].velocity
);
assert!(
!returned_to_launcher,
@@ -2879,20 +3381,54 @@ mod tests {
#[test]
fn shooter_stop_rearms_a_returning_ball() {
let mut game = Game::new(1);
game.ball.in_launcher = false;
game.ball.position = Vec2::new(325.0, 410.0);
game.ball.velocity = Vec2::new(0.0, 80.0);
game.balls[0].in_launcher = false;
game.balls[0].position = Vec2::new(325.0, 410.0);
game.balls[0].velocity = Vec2::new(0.0, 80.0);
for _ in 0..8 {
game.update(1.0 / 120.0, 5, Controls::default());
if game.ball.in_launcher {
if game.balls[0].in_launcher {
break;
}
}
assert!(game.ball.in_launcher);
assert_eq!(game.ball.position, LAUNCHER_POSITION);
assert_eq!(game.ball.velocity, Vec2::ZERO);
assert!(game.balls[0].in_launcher);
assert_eq!(game.balls[0].position, LAUNCHER_POSITION);
assert_eq!(game.balls[0].velocity, Vec2::ZERO);
}
#[test]
fn shooter_stop_rearms_a_returning_ball_from_any_slot() {
let returning = Ball {
position: Vec2::new(325.0, 410.0),
velocity: Vec2::new(0.0, 80.0),
in_launcher: false,
..Ball::default()
};
let mut game = Game::new_with_seed(1, 7);
game.balls = vec![
Ball {
position: vec2(170.0, 112.0),
in_launcher: false,
..Ball::default()
},
returning,
];
for _ in 0..8 {
game.update(1.0 / 120.0, 5, Controls::default());
}
assert_eq!(game.balls.len(), 2);
assert!(game.balls[1].in_launcher);
// With the launcher already occupied the ball leaves the table.
let mut occupied = Game::new_with_seed(1, 7);
occupied.balls = vec![Ball::default(), returning];
for _ in 0..8 {
occupied.update(1.0 / 120.0, 5, Controls::default());
}
assert_eq!(occupied.balls.len(), 1);
assert!(occupied.balls[0].in_launcher);
assert_eq!(occupied.player().balls, 3);
}
#[test]
@@ -2900,16 +3436,19 @@ mod tests {
let mut game = Game::new(1);
game.object_active.fill(false);
game.object_active[25] = true;
game.ball.in_launcher = false;
game.ball.position = Vec2::new(320.0, 412.0);
game.ball.velocity = MilliVec { x: 500, y: 1_000 }.to_velocity_per_second();
game.balls[0].in_launcher = false;
game.balls[0].position = Vec2::new(320.0, 412.0);
game.balls[0].velocity = MilliVec { x: 500, y: 1_000 }.to_velocity_per_second();
assert!(game.fixed_update(&mut Vec::new()));
assert!(!game.ball.in_launcher);
assert!(game.ball.velocity.y < 0.0);
assert_eq!(MilliVec::from_velocity_per_second(game.ball.velocity).x, 0);
assert_eq!(MilliVec::from_position(game.ball.position).x, 320_000);
assert!(!game.balls[0].in_launcher);
assert!(game.balls[0].velocity.y < 0.0);
assert_eq!(
MilliVec::from_velocity_per_second(game.balls[0].velocity).x,
0
);
assert_eq!(MilliVec::from_position(game.balls[0].position).x, 320_000);
}
#[test]
@@ -2918,11 +3457,11 @@ mod tests {
game.tilted = true;
game.tilt_counter = 39;
game.reset_ball_to_launcher();
game.reset_ball_to_launcher(0);
assert!(!game.tilted);
assert_eq!(game.tilt_counter, 0);
assert!(game.ball.in_launcher);
assert!(game.balls[0].in_launcher);
}
#[test]
@@ -2995,7 +3534,7 @@ mod tests {
special.record_contacts[148] = 2;
special.drain(&mut Vec::new());
assert_eq!(special.ball_double, BallDoubleState::Active);
assert_eq!(special.ball.position, vec2(17.0, 23.0));
assert_eq!(special.balls[0].position, vec2(17.0, 23.0));
}
#[test]
@@ -3018,10 +3557,10 @@ mod tests {
fn tilted_type_three_completion_finishes_or_special_respawns() {
let mut lock = Game::new(1);
lock.tilted = true;
lock.ball.in_launcher = false;
lock.ball.position = LOCK_HOLES[0].center;
lock.ball.velocity = Vec2::ZERO;
lock.ball.capture_age = 300;
lock.balls[0].in_launcher = false;
lock.balls[0].position = LOCK_HOLES[0].center;
lock.balls[0].velocity = Vec2::ZERO;
lock.balls[0].capture_age = 300;
let mut lock_events = Vec::new();
assert_eq!(
lock.check_sensor_objects(
@@ -3033,17 +3572,17 @@ mod tests {
);
assert_eq!(lock.player().balls, 2);
assert_eq!(lock.player().secondary_score, 10_000);
assert!(lock.ball.in_launcher);
assert!(lock.balls[0].in_launcher);
assert!(!lock.tilted);
assert!(lock_events.contains(&Event::Drain));
assert!(!lock_events.contains(&Event::Sound(2008)));
let mut special = Game::new(1);
special.tilted = true;
special.ball.in_launcher = false;
special.ball.position = SPECIAL_HOLE_SENSOR.center;
special.ball.velocity = Vec2::ZERO;
special.ball.capture_age = 300;
special.balls[0].in_launcher = false;
special.balls[0].position = SPECIAL_HOLE_SENSOR.center;
special.balls[0].velocity = Vec2::ZERO;
special.balls[0].capture_age = 300;
let balls = special.player().balls;
let mut special_events = Vec::new();
assert_eq!(
@@ -3055,7 +3594,7 @@ mod tests {
BallAction::Finish
);
assert_eq!(special.player().balls, balls);
assert_eq!(special.ball.position, vec2(17.0, 23.0));
assert_eq!(special.balls[0].position, vec2(17.0, 23.0));
assert!(special.tilted);
assert_eq!(special.multiball_state, MultiballState::Unavailable);
assert_eq!(special.record_contacts[148], 0);
@@ -3105,8 +3644,8 @@ mod tests {
assert!(game.effect_target_active());
game.target_effect = 3;
assert_eq!(game.player_effect(), 3);
game.ball.in_launcher = false;
game.ball.position = EFFECT_SENSOR.center;
game.balls[0].in_launcher = false;
game.balls[0].position = EFFECT_SENSOR.center;
game.check_sensor_objects(
MilliVec::from_position(EFFECT_SENSOR.center),
MilliVec::default(),
@@ -3169,7 +3708,7 @@ mod tests {
.into_iter()
.find(|sensor| sensor.id == 150)
.expect("record 150 must be present");
let mut target_ball = target_game.ball;
let mut target_ball = target_game.balls[0];
target_ball.position = sensor.center;
let mut target_events = Vec::new();
target_game.check_target_sensors(
@@ -3193,12 +3732,11 @@ mod tests {
let mut effect_game = Game::new_with_seed(1, 7);
effect_game.players[0].score = 139_500;
effect_game.begin_effect_scenario(1);
let mut effect_ball = effect_game.ball;
let mut effect_ball = effect_game.balls[0];
effect_ball.position = EFFECT_SENSOR.center;
let mut effect_events = Vec::new();
effect_game.check_effect_sensor(
&mut effect_ball,
1,
MilliVec::from_position(EFFECT_SENSOR.center),
MilliVec::default(),
&mut effect_events,
@@ -3238,8 +3776,7 @@ mod tests {
let (_, _, predicted) = game.scan_ordered_records_for_ball(
&mut ball,
1,
1,
BallPass::slot(1, 1),
old_position,
&mut velocity,
&mut events,
@@ -3317,15 +3854,15 @@ mod tests {
game.apply_wall_rule(84, &mut events);
assert_eq!(game.target_effect, 7);
game.ball.in_launcher = false;
game.ball.position = EFFECT_SENSOR.center;
game.balls[0].in_launcher = false;
game.balls[0].position = EFFECT_SENSOR.center;
game.check_sensor_objects(
MilliVec::from_position(EFFECT_SENSOR.center),
MilliVec::default(),
&mut events,
);
let spawned = game
.secondary_ball
.secondary()
.expect("effect seven should spawn a second ball");
assert_eq!(game.multiball_state, MultiballState::Active);
assert!(!game.special_hole_active());
@@ -3336,24 +3873,22 @@ mod tests {
);
game.advance_secondary_ball(&mut events);
let moving_ball = game
.secondary_ball
.expect("second ball should remain active");
let moving_ball = game.secondary().expect("second ball should remain active");
assert!(moving_ball.position.y > spawned.position.y);
let balls_before = game.player().balls;
game.drain(&mut events);
assert!(game.secondary_ball.is_none());
assert!(game.secondary().is_none());
assert_eq!(game.player().balls, balls_before);
assert_eq!(game.ball.position, moving_ball.position);
assert_eq!(game.balls[0].position, moving_ball.position);
events.clear();
game.drain(&mut events);
assert!(events.is_empty());
assert_eq!(game.player().balls, balls_before);
assert_eq!(game.ball.position, vec2(17.0, 23.0));
assert_eq!(game.balls[0].position, vec2(17.0, 23.0));
assert_eq!(
MilliVec::from_velocity_per_second(game.ball.velocity),
MilliVec::from_velocity_per_second(game.balls[0].velocity),
MilliVec { x: 0, y: 3_040 }
);
assert_eq!(game.multiball_state, MultiballState::Unavailable);
@@ -3362,22 +3897,22 @@ mod tests {
assert_eq!(game.special_hole_gate, SpecialHoleGate::Suppressed);
game.fixed_update(&mut events);
assert!(!game.ball.in_launcher);
assert!(!game.balls[0].in_launcher);
assert_eq!(
MilliVec::from_position(game.ball.position),
MilliVec::from_position(game.balls[0].position),
MilliVec {
x: 17_000,
y: 26_055,
}
);
assert_eq!(
MilliVec::from_velocity_per_second(game.ball.velocity),
MilliVec::from_velocity_per_second(game.balls[0].velocity),
MilliVec { x: 0, y: 3_055 }
);
assert_eq!(game.special_hole_gate, SpecialHoleGate::Suppressed);
game.ball.position = vec2(17.0, 31.0);
game.ball.velocity = Vec2::ZERO;
game.balls[0].position = vec2(17.0, 31.0);
game.balls[0].velocity = Vec2::ZERO;
game.fixed_update(&mut events);
assert_eq!(game.special_hole_gate, SpecialHoleGate::Enabled);
}
@@ -3392,14 +3927,14 @@ mod tests {
assert_eq!(game.target_effect, 7);
assert!(game.special_hole_active());
game.ball.in_launcher = false;
game.ball.position = EFFECT_SENSOR.center;
game.balls[0].in_launcher = false;
game.balls[0].position = EFFECT_SENSOR.center;
game.check_sensor_objects(
MilliVec::from_position(EFFECT_SENSOR.center),
MilliVec::default(),
&mut events,
);
assert!(game.secondary_ball.is_some());
assert!(game.secondary().is_some());
assert_eq!(game.target_effect, 0);
assert!(!game.special_hole_active());
@@ -3408,9 +3943,9 @@ mod tests {
}
assert_eq!(game.record_contacts[148], 0);
game.drain(&mut events);
assert!(game.secondary_ball.is_none());
assert!(game.secondary().is_none());
game.drain(&mut events);
assert!(game.ball.in_launcher);
assert!(game.balls[0].in_launcher);
assert_eq!(game.player().balls, 2);
assert_eq!(game.multiball_state, MultiballState::Unavailable);
@@ -3421,13 +3956,13 @@ mod tests {
game.target_effect = 7;
game.trigger_flags[usize::from(EFFECT_SENSOR.id)] = false;
game.ball.position = EFFECT_SENSOR.center;
game.balls[0].position = EFFECT_SENSOR.center;
game.check_sensor_objects(
MilliVec::from_position(EFFECT_SENSOR.center),
MilliVec::default(),
&mut events,
);
assert!(game.secondary_ball.is_none());
assert!(game.secondary().is_none());
}
#[test]
@@ -3435,21 +3970,21 @@ mod tests {
let mut game = Game::new(1);
game.multiball_state = MultiballState::Active;
game.record_contacts[148] = 2;
game.ball.in_launcher = false;
game.ball.position = vec2(157.0, 453.0);
game.ball.velocity = MilliVec { x: 0, y: 3_000 }.to_velocity_per_second();
game.balls[0].in_launcher = false;
game.balls[0].position = vec2(157.0, 453.0);
game.balls[0].velocity = MilliVec { x: 0, y: 3_000 }.to_velocity_per_second();
assert!(game.fixed_update(&mut Vec::new()));
assert_eq!(
MilliVec::from_position(game.ball.position),
MilliVec::from_position(game.balls[0].position),
MilliVec {
x: 16_698,
y: 21_191,
}
);
assert_eq!(
MilliVec::from_velocity_per_second(game.ball.velocity),
MilliVec::from_velocity_per_second(game.balls[0].velocity),
MilliVec { x: -302, y: -1_809 }
);
assert_eq!(game.multiball_state, MultiballState::Unavailable);
@@ -3468,7 +4003,7 @@ mod tests {
assert!(events.is_empty());
assert_eq!(game.player().balls, balls);
assert_eq!(game.ball.position, vec2(17.0, 23.0));
assert_eq!(game.balls[0].position, vec2(17.0, 23.0));
assert_eq!(game.multiball_state, MultiballState::Unavailable);
assert_eq!(game.record_contacts[148], 0);
assert_eq!(game.special_hole_gate, SpecialHoleGate::Suppressed);
@@ -3481,7 +4016,7 @@ mod tests {
.find(|sensor| sensor.id == 150)
.expect("record 150 must be present");
let mut game = Game::new_with_seed(1, 7);
let primary_velocity = game.ball.velocity;
let primary_velocity = game.balls[0].velocity;
let mut second = Ball {
position: sensor.center,
velocity: vec2(12.0, -34.0),
@@ -3500,8 +4035,7 @@ mod tests {
assert_eq!(
game.check_sensor_objects_for_ball(
&mut second,
2,
2,
BallPass::slot(2, 2),
old_position,
movement,
&mut events,
@@ -3513,7 +4047,7 @@ mod tests {
assert!(game.top_targets[0]);
assert!(game.magnetic_field_active(153));
assert_eq!(game.player().score, sensor.score);
assert_eq!(game.ball.velocity, primary_velocity);
assert_eq!(game.balls[0].velocity, primary_velocity);
assert_ne!(second.velocity, vec2(12.0, -34.0));
}
@@ -3535,8 +4069,7 @@ mod tests {
assert_eq!(
game.check_sensor_objects_for_ball(
&mut second,
2,
2,
BallPass::slot(2, 2),
MilliVec::from_position(EFFECT_SENSOR.center),
MilliVec::default(),
&mut events,
@@ -3545,7 +4078,7 @@ mod tests {
BallAction::Keep
);
assert!(game.secondary_ball.is_none());
assert!(game.secondary().is_none());
assert_eq!(game.multiball_state, MultiballState::Ready);
assert_eq!(game.target_effect, 0);
assert!(!game.effect_target_active());
@@ -3564,15 +4097,14 @@ mod tests {
spin: Real48::ZERO,
capture_age: 0,
};
game.secondary_ball = Some(secondary);
let mut primary = game.ball;
game.set_secondary(Some(secondary));
let mut primary = game.balls[0];
let mut events = Vec::new();
assert_eq!(
game.check_sensor_objects_for_ball(
&mut primary,
1,
2,
BallPass::slot(1, 2),
MilliVec::from_position(EFFECT_SENSOR.center),
MilliVec::default(),
&mut events,
@@ -3582,7 +4114,7 @@ mod tests {
);
assert_eq!(
game.secondary_ball.map(|ball| ball.position),
game.secondary().map(|ball| ball.position),
Some(secondary.position)
);
assert_eq!(game.multiball_state, MultiballState::Active);
@@ -3593,23 +4125,23 @@ mod tests {
fn multiball_capture_age_is_per_slot_and_removes_only_ball_two() {
let sensor = LOCK_HOLES[0];
let mut game = Game::new(1);
game.ball.capture_age = 17;
game.balls[0].capture_age = 17;
game.record_contacts[usize::from(sensor.id)] = 2;
game.secondary_ball = Some(Ball {
game.set_secondary(Some(Ball {
position: sensor.center,
velocity: Vec2::ZERO,
in_launcher: false,
spin: Real48::ZERO,
capture_age: 300,
});
}));
game.score_mode = ScoreMode::Multiball;
let mut events = Vec::new();
game.advance_secondary_ball(&mut events);
assert!(game.secondary_ball.is_none());
assert!(game.secondary().is_none());
assert_eq!(game.score_mode, ScoreMode::Multiball);
assert_eq!(game.ball.capture_age, 17);
assert_eq!(game.balls[0].capture_age, 17);
assert_eq!(game.record_contacts[usize::from(sensor.id)], 99);
assert!(game.wheel_holes[0]);
assert!(events.contains(&Event::Lock));
@@ -3622,18 +4154,18 @@ mod tests {
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.balls[0].in_launcher = false;
game.balls[0].position = sensor.center;
game.balls[0].velocity = Vec2::ZERO;
game.balls[0].capture_age = 300;
game.record_contacts[usize::from(sensor.id)] = 1;
game.secondary_ball = Some(Ball {
game.set_secondary(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;
@@ -3641,7 +4173,7 @@ mod tests {
game.timer_tick(0.01, 1, &mut events);
assert!(game.secondary_ball.is_none());
assert!(game.secondary().is_none());
assert_eq!(game.player().score, 1_000);
assert_eq!(game.player().secondary_score, 20_000);
assert_eq!(game.score_mode, ScoreMode::Multiball);
@@ -3659,7 +4191,7 @@ mod tests {
game.claw.bank = ClawSpriteBank::Opening;
game.claw.frame_accumulator = 0.0;
game.claw.ball_suspended = true;
game.claw.captured_slot = Some(ClawBallSlot::Primary);
game.claw.captured_slot = Some(0);
game.score_mode = ScoreMode::Multiball;
let mut events = Vec::new();
@@ -3671,10 +4203,10 @@ mod tests {
fn capturing_ball_one_during_multiball_promotes_ball_two() {
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.balls[0].in_launcher = false;
game.balls[0].position = sensor.center;
game.balls[0].velocity = Vec2::ZERO;
game.balls[0].capture_age = 300;
game.record_contacts[usize::from(sensor.id)] = 1;
let survivor = Ball {
position: vec2(210.0, 240.0),
@@ -3683,7 +4215,7 @@ mod tests {
spin: Real48::ZERO,
capture_age: 23,
};
game.secondary_ball = Some(survivor);
game.set_secondary(Some(survivor));
let mut events = Vec::new();
game.check_sensor_objects(
@@ -3692,10 +4224,10 @@ mod tests {
&mut events,
);
assert!(game.secondary_ball.is_none());
assert_eq!(game.ball.position, survivor.position);
assert_eq!(game.ball.velocity, survivor.velocity);
assert_eq!(game.ball.capture_age, survivor.capture_age);
assert!(game.secondary().is_none());
assert_eq!(game.balls[0].position, survivor.position);
assert_eq!(game.balls[0].velocity, survivor.velocity);
assert_eq!(game.balls[0].capture_age, survivor.capture_age);
assert_eq!(game.record_contacts[usize::from(sensor.id)], 99);
assert!(game.wheel_holes[0]);
}
@@ -3704,10 +4236,10 @@ mod tests {
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.balls[0].in_launcher = false;
game.balls[0].position = sensor.center;
game.balls[0].velocity = Vec2::ZERO;
game.balls[0].capture_age = 300;
game.record_contacts[usize::from(sensor.id)] = 99;
game.wheel_holes[0] = true;
let mut events = Vec::new();
@@ -3722,21 +4254,21 @@ mod tests {
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);
assert!(!game.balls[0].in_launcher);
}
#[test]
fn center_drain_advances_to_a_fresh_ball() {
let mut game = Game::new(1);
game.ball.in_launcher = false;
game.ball.position = vec2(157.0, 450.0);
game.ball.velocity = vec2(0.0, 300.0);
game.balls[0].in_launcher = false;
game.balls[0].position = vec2(157.0, 450.0);
game.balls[0].velocity = vec2(0.0, 300.0);
let events = game.update(1.0 / 30.0, 5, Controls::default());
assert!(events.contains(&Event::Drain));
assert_eq!(game.players[0].balls, 2);
assert!(game.ball.in_launcher);
assert!(game.balls[0].in_launcher);
}
#[test]
@@ -3749,14 +4281,14 @@ mod tests {
] {
let mut game = Game::new(1);
game.object_active.fill(false);
game.ball.in_launcher = false;
game.ball.position = position;
game.ball.velocity = velocity.to_velocity_per_second();
game.balls[0].in_launcher = false;
game.balls[0].position = position;
game.balls[0].velocity = velocity.to_velocity_per_second();
let mut events = Vec::new();
assert!(game.fixed_update(&mut events));
assert!(game.ball.in_launcher);
assert!(game.balls[0].in_launcher);
assert_eq!(game.player().balls, 2);
assert!(events.contains(&Event::Drain));
}
@@ -3766,8 +4298,8 @@ mod tests {
fn prediction_bounds_remove_only_the_escaping_multiball_slot() {
let mut game = Game::new(1);
game.object_active.fill(false);
game.ball.in_launcher = false;
game.ball.position = vec2(200.0, 200.0);
game.balls[0].in_launcher = false;
game.balls[0].position = vec2(200.0, 200.0);
let second = Ball {
position: vec2(1.0, 200.0),
velocity: MilliVec { x: -2_000, y: 0 }.to_velocity_per_second(),
@@ -3775,12 +4307,12 @@ mod tests {
spin: Real48::ZERO,
capture_age: 0,
};
game.secondary_ball = Some(second);
game.set_secondary(Some(second));
game.score_mode = ScoreMode::Multiball;
assert!(game.advance_secondary_ball(&mut Vec::new()));
assert!(game.secondary_ball.is_none());
assert!(game.secondary().is_none());
assert_eq!(game.player().balls, 3);
assert_eq!(game.score_mode, ScoreMode::Normal);
}
@@ -3826,7 +4358,10 @@ mod tests {
let mut events = Vec::new();
game.drain(&mut events);
assert!(events.contains(&Event::HighScoreCandidate(123_456)));
assert!(events.contains(&Event::HighScoreCandidate {
score: 123_456,
crazy: false
}));
assert_eq!(game.current_player, 1);
assert!(!game.finished);
@@ -3834,58 +4369,64 @@ mod tests {
game.players[1].balls = 1;
game.players[1].score = 654_321;
game.drain(&mut events);
assert!(events.contains(&Event::HighScoreCandidate(654_321)));
assert!(events.contains(&Event::HighScoreCandidate {
score: 654_321,
crazy: false
}));
assert!(game.finished);
}
#[test]
fn fast_ball_cannot_tunnel_through_the_top_rail() {
let mut game = Game::new(1);
game.ball.in_launcher = false;
game.ball.position = vec2(270.0, 30.0);
game.ball.velocity = vec2(0.0, -380.0);
game.balls[0].in_launcher = false;
game.balls[0].position = vec2(270.0, 30.0);
game.balls[0].velocity = vec2(0.0, -380.0);
for _ in 0..5 {
game.fixed_update(&mut Vec::new());
}
assert!(game.ball.position.y >= 15.0);
assert!(game.ball.velocity.y > 0.0);
assert!(game.balls[0].position.y >= 15.0);
assert!(game.balls[0].velocity.y > 0.0);
}
#[test]
fn relative_record_57_matches_live_two_substep_transition() {
let mut game = Game::new(1);
game.ball.in_launcher = false;
game.ball.position = vec2(72.530, 354.750);
game.ball.velocity = MilliVec { x: 3_000, y: 0 }.to_velocity_per_second();
game.balls[0].in_launcher = false;
game.balls[0].position = vec2(72.530, 354.750);
game.balls[0].velocity = MilliVec { x: 3_000, y: 0 }.to_velocity_per_second();
let mut events = Vec::new();
assert!(!game.fixed_update(&mut events));
assert_eq!(
MilliVec::from_position(game.ball.position),
MilliVec::from_position(game.balls[0].position),
MilliVec {
x: 75_530,
y: 354_765,
}
);
assert_eq!(
MilliVec::from_velocity_per_second(game.ball.velocity),
MilliVec::from_velocity_per_second(game.balls[0].velocity),
MilliVec { x: 3_000, y: 15 }
);
assert!(game.fixed_update(&mut events));
assert_eq!(game.last_collision_id, Some(57));
assert_eq!(game.ball.spin.bytes(), [0x83, 0x5d, 0x8f, 0xc2, 0xf5, 0xa8]);
assert_eq!(
MilliVec::from_position(game.ball.position),
game.balls[0].spin.bytes(),
[0x83, 0x5d, 0x8f, 0xc2, 0xf5, 0xa8]
);
assert_eq!(
MilliVec::from_position(game.balls[0].position),
MilliVec {
x: 77_369,
y: 356_597,
}
);
assert_eq!(
MilliVec::from_velocity_per_second(game.ball.velocity),
MilliVec::from_velocity_per_second(game.balls[0].velocity),
MilliVec { x: 1_839, y: 1_832 }
);
}
@@ -3893,36 +4434,39 @@ mod tests {
#[test]
fn relative_record_72_matches_live_two_substep_transition() {
let mut game = Game::new(1);
game.ball.in_launcher = false;
game.ball.position = vec2(238.348, 356.810);
game.ball.velocity = MilliVec { x: -3_000, y: 0 }.to_velocity_per_second();
game.balls[0].in_launcher = false;
game.balls[0].position = vec2(238.348, 356.810);
game.balls[0].velocity = MilliVec { x: -3_000, y: 0 }.to_velocity_per_second();
let mut events = Vec::new();
assert!(!game.fixed_update(&mut events));
assert_eq!(
MilliVec::from_position(game.ball.position),
MilliVec::from_position(game.balls[0].position),
MilliVec {
x: 235_348,
y: 356_825,
}
);
assert_eq!(
MilliVec::from_velocity_per_second(game.ball.velocity),
MilliVec::from_velocity_per_second(game.balls[0].velocity),
MilliVec { x: -3_000, y: 15 }
);
assert!(game.fixed_update(&mut events));
assert_eq!(game.last_collision_id, Some(72));
assert_eq!(game.ball.spin.bytes(), [0x83, 0x86, 0xeb, 0x51, 0xb8, 0x2e]);
assert_eq!(
MilliVec::from_position(game.ball.position),
game.balls[0].spin.bytes(),
[0x83, 0x86, 0xeb, 0x51, 0xb8, 0x2e]
);
assert_eq!(
MilliVec::from_position(game.balls[0].position),
MilliVec {
x: 233_253,
y: 358_494,
}
);
assert_eq!(
MilliVec::from_velocity_per_second(game.ball.velocity),
MilliVec::from_velocity_per_second(game.balls[0].velocity),
MilliVec {
x: -2_095,
y: 1_669
@@ -3933,10 +4477,10 @@ mod tests {
#[test]
fn retained_collision_candidate_clears_the_slot_capture_age() {
let mut game = Game::new(1);
game.ball.in_launcher = false;
game.ball.position = vec2(270.0, 30.0);
game.ball.velocity = vec2(0.0, -380.0);
game.ball.capture_age = 42;
game.balls[0].in_launcher = false;
game.balls[0].position = vec2(270.0, 30.0);
game.balls[0].velocity = vec2(0.0, -380.0);
game.balls[0].capture_age = 42;
for _ in 0..5 {
if game.fixed_update(&mut Vec::new()) {
@@ -3944,7 +4488,7 @@ mod tests {
}
}
assert_eq!(game.ball.capture_age, 0);
assert_eq!(game.balls[0].capture_age, 0);
}
#[test]
@@ -4029,9 +4573,9 @@ mod tests {
weak.object_active.fill(false);
weak.object_active[51] = true;
weak.players[0].bumper_value = 2_000;
weak.ball.in_launcher = false;
weak.ball.position = vec2(165.0, 171.1);
weak.ball.velocity = vec2(0.0, -20.0);
weak.balls[0].in_launcher = false;
weak.balls[0].position = vec2(165.0, 171.1);
weak.balls[0].velocity = vec2(0.0, -20.0);
let mut weak_events = Vec::new();
assert!(weak.fixed_update(&mut weak_events));
@@ -4039,9 +4583,9 @@ mod tests {
fast.object_active.fill(false);
fast.object_active[51] = true;
fast.players[0].bumper_value = 2_000;
fast.ball.in_launcher = false;
fast.ball.position = vec2(165.0, 172.015);
fast.ball.velocity = vec2(0.0, -198.5);
fast.balls[0].in_launcher = false;
fast.balls[0].position = vec2(165.0, 172.015);
fast.balls[0].velocity = vec2(0.0, -198.5);
let mut tilted = fast.clone();
tilted.tilted = true;
let mut fast_events = Vec::new();
@@ -4055,11 +4599,11 @@ mod tests {
assert!(fast_events.contains(&Event::Sound(2006)));
assert_eq!(weak.record_countdown(51), 5);
assert_eq!(fast.record_countdown(51), 5);
assert!(fast.ball.velocity.y > weak.ball.velocity.y);
assert!(fast.balls[0].velocity.y > weak.balls[0].velocity.y);
assert_eq!(tilted.player().score, 0);
assert_eq!(tilted.record_countdown(51), 0);
assert!(!tilted_events.contains(&Event::Sound(2006)));
assert!(fast.ball.velocity.y > tilted.ball.velocity.y);
assert!(fast.balls[0].velocity.y > tilted.balls[0].velocity.y);
}
#[test]
@@ -4083,9 +4627,9 @@ mod tests {
game.flippers.left_raised = true;
game.flippers.right_raised = true;
let score = game.player().score;
game.ball.in_launcher = false;
game.ball.position = vec2(201.0, 285.0);
game.ball.velocity = Vec2::ZERO;
game.balls[0].in_launcher = false;
game.balls[0].position = vec2(201.0, 285.0);
game.balls[0].velocity = Vec2::ZERO;
game.update(
1.0 / 30.0,
5,
@@ -4270,14 +4814,14 @@ mod tests {
#[test]
fn raising_flipper_uses_the_reconstructed_moving_response() {
let mut game = Game::new(1);
game.ball.in_launcher = false;
game.ball.position = vec2(125.0, 390.0);
game.ball.velocity = Vec2::ZERO;
game.balls[0].in_launcher = false;
game.balls[0].position = vec2(125.0, 390.0);
game.balls[0].velocity = Vec2::ZERO;
game.pending_flipper_edges[0] = -1;
game.apply_flipper_kicks();
let velocity_after_edge = game.ball.velocity;
assert_eq!(game.ball.position, vec2(138.124, 355.0));
let velocity_after_edge = game.balls[0].velocity;
assert_eq!(game.balls[0].position, vec2(138.124, 355.0));
let raw_velocity = MilliVec::from_velocity_per_second(velocity_after_edge);
assert_eq!(
raw_velocity,
@@ -4290,7 +4834,7 @@ mod tests {
game.apply_flipper_kicks();
assert_eq!(
game.ball.velocity, velocity_after_edge,
game.balls[0].velocity, velocity_after_edge,
"holding a raised flipper must not add another impulse"
);
}
@@ -4299,16 +4843,16 @@ mod tests {
fn returning_flipper_uses_the_raised_record_geometry() {
let mut game = Game::new(1);
game.flippers.left_raised = true;
game.ball.in_launcher = false;
game.ball.position = vec2(110.0, 410.0);
game.ball.velocity = vec2(100.0, 200.0);
game.balls[0].in_launcher = false;
game.balls[0].position = vec2(110.0, 410.0);
game.balls[0].velocity = vec2(100.0, 200.0);
let mut events = Vec::new();
game.timer_tick(0.030, 0, &mut events);
assert_eq!(events, [Event::FlipperMove, Event::Sound(2021)]);
let raw_velocity = MilliVec::from_velocity_per_second(game.ball.velocity);
let raw_velocity = MilliVec::from_velocity_per_second(game.balls[0].velocity);
assert_eq!(raw_velocity, MilliVec { x: 765, y: 3_997 });
assert_eq!(game.ball.position, vec2(110.380, 411.984));
assert_eq!(game.balls[0].position, vec2(110.380, 411.984));
assert_eq!(game.pending_flipper_edges, [0, 0]);
}
@@ -4326,7 +4870,7 @@ mod tests {
#[test]
fn nudge_and_tilt_emit_the_original_ordered_sound_sequence() {
let mut game = Game::new_with_seed(1, 7);
game.ball.in_launcher = false;
game.balls[0].in_launcher = false;
let controls = Controls {
nudge: Nudge::Right,
..Controls::default()
@@ -4337,7 +4881,7 @@ mod tests {
[Event::Sound(2019), Event::Nudge]
);
assert_eq!(
MilliVec::from_velocity_per_second(game.ball.velocity),
MilliVec::from_velocity_per_second(game.balls[0].velocity),
MilliVec { x: 690, y: 0 }
);
assert_eq!(game.tilt_counter, 25);
@@ -4362,7 +4906,7 @@ mod tests {
);
assert_eq!(events, [Event::Sound(2019), Event::Nudge]);
assert_eq!(
MilliVec::from_velocity_per_second(game.ball.velocity),
MilliVec::from_velocity_per_second(game.balls[0].velocity),
MilliVec { x: -84, y: -660 }
);
assert_eq!(game.tilt_counter, 25);
@@ -4395,15 +4939,15 @@ mod tests {
#[test]
fn multiball_nudge_consumes_active_draws_then_updates_both_saved_slots() {
let mut game = Game::new_with_seed(1, 7);
game.ball.in_launcher = false;
game.ball.velocity = MilliVec { x: 100, y: 200 }.to_velocity_per_second();
game.secondary_ball = Some(Ball {
game.balls[0].in_launcher = false;
game.balls[0].velocity = MilliVec { x: 100, y: 200 }.to_velocity_per_second();
game.set_secondary(Some(Ball {
position: vec2(200.0, 200.0),
velocity: MilliVec { x: -300, y: 400 }.to_velocity_per_second(),
in_launcher: false,
spin: Real48::ZERO,
capture_age: 0,
});
}));
let mut reference = BorlandRandom::new(7);
let _discarded_active_x = reference.real48();
let _discarded_active_y = reference.below(20);
@@ -4416,13 +4960,13 @@ mod tests {
game.apply_nudge(Nudge::Center, &mut Vec::new());
assert_eq!(
MilliVec::from_velocity_per_second(game.ball.velocity),
MilliVec::from_velocity_per_second(game.balls[0].velocity),
MilliVec {
x: 100 + shared_impulse.x,
y: 200 + shared_impulse.y,
}
);
let secondary = game.secondary_ball.expect("slot two must remain active");
let secondary = game.secondary().expect("slot two must remain active");
assert_eq!(
MilliVec::from_velocity_per_second(secondary.velocity),
MilliVec {
@@ -4476,10 +5020,10 @@ mod tests {
#[test]
fn claw_capture_holds_releases_and_returns_in_original_frame_order() {
let mut game = Game::new(1);
game.ball.in_launcher = false;
game.ball.position = CLAW_TRIGGER_CENTER;
game.ball.velocity = vec2(120.0, -40.0);
let held_position = game.ball.position;
game.balls[0].in_launcher = false;
game.balls[0].position = CLAW_TRIGGER_CENTER;
game.balls[0].velocity = vec2(120.0, -40.0);
let held_position = game.balls[0].position;
let mut events = Vec::new();
game.begin_claw_capture(6, &mut events);
@@ -4487,10 +5031,10 @@ mod tests {
assert_eq!(game.claw.frame, 10);
assert_eq!(game.claw.bank, ClawSpriteBank::Closing);
assert!(game.claw.ball_suspended);
assert_eq!(game.ball.velocity, Vec2::ZERO);
assert_eq!(game.balls[0].velocity, Vec2::ZERO);
game.timer_tick(CLAW_FRAME_SECONDS / 3.0, 0, &mut events);
assert_eq!(game.ball.position, held_position);
assert_eq!(game.balls[0].position, held_position);
assert_eq!(game.claw.frame, 10);
for expected_frame in [9, 8, 7, 6] {
@@ -4504,9 +5048,9 @@ mod tests {
assert_eq!(game.claw.frame, 6);
assert_eq!(game.claw.bank, ClawSpriteBank::Opening);
assert!(!game.claw.ball_suspended);
assert_eq!(game.ball.position, vec2(270.0, 94.0));
assert!((game.ball.velocity.x - -152.0).abs() < 0.001);
assert!((game.ball.velocity.y - 228.0).abs() < 0.001);
assert_eq!(game.balls[0].position, vec2(270.0, 94.0));
assert!((game.balls[0].velocity.x - -152.0).abs() < 0.001);
assert!((game.balls[0].velocity.y - 228.0).abs() < 0.001);
for expected_frame in [7, 8, 9, 10] {
game.update_claw(CLAW_FRAME_SECONDS, &mut events);
@@ -4521,16 +5065,16 @@ mod tests {
#[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 {
game.balls[0].in_launcher = false;
game.balls[0].position = CLAW_TRIGGER_CENTER;
game.balls[0].velocity = Vec2::ZERO;
game.set_secondary(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);
@@ -4540,22 +5084,22 @@ mod tests {
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.claw.capture_entry, Some((0, 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!(game.ball_suspended(0));
assert!(!game.ball_suspended(1));
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;
let survivor_before = game.secondary().expect("survivor must remain").position;
game.timer_tick(CLAW_FRAME_SECONDS, 1, &mut events);
assert_ne!(
game.secondary_ball.expect("survivor must remain").position,
game.secondary().expect("survivor must remain").position,
survivor_before
);
@@ -4563,15 +5107,15 @@ mod tests {
game.update_claw(CLAW_FRAME_SECONDS, &mut events);
}
assert!(events.contains(&Event::ClawRelease));
assert!(!game.primary_ball_suspended());
assert!(game.secondary_ball.is_some());
assert!(!game.ball_suspended(0));
assert!(game.secondary().is_some());
game.ball.position = CLAW_TRIGGER_CENTER + vec2(20.0, 0.0);
game.ball.velocity = Vec2::ZERO;
game.ball.capture_age = 0;
game.balls[0].position = CLAW_TRIGGER_CENTER + vec2(20.0, 0.0);
game.balls[0].velocity = Vec2::ZERO;
game.balls[0].capture_age = 0;
assert_eq!(
game.check_sensor_objects(
MilliVec::from_position(game.ball.position),
MilliVec::from_position(game.balls[0].position),
MilliVec::default(),
&mut events,
),
@@ -4583,16 +5127,16 @@ mod tests {
#[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 {
game.balls[0].in_launcher = false;
game.balls[0].position = CLAW_TRIGGER_CENTER;
game.balls[0].velocity = vec2(100.0, 0.0);
game.set_secondary(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);
@@ -4602,40 +5146,40 @@ mod tests {
game.check_sensor_objects(claw_position, MilliVec::default(), &mut events),
BallAction::Keep
);
game.secondary_ball = None;
game.set_secondary(None);
game.score_mode = ScoreMode::Normal;
game.ball.velocity = vec2(100.0, 0.0);
game.balls[0].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)));
assert_eq!(game.claw.capture_entry, Some((0, true)));
game.ball.velocity = Vec2::ZERO;
game.ball.capture_age = 300;
game.balls[0].velocity = Vec2::ZERO;
game.balls[0].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_eq!(game.claw.capture_entry, Some((0, 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 {
game.balls[0].in_launcher = false;
game.balls[0].position = CLAW_TRIGGER_CENTER;
game.balls[0].velocity = vec2(100.0, 0.0);
game.set_secondary(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();
@@ -4648,15 +5192,14 @@ mod tests {
),
BallAction::Keep
);
assert_eq!(game.claw.capture_entry, Some((ClawBallSlot::Primary, true)));
assert_eq!(game.claw.capture_entry, Some((0, true)));
let mut secondary = game.secondary_ball.take().expect("secondary ball");
let mut secondary = game.take_secondary().expect("secondary ball");
let secondary_position = MilliVec::from_position(secondary.position);
assert_eq!(
game.check_sensor_objects_for_ball(
&mut secondary,
2,
2,
BallPass::slot(2, 2),
secondary_position,
MilliVec::default(),
&mut events,
@@ -4664,23 +5207,23 @@ mod tests {
.action,
BallAction::Keep
);
game.secondary_ball = Some(secondary);
assert_eq!(game.claw.capture_entry, Some((ClawBallSlot::Primary, true)));
game.set_secondary(Some(secondary));
assert_eq!(game.claw.capture_entry, Some((0, 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 {
game.balls[0].in_launcher = false;
game.balls[0].position = CLAW_TRIGGER_CENTER;
game.balls[0].velocity = vec2(100.0, 0.0);
game.set_secondary(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();
@@ -4693,13 +5236,13 @@ mod tests {
),
BallAction::Keep
);
assert_eq!(game.claw.capture_entry, Some((ClawBallSlot::Primary, true)));
assert_eq!(game.claw.capture_entry, Some((0, true)));
game.ball.position = CLAW_TRIGGER_CENTER + vec2(20.0, 0.0);
game.ball.velocity = Vec2::ZERO;
game.balls[0].position = CLAW_TRIGGER_CENTER + vec2(20.0, 0.0);
game.balls[0].velocity = Vec2::ZERO;
assert_eq!(
game.check_sensor_objects(
MilliVec::from_position(game.ball.position),
MilliVec::from_position(game.balls[0].position),
MilliVec::default(),
&mut events,
),
@@ -4707,10 +5250,10 @@ mod tests {
);
assert_eq!(game.claw.capture_entry, None);
game.secondary_ball = None;
game.set_secondary(None);
game.score_mode = ScoreMode::Normal;
game.ball.position = CLAW_TRIGGER_CENTER;
game.ball.velocity = vec2(100.0, 0.0);
game.balls[0].position = CLAW_TRIGGER_CENTER;
game.balls[0].velocity = vec2(100.0, 0.0);
assert_eq!(
game.check_sensor_objects(
MilliVec::from_position(CLAW_TRIGGER_CENTER),
@@ -4719,10 +5262,7 @@ mod tests {
),
BallAction::Keep
);
assert_eq!(
game.claw.capture_entry,
Some((ClawBallSlot::Primary, false))
);
assert_eq!(game.claw.capture_entry, Some((0, false)));
}
#[test]
@@ -4754,7 +5294,7 @@ mod tests {
for first_speed in speeds {
for second_speed in speeds {
let mut game = Game::new_with_seed(1, 7);
game.ball = Ball {
game.balls[0] = Ball {
position: first_position,
velocity: (capture_center - first_position).normalize()
* first_speed,
@@ -4762,14 +5302,14 @@ mod tests {
spin: Real48::ZERO,
capture_age: 0,
};
game.secondary_ball = Some(Ball {
game.set_secondary(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();
@@ -4788,7 +5328,7 @@ mod tests {
);
break;
}
if game.secondary_ball.is_none() || game.finished {
if game.secondary().is_none() || game.finished {
break;
}
}
@@ -4805,27 +5345,26 @@ mod tests {
#[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 {
game.balls[0].in_launcher = false;
game.balls[0].position = vec2(210.0, 240.0);
game.balls[0].velocity = Vec2::ZERO;
game.set_secondary(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");
let mut secondary = game.take_secondary().expect("secondary ball");
assert_eq!(
game.check_sensor_objects_for_ball(
&mut secondary,
2,
2,
BallPass::slot(2, 2),
claw_position,
MilliVec::default(),
&mut events,
@@ -4833,13 +5372,12 @@ mod tests {
.action,
BallAction::Keep
);
game.secondary_ball = Some(secondary);
let mut secondary = game.secondary_ball.take().expect("secondary ball");
game.set_secondary(Some(secondary));
let mut secondary = game.take_secondary().expect("secondary ball");
assert_eq!(
game.check_sensor_objects_for_ball(
&mut secondary,
2,
2,
BallPass::slot(2, 2),
claw_position,
MilliVec::default(),
&mut events,
@@ -4847,29 +5385,29 @@ mod tests {
.action,
BallAction::Suspend
);
game.secondary_ball = Some(secondary);
game.set_secondary(Some(secondary));
assert!(!game.primary_ball_suspended());
assert!(game.secondary_ball_suspended());
assert!(!game.ball_suspended(0));
assert!(game.ball_suspended(1));
assert!(matches!(game.claw.target_frame, 1 | 6 | 7));
let primary_before = game.ball.position;
let primary_before = game.balls[0].position;
game.timer_tick(CLAW_FRAME_SECONDS, 1, &mut events);
assert_ne!(game.ball.position, primary_before);
assert_ne!(game.balls[0].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 {
game.balls[0].in_launcher = false;
game.balls[0].position = CLAW_TRIGGER_CENTER;
game.balls[0].velocity = Vec2::ZERO;
game.set_secondary(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);
@@ -4885,7 +5423,7 @@ mod tests {
let balls_before = game.player().balls;
game.timer_tick(CLAW_FRAME_SECONDS, 1, &mut events);
assert!(game.secondary_ball.is_none());
assert!(game.secondary().is_none());
assert_eq!(game.player().balls, balls_before);
assert_eq!(game.score_mode, ScoreMode::Normal);
assert!(game.claw.ball_suspended);
@@ -4894,32 +5432,31 @@ mod tests {
game.update_claw(CLAW_FRAME_SECONDS, &mut events);
}
assert!(events.contains(&Event::ClawRelease));
assert!(!game.ball.in_launcher);
assert!(!game.balls[0].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 {
game.balls[0].in_launcher = false;
game.balls[0].position = vec2(340.0, 100.0);
game.balls[0].velocity = vec2(1.0, 0.0);
game.set_secondary(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");
let mut secondary = game.take_secondary().expect("secondary ball");
assert_eq!(
game.check_sensor_objects_for_ball(
&mut secondary,
2,
2,
BallPass::slot(2, 2),
claw_position,
MilliVec::default(),
&mut events,
@@ -4927,13 +5464,12 @@ mod tests {
.action,
BallAction::Keep
);
game.secondary_ball = Some(secondary);
let mut secondary = game.secondary_ball.take().expect("secondary ball");
game.set_secondary(Some(secondary));
let mut secondary = game.take_secondary().expect("secondary ball");
assert_eq!(
game.check_sensor_objects_for_ball(
&mut secondary,
2,
2,
BallPass::slot(2, 2),
claw_position,
MilliVec::default(),
&mut events,
@@ -4941,18 +5477,15 @@ mod tests {
.action,
BallAction::Suspend
);
game.secondary_ball = Some(secondary);
game.set_secondary(Some(secondary));
assert_eq!(game.record_contacts[89], 2);
assert_eq!(
game.claw.capture_entry,
Some((ClawBallSlot::Secondary, true))
);
assert_eq!(game.claw.capture_entry, Some((1, true)));
game.timer_tick(CLAW_FRAME_SECONDS, 1, &mut events);
assert!(game.secondary_ball.is_none());
assert!(game.primary_ball_suspended());
assert!(game.secondary().is_none());
assert!(game.ball_suspended(0));
assert_eq!(game.record_contacts[89], 1);
assert_eq!(game.claw.capture_entry, Some((ClawBallSlot::Primary, true)));
assert_eq!(game.claw.capture_entry, Some((0, true)));
assert_eq!(game.score_mode, ScoreMode::Normal);
assert!(events.contains(&Event::Drain));
}
@@ -4973,16 +5506,16 @@ mod tests {
#[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 {
game.balls[0].in_launcher = false;
game.balls[0].position = CLAW_TRIGGER_CENTER;
game.balls[0].velocity = Vec2::ZERO;
game.set_secondary(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);
@@ -4995,9 +5528,9 @@ mod tests {
game.check_sensor_objects(claw_position, MilliVec::default(), &mut events),
BallAction::Suspend
);
assert_eq!(game.claw.capture_entry, Some((ClawBallSlot::Primary, true)));
assert_eq!(game.claw.capture_entry, Some((0, true)));
game.secondary_ball = None;
game.set_secondary(None);
game.score_mode = ScoreMode::Normal;
while game.claw.active {
game.update_claw(CLAW_FRAME_SECONDS, &mut events);
@@ -5010,19 +5543,16 @@ mod tests {
})
.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.balls[0].position = CLAW_TRIGGER_CENTER;
game.balls[0].velocity = Vec2::ZERO;
game.balls[0].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.claw.capture_entry, Some((0, false)));
assert_eq!(
game.check_sensor_objects(claw_position, MilliVec::default(), &mut events),
BallAction::Suspend
@@ -5049,26 +5579,26 @@ mod tests {
#[test]
fn claw_uses_the_original_circular_trigger_not_a_broad_rectangle() {
let mut game = Game::new(1);
game.ball.in_launcher = false;
game.ball.position = CLAW_TRIGGER_CENTER + vec2(CLAW_TRIGGER_RADIUS + 0.1, 0.0);
game.balls[0].in_launcher = false;
game.balls[0].position = CLAW_TRIGGER_CENTER + vec2(CLAW_TRIGGER_RADIUS + 0.1, 0.0);
game.check_sensor_objects(
MilliVec::from_position(game.ball.position),
MilliVec::from_position(game.balls[0].position),
MilliVec::default(),
&mut Vec::new(),
);
assert!(!game.claw.active);
game.ball.position = CLAW_TRIGGER_CENTER;
game.balls[0].position = CLAW_TRIGGER_CENTER;
let mut events = Vec::new();
game.check_sensor_objects(
MilliVec::from_position(game.ball.position),
MilliVec::from_position(game.balls[0].position),
MilliVec::default(),
&mut events,
);
assert!(!game.claw.active);
assert_eq!(events, [Event::Sound(2015)]);
game.check_sensor_objects(
MilliVec::from_position(game.ball.position),
MilliVec::from_position(game.balls[0].position),
MilliVec::default(),
&mut events,
);
@@ -5080,9 +5610,9 @@ mod tests {
#[test]
fn top_sensor_uses_the_original_score_and_contact_latch() {
let mut game = Game::new_with_seed(1, 7);
game.ball.in_launcher = false;
game.ball.position = vec2(205.0, 55.0);
game.ball.velocity = vec2(100.0, 200.0);
game.balls[0].in_launcher = false;
game.balls[0].position = vec2(205.0, 55.0);
game.balls[0].velocity = vec2(100.0, 200.0);
let mut events = Vec::new();
game.check_sensor_objects(
@@ -5096,7 +5626,7 @@ mod tests {
assert!(!game.object_active[6]);
assert!(!game.object_active[154]);
assert_eq!(
MilliVec::from_velocity_per_second(game.ball.velocity),
MilliVec::from_velocity_per_second(game.balls[0].velocity),
MilliVec { x: 1_012, y: 1_926 }
);
@@ -5119,19 +5649,19 @@ mod tests {
assert!(!game.object_active[153]);
game.check_sensor_objects(
MilliVec::from_position(game.ball.position),
MilliVec::from_position(game.balls[0].position),
MilliVec::default(),
&mut events,
);
assert_eq!(game.player().score, 500, "contact must score only once");
game.ball.position = vec2(205.0, 62.0);
game.balls[0].position = vec2(205.0, 62.0);
game.check_sensor_objects(
MilliVec::from_position(game.ball.position),
MilliVec::from_position(game.balls[0].position),
MilliVec::default(),
&mut events,
);
game.ball.position = vec2(205.0, 55.0);
game.balls[0].position = vec2(205.0, 55.0);
game.check_sensor_objects(
MilliVec::from_position(vec2(205.0, 60.0)),
MilliVec { x: 0, y: -5_000 },
@@ -5147,7 +5677,7 @@ mod tests {
.find(|sensor| sensor.id == 140)
.expect("record 140 must be present");
let mut game = Game::new(1);
let mut primary = game.ball;
let mut primary = game.balls[0];
primary.position = sensor.center;
let mut events = Vec::new();
@@ -5234,10 +5764,10 @@ mod tests {
game.object_active.fill(false);
game.object_active[5] = true;
game.object_active[6] = true;
game.ball.in_launcher = false;
game.ball.position = vec2(149.0, 430.0);
game.ball.velocity = MilliVec { x: -2_000, y: 0 }.to_velocity_per_second();
let old_position = MilliVec::from_position(game.ball.position);
game.balls[0].in_launcher = false;
game.balls[0].position = vec2(149.0, 430.0);
game.balls[0].velocity = MilliVec { x: -2_000, y: 0 }.to_velocity_per_second();
let old_position = MilliVec::from_position(game.balls[0].position);
let mut expected_game = game.clone();
let mut expected_motion = MilliVec { x: -2_000, y: 15 };
@@ -5253,7 +5783,7 @@ mod tests {
assert!(expected_game.apply_magnetic_record(6, old_position, &mut expected_motion));
let expected_response = candidate
.2
.resolve(expected_motion, expected_game.ball.spin);
.resolve(expected_motion, expected_game.balls[0].spin);
let mut expected_velocity = expected_response.velocity;
expected_velocity.clamp_speed(MAXIMUM_SPEED_MILLI_PER_STEP);
@@ -5261,7 +5791,7 @@ mod tests {
assert_eq!(game.last_collision_id, Some(5));
assert_eq!(
MilliVec::from_velocity_per_second(game.ball.velocity),
MilliVec::from_velocity_per_second(game.balls[0].velocity),
expected_velocity
);
assert_eq!(game.random.seed(), expected_game.random.seed());
@@ -5270,14 +5800,14 @@ mod tests {
#[test]
fn wheel_holes_accumulate_and_transfer_the_per_player_secondary_score() {
let mut game = Game::new(1);
game.ball.in_launcher = false;
game.balls[0].in_launcher = false;
let mut events = Vec::new();
let expected_bonus = [10_000, 30_000, 70_000, 150_000, 0];
for (index, sensor) in LOCK_HOLES.into_iter().enumerate() {
complete_stationary_type_three_capture(&mut game, sensor.center, &mut events);
assert_eq!(game.player().secondary_score, expected_bonus[index]);
assert!(game.ball.in_launcher);
assert!(game.balls[0].in_launcher);
}
assert_eq!(game.player().score, 310_000);
assert_eq!(game.player().score_multiplier, 2);
@@ -5320,7 +5850,7 @@ mod tests {
}
#[test]
fn fifth_multiball_lock_defers_panel_and_caps_survivor_reaward() {
fn fifth_multiball_lock_starts_the_panel_and_caps_survivor_reaward() {
let mut game = Game::new(1);
game.wheel_holes = [true, true, true, true, false];
game.record_contacts[129..=133].fill(2);
@@ -5335,13 +5865,9 @@ mod tests {
assert_eq!(game.player().score, 620_000);
assert_eq!(game.player().secondary_score, 0);
assert_eq!(game.player().score_multiplier, 2);
assert_eq!(game.panel_frame, None);
assert_eq!(game.panel_frame, Some(0));
assert_eq!(game.score_mode, ScoreMode::Normal);
let mut reset_by_special_hole = game.clone();
reset_by_special_hole.reset_ball_to_launcher();
assert_eq!(reset_by_special_hole.panel_frame, Some(0));
game.score_mode = ScoreMode::Normal;
assert_eq!(
game.complete_lock_hole(4, 1, &mut events),
@@ -5357,10 +5883,10 @@ mod tests {
fn type_three_capture_pulls_holds_and_publishes_contact_sentinels() {
let sensor = LOCK_HOLES[0];
let mut game = Game::new(1);
game.ball.in_launcher = false;
game.ball.position = sensor.center + vec2(5.0, 0.0);
game.ball.velocity = vec2(100.0, 0.0);
let previous = MilliVec::from_position(game.ball.position);
game.balls[0].in_launcher = false;
game.balls[0].position = sensor.center + vec2(5.0, 0.0);
game.balls[0].velocity = vec2(100.0, 0.0);
let previous = MilliVec::from_position(game.balls[0].position);
let mut events = Vec::new();
assert_eq!(
@@ -5375,14 +5901,14 @@ mod tests {
CaptureStep::Holding
);
assert_eq!(
MilliVec::from_velocity_per_second(game.ball.velocity),
MilliVec::from_velocity_per_second(game.balls[0].velocity),
MilliVec { x: 750, y: -150 }
);
assert_eq!(game.record_contacts[usize::from(sensor.id)], 0);
assert_eq!(game.ball.capture_age, 0);
assert_eq!(game.balls[0].capture_age, 0);
game.ball.position = sensor.center;
game.ball.velocity = Vec2::ZERO;
game.balls[0].position = sensor.center;
game.balls[0].velocity = Vec2::ZERO;
assert_eq!(
capture_primary_record_step(
&mut game,
@@ -5395,10 +5921,10 @@ mod tests {
CaptureStep::Holding
);
assert_eq!(game.record_contacts[usize::from(sensor.id)], 1);
assert_eq!(game.ball.capture_age, 5);
assert_eq!(game.balls[0].capture_age, 5);
assert_eq!(events, [Event::Sound(2015)]);
game.ball.capture_age = 300;
game.balls[0].capture_age = 300;
assert_eq!(
capture_primary_record_step(
&mut game,
@@ -5411,7 +5937,7 @@ mod tests {
CaptureStep::Complete
);
assert_eq!(game.record_contacts[usize::from(sensor.id)], 99);
assert_eq!(game.ball.capture_age, 0);
assert_eq!(game.balls[0].capture_age, 0);
}
#[test]
@@ -5420,10 +5946,10 @@ mod tests {
let mut game = Game::new(1);
game.object_active.fill(false);
game.object_active[usize::from(sensor.id)] = true;
game.ball.in_launcher = false;
game.ball.position = sensor.center + vec2(7.5, 0.0);
game.ball.velocity = MilliVec { x: 3_800, y: 0 }.to_velocity_per_second();
let old_position = MilliVec::from_position(game.ball.position);
game.balls[0].in_launcher = false;
game.balls[0].position = sensor.center + vec2(7.5, 0.0);
game.balls[0].velocity = MilliVec { x: 3_800, y: 0 }.to_velocity_per_second();
let old_position = MilliVec::from_position(game.balls[0].position);
let mut expected_velocity = MilliVec { x: 3_800, y: 15 };
expected_velocity.clamp_speed(MAXIMUM_SPEED_MILLI_PER_STEP);
let damping = Real48::from_bytes([0x80, 0x66, 0x66, 0x66, 0x66, 0x66]);
@@ -5439,11 +5965,11 @@ mod tests {
assert!(!game.fixed_update(&mut Vec::new()));
assert_eq!(
MilliVec::from_velocity_per_second(game.ball.velocity),
MilliVec::from_velocity_per_second(game.balls[0].velocity),
expected_velocity
);
assert_eq!(
MilliVec::from_position(game.ball.position),
MilliVec::from_position(game.balls[0].position),
old_position.add(expected_velocity)
);
}
@@ -5455,18 +5981,18 @@ mod tests {
game.object_active.fill(false);
game.object_active[usize::from(sensor.id)] = true;
game.record_contacts[usize::from(sensor.id)] = 99;
game.ball.in_launcher = false;
game.ball.position = sensor.center + vec2(17.0, 0.0);
game.ball.velocity = MilliVec { x: -3_000, y: 0 }.to_velocity_per_second();
let old_position = MilliVec::from_position(game.ball.position);
game.balls[0].in_launcher = false;
game.balls[0].position = sensor.center + vec2(17.0, 0.0);
game.balls[0].velocity = MilliVec { x: -3_000, y: 0 }.to_velocity_per_second();
let old_position = MilliVec::from_position(game.balls[0].position);
assert!(game.fixed_update(&mut Vec::new()));
let velocity = MilliVec::from_velocity_per_second(game.ball.velocity);
let velocity = MilliVec::from_velocity_per_second(game.balls[0].velocity);
assert!(velocity.x > 0, "the type-three rim must reflect the ball");
assert_eq!(game.last_collision_id, Some(sensor.id));
assert_eq!(
MilliVec::from_position(game.ball.position),
MilliVec::from_position(game.balls[0].position),
old_position.add(velocity)
);
}
@@ -5478,9 +6004,9 @@ mod tests {
game.object_active.fill(false);
game.object_active[usize::from(sensor.id)] = true;
game.record_contacts[usize::from(sensor.id)] = 1;
game.ball.in_launcher = false;
game.ball.position = sensor.center + vec2(23.0, 0.0);
game.ball.velocity = MilliVec { x: 3_800, y: 0 }.to_velocity_per_second();
game.balls[0].in_launcher = false;
game.balls[0].position = sensor.center + vec2(23.0, 0.0);
game.balls[0].velocity = MilliVec { x: 3_800, y: 0 }.to_velocity_per_second();
game.fixed_update(&mut Vec::new());
@@ -5496,12 +6022,12 @@ mod tests {
let mut game = Game::new_with_seed(1, 7);
game.object_active.fill(false);
game.object_active[usize::from(sensor.id)] = true;
game.ball.in_launcher = false;
game.ball.position = vec2(205.0, 58.0);
game.ball.velocity = MilliVec { x: 0, y: -2_000 }.to_velocity_per_second();
let old_position = MilliVec::from_position(game.ball.position);
game.balls[0].in_launcher = false;
game.balls[0].position = vec2(205.0, 58.0);
game.balls[0].velocity = MilliVec { x: 0, y: -2_000 }.to_velocity_per_second();
let old_position = MilliVec::from_position(game.balls[0].position);
let mut expected_game = game.clone();
let mut expected_ball = game.ball;
let mut expected_ball = game.balls[0];
expected_ball.velocity = MilliVec { x: 0, y: -1_985 }.to_velocity_per_second();
expected_game.randomize_trigger_velocity(&mut expected_ball);
let expected_velocity = MilliVec::from_velocity_per_second(expected_ball.velocity);
@@ -5510,11 +6036,11 @@ mod tests {
assert_eq!(game.player().score, 500);
assert_eq!(
MilliVec::from_velocity_per_second(game.ball.velocity),
MilliVec::from_velocity_per_second(game.balls[0].velocity),
expected_velocity
);
assert_eq!(
MilliVec::from_position(game.ball.position),
MilliVec::from_position(game.balls[0].position),
old_position.add(expected_velocity)
);
assert_eq!(game.random.seed(), expected_game.random.seed());
@@ -5560,7 +6086,7 @@ mod tests {
#[test]
fn target_rotation_uses_six_callbacks_and_rotates_player_state() {
let mut game = Game::new(1);
game.ball.in_launcher = false;
game.balls[0].in_launcher = false;
game.wheel_holes = [true, false, true, false, false];
game.record_contacts[129..=133].copy_from_slice(&[11, 22, 33, 44, 55]);
game.target_rotation_state = Some(0);
@@ -5586,7 +6112,7 @@ mod tests {
game.timer_tick(0.030, 0, &mut Vec::new());
assert_eq!(game.target_rotation_state, Some(2));
game.ball.in_launcher = false;
game.balls[0].in_launcher = false;
game.timer_tick(0.030, 0, &mut Vec::new());
assert_eq!(game.target_rotation_state, Some(3));
@@ -5608,7 +6134,7 @@ mod tests {
"launcher idle pauses countdowns"
);
game.ball.in_launcher = false;
game.balls[0].in_launcher = false;
for expected in (0..5).rev() {
game.timer_tick(0.030, 0, &mut Vec::new());
assert_eq!(game.record_countdown(51), expected);
@@ -5650,9 +6176,9 @@ mod tests {
fn drain_clears_latched_tilt() {
let mut game = Game::new(1);
game.tilted = true;
game.ball.in_launcher = false;
game.ball.position = vec2(157.0, 454.0);
game.ball.velocity = vec2(0.0, 200.0);
game.balls[0].in_launcher = false;
game.balls[0].position = vec2(157.0, 454.0);
game.balls[0].velocity = vec2(0.0, 200.0);
let held = Controls {
left_flipper: true,
@@ -5661,7 +6187,7 @@ mod tests {
let events = game.update(0.030, 3, held);
assert!(!game.tilted);
assert!(game.ball.in_launcher);
assert!(game.balls[0].in_launcher);
assert!(!events.contains(&Event::Sound(2008)));
assert!(game.flipper_release_latch[0]);
game.update(0.0, 3, held);
@@ -5722,7 +6248,7 @@ mod tests {
}
assert!(game.tilted);
assert!(game.ball.in_launcher);
assert!(game.balls[0].in_launcher);
}
#[test]
@@ -5740,7 +6266,7 @@ mod tests {
);
}
assert!(game.tilted);
assert!(game.ball.in_launcher);
assert!(game.balls[0].in_launcher);
game.update(
0.0,
@@ -5755,7 +6281,7 @@ mod tests {
let events = game.update(0.0, 3, Controls::default());
assert!(events.contains(&Event::Launch));
assert!(!events.contains(&Event::Sound(2002)));
assert!(!game.ball.in_launcher);
assert!(!game.balls[0].in_launcher);
assert!(game.tilted);
}
}
+18
View File
@@ -62,6 +62,10 @@ impl Default for Settings {
pub struct HighScore {
pub name: String,
pub score: u32,
/// Set when the score was reached with crazy-mode balls. Saved tables
/// and server responses from before crazy mode omit it.
#[serde(default)]
pub crazy: bool,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
@@ -244,6 +248,7 @@ pub fn parse_original_high_scores(bytes: &[u8]) -> Vec<HighScore> {
HighScore {
name: String::from_utf8_lossy(&name_bytes[..length]).into_owned(),
score,
crazy: false,
}
})
.collect()
@@ -335,6 +340,7 @@ mod tests {
HighScore {
name: "TEST".to_owned(),
score: 6_537_393,
crazy: false,
},
);
assert_eq!(scores.len(), 10);
@@ -349,12 +355,23 @@ mod tests {
HighScore {
name: "WRAPPED".to_owned(),
score: u32::MAX,
crazy: false,
},
);
assert!(!scores.iter().any(|entry| entry.name == "WRAPPED"));
assert!(!qualifies_high_score(&scores, u32::MAX));
}
#[test]
fn high_scores_without_a_crazy_flag_load_as_normal_scores() {
let entry: HighScore = serde_json::from_str(r#"{"name":"Paul","score":42}"#)
.expect("pre-crazy-mode entries must still load");
assert!(!entry.crazy);
let crazy: HighScore = serde_json::from_str(r#"{"name":"Paul","score":42,"crazy":true}"#)
.expect("crazy entries load");
assert!(crazy.crazy);
}
#[test]
fn original_dialog_allows_an_accepted_empty_name() {
let mut scores = parse_original_high_scores(ORIGINAL_HIGHSCORES);
@@ -363,6 +380,7 @@ mod tests {
HighScore {
name: String::new(),
score: 6_537_393,
crazy: false,
},
);
+45 -39
View File
@@ -29,6 +29,7 @@ pub enum Scenario {
Claw6,
Claw7,
Claw18,
Crazy,
}
impl Scenario {
@@ -48,6 +49,7 @@ impl Scenario {
Self::Claw6 => "claw-6",
Self::Claw7 => "claw-7",
Self::Claw18 => "claw-18",
Self::Crazy => "crazy",
}
}
@@ -66,7 +68,8 @@ impl Scenario {
| Self::Launcher
| Self::Flippers
| Self::Panel
| Self::Targets => None,
| Self::Targets
| Self::Crazy => None,
}
}
}
@@ -90,8 +93,9 @@ impl FromStr for Scenario {
"claw-6" => Ok(Self::Claw6),
"claw-7" => Ok(Self::Claw7),
"claw-18" => Ok(Self::Claw18),
"crazy" => Ok(Self::Crazy),
_ => Err(format!(
"unknown scenario {value:?}; expected loading, attract, highscores, name-entry, effect-7, autoplay, launcher, flippers, panel, targets, claw-1, claw-6, claw-7, or claw-18"
"unknown scenario {value:?}; expected loading, attract, highscores, name-entry, effect-7, autoplay, launcher, flippers, panel, targets, claw-1, claw-6, claw-7, claw-18, or crazy"
)),
}
}
@@ -175,7 +179,7 @@ fn seconds_to_step(value: &str) -> Result<u64, String> {
}
pub const fn usage() -> &'static str {
"Usage:\n tdkpin-rs\n tdkpin-rs --simulate SCENARIO [--at SECONDS | --step N] [--screenshot FILE.png] [--trace FILE.json] [--seed N]\n\nScenarios: loading, attract, highscores, name-entry, effect-7, autoplay, launcher, flippers, panel, targets, claw-1, claw-6, claw-7, claw-18"
"Usage:\n tdkpin-rs\n tdkpin-rs --simulate SCENARIO [--at SECONDS | --step N] [--screenshot FILE.png] [--trace FILE.json] [--seed N]\n\nScenarios: loading, attract, highscores, name-entry, effect-7, autoplay, launcher, flippers, panel, targets, claw-1, claw-6, claw-7, claw-18, crazy"
}
#[derive(Debug, Serialize, PartialEq)]
@@ -183,8 +187,8 @@ pub struct Snapshot {
pub scenario: &'static str,
pub step: u64,
pub seconds: f64,
pub ball: BallSnapshot,
pub secondary_ball: Option<BallSnapshot>,
/// Every ball in slot order, including one held by the claw.
pub balls: Vec<BallSnapshot>,
pub claw: ClawSnapshot,
pub flippers: FlipperSnapshot,
pub launcher_charge: f32,
@@ -237,6 +241,9 @@ impl Simulation {
if scenario == Scenario::Effect7 {
game.begin_effect_scenario(7);
}
if scenario == Scenario::Crazy {
game.add_crazy_balls();
}
let initial_events = scenario
.claw_terminal()
.map_or_else(Vec::new, |terminal| game.begin_claw_scenario(terminal));
@@ -298,20 +305,18 @@ impl Simulation {
scenario: self.scenario.name(),
step: self.step,
seconds: f64::from(step) / SIMULATION_HZ_F64,
ball: BallSnapshot {
x: self.game.ball.position.x,
y: self.game.ball.position.y,
velocity_x: self.game.ball.velocity.x,
velocity_y: self.game.ball.velocity.y,
in_launcher: self.game.ball.in_launcher,
},
secondary_ball: self.game.secondary_ball.map(|ball| BallSnapshot {
x: ball.position.x,
y: ball.position.y,
velocity_x: ball.velocity.x,
velocity_y: ball.velocity.y,
in_launcher: ball.in_launcher,
}),
balls: self
.game
.balls
.iter()
.map(|ball| BallSnapshot {
x: ball.position.x,
y: ball.position.y,
velocity_x: ball.velocity.x,
velocity_y: ball.velocity.y,
in_launcher: ball.in_launcher,
})
.collect(),
claw: ClawSnapshot {
active: self.game.claw.active,
frame: self.game.claw.frame,
@@ -333,7 +338,7 @@ impl Simulation {
}
fn autoplay_controls(&mut self) -> Controls {
if self.game.ball.in_launcher {
if self.game.balls.iter().any(|ball| ball.in_launcher) {
let launch_down = self.autoplay_launcher_frames < 132;
self.autoplay_launcher_frames += 1;
Controls {
@@ -342,11 +347,15 @@ impl Simulation {
}
} else {
self.autoplay_launcher_frames = 0;
let near_flipper = |x_range: std::ops::Range<f32>| {
self.game
.balls
.iter()
.any(|ball| ball.position.y > 340.0 && x_range.contains(&ball.position.x))
};
Controls {
left_flipper: self.game.ball.position.y > 340.0
&& self.game.ball.position.x < 175.0,
right_flipper: self.game.ball.position.y > 340.0
&& self.game.ball.position.x > 140.0,
left_flipper: near_flipper(0.0..175.0),
right_flipper: near_flipper(140.0..f32::INFINITY),
..Controls::default()
}
}
@@ -378,7 +387,8 @@ fn controls_for(scenario: Scenario, step: u64) -> Controls {
| Scenario::Claw1
| Scenario::Claw6
| Scenario::Claw7
| Scenario::Claw18 => Controls::default(),
| Scenario::Claw18
| Scenario::Crazy => Controls::default(),
}
}
@@ -448,16 +458,12 @@ mod tests {
assert!(event_count("Target") >= 1);
assert!(event_count("Bumper") >= 1);
assert!(simulation.trace.iter().all(|snapshot| {
snapshot.ball.x.is_finite()
&& snapshot.ball.y.is_finite()
&& snapshot.ball.velocity_x.is_finite()
&& snapshot.ball.velocity_y.is_finite()
&& snapshot.secondary_ball.as_ref().is_none_or(|ball| {
ball.x.is_finite()
&& ball.y.is_finite()
&& ball.velocity_x.is_finite()
&& ball.velocity_y.is_finite()
})
snapshot.balls.iter().all(|ball| {
ball.x.is_finite()
&& ball.y.is_finite()
&& ball.velocity_x.is_finite()
&& ball.velocity_y.is_finite()
})
}));
}
@@ -465,11 +471,11 @@ mod tests {
fn launcher_scenario_holds_for_one_second_then_releases() {
let mut simulation = Simulation::new(Scenario::Launcher, 1);
simulation.advance_to(60);
assert!(simulation.game.ball.in_launcher);
assert!(simulation.game.balls[0].in_launcher);
assert!((0.19..=0.21).contains(&simulation.game.launcher_charge));
simulation.advance_to(121);
assert!(!simulation.game.ball.in_launcher);
assert!(!simulation.game.balls[0].in_launcher);
assert!(simulation.trace[121].events.contains(&"Launch".to_owned()));
}
@@ -586,8 +592,8 @@ mod tests {
scenario.name()
);
assert!(captures >= releases);
assert!(simulation.game.ball.position.is_finite());
assert!(simulation.game.ball.velocity.is_finite());
assert!(simulation.game.balls[0].position.is_finite());
assert!(simulation.game.balls[0].velocity.is_finite());
}
}
}