Compare commits

39 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
ddidderr 3fc1096500 fmt: README.md files and .cargo/config.toml 2026-09-01 05:27:10 +02:00
ddidderr a5f2aab005 chore(release): prepare v1.2.1
Bump the package version to 1.2.1 for multiball Greifarm capture preservation and scoping fixes. Update CHANGELOG.md and refresh the tracked web WASM artifact.

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

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

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

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

Test Plan:
- `just --justfile tdkpin-rs/justfile test` -- passed (149 game/service tests)
- `just --justfile tdkpin-rs/justfile clippy` -- passed
- `just --justfile tdkpin-rs/justfile build-production` -- passed
- `just --justfile tdkpin-rs/justfile web-build` -- passed
- `cargo +nightly fmt --manifest-path tdkpin-rs/Cargo.toml --all -- --check` -- passed
- `rumdl check --flavor commonmark tdkpin-rs/CHANGELOG.md tdkpin-rs/AGENTS.md` -- passed
- `git diff --cached --check` -- passed
2026-08-31 22:15:39 +02:00
ddidderr c4fb345197 chore(release): prepare v1.2.0
Bump the package version to 1.2.0 for the high-score demo database seeding, service hardening, web retry bounds, and multiball parity and divergence fixes. Update CHANGELOG.md and refresh the tracked web WASM artifact.

Test Plan:
- `just test` -- passed (142 game tests and 8 service tests)
- `just clippy` -- passed
- `just build-production` -- passed
- `just web-build` -- passed
- `cargo +nightly fmt --all -- --check` -- passed
- `rumdl check --flavor commonmark tdkpin-rs/CHANGELOG.md` -- passed
- `cargo metadata --locked --format-version 1 --no-deps` -- passed
- `git diff --cached --check` -- passed
2026-08-31 20:48:59 +02:00
ddidderr 4385a661b3 web 2026-08-31 20:46:20 +02:00
ddidderr a6967b4d1b fix(divergence): reject occupied multiball wheel slots
The original leaves contact owner 2 after either ball fills a wheel hole during
multiball. Once play collapses to one ball, that sentinel permits one more
capture and award in the visibly occupied hole before becoming permanent.

Deliberately replace the completed wheel contact with the permanent 99 sentinel
for both ball slots. This keeps visual occupancy, collision admission, and
scoring consistent: a filled hole cannot consume or reward another ball. The
special reserve hole and claw retain their original contact behavior.

Document `divergence` as the required Conventional Commit scope for future fixes
that intentionally differ from original-game behavior.

Test Plan:
- `just test` -- passed (142 game tests and 8 service tests)
- `just clippy` -- passed
- `just build-production` -- passed
- `cargo +nightly fmt --all -- --check` -- passed
- `rumdl check --flavor commonmark CHANGELOG.md AGENTS.md` -- passed
- `git diff --cached --check` -- passed
2026-08-31 19:54:11 +02:00
ddidderr a394df23e7 fix(multiball): match capture collapse timing
Capture removal and ordinary drains do not clear the original game's shared
multiball flag at the same point. The clone treated both transitions alike,
which could end double scoring before ball two's remaining slot pass or leave
it active after ball two entered a capture hole. It also failed to pause the
returning claw until collapse and to clear the flag on a fifth lock.

Collapse capture-driven multiball state at the next timer callback, retain the
immediate drain behavior, and end the mode explicitly when all five lock
contacts complete. Calculate lock awards from the live contact words so a
second ball still settling in another hole is counted. Stage effect-seven slot
creation until the primary substep batch returns, matching the timer's spawn
request ordering.

The original leaves a multiball capture contact as value 2, so a later single
ball may enter that same wheel slot once more and convert it to the permanent
99 sentinel. This possibly unintended original-game quirk remains for binary
parity.

Test Plan:
- `just test` -- passed (141 game tests and 8 service tests)
- `just clippy` -- passed
- `just build-production` -- passed
- `cargo +nightly fmt --all -- --check` -- passed
- `rumdl check --flavor commonmark CHANGELOG.md` -- passed
- `git diff --cached --check` -- passed
2026-08-31 19:54:10 +02:00
ddidderr d5336a2a92 build: production profile for highscore-server 2026-08-29 20:50:41 +02:00
ddidderr 8e1a1c91e2 feat(highscores): seed new server databases with demo scores
New SQLite high-score databases were previously created empty, so the first
shared table had no original entries. Seed only database paths that did not
exist before opening with the ten distributed demo scores, while leaving
existing files and in-memory stores unchanged. Add coverage for both first
creation and existing empty files, and document the behavior.

Test Plan:
- `just test-highscore-server` -- passed (8 tests)
- `just clippy-highscore-server` -- passed
- `cargo +nightly fmt --manifest-path highscore-server/Cargo.toml -- --check` -- passed
- `git diff --cached --check` -- passed
2026-08-29 20:44:05 +02:00
ddidderr 9023af7e7e fix(web): define high-score retry bounds
The browser storage plugin initializes and updates its high-score retry
backoff, but the constants supplying its initial and maximum delays were
lost during the merge that combined the retry and relative-endpoint fixes.
Define the intended 250 ms initial delay and 30 s cap so the plugin can load
and retain bounded retry behavior after transient submission failures.

Test Plan:
- `node --check tdkpin-rs/web/storage.js` -- passed
- Node VM top-level load harness -- passed
- `prettier --check tdkpin-rs/web/storage.js` -- passed
- `cargo +nightly fmt --all -- --check` -- passed
- `just test` -- passed (144 tests)
- `just clippy` -- passed
- `git diff --cached --check` -- passed
2026-08-29 20:18:53 +02:00
ddidderr 12d1ec0aab Merge branch 'tomerge' 2026-08-29 20:11:13 +02:00
ddidderr 9c5b033c0a fix(web): use relative path for high-score API endpoint
Change the high-score endpoint URL in storage.js from "/api/highscores" to
"./api/highscores". When serving the web build from a subpath rather than the
domain root, an absolute path sends fetch requests to the domain root instead
of the nested application path. Using a relative URL ensures requests resolve
relative to the active document path while still working when hosted at root.

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

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

Test Plan:
- `just --justfile tdkpin-rs/justfile test` -- passed (144 tests)
- `just --justfile tdkpin-rs/justfile clippy` -- passed
- `cargo +nightly fmt --manifest-path tdkpin-rs/highscore-server/Cargo.toml -- --check` -- passed
- `rumdl check --flavor commonmark tdkpin-rs/highscore-server/README.md` -- passed
- `git diff --cached --check` -- passed
2026-08-29 19:36:53 +02:00
ddidderr 7ee2e71bc7 fix: relative api/highscores path 2026-08-29 18:46:10 +02:00
ddidderr 86434aaa2b fix: remote unused github workflow 2026-08-29 18:07:54 +02:00
ddidderr 51502ef92f chore(release): prepare v1.1.0
Build TDK Pinball / build (macos-latest) (push) Canceled after 0s
Build TDK Pinball / build (ubuntu-latest) (push) Canceled after 0s
Build TDK Pinball / build (windows-latest) (push) Canceled after 0s
Bump the package version to 1.1.0 for the web port, optional shared high-score service, and post-1.0 parity and edge fixes. Update CHANGELOG.md and refresh the tracked web WASM artifact.

Test Plan:
- `just test` -- passed (138 game tests, 3 highscore-server tests)
- `just clippy` -- passed
- `just build-production` -- passed
- `just web-build` -- passed
- `cargo metadata --locked --format-version 1 --no-deps` -- passed
- `git diff --cached --check` -- passed
2026-08-29 17:52:53 +02:00
ddidderr f9063bd2ff chore(web): regenerate browser artifact
Build TDK Pinball / build (macos-latest) (push) Canceled after 0s
Build TDK Pinball / build (ubuntu-latest) (push) Canceled after 0s
Build TDK Pinball / build (windows-latest) (push) Canceled after 0s
2026-08-29 17:50:49 +02:00
ddidderr a36856e526 fix(game): allow launcher input while tilted
Build TDK Pinball / build (macos-latest) (push) Canceled after 0s
Build TDK Pinball / build (ubuntu-latest) (push) Canceled after 0s
Build TDK Pinball / build (windows-latest) (push) Canceled after 0s
Preserve the original launcher path when a nudge tilts a game before launch.
The Win16 press and release handlers process scan code 0x50 without consulting
`game_tilted`, so Tilt disables flippers and scoring but does not strand a ball
in the shooter lane. Remove the extra Rust gate, retain the original launch
sound suppression during Tilt, and add regression coverage for the waiting-ball
case.

Test Plan:
- `just test` -- passed (138 Rust tests and 3 highscore-server tests)
- `just clippy` -- passed
- `cargo +nightly fmt --all -- --check` -- passed
- `git diff --check` and `git diff --cached --check` -- passed
2026-08-29 17:46:24 +02:00
ddidderr acb0c20b59 fix(web): vendor the Macroquad browser loader
The page depended on the externally hosted miniquad bundle, which violates a
same-origin `script-src 'self'` policy and makes the game depend on a third
party at runtime. Vendor the current official loader alongside the web assets
and move the WASM `load` call into a local bootstrap script, preserving plugin
registration order without inline JavaScript.

Document the CSP requirement for WebAssembly compilation and same-origin
connections. The vendored bundle is the current response from the official
Macroquad loader URL and was syntax-checked before committing.

Test Plan:
- `just web-build` -- passed
- `node --check web/mq_js_bundle.js web/storage.js web/bootstrap.js` -- passed
- `prettier --check web/storage.js web/bootstrap.js` -- passed
- Browser smoke test with `script-src 'self' 'wasm-unsafe-eval'` and `connect-src 'self'` -- passed; 640x460 canvas and no CSP/script errors
- `git diff --cached --check` -- passed
2026-08-29 17:12:43 +02:00
ddidderr a81741b470 chore(highscores): align dependency and lint standards
Build TDK Pinball / build (macos-latest) (push) Canceled after 0s
Build TDK Pinball / build (ubuntu-latest) (push) Canceled after 0s
Build TDK Pinball / build (windows-latest) (push) Canceled after 0s
Update the high-score service to the latest compatible direct releases while
keeping the requested Cargo.toml ranges: 0.x crates use their latest minor
line and 1.x crates use their major-only range. Refresh the standalone lockfile
so it resolves axum 0.8.9, rusqlite 0.40.2, serde 1.0.229, serde_json 1.0.151,
Tokio 1.53.1, and the latest dev-tool releases.

Copy the main package's Clippy policy and make the repository's formatting
recipes cover the service. The nested crate inherits the parent rustfmt.toml,
so one configuration remains authoritative; newly required documentation and
borrow style also keep the stricter pedantic policy clean.

Test Plan:
- `cargo update --manifest-path highscore-server/Cargo.toml` -- passed
- `just test` -- passed (3 service tests and 137 game tests)
- `just clippy` -- passed with the shared lint policy
- `just fmt-highscore-server` and `cargo +nightly fmt --manifest-path highscore-server/Cargo.toml -- --check` -- passed
- `cargo tree --manifest-path highscore-server/Cargo.toml --depth 1` -- confirmed latest direct resolutions
- `rustfmt +nightly --print-config current` -- confirmed the parent rustfmt settings
- `rumdl check --flavor commonmark highscore-server/README.md` -- passed
- `git diff --cached --check` -- passed
2026-08-29 16:03:11 +02:00
ddidderr 3b7b84affe chore(highscores): ignore the local SQLite database
The service defaults to a database file in the Rust project directory when no
production path is configured. Ignore that file and SQLite sidecars so local
runs do not create accidental repository changes.

Test Plan:
- `git diff --cached --check` -- passed
- Ignore patterns verified against the default database filename
2026-08-29 15:55:41 +02:00
ddidderr 5dd7f38450 feat(web): use the shared high-score service
Keep browser settings and the existing local save fallback, but route the
browser high-score table through the same-origin service when it is available.
The WASM storage bridge now accepts fetched score JSON and queues one validated
submission at a time. The JavaScript plugin fetches the canonical table,
submits accepted name-entry scores, ignores stale responses, retries failures,
and feeds successful responses back into the running game.

Update the web and project documentation to describe the optional shared
leaderboard and regenerate the tracked browser artifact. A missing service
continues to leave the local table usable; the service documentation records
that anonymous client scores are intentionally not tamper-resistant.

Test Plan:
- `just test` -- passed (3 service tests and 137 game tests)
- `just clippy` -- passed
- `just web-build` -- passed
- `cargo +nightly fmt -- --check` -- passed
- `node --check web/storage.js` and `prettier --check web/storage.js` -- passed
- `rumdl check --flavor commonmark CHANGELOG.md README.md web/README.md highscore-server/README.md` -- passed
- Browser WASM load through same-origin API proxy -- passed
- `git diff --cached --check` -- passed
2026-08-29 15:55:15 +02:00
ddidderr ec0fdfd6b4 feat(highscores): add SQLite Axum service
Add a small standalone Axum service for the shared anonymous top-ten table.
SQLite keeps the deployment self-contained, while one transaction inserts a
validated name and score and removes entries below the canonical top ten.
Expose a health endpoint and same-origin API, with an nginx proxy block and
run instructions beside the service. Keep the service outside the game crate
so native gameplay persistence remains unchanged.

Test Plan:
- `cargo test --manifest-path highscore-server/Cargo.toml` -- passed (3 tests)
- `cargo clippy --manifest-path highscore-server/Cargo.toml --all-targets --all-features -- -D warnings` -- passed
- `cargo +nightly fmt --manifest-path highscore-server/Cargo.toml -- --check` -- passed
- Live executable health, POST, and GET smoke test -- passed
- `git diff --cached --check` -- passed
2026-08-29 15:50:04 +02:00
ddidderr 8fe9431989 fix(game): restore startup and multiball edge behavior
Build TDK Pinball / build (macos-latest) (push) Canceled after 0s
Build TDK Pinball / build (ubuntu-latest) (push) Canceled after 0s
Build TDK Pinball / build (windows-latest) (push) Canceled after 0s
The interactive build inherited Macroquad's fixed zero random state, making
maximum-power launcher shots repeat across fresh runs. Seed the platform
generator from the startup clock before taking the program-global gameplay
seed. Require record 148's still-live contact before the effect-seven special
respawn and clear active multiball state on an ordinary ball drain, so a
released reserve ball cannot be recreated after both slots are lost.

Type-4 target and effect handlers now honor each record's predicted broadphase
before changing its entry latch. This preserves the latch while another
multiball slot is elsewhere, preventing repeated upper-left corridor scoring.
The regressions and regenerated browser artifact stay with the implementation.

Test Plan:
- `just test` -- passed (137 tests)
- `just clippy` -- passed
- `cargo build --profile production` -- passed
- `just web-build` -- passed
- `cargo +nightly fmt --check` -- passed
- `rumdl check --flavor commonmark CHANGELOG.md` -- passed
- `git diff --cached --check` -- passed
2026-08-29 15:27:28 +02:00
ddidderr a21f01c02d fix(web): keep game at original canvas size
Build TDK Pinball / build (macos-latest) (push) Canceled after 0s
Build TDK Pinball / build (ubuntu-latest) (push) Canceled after 0s
Build TDK Pinball / build (windows-latest) (push) Canceled after 0s
The browser shell previously sized the canvas to the full viewport, causing
Macroquad to scale the 640x460 game presentation as the website changed size.
Keep the canvas at the original 640x460 CSS-pixel dimensions and center it on
a black page instead. A minimum page size and scrolling preserve access to the
fixed canvas on viewports smaller than the original presentation.

Test Plan:
- `just web-build` -- passed
- `git diff --check` -- passed
- Browser smoke test -- canvas measured 640x460 and was centered at (320,130)
  in a 1280x720 viewport; the rendered game remained at the intended fixed
  presentation size with no runtime errors
2026-08-29 14:49:37 +02:00
ddidderr b079cfa196 feat(web): add browser build for TDK Pinball
Expose the existing Macroquad game as a static WASM website while preserving
native desktop behavior. Native-only simulation/file-export code and the
per-user filesystem save path are now separated from the browser build.

The browser version uses a small WASM-only storage support crate and a
Macroquad-compatible JavaScript plugin to persist the same JSON settings and
high scores in localStorage. Browser audio decoding starts in an owned
background coroutine so the game can render its original loading/attract
screens while the embedded sounds finish loading. The checked-in web bundle
contains the optimized WASM, centered black HTML shell, and build/serve
instructions.

Test Plan:
- `just test` -- passed, 135 tests
- `just clippy` -- passed
- `cargo clippy --target wasm32-unknown-unknown -- -D warnings` -- passed
- `cargo +nightly fmt --check` and web-storage format check -- passed
- `just web-build` -- passed; packaged WASM matches the production artifact
- Browser smoke test at `http://127.0.0.1:8000/` -- rendered the centered
  game, started gameplay, opened settings, and restored a changed language
  from browser storage in a fresh page with no runtime errors
2026-08-29 14:32:41 +02:00
ddidderr c2f1443436 fmt: just fmt (rust only)
Build TDK Pinball / build (macos-latest) (push) Canceled after 0s
Build TDK Pinball / build (ubuntu-latest) (push) Canceled after 0s
Build TDK Pinball / build (windows-latest) (push) Canceled after 0s
2026-08-29 09:57:39 +02:00
36 changed files with 5233 additions and 1227 deletions

No files matched your search

-27
View File
@@ -1,27 +0,0 @@
name: Build TDK Pinball
on:
push:
pull_request:
jobs:
build:
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
runs-on: ${{ matrix.os }}
defaults:
run:
working-directory: tdkpin-rs
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
components: clippy, rustfmt
- name: Install Linux development libraries
if: runner.os == 'Linux'
run: sudo apt-get update && sudo apt-get install -y libasound2-dev libgl1-mesa-dev libxi-dev
- run: cargo check --all-targets
- run: cargo test --all-targets
- run: cargo clippy --all-targets -- -D warnings
+2
View File
@@ -1,2 +1,4 @@
target/ target/
__pycache__/ __pycache__/
/tdkpin-rs/highscores.sqlite3
/tdkpin-rs/highscores.sqlite3-*
+21
View File
@@ -0,0 +1,21 @@
[target.wasm32-unknown-unknown]
rustflags = [
"-C",
"link-arg=--import-undefined",
"-C",
"link-arg=--export=tdkpin_storage_crate_version",
"-C",
"link-arg=--export=tdkpin_browser_storage_clear",
"-C",
"link-arg=--export=tdkpin_browser_storage_push",
"-C",
"link-arg=--export=tdkpin_browser_storage_finish",
"-C",
"link-arg=--export=tdkpin_browser_storage_save_revision",
"-C",
"link-arg=--export=tdkpin_browser_storage_save_length",
"-C",
"link-arg=--export=tdkpin_browser_storage_save_byte",
"-C",
"link-arg=--export=tdkpin_browser_storage_save_ack",
]
+4
View File
@@ -5,6 +5,10 @@
Automatically commit changes once a full feature, bugfix, refactor, or other Automatically commit changes once a full feature, bugfix, refactor, or other
coherent unit of work is finished. Do not wait for the user to ask for a commit. coherent unit of work is finished. Do not wait for the user to ask for a commit.
Use `divergence` as the Conventional Commit scope for every change that
deliberately differs from original-game behavior to fix an original bug, for
example `fix(divergence): reject occupied wheel slots`.
## Versioning Policy ## Versioning Policy
Only update the version, when the user explicitly asks for it. Only update the version, when the user explicitly asks for it.
+170 -58
View File
@@ -8,6 +8,117 @@ and this project adheres to
## [Unreleased] ## [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
- Keep a ball captured by the Greifarm during multiball instead of consuming it;
latch the two-ball capture rule at each entry, exclude the launcher release
while that capture is active, and let later single-ball re-entries use all
four original release choices.
- Preserve that entry decision through the complete pull phase, scope it to the
entering slot until that ball exits the capture zone, and migrate Greifarm
state when a held secondary ball becomes the primary ball.
## [1.2.0] - 2026-08-31
### Added
- Seed new SQLite high-score databases that did not exist before opening with
the ten distributed demo scores.
### Fixed
- Deliberately diverge from the original game's multiball contact sentinel so a
reserve ball that fills a wheel hole leaves it permanently occupied; the
surviving ball can no longer enter and score that visibly filled slot again.
- Match capture-driven multiball collapse timing so the surviving slot keeps
double scoring through the current callback, then returns to normal scoring on
the next callback and the returning claw remains paused until that collapse;
completing the fifth wheel lock still ends multiball scoring immediately.
- Derive wheel-lock awards from the original live contact words so two balls
settling into different slots at once receive the same accumulated award as
the original game.
- Stage an effect-seven reserve-ball request until ball 1's full substep batch
has returned, preventing the new slot from participating in collision and
capture rules before the original creates it.
- Bound high-score server resource usage with 1 KiB route body limits,
single-operation database concurrency gating, 503 shedding, and offloading
SQLite operations to blocking worker threads.
- Use a relative path for the web build's high-score API endpoint so deployments
under nested URL subpaths route requests correctly.
- Implement bounded exponential backoff with jitter, initial delay and maximum
delay bounds, and `Retry-After` header support for failed web high-score
submissions.
## [1.1.0] - 2026-08-29
### Added
- Add a static WebAssembly (WASM) browser build and web shell for TDK Pinball,
persisting settings and scores in browser localStorage.
- Add a standalone Axum and SQLite high-score service (`highscore-server`) for
an optional shared top-ten leaderboard, including an nginx reverse-proxy
configuration and API endpoints.
- Route the browser high-score table through the shared service when available,
retaining the local-storage fallback when offline or unconfigured.
### Fixed
- Allow launcher input while tilted so a waiting ball in the shooter lane is not
stranded after a tilt, while retaining launch sound suppression.
- Bundle the official Macroquad browser loader and WASM bootstrap locally so a
CSP does not require inline JavaScript or code from `not-fl3.github.io`.
- Keep the web canvas centered at the original fixed 640x460 presentation size
instead of scaling with the browser viewport.
- Seed the initial gameplay random stream from the startup clock so the first
maximum-power launcher shot is not identical on every run.
- Require an unconsumed record-148 contact before the effect-seven special
respawn, preventing a released reserve ball from being duplicated after both
multiball balls drain.
- Apply type-4 entry latches only inside each target's registered broadphase,
preventing an unrelated multiball slot from clearing an upper-left target's
latch and causing repeated scoring while the ball remains there.
## [1.0.0] - 2026-08-29 ## [1.0.0] - 2026-08-29
### Fixed ### Fixed
@@ -23,13 +134,13 @@ and this project adheres to
a held ball remains velocity-stationary and the moving response retains its a held ball remains velocity-stationary and the moving response retains its
full normal velocity instead of always subtracting 1,000. full normal velocity instead of always subtracting 1,000.
- Restore `1000:ae6e`'s required-ball special respawn: armed or completed - Restore `1000:ae6e`'s required-ball special respawn: armed or completed
effect-seven play grants one final `(17,23)/(0,3040)` ball without consuming effect-seven play grants one final `(17,23)/(0,3040)` ball without consuming a
a marker, clears required state including record 148's contact word, and marker, clears required state including record 148's contact word, and
suppresses record 148 until the next broadphase-active type-4 record suppresses record 148 until the next broadphase-active type-4 record reenables
reenables it. it.
- Replace the invented post-move `y>470` fallback with the original pre-scan - Replace the invented post-move `y>470` fallback with the original pre-scan
prediction bounds `x<=0 || x>340000 || y<=0 || y>460000`, including prediction bounds `x<=0 || x>340000 || y<=0 || y>460000`, including one-slot
one-slot removal during multiball and next-substep handling after a response. removal during multiball and next-substep handling after a response.
- Remove the invented 180 ms whole-frame nudge shake; original nudges affect - Remove the invented 180 ms whole-frame nudge shake; original nudges affect
ball-slot velocities, sound, and Tilt state without moving the viewport. ball-slot velocities, sound, and Tilt state without moving the viewport.
- Match `SndPlaySound(SND_ASYNC|SND_NODEFAULT)` playback: stop the previous WAV - Match `SndPlaySound(SND_ASYNC|SND_NODEFAULT)` playback: stop the previous WAV
@@ -40,8 +151,8 @@ and this project adheres to
collect-bonus, and release-ball states are now visible. collect-bonus, and release-ball states are now visible.
- Restore display blits from `1008:0996`/`1008:0b9e`: 130x40 score fields at - Restore display blits from `1008:0996`/`1008:0b9e`: 130x40 score fields at
X=478, eight places ending at X=606, exact 139x31 configured-player rows, X=478, eight places ending at X=606, exact 139x31 configured-player rows,
11x11 active/status markers, 5x5 remaining-ball markers for every player, 11x11 active/status markers, 5x5 remaining-ball markers for every player, and
and strict signed score-status thresholds independent of media awards. strict signed score-status thresholds independent of media awards.
- Import exact `[Settings]` `Language`, `Speed`, and singular `Sound` values - Import exact `[Settings]` `Language`, `Speed`, and singular `Sound` values
from an adjacent/original TDKPIN.INI before creating portable JSON. Fresh from an adjacent/original TDKPIN.INI before creating portable JSON. Fresh
installs now inherit the distributed German help setting; F12 is again a installs now inherit the distributed German help setting; F12 is again a
@@ -54,17 +165,17 @@ and this project adheres to
DAT995 at `(202,328)`, height 13 and clipped width `(stage-1)*7`; correct the DAT995 at `(202,328)`, height 13 and clipped width `(stage-1)*7`; correct the
previously impossible Pascal-order C interpretation of a 328x202 blit. previously impossible Pascal-order C interpretation of a 328x202 blit.
- Restore the original idle/attract timer animation: target and word-quad - Restore the original idle/attract timer animation: target and word-quad
chases, magnetic flashes, five-record strip, four-point chase, DAT801-809 chases, magnetic flashes, five-record strip, four-point chase, DAT801-809 item
item progression, and the circular `WELCOME TO THE MACHINE` BITMAP500 progression, and the circular `WELCOME TO THE MACHINE` BITMAP500 marquee. Each
marquee. Each nested phase uses the binary's remainder first: `%32/2`, nested phase uses the binary's remainder first: `%32/2`, `%32/4`, `%40/4`,
`%32/4`, `%40/4`, `%16/4`, and `%144/8`. Move gameplay item frames to their `%16/4`, and `%144/8`. Move gameplay item frames to their exact `(123,300)`
exact `(123,300)` destination. destination.
- Match central score/event side effects: every stored collision candidate - Match central score/event side effects: every stored collision candidate
clears its ball slot's capture age, Tilt suppresses all score and rule-record clears its ball slot's capture age, Tilt suppresses all score and rule-record
mutations, and active multiball doubles points before permanent Double can mutations, and active multiball doubles points before permanent Double can
double them again. double them again.
- Scan static, capture, trigger, magnetic, and dynamic record ranges in exact - Scan static, capture, trigger, magnetic, and dynamic record ranges in exact ID
ID order with a separately tracked prediction. Preserve the raw scanner's order with a separately tracked prediction. Preserve the raw scanner's
first-detected-candidate quirk even when a later record is geometrically first-detected-candidate quirk even when a later record is geometrically
nearer, plus type-3 and dynamic-record broadphase behavior. nearer, plus type-3 and dynamic-record broadphase behavior.
- Run type-3/type-4 processing before response and position publication: - Run type-3/type-4 processing before response and position publication:
@@ -72,16 +183,16 @@ and this project adheres to
candidates, triggers mutate the live motion first, and sensor IDs execute in candidates, triggers mutate the live motion first, and sensor IDs execute in
binary order without Hold/Complete prematurely ending the record scan. binary order without Hold/Complete prematurely ending the record scan.
- Apply the collision table's predicted-position broadphase bounds before - Apply the collision table's predicted-position broadphase bounds before
type-1/type-2 detection, including the registered five-pixel ball margin, type-1/type-2 detection, including the registered five-pixel ball margin, and
and retain unresolved candidates until the selected response phase. retain unresolved candidates until the selected response phase.
- Correct the two relative slingshot initializer chains after records 55 and - Correct the two relative slingshot initializer chains after records 55 and 70:
70: circles 56/58/71/73 and lines 57/59/72/74 now accumulate from each circles 56/58/71/73 and lines 57/59/72/74 now accumulate from each preceding
preceding path endpoint exactly as the 175-record ledger specifies. The path endpoint exactly as the 175-record ledger specifies. The flagless TDK
flagless TDK companion lines also retain their zero contact/link field rather companion lines also retain their zero contact/link field rather than carrying
than carrying unused synthetic head IDs. Record 57 additionally matches the unused synthetic head IDs. Record 57 additionally matches the live original's
live original's `(75530,354765)/(3000,15)` to `(75530,354765)/(3000,15)` to `(77369,356597)/(1839,1832)` transition;
`(77369,356597)/(1839,1832)` transition; symmetric record 72 matches symmetric record 72 matches `(235348,356825)/(-3000,15)` to
`(235348,356825)/(-3000,15)` to `(233253,358494)/(-2095,1669)`. `(233253,358494)/(-2095,1669)`.
- Match rectangular type-4 magnetic gates to raw `1000:9b69`: enter only when - Match rectangular type-4 magnetic gates to raw `1000:9b69`: enter only when
the previous position is inside, then deactivate when the newly predicted the previous position is inside, then deactivate when the newly predicted
position exits any edge, replacing a swept-intersection approximation. position exits any edge, replacing a swept-intersection approximation.
@@ -90,11 +201,11 @@ and this project adheres to
- Match the multiball nudge path's saved-slot behavior: consume the active - Match the multiball nudge path's saved-slot behavior: consume the active
formula's random draws, then add one shared fixed/random impulse to both slot formula's random draws, then add one shared fixed/random impulse to both slot
velocity records instead of applying different formulas to each ball. velocity records instead of applying different formulas to each ball.
- Restore auxiliary response thresholds/kicks for bumpers 51-53 and line - Restore auxiliary response thresholds/kicks for bumpers 51-53 and line records
records 55, 74, and 107, including weak-hit bumper scoring, WAVE 2019 on 55, 74, and 107, including weak-hit bumper scoring, WAVE 2019 on kicked
kicked unscored rails, and the original tilt suppression. unscored rails, and the original tilt suppression.
- Preserve every collision record's `+0x49` layer mask and select upper layer - Preserve every collision record's `+0x49` layer mask and select upper layer 1
1 or lower layer 2 from the ball's previous 250,000-millipixel Y boundary. or lower layer 2 from the ball's previous 250,000-millipixel Y boundary.
- Match the original detail callback's slot-major integration order, stop a - Match the original detail callback's slot-major integration order, stop a
slot's remaining 10 ms substeps after its first collision/action, delay a slot's remaining 10 ms substeps after its first collision/action, delay a
newly requested second ball until the next callback, and retain ball-two newly requested second ball until the next callback, and retain ball-two
@@ -102,9 +213,9 @@ and this project adheres to
- Run type-3 captures and type-4 triggers for both live ball slots, including - Run type-3 captures and type-4 triggers for both live ball slots, including
per-slot capture age/contact ownership, exact captured-slot removal, survivor per-slot capture age/contact ownership, exact captured-slot removal, survivor
promotion, and the effect-seven ball-number guard against a third ball. promotion, and the effect-seven ball-number guard against a third ball.
- Spawn effect-seven ball 2 from record 148 at `(17,23)` with exact - Spawn effect-seven ball 2 from record 148 at `(17,23)` with exact `(0,3040)`
`(0,3040)` millipixel velocity, instead of launching a guessed second ball millipixel velocity, instead of launching a guessed second ball upward from
upward from the shooter lane. the shooter lane.
- Treat record 148 as the special/multiball hole represented by player item 20; - Treat record 148 as the special/multiball hole represented by player item 20;
capturing it preserves all five lock-hole items and contacts instead of capturing it preserves all five lock-hole items and contacts instead of
inventing an immediate wheel reset. Its exact 17x17 overlay-A Word-Quad at inventing an immediate wheel reset. Its exact 17x17 overlay-A Word-Quad at
@@ -115,12 +226,12 @@ and this project adheres to
- Render the five cumulative bumper-value lamps from original player items 1-5 - Render the five cumulative bumper-value lamps from original player items 1-5
at their recovered Word-Quad bounds as the value rises from 1,000 to 6,000. at their recovered Word-Quad bounds as the value rises from 1,000 to 6,000.
- Render the nine in-bank hit states for record heads 90/93/96/99/102 and - Render the nine in-bank hit states for record heads 90/93/96/99/102 and
109/112/115/118 from their exact overlay-A bounds until bank completion 109/112/115/118 from their exact overlay-A bounds until bank completion rearms
rearms each three-line group. each three-line group.
- Replace the free-running wheel animation with the original six-callback - Replace the free-running wheel animation with the original six-callback 91x90
91x90 target rotation, WAVE 2011 start, exact DAT600 frames/target positions, target rotation, WAVE 2011 start, exact DAT600 frames/target positions, and
and state-6 rotation of the five per-player contact/item values. It advances state-6 rotation of the five per-player contact/item values. It advances only
only for an active ball, pauses in the launcher, and survives a normal drain. for an active ball, pauses in the launcher, and survives a normal drain.
- Restore the complete 281-callback DAT600 panel sequence after the fifth lock - Restore the complete 281-callback DAT600 panel sequence after the fifth lock
hole, including physics suspension, contact/item cleanup, all nine image hole, including physics suspension, contact/item cleanup, all nine image
phases, and the original WAVE 2013/2012/stop boundaries. A fifth lock captured phases, and the original WAVE 2013/2012/stop boundaries. A fifth lock captured
@@ -131,9 +242,9 @@ and this project adheres to
- Remove the invented attract-screen instruction panel and stop assigning the - Remove the invented attract-screen instruction panel and stop assigning the
original F2/F3 keys to modern overlays. The idle view is the original table; original F2/F3 keys to modern overlays. The idle view is the original table;
optional settings/high-score viewers move to F10/F9. optional settings/high-score viewers move to F10/F9.
- Batch physics and flipper publication on the original 50/40/30/20/10-ms - Batch physics and flipper publication on the original 50/40/30/20/10-ms detail
detail callbacks with exactly 5/4/3/2/1 internal 10-ms substeps, instead of callbacks with exactly 5/4/3/2/1 internal 10-ms substeps, instead of exposing
exposing every substep on host render frames. every substep on host render frames.
- Separate flipper key flags from rendered/collision position so press/release - Separate flipper key flags from rendered/collision position so press/release
edges and WAVE 2021 occur only when the detail timer processes them. edges and WAVE 2021 occur only when the detail timer processes them.
- Add a bit-exact Borland Real48 core and route speed clamps, type-1 circles, - Add a bit-exact Borland Real48 core and route speed clamps, type-1 circles,
@@ -141,8 +252,8 @@ and this project adheres to
magnetic impulses through its original rounding behavior. magnetic impulses through its original rounding behavior.
- Preserve per-ball Real48 spin and use it in collision tangent response, so - Preserve per-ball Real48 spin and use it in collision tangent response, so
zero-spin C fixtures and retained-spin Wine traces are both represented. zero-spin C fixtures and retained-spin Wine traces are both represented.
- Implement dynamic records 174/175 with bidirectional ball impulse transfer - Implement dynamic records 174/175 with bidirectional ball impulse transfer and
and the original post-collision speed clamp instead of a one-sided secondary the original post-collision speed clamp instead of a one-sided secondary
bounce. bounce.
- Split collision contact state into per-player 16-bit type-3 contact words and - Split collision contact state into per-player 16-bit type-3 contact words and
transient type-4 entry flags. Type-3 records now use the original deep-inside transient type-4 entry flags. Type-3 records now use the original deep-inside
@@ -182,11 +293,12 @@ and this project adheres to
- Match the original low-byte keyboard scans: both Ctrl keys operate the left - Match the original low-byte keyboard scans: both Ctrl keys operate the left
flipper, both Enter keys operate the right, and the physical `0x1b` flipper, both Enter keys operate the right, and the physical `0x1b`
main-keyboard plus/right-bracket position starts or adds players. main-keyboard plus/right-bracket position starts or adds players.
- Dispatch add-player, nudge, and F12 sound-toggle actions on key release through - Dispatch add-player, nudge, and F12 sound-toggle actions on key release
the original `1000:638e` timing rather than on their initial key press. through the original `1000:638e` timing rather than on their initial key
press.
- Enforce the original auxiliary-window input gate: F1 and F12 are ignored while - Enforce the original auxiliary-window input gate: F1 and F12 are ignored while
Help, HighScore, or name-entry UI is open instead of controlling the main Help, HighScore, or name-entry UI is open instead of controlling the main game
game through a child window. through a child window.
- Route a single-ball type-3 completion during Tilt through `1000:ae6e` instead - Route a single-ball type-3 completion during Tilt through `1000:ae6e` instead
of granting a free launcher reset: lock holes consume the ball, while record of granting a free launcher reset: lock holes consume the ball, while record
148 can still take its required-state special-respawn branch. 148 can still take its required-state special-respawn branch.
@@ -202,18 +314,18 @@ and this project adheres to
playback and restores bank, trigger, capture, nudge, tilt, and drain cues. playback and restores bank, trigger, capture, nudge, tilt, and drain cues.
Type-4 WAVE 2004 and bumper WAVE 2006 now precede their score mutations so a Type-4 WAVE 2004 and bumper WAVE 2006 now precede their score mutations so a
simultaneously crossed media marker leaves WAVE 2007 audible, as in the simultaneously crossed media marker leaves WAVE 2007 audible, as in the
monophonic original. A tilted drain suppresses WAVE 2008 before reset, while monophonic original. A tilted drain suppresses WAVE 2008 before reset, while a
a following flipper-release edge may still play WAVE 2021 after reset. following flipper-release edge may still play WAVE 2021 after reset.
Bumper/TDK bank completion WAVE 2017 likewise precedes its 50,000 or Bumper/TDK bank completion WAVE 2017 likewise precedes its 50,000 or
10,000..24,464 award, preserving the marker cue when that award crosses one. 10,000..24,464 award, preserving the marker cue when that award crosses one.
- Advance media/extra-ball thresholds only from score additions, preserve - Advance media/extra-ball thresholds only from score additions, preserve 32-bit
32-bit score wrapping, and restore the ninth-diamond ordering: its 24,464 score wrapping, and restore the ninth-diamond ordering: its 24,464 award
award remains single, then ball-scoped double scoring and all three magnetic remains single, then ball-scoped double scoring and all three magnetic fields
fields activate before the collision's static score. Further completed banks activate before the collision's static score. Further completed banks during
during that ball add 100,000 to the secondary score; a normal ball end clears that ball add 100,000 to the secondary score; a normal ball end clears the
the multiplier, while effect seven's special respawn preserves it. Marker multiplier, while effect seven's special respawn preserves it. Marker
comparisons retain the binary's signed-high/unsigned-low 32-bit ordering comparisons retain the binary's signed-high/unsigned-low 32-bit ordering after
after score wrap. score wrap.
- Match the original 640x460 window, native 16x28 score glyphs, and - Match the original 640x460 window, native 16x28 score glyphs, and
`TDK Pinball Machine 1.00` title so the recovered artwork is presented `TDK Pinball Machine 1.00` title so the recovered artwork is presented
one-for-one instead of enlarged or resampled. one-for-one instead of enlarged or resampled.
+55 -1
View File
@@ -164,6 +164,42 @@ dependencies = [
"ttf-parser", "ttf-parser",
] ]
[[package]]
name = "futures-core"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
[[package]]
name = "futures-macro"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "futures-task"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd"
[[package]]
name = "futures-util"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
dependencies = [
"futures-core",
"futures-macro",
"futures-task",
"pin-project-lite",
"slab",
]
[[package]] [[package]]
name = "getrandom" name = "getrandom"
version = "0.2.17" version = "0.2.17"
@@ -362,6 +398,12 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
[[package]]
name = "pin-project-lite"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]] [[package]]
name = "png" name = "png"
version = "0.17.16" version = "0.17.16"
@@ -481,6 +523,12 @@ version = "0.3.10"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
[[package]]
name = "slab"
version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
[[package]] [[package]]
name = "smallvec" name = "smallvec"
version = "0.6.14" version = "0.6.14"
@@ -503,14 +551,20 @@ dependencies = [
[[package]] [[package]]
name = "tdkpin-rs" name = "tdkpin-rs"
version = "1.0.0" version = "1.2.1"
dependencies = [ dependencies = [
"directories", "directories",
"futures-util",
"macroquad", "macroquad",
"serde", "serde",
"serde_json", "serde_json",
"tdkpin-web-storage",
] ]
[[package]]
name = "tdkpin-web-storage"
version = "1.0.0"
[[package]] [[package]]
name = "thiserror" name = "thiserror"
version = "2.0.20" version = "2.0.20"
+8 -2
View File
@@ -1,14 +1,20 @@
[package] [package]
name = "tdkpin-rs" name = "tdkpin-rs"
version = "1.0.0" version = "1.2.1"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
directories = "6"
macroquad = { version = "0.4", features = ["audio"] } macroquad = { version = "0.4", features = ["audio"] }
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
directories = "6"
[target.'cfg(target_arch = "wasm32")'.dependencies]
futures-util = "0.3"
tdkpin-web-storage = { path = "web_storage" }
[lints.clippy] [lints.clippy]
pedantic = { level = "warn", priority = -1 } pedantic = { level = "warn", priority = -1 }
todo = "warn" todo = "warn"
+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 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. 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 ## Reproducible final gates
```sh ```sh
+29 -5
View File
@@ -31,6 +31,20 @@ development packages are also required (X11, OpenGL, and ALSA). Windows needs
no extra runtime installation; macOS builds with the normal Apple developer no extra runtime installation; macOS builds with the normal Apple developer
command-line tools. command-line tools.
## Browser version
Build and serve the WASM website locally with:
```sh
just web-serve
```
Then open <http://127.0.0.1:8000/>. The browser build keeps the original
640x460 presentation centered on a black page and stores settings and a local
fallback table in browser storage unless the optional same-origin
`/api/highscores` service is deployed. See [web/README.md](web/README.md) and
[highscore-server/README.md](highscore-server/README.md) for deployment details.
## Deterministic mechanics validation ## Deterministic mechanics validation
Named scenarios can be advanced without waiting in real time. The simulator Named scenarios can be advanced without waiting in real time. The simulator
@@ -46,7 +60,7 @@ cargo run -- --simulate claw-6 --at 0.15 \
Use `--step N` instead of `--at SECONDS` to reproduce one exact update. The Use `--step N` instead of `--at SECONDS` to reproduce one exact update. The
available scenarios are `loading`, `attract`, `highscores`, `name-entry`, available scenarios are `loading`, `attract`, `highscores`, `name-entry`,
`effect-7`, `autoplay`, `launcher`, `flippers`, `panel`, `targets`, `claw-1`, `claw-6`, `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 `autoplay` charges each ball and operates the flippers from live ball position
for long end-to-end validation runs. for long end-to-end validation runs.
@@ -63,6 +77,13 @@ for long end-to-end validation runs.
| Settings | `TDKPIN.INI` before launch | F10 opens the portable settings screen | | Settings | `TDKPIN.INI` before launch | F10 opens the portable settings screen |
| High scores | Automatic when a player finishes | F9 opens the portable table viewer | | High scores | Automatic when a player finishes | F9 opens the portable table viewer |
| Sound | F12 | - | | 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 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 the original invariant 100 Hz millipixel substep, batched as 5/4/3/2/1 steps on
@@ -75,10 +96,13 @@ box closes the program.
## Saved data ## Saved data
Settings and the ten-entry high-score table are stored as `save.json` in the Native settings and the ten-entry high-score table are stored as `save.json` in
platform's normal per-user application-data directory. On first run, settings the platform's normal per-user application-data directory. Browser settings
are imported from an adjacent original INI (or the embedded distributed and the fallback table use `localStorage`; a deployed browser build uses the
TDKPIN.INI), and the table is imported from the original `HISCORES.DAT`. same-origin high-score service as its shared table when available. On first
run, native settings are imported from an adjacent original INI (or the
embedded distributed TDKPIN.INI), and the table is imported from the original
`HISCORES.DAT`.
## Reconstruction status ## Reconstruction status
+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; - `app.rs`: screen flow, controls, rendering, audio dispatch, and overlays;
- `assets.rs`: compile-time original asset embedding; - `assets.rs`: compile-time original asset embedding;
- `game.rs`: fixed-step game state, recovered walls, rules, and scoring; - `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; - `geometry.rs`: segment/circle collision primitives;
- `original_physics.rs`: millipixel integration and recovered type-2 responses; - `original_physics.rs`: millipixel integration and recovered type-2 responses;
- `real48.rs`: bit-exact Borland six-byte arithmetic, conversion, and square root; - `real48.rs`: bit-exact Borland six-byte arithmetic, conversion, and square root;
- `flipper_physics.rs`: reconstructed moving-flipper geometry and response; - `flipper_physics.rs`: reconstructed moving-flipper geometry and response;
- `table.rs`: source-traceable collision objects recovered from the Win16 table; - `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.
+805
View File
@@ -0,0 +1,805 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "atomic-waker"
version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
[[package]]
name = "axum"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90"
dependencies = [
"axum-core",
"bytes",
"form_urlencoded",
"futures-util",
"http",
"http-body",
"http-body-util",
"hyper",
"hyper-util",
"itoa",
"matchit",
"memchr",
"mime",
"percent-encoding",
"pin-project-lite",
"serde_core",
"serde_json",
"serde_path_to_error",
"serde_urlencoded",
"sync_wrapper",
"tokio",
"tower",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
name = "axum-core"
version = "0.5.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1"
dependencies = [
"bytes",
"futures-core",
"http",
"http-body",
"http-body-util",
"mime",
"pin-project-lite",
"sync_wrapper",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
name = "bitflags"
version = "2.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da"
[[package]]
name = "bumpalo"
version = "3.20.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
[[package]]
name = "bytes"
version = "1.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
[[package]]
name = "cc"
version = "1.4.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ad534f4357a5264cce5019c989cf66a4f0dc4e0d1b1d15f8aacec0ff7360273"
dependencies = [
"find-msvc-tools",
"shlex",
]
[[package]]
name = "cfg-if"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
[[package]]
name = "errno"
version = "0.3.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys",
]
[[package]]
name = "fallible-iterator"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649"
[[package]]
name = "fallible-streaming-iterator"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
[[package]]
name = "fastrand"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
[[package]]
name = "find-msvc-tools"
version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890"
[[package]]
name = "foldhash"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
[[package]]
name = "form_urlencoded"
version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
dependencies = [
"percent-encoding",
]
[[package]]
name = "futures-channel"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4"
dependencies = [
"futures-core",
]
[[package]]
name = "futures-core"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
[[package]]
name = "futures-task"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd"
[[package]]
name = "futures-util"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
dependencies = [
"futures-core",
"futures-task",
"pin-project-lite",
"slab",
]
[[package]]
name = "getrandom"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
dependencies = [
"cfg-if",
"libc",
"r-efi",
]
[[package]]
name = "hashbrown"
version = "0.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
dependencies = [
"foldhash",
]
[[package]]
name = "hashbrown"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
dependencies = [
"foldhash",
]
[[package]]
name = "hashlink"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32069d97bb81e38fa67eab65e3393bf804bb85969f2bc06bf13f64aef5aba248"
dependencies = [
"hashbrown 0.17.1",
]
[[package]]
name = "http"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0"
dependencies = [
"bytes",
"itoa",
]
[[package]]
name = "http-body"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c"
dependencies = [
"bytes",
"http",
]
[[package]]
name = "http-body-util"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c"
dependencies = [
"bytes",
"futures-core",
"http",
"http-body",
"pin-project-lite",
]
[[package]]
name = "httparse"
version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
[[package]]
name = "httpdate"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
[[package]]
name = "hyper"
version = "1.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43"
dependencies = [
"atomic-waker",
"bytes",
"futures-channel",
"futures-core",
"http",
"http-body",
"httparse",
"httpdate",
"itoa",
"pin-project-lite",
"smallvec",
"tokio",
]
[[package]]
name = "hyper-util"
version = "0.1.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0"
dependencies = [
"bytes",
"http",
"http-body",
"hyper",
"pin-project-lite",
"tokio",
"tower-service",
]
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "js-sys"
version = "0.3.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a"
dependencies = [
"cfg-if",
"wasm-bindgen",
]
[[package]]
name = "libc"
version = "0.2.189"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
[[package]]
name = "libsqlite3-sys"
version = "0.38.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1d20bef17f513b9b3004532233187769cd072d790971f4e4da0e346eb6401e8"
dependencies = [
"cc",
"pkg-config",
"vcpkg",
]
[[package]]
name = "linux-raw-sys"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
[[package]]
name = "log"
version = "0.4.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
[[package]]
name = "matchit"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
[[package]]
name = "memchr"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "mime"
version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
[[package]]
name = "mio"
version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427"
dependencies = [
"libc",
"wasi",
"windows-sys",
]
[[package]]
name = "once_cell"
version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "percent-encoding"
version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "pin-project-lite"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "pkg-config"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548"
[[package]]
name = "proc-macro2"
version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
dependencies = [
"proc-macro2",
]
[[package]]
name = "r-efi"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
[[package]]
name = "rsqlite-vfs"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c"
dependencies = [
"hashbrown 0.16.1",
"thiserror",
]
[[package]]
name = "rusqlite"
version = "0.40.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23f2a97da3e3873c73cb2a2e71b35c40ff95e0b1eefa8d72d8499a6928c3b5b3"
dependencies = [
"bitflags",
"fallible-iterator",
"fallible-streaming-iterator",
"hashlink",
"libsqlite3-sys",
"smallvec",
"sqlite-wasm-rs",
]
[[package]]
name = "rustix"
version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
dependencies = [
"bitflags",
"errno",
"libc",
"linux-raw-sys",
"windows-sys",
]
[[package]]
name = "rustversion"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
[[package]]
name = "ryu"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
[[package]]
name = "serde"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
name = "serde_core"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.4",
]
[[package]]
name = "serde_json"
version = "1.0.151"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "serde_path_to_error"
version = "0.1.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457"
dependencies = [
"itoa",
"serde",
"serde_core",
]
[[package]]
name = "serde_urlencoded"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
dependencies = [
"form_urlencoded",
"itoa",
"ryu",
"serde",
]
[[package]]
name = "shlex"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
[[package]]
name = "slab"
version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
[[package]]
name = "smallvec"
version = "1.15.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90"
[[package]]
name = "socket2"
version = "0.6.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
dependencies = [
"libc",
"windows-sys",
]
[[package]]
name = "sqlite-wasm-rs"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc3efc0da82635d7e1ced0053bbbfa8c7ab9645d0bf36ceb4f7127bb85315d75"
dependencies = [
"cc",
"js-sys",
"rsqlite-vfs",
"wasm-bindgen",
]
[[package]]
name = "syn"
version = "2.0.119"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "syn"
version = "3.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "sync_wrapper"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
[[package]]
name = "tdkpin-highscore-server"
version = "0.1.0"
dependencies = [
"axum",
"http-body-util",
"rusqlite",
"serde",
"serde_json",
"tempfile",
"tokio",
"tower",
]
[[package]]
name = "tempfile"
version = "3.27.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
"getrandom",
"once_cell",
"rustix",
"windows-sys",
]
[[package]]
name = "thiserror"
version = "2.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f"
dependencies = [
"thiserror-impl",
]
[[package]]
name = "thiserror-impl"
version = "2.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.4",
]
[[package]]
name = "tokio"
version = "1.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed"
dependencies = [
"libc",
"mio",
"pin-project-lite",
"socket2",
"tokio-macros",
"windows-sys",
]
[[package]]
name = "tokio-macros"
version = "2.7.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.4",
]
[[package]]
name = "tower"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4"
dependencies = [
"futures-core",
"futures-util",
"pin-project-lite",
"sync_wrapper",
"tokio",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
name = "tower-layer"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e"
[[package]]
name = "tower-service"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
[[package]]
name = "tracing"
version = "0.1.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
dependencies = [
"log",
"pin-project-lite",
"tracing-core",
]
[[package]]
name = "tracing-core"
version = "0.1.36"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
dependencies = [
"once_cell",
]
[[package]]
name = "unicode-ident"
version = "1.0.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]]
name = "vcpkg"
version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
[[package]]
name = "wasi"
version = "0.11.1+wasi-snapshot-preview1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]]
name = "wasm-bindgen"
version = "0.2.127"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70"
dependencies = [
"cfg-if",
"once_cell",
"rustversion",
"wasm-bindgen-macro",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.127"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
]
[[package]]
name = "wasm-bindgen-macro-support"
version = "0.2.127"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284"
dependencies = [
"bumpalo",
"proc-macro2",
"quote",
"syn 2.0.119",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-shared"
version = "0.2.127"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf"
dependencies = [
"unicode-ident",
]
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-sys"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [
"windows-link",
]
[[package]]
name = "zmij"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
+45
View File
@@ -0,0 +1,45 @@
[package]
name = "tdkpin-highscore-server"
version = "0.1.0"
edition = "2024"
publish = false
[dependencies]
axum = "0.8"
rusqlite = { version = "0.40", features = ["bundled"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["macros", "net", "rt-multi-thread", "sync"] }
[dev-dependencies]
http-body-util = "0.1"
tempfile = "3"
tokio = { version = "1", features = ["time"] }
tower = { version = "0.5", features = ["util"] }
[lints.clippy]
pedantic = { level = "warn", priority = -1 }
todo = "warn"
unwrap_used = "warn"
[lints.rust]
unsafe_code = "forbid"
[profile.release]
debug = true
strip = false
debug-assertions = true
overflow-checks = true
lto = false
panic = "unwind"
incremental = true
[profile.production]
inherits = "release"
debug = false
strip = true
debug-assertions = false
overflow-checks = false
lto = true
incremental = false
codegen-units = 1
+41
View File
@@ -0,0 +1,41 @@
# TDK high-score service
This small Axum service stores the shared top-ten table in SQLite. It exposes:
```text
GET /healthz
GET /api/highscores
POST /api/highscores
```
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.
Submissions are anonymous and intentionally trust the browser's score. Add rate
limiting, moderation, or server-side run verification if the table needs to
resist forged scores.
Run it from `tdkpin-rs` with:
```sh
TDKPIN_HIGHSCORE_BIND=127.0.0.1:3000 \
TDKPIN_HIGHSCORE_DB=/var/lib/tdkpin/highscores.sqlite3 \
cargo run --manifest-path highscore-server/Cargo.toml
```
Copy the rate and connection zone declarations from
[nginx.conf.example](./nginx.conf.example) into the existing `http` block, then
place its two `location` blocks inside the public site's `server` block. The
example bounds per-client and aggregate API traffic, request bodies, and proxy
waits. The browser client expects the API at `/api/highscores` on the same
origin as the game.
The crate inherits the parent [`rustfmt.toml`](../rustfmt.toml); run
`just fmt-highscore-server` when formatting it directly.
@@ -0,0 +1,36 @@
# Add these directives inside the existing nginx http block. The server-wide
# zones bound aggregate traffic, while the address-keyed zones prevent one
# client from consuming the whole allowance.
limit_req_zone $binary_remote_addr zone=tdkpin_highscore_client_rate:10m rate=5r/s;
limit_req_zone $server_name zone=tdkpin_highscore_global_rate:1m rate=50r/s;
limit_conn_zone $binary_remote_addr zone=tdkpin_highscore_client_connections:10m;
limit_conn_zone $server_name zone=tdkpin_highscore_global_connections:1m;
# Add these blocks inside the server block that serves the game.
location /api/highscores {
limit_req zone=tdkpin_highscore_client_rate burst=10 nodelay;
limit_req zone=tdkpin_highscore_global_rate burst=25 nodelay;
limit_req_status 429;
limit_conn tdkpin_highscore_client_connections 10;
limit_conn tdkpin_highscore_global_connections 100;
limit_conn_status 429;
client_max_body_size 1k;
client_body_timeout 5s;
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_connect_timeout 2s;
proxy_send_timeout 5s;
proxy_read_timeout 5s;
proxy_next_upstream off;
}
# Optional health check for local monitoring.
location = /healthz {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Host $host;
}
+692
View File
@@ -0,0 +1,692 @@
use std::{
path::Path,
sync::{Arc, Mutex},
};
use axum::{
Json,
Router,
extract::{DefaultBodyLimit, State},
http::StatusCode,
response::{IntoResponse, Response},
routing::get,
};
use rusqlite::{Connection, params, types::Type};
use serde::{Deserialize, Serialize};
use tokio::{sync::Semaphore, task};
const MAX_HIGH_SCORES: usize = 10;
const MAX_NAME_CHARS: usize = 21;
const MAX_SUBMISSION_BODY_BYTES: usize = 1024;
const MAX_CONCURRENT_DATABASE_OPERATIONS: usize = 1;
const DEFAULT_HIGH_SCORES: &[(&str, u32)] = &[
("Paul", 6_537_392),
("Paul Schulze", 2_979_000),
("Kalle", 2_393_464),
("Paul Schulze", 2_328_000),
("Martina Sommer", 2_326_000),
("Martina Sommer", 1_093_000),
("Sommer Martina", 1_027_000),
("No Name", 1_000_000),
("TDK Pinball Player", 923_000),
("Martina Sommer", 905_000),
];
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
pub struct HighScore {
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)]
pub enum StoreError {
Database(rusqlite::Error),
Poisoned,
}
impl From<rusqlite::Error> for StoreError {
fn from(error: rusqlite::Error) -> Self {
Self::Database(error)
}
}
#[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.
///
/// # Errors
///
/// Returns the SQLite error raised while opening or initializing the
/// database.
pub fn open(path: impl AsRef<Path>) -> Result<Self, rusqlite::Error> {
let path = path.as_ref();
let seed_defaults = !path.exists();
Self::from_connection(Connection::open(path)?, seed_defaults)
}
/// Create an in-memory high-score store for tests or short-lived runs.
///
/// # Errors
///
/// Returns the SQLite error raised while initializing the database.
pub fn open_in_memory() -> Result<Self, rusqlite::Error> {
Self::from_connection(Connection::open_in_memory()?, false)
}
fn from_connection(
mut connection: Connection,
seed_defaults: bool,
) -> Result<Self, rusqlite::Error> {
connection.execute_batch(
"CREATE TABLE IF NOT EXISTS high_scores (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
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 {
transaction.execute(
"INSERT INTO high_scores (name, score) VALUES (?1, ?2)",
params![name, i64::from(score)],
)?;
}
transaction.commit()?;
}
Ok(Self(Arc::new(Mutex::new(connection))))
}
/// Return the current table in descending score order.
///
/// # Errors
///
/// Returns an error if the database lock or query fails.
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, crazy
FROM high_scores
ORDER BY score DESC, id ASC
LIMIT ?1",
)?;
let scores = statement
.query_map([i64::try_from(MAX_HIGH_SCORES).unwrap_or(10)], |row| {
let score: i64 = row.get(1)?;
let score = u32::try_from(score).map_err(|error| {
rusqlite::Error::FromSqlConversionFailure(1, Type::Integer, Box::new(error))
})?;
Ok(HighScore {
name: row.get(0)?,
score,
crazy: row.get(2)?,
})
})?
.collect::<Result<Vec<_>, _>>()?;
Ok(scores)
}
/// Add one score and return the resulting canonical top ten.
///
/// # Errors
///
/// Returns an error if the database lock or transaction fails.
pub fn submit(&self, entry: &HighScore) -> Result<Vec<HighScore>, StoreError> {
{
let mut connection = self.0.lock().map_err(|_| StoreError::Poisoned)?;
let transaction = connection.transaction()?;
transaction.execute(
"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
WHERE id NOT IN (
SELECT id FROM high_scores
ORDER BY score DESC, id ASC
LIMIT ?1
)",
[i64::try_from(MAX_HIGH_SCORES).unwrap_or(10)],
)?;
transaction.commit()?;
}
self.list()
}
}
#[derive(Clone)]
struct AppState {
store: HighScoreStore,
database_slots: Arc<Semaphore>,
}
impl AppState {
fn new(store: HighScoreStore) -> Self {
Self {
store,
database_slots: Arc::new(Semaphore::new(MAX_CONCURRENT_DATABASE_OPERATIONS)),
}
}
}
#[derive(Debug)]
enum DatabaseRequestError {
Busy,
Failed,
}
async fn run_database_operation<T, F>(
state: &AppState,
operation: F,
) -> Result<T, DatabaseRequestError>
where
T: Send + 'static,
F: FnOnce(&HighScoreStore) -> Result<T, StoreError> + Send + 'static,
{
let permit = state
.database_slots
.clone()
.try_acquire_owned()
.map_err(|_| DatabaseRequestError::Busy)?;
let store = state.store.clone();
task::spawn_blocking(move || {
let _permit = permit;
operation(&store)
})
.await
.map_err(|_| DatabaseRequestError::Failed)?
.map_err(|_| DatabaseRequestError::Failed)
}
#[derive(Debug, Deserialize)]
struct SubmitRequest {
name: String,
score: u32,
#[serde(default)]
crazy: bool,
}
#[derive(Debug, Serialize)]
struct ErrorResponse {
error: &'static str,
}
fn invalid_request(message: &'static str) -> Response {
(
StatusCode::BAD_REQUEST,
Json(ErrorResponse { error: message }),
)
.into_response()
}
fn database_error_response(error: &DatabaseRequestError) -> Response {
match error {
DatabaseRequestError::Busy => (
StatusCode::SERVICE_UNAVAILABLE,
Json(ErrorResponse {
error: "service is busy",
}),
)
.into_response(),
DatabaseRequestError::Failed => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
}
}
fn validate_request(request: &SubmitRequest) -> Result<HighScore, &'static str> {
let name = request.name.trim();
if name.is_empty() {
return Err("name must not be empty");
}
if name.chars().count() > MAX_NAME_CHARS {
return Err("name is too long");
}
if name.chars().any(char::is_control) {
return Err("name contains a control character");
}
Ok(HighScore {
name: name.to_owned(),
score: request.score,
crazy: request.crazy,
})
}
async fn health() -> &'static str {
"ok"
}
async fn list_high_scores(State(state): State<AppState>) -> Response {
match run_database_operation(&state, HighScoreStore::list).await {
Ok(scores) => Json(scores).into_response(),
Err(error) => database_error_response(&error),
}
}
async fn submit_high_score(
State(state): State<AppState>,
Json(request): Json<SubmitRequest>,
) -> Response {
let entry = match validate_request(&request) {
Ok(entry) => entry,
Err(message) => return invalid_request(message),
};
match run_database_operation(&state, move |store| store.submit(&entry)).await {
Ok(scores) => (StatusCode::CREATED, Json(scores)).into_response(),
Err(error) => database_error_response(&error),
}
}
pub fn router(store: HighScoreStore) -> Router {
router_with_state(AppState::new(store))
}
fn router_with_state(state: AppState) -> Router {
Router::new()
.route("/healthz", get(health))
.route(
"/api/highscores",
get(list_high_scores)
.post(submit_high_score)
.layer(DefaultBodyLimit::max(MAX_SUBMISSION_BODY_BYTES)),
)
.with_state(state)
}
#[cfg(test)]
mod tests {
use std::{sync::mpsc, time::Duration};
use axum::{
body::Body,
http::{Request, StatusCode},
};
use http_body_util::BodyExt;
use tempfile::tempdir;
use tower::ServiceExt;
use super::*;
async fn submit(app: &Router, name: &str, score: u32) -> StatusCode {
app.clone()
.oneshot(
Request::post("/api/highscores")
.header("content-type", "application/json")
.body(Body::from(
serde_json::to_vec(&serde_json::json!({
"name": name,
"score": score,
}))
.expect("request JSON should encode"),
))
.expect("request should build"),
)
.await
.expect("router should respond")
.status()
}
async fn request(app: &Router, request: Request<Body>) -> StatusCode {
app.clone()
.oneshot(request)
.await
.expect("router should respond")
.status()
}
fn hold_database_lock(
store: HighScoreStore,
) -> (mpsc::SyncSender<()>, std::thread::JoinHandle<()>) {
let (locked_sender, locked_receiver) = mpsc::sync_channel(0);
let (release_sender, release_receiver) = mpsc::sync_channel(0);
let lock_thread = std::thread::spawn(move || {
let _connection = store.0.lock().expect("database lock should succeed");
locked_sender
.send(())
.expect("test should observe the held database lock");
release_receiver
.recv()
.expect("test should release the database lock");
});
locked_receiver
.recv()
.expect("database lock thread should start");
(release_sender, lock_thread)
}
async fn wait_until_database_is_busy(database_slots: &Semaphore) {
tokio::time::timeout(Duration::from_secs(1), async {
while database_slots.available_permits() != 0 {
tokio::task::yield_now().await;
}
})
.await
.expect("first database request should acquire admission");
}
async fn list(app: &Router) -> Vec<HighScore> {
let response = app
.clone()
.oneshot(
Request::get("/api/highscores")
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("router should respond");
assert_eq!(response.status(), StatusCode::OK);
let body = response
.into_body()
.collect()
.await
.expect("response body should read")
.to_bytes();
serde_json::from_slice(&body).expect("response should contain scores")
}
#[tokio::test]
async fn api_persists_and_keeps_the_top_ten() {
let directory = tempdir().expect("temporary directory should exist");
let database = directory.path().join("highscores.sqlite3");
std::fs::File::create(&database).expect("database file should exist");
let store = HighScoreStore::open(&database).expect("database should open");
let app = router(store);
for score in 0..12 {
assert_eq!(
submit(&app, &format!("Player {score}"), score).await,
StatusCode::CREATED
);
}
let scores = list(&app).await;
assert_eq!(scores.len(), MAX_HIGH_SCORES);
assert_eq!(scores[0].score, 11);
assert_eq!(scores[9].score, 2);
drop(app);
let reopened = router(HighScoreStore::open(&database).expect("database should reopen"));
assert_eq!(list(&reopened).await, scores);
}
#[test]
fn new_database_starts_with_the_original_demo_scores() {
let directory = tempdir().expect("temporary directory should exist");
let database = directory.path().join("highscores.sqlite3");
let store = HighScoreStore::open(&database).expect("database should open");
let scores = store.list().expect("scores should list");
let expected = DEFAULT_HIGH_SCORES
.iter()
.map(|&(name, score)| HighScore {
name: name.to_owned(),
score,
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");
let database = directory.path().join("highscores.sqlite3");
std::fs::File::create(&database).expect("database file should exist");
let store = HighScoreStore::open(&database).expect("database should open");
assert!(store.list().expect("scores should list").is_empty());
}
#[tokio::test]
async fn api_rejects_invalid_names() {
let app = router(HighScoreStore::open_in_memory().expect("database should open"));
assert_eq!(submit(&app, "", 10).await, StatusCode::BAD_REQUEST);
assert_eq!(
submit(&app, "abcdefghijklmnopqrstuv", 10).await,
StatusCode::BAD_REQUEST
);
assert_eq!(submit(&app, "ok\nno", 10).await, StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn api_rejects_oversized_submission_bodies() {
let app = router(HighScoreStore::open_in_memory().expect("database should open"));
let body = serde_json::to_vec(&serde_json::json!({
"name": "Player",
"score": 10,
"padding": "x".repeat(MAX_SUBMISSION_BODY_BYTES),
}))
.expect("request JSON should encode");
let status = request(
&app,
Request::post("/api/highscores")
.header("content-type", "application/json")
.body(Body::from(body))
.expect("request should build"),
)
.await;
assert_eq!(status, StatusCode::PAYLOAD_TOO_LARGE);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
async fn busy_database_load_sheds_api_without_blocking_health() {
let store = HighScoreStore::open_in_memory().expect("database should open");
let (release_sender, lock_thread) = hold_database_lock(store.clone());
let state = AppState::new(store);
let database_slots = state.database_slots.clone();
let app = router_with_state(state);
let accepted_app = app.clone();
let accepted = tokio::spawn(async move { submit(&accepted_app, "Player", 10).await });
wait_until_database_is_busy(&database_slots).await;
assert_eq!(
tokio::time::timeout(
Duration::from_millis(250),
request(
&app,
Request::get("/healthz")
.body(Body::empty())
.expect("request should build"),
),
)
.await
.expect("health request should not wait for the database"),
StatusCode::OK
);
assert_eq!(
submit(&app, "Other Player", 20).await,
StatusCode::SERVICE_UNAVAILABLE
);
assert_eq!(
request(
&app,
Request::get("/api/highscores")
.body(Body::empty())
.expect("request should build"),
)
.await,
StatusCode::SERVICE_UNAVAILABLE
);
assert_eq!(
request(
&app,
Request::builder()
.method("HEAD")
.uri("/api/highscores")
.body(Body::empty())
.expect("request should build"),
)
.await,
StatusCode::SERVICE_UNAVAILABLE
);
release_sender
.send(())
.expect("database lock should be released");
assert_eq!(
accepted.await.expect("accepted request should complete"),
StatusCode::CREATED
);
lock_thread
.join()
.expect("database lock thread should stop");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
async fn canceled_request_holds_admission_until_blocking_work_stops() {
let store = HighScoreStore::open_in_memory().expect("database should open");
let (release_sender, lock_thread) = hold_database_lock(store.clone());
let state = AppState::new(store);
let database_slots = state.database_slots.clone();
let app = router_with_state(state);
let canceled_app = app.clone();
let canceled = tokio::spawn(async move { submit(&canceled_app, "Player", 10).await });
wait_until_database_is_busy(&database_slots).await;
canceled.abort();
assert!(
canceled
.await
.expect_err("request should be canceled")
.is_cancelled()
);
assert_eq!(database_slots.available_permits(), 0);
assert_eq!(
submit(&app, "Other Player", 20).await,
StatusCode::SERVICE_UNAVAILABLE
);
release_sender
.send(())
.expect("database lock should be released");
lock_thread
.join()
.expect("database lock thread should stop");
tokio::time::timeout(Duration::from_secs(1), async {
while database_slots.available_permits() == 0 {
tokio::task::yield_now().await;
}
})
.await
.expect("completed blocking work should release admission");
assert_eq!(submit(&app, "Other Player", 20).await, StatusCode::CREATED);
}
#[tokio::test]
async fn health_endpoint_is_available_for_nginx() {
let app = router(HighScoreStore::open_in_memory().expect("database should open"));
let response = app
.oneshot(
Request::get("/healthz")
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("router should respond");
assert_eq!(response.status(), StatusCode::OK);
}
}
+17
View File
@@ -0,0 +1,17 @@
use std::{env, error::Error, path::PathBuf};
use axum::Router;
use tdkpin_highscore_server::{HighScoreStore, router};
use tokio::net::TcpListener;
#[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> {
let bind = env::var("TDKPIN_HIGHSCORE_BIND").unwrap_or_else(|_| "127.0.0.1:3000".to_owned());
let database = env::var_os("TDKPIN_HIGHSCORE_DB")
.map_or_else(|| PathBuf::from("highscores.sqlite3"), PathBuf::from);
let app: Router = router(HighScoreStore::open(database)?);
let listener = TcpListener::bind(&bind).await?;
println!("TDK high-score service listening on http://{bind}");
axum::serve(listener, app).await?;
Ok(())
}
+26 -4
View File
@@ -9,10 +9,32 @@ build:
build-release: build-release:
cargo build --release cargo build --release
build-production: build-production-highscore-server:
cargo build --manifest-path highscore-server/Cargo.toml --profile production
build-production: build-production-highscore-server
cargo build --profile production cargo build --profile production
fmt: web-build:
cargo build --profile production --target wasm32-unknown-unknown
cp target/wasm32-unknown-unknown/production/tdkpin-rs.wasm web/tdkpin-rs.wasm
web-serve: web-build
python3 -m http.server 8000 --directory web
highscore-server:
cargo run --manifest-path highscore-server/Cargo.toml
test-highscore-server:
cargo test --manifest-path highscore-server/Cargo.toml
clippy-highscore-server:
cargo clippy --manifest-path highscore-server/Cargo.toml --all-targets --all-features -- -D warnings
fmt-highscore-server:
cargo +nightly fmt --manifest-path highscore-server/Cargo.toml
fmt: fmt-highscore-server
cargo +nightly fmt cargo +nightly fmt
tombi format tombi format
fd -tf -e md -x prettier --write --prose-wrap always --print-width 80 fd -tf -e md -x prettier --write --prose-wrap always --print-width 80
@@ -25,10 +47,10 @@ _fix:
fix: _fix fmt fix: _fix fmt
clippy: clippy: clippy-highscore-server
cargo clippy --workspace --all-targets --all-features -- -D warnings cargo clippy --workspace --all-targets --all-features -- -D warnings
test: test: test-highscore-server
cargo test --workspace --all-targets --all-features cargo test --workspace --all-targets --all-features
clean: clean:
+141 -74
View File
@@ -1,24 +1,71 @@
#[cfg(not(target_arch = "wasm32"))]
use std::path::Path;
use macroquad::prelude::*;
use crate::{ use crate::{
assets::Assets, assets::Assets,
game::{Controls, Event, Game, Nudge}, game::{Controls, Event, Game, Nudge},
persistence::{ persistence::{
HighScore, Language, Persistence, SavedData, insert_high_score, qualifies_high_score, HighScore,
Language,
Persistence,
SavedData,
insert_high_score,
qualifies_high_score,
}, },
}; };
use macroquad::prelude::*;
use std::path::Path;
const WIDTH: f32 = 640.0; const WIDTH: f32 = 640.0;
const HEIGHT: f32 = 460.0; const HEIGHT: f32 = 460.0;
const DETAIL_TIMER_SECONDS: [f32; 5] = [0.050, 0.040, 0.030, 0.020, 0.010]; const DETAIL_TIMER_SECONDS: [f32; 5] = [0.050, 0.040, 0.030, 0.020, 0.010];
const ADD_PLAYER_KEYS: [KeyCode; 3] = [KeyCode::KpAdd, KeyCode::RightBracket, KeyCode::Equal]; 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] = [ const TARGET_POSITIONS: [[(f32, f32); 5]; 6] = [
[(9.0, 41.0), (23.0, 11.0), (56.0, 14.0), (63.0, 47.0), (35.0, 63.0)], [
[(11.0, 45.0), (18.0, 13.0), (52.0, 11.0), (63.0, 42.0), (39.0, 64.0)], (9.0, 41.0),
[(13.0, 50.0), (15.0, 17.0), (46.0, 9.0), (64.0, 37.0), (44.0, 62.0)], (23.0, 11.0),
[(17.0, 55.0), (11.0, 23.0), (39.0, 7.0), (64.0, 30.0), (50.0, 59.0)], (56.0, 14.0),
[(21.0, 60.0), (9.0, 28.0), (35.0, 7.0), (62.0, 25.0), (54.0, 56.0)], (63.0, 47.0),
[(8.0, 35.0), (26.0, 8.0), (59.0, 18.0), (59.0, 50.0), (27.0, 61.0)], (35.0, 63.0),
],
[
(11.0, 45.0),
(18.0, 13.0),
(52.0, 11.0),
(63.0, 42.0),
(39.0, 64.0),
],
[
(13.0, 50.0),
(15.0, 17.0),
(46.0, 9.0),
(64.0, 37.0),
(44.0, 62.0),
],
[
(17.0, 55.0),
(11.0, 23.0),
(39.0, 7.0),
(64.0, 30.0),
(50.0, 59.0),
],
[
(21.0, 60.0),
(9.0, 28.0),
(35.0, 7.0),
(62.0, 25.0),
(54.0, 56.0),
],
[
(8.0, 35.0),
(26.0, 8.0),
(59.0, 18.0),
(59.0, 50.0),
(27.0, 61.0),
],
]; ];
const BUMPER_VALUE_REGIONS: [(i32, i32, i32, i32); 5] = [ const BUMPER_VALUE_REGIONS: [(i32, i32, i32, i32); 5] = [
(103, 304, 121, 323), (103, 304, 121, 323),
@@ -50,6 +97,13 @@ fn add_player_released() -> bool {
ADD_PLAYER_KEYS.into_iter().any(is_key_released) ADD_PLAYER_KEYS.into_iter().any(is_key_released)
} }
fn initial_game_random_seed() -> u32 {
// The original seeds RandSeed from the startup clock. Macroquad's random
// generator starts from a fixed zero state unless the application seeds it.
macroquad::rand::srand(macroquad::miniquad::date::now().to_bits());
macroquad::rand::rand()
}
fn visible_score_digits(mut value: u32) -> Vec<(u8, u8)> { fn visible_score_digits(mut value: u32) -> Vec<(u8, u8)> {
let mut digits = Vec::with_capacity(8); let mut digits = Vec::with_capacity(8);
for position in 0..8 { for position in 0..8 {
@@ -116,11 +170,7 @@ impl AttractAnimation {
fn target_active(self, index: usize) -> bool { fn target_active(self, index: usize) -> bool {
let index = u32::try_from(index).unwrap_or(0); let index = u32::try_from(index).unwrap_or(0);
let first_activation = if index == 0 { let first_activation = if index == 0 { 32 } else { index * 2 };
32
} else {
index * 2
};
self.tick >= first_activation && self.tick.wrapping_sub(index * 2) % 32 < 16 self.tick >= first_activation && self.tick.wrapping_sub(index * 2) % 32 < 16
} }
@@ -137,11 +187,7 @@ impl AttractAnimation {
fn record_strip_active(self, index: usize) -> bool { fn record_strip_active(self, index: usize) -> bool {
let index = u32::try_from(index).unwrap_or(0); let index = u32::try_from(index).unwrap_or(0);
let first_activation = if index == 0 { let first_activation = if index == 0 { 40 } else { index * 4 };
40
} else {
index * 4
};
self.tick >= first_activation && self.tick.wrapping_sub(index * 4) % 40 < 20 self.tick >= first_activation && self.tick.wrapping_sub(index * 4) % 40 < 20
} }
@@ -151,11 +197,7 @@ impl AttractAnimation {
fn item_index(self) -> Option<usize> { fn item_index(self) -> Option<usize> {
let phase = (self.tick % 144) / 8; let phase = (self.tick % 144) / 8;
let item = if phase < 10 { let item = if phase < 10 { phase } else { 18 - phase };
phase
} else {
18 - phase
};
(item != 0).then(|| usize::try_from(item - 1).unwrap_or(0)) (item != 0).then(|| usize::try_from(item - 1).unwrap_or(0))
} }
@@ -183,6 +225,7 @@ pub struct App {
last_help_click: f64, last_help_click: f64,
name: String, name: String,
pending_score: u32, pending_score: u32,
pending_crazy: bool,
attract: AttractAnimation, attract: AttractAnimation,
} }
@@ -204,18 +247,23 @@ impl App {
screen: Screen::Loading, screen: Screen::Loading,
return_screen: Screen::Attract, return_screen: Screen::Attract,
game: None, game: None,
game_random_seed: macroquad::rand::rand(), game_random_seed: initial_game_random_seed(),
setting_row: 0, setting_row: 0,
loading_started, loading_started,
loading_until: loading_started + 1.1, loading_until: loading_started + 1.1,
last_help_click: -1.0, last_help_click: -1.0,
name: String::new(), name: String::new(),
pending_score: 0, pending_score: 0,
pending_crazy: false,
attract: AttractAnimation::default(), attract: AttractAnimation::default(),
} }
} }
pub fn frame(&mut self) { pub fn frame(&mut self) {
#[cfg(target_arch = "wasm32")]
self.assets.update();
#[cfg(target_arch = "wasm32")]
self.update_remote_high_scores();
self.handle_global_input(); self.handle_global_input();
match self.screen { match self.screen {
Screen::Loading => { Screen::Loading => {
@@ -235,11 +283,13 @@ impl App {
self.present(); self.present();
} }
#[cfg(not(target_arch = "wasm32"))]
pub fn set_simulation_game(&mut self, game: Game) { pub fn set_simulation_game(&mut self, game: Game) {
self.game = Some(game); self.game = Some(game);
self.screen = Screen::Playing; self.screen = Screen::Playing;
} }
#[cfg(not(target_arch = "wasm32"))]
pub fn set_simulation_attract(&mut self, steps: u64) { pub fn set_simulation_attract(&mut self, steps: u64) {
self.game = None; self.game = None;
self.screen = Screen::Attract; self.screen = Screen::Attract;
@@ -249,6 +299,7 @@ impl App {
} }
} }
#[cfg(not(target_arch = "wasm32"))]
pub fn set_simulation_loading(&mut self, steps: u64) { pub fn set_simulation_loading(&mut self, steps: u64) {
self.game = None; self.game = None;
self.screen = Screen::Loading; self.screen = Screen::Loading;
@@ -257,12 +308,14 @@ impl App {
self.loading_until = get_time() + 60.0; self.loading_until = get_time() + 60.0;
} }
#[cfg(not(target_arch = "wasm32"))]
pub fn set_simulation_highscores(&mut self) { pub fn set_simulation_highscores(&mut self) {
self.game = None; self.game = None;
self.return_screen = Screen::Attract; self.return_screen = Screen::Attract;
self.screen = Screen::HighScores; self.screen = Screen::HighScores;
} }
#[cfg(not(target_arch = "wasm32"))]
pub fn set_simulation_name_entry(&mut self) { pub fn set_simulation_name_entry(&mut self) {
self.game = Some(Game::new(1)); self.game = Some(Game::new(1));
self.return_screen = Screen::Playing; self.return_screen = Screen::Playing;
@@ -271,10 +324,12 @@ impl App {
self.screen = Screen::NameEntry; self.screen = Screen::NameEntry;
} }
#[cfg(not(target_arch = "wasm32"))]
pub fn render_simulation(&self) { pub fn render_simulation(&self) {
self.draw_logical(); self.draw_logical();
} }
#[cfg(not(target_arch = "wasm32"))]
pub fn export_simulation_png(&self, path: &Path) -> Result<(), String> { pub fn export_simulation_png(&self, path: &Path) -> Result<(), String> {
let path = path let path = path
.to_str() .to_str()
@@ -348,6 +403,9 @@ impl App {
|| is_key_down(KeyCode::Enter) || is_key_down(KeyCode::Enter)
|| is_key_down(KeyCode::D) || is_key_down(KeyCode::D)
|| is_key_down(KeyCode::Right); || 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) { let nudge = if is_key_released(KeyCode::LeftShift) {
Nudge::Left Nudge::Left
} else if is_key_released(KeyCode::RightShift) || is_key_released(KeyCode::Kp3) { } else if is_key_released(KeyCode::RightShift) || is_key_released(KeyCode::Kp3) {
@@ -368,16 +426,17 @@ impl App {
}); });
let mut high_score_candidate = None; let mut high_score_candidate = None;
for event in events { for event in events {
if let Event::HighScoreCandidate(score) = event { if let Event::HighScoreCandidate { score, crazy } = event {
high_score_candidate = Some(score); high_score_candidate = Some((score, crazy));
} }
self.play_event(event); 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) && qualifies_high_score(&self.saved.high_scores, score)
{ {
self.pending_score = score; self.pending_score = score;
self.pending_crazy = crazy;
"TDK Pinball Player".clone_into(&mut self.name); "TDK Pinball Player".clone_into(&mut self.name);
self.screen = Screen::NameEntry; self.screen = Screen::NameEntry;
return; return;
@@ -420,6 +479,7 @@ impl App {
let now = get_time(); let now = get_time();
if now - self.last_help_click <= 0.40 { if now - self.last_help_click <= 0.40 {
self.save(); self.save();
#[cfg(not(target_arch = "wasm32"))]
macroquad::miniquad::window::request_quit(); macroquad::miniquad::window::request_quit();
} }
self.last_help_click = now; self.last_help_click = now;
@@ -495,7 +555,8 @@ impl App {
self.name.pop(); self.name.pop();
} }
let click = is_mouse_button_pressed(MouseButton::Left).then(Self::logical_mouse); let click = is_mouse_button_pressed(MouseButton::Left).then(Self::logical_mouse);
let clicked_ok = click.is_some_and(|point| Rect::new(255.0, 256.0, 60.0, 23.0).contains(point)); let clicked_ok =
click.is_some_and(|point| Rect::new(255.0, 256.0, 60.0, 23.0).contains(point));
let clicked_cancel = let clicked_cancel =
click.is_some_and(|point| Rect::new(324.0, 256.0, 60.0, 23.0).contains(point)); click.is_some_and(|point| Rect::new(324.0, 256.0, 60.0, 23.0).contains(point));
if is_key_pressed(KeyCode::Escape) || clicked_cancel { if is_key_pressed(KeyCode::Escape) || clicked_cancel {
@@ -507,13 +568,14 @@ impl App {
} }
fn accept_high_score_name(&mut self) { fn accept_high_score_name(&mut self) {
insert_high_score( let entry = HighScore {
&mut self.saved.high_scores, name: self.name.clone(),
HighScore { score: self.pending_score,
name: self.name.clone(), crazy: self.pending_crazy,
score: self.pending_score, };
}, insert_high_score(&mut self.saved.high_scores, entry.clone());
); #[cfg(target_arch = "wasm32")]
self.persistence.submit_high_score(&entry);
self.save(); self.save();
if self.game.as_ref().is_some_and(|game| game.finished) { if self.game.as_ref().is_some_and(|game| game.finished) {
self.take_game_and_preserve_random_seed(); self.take_game_and_preserve_random_seed();
@@ -574,11 +636,8 @@ impl App {
} }
if self.attract.magnetic_records_active() { if self.attract.magnetic_records_active() {
for (x, y, width, height) in [ for (x, y, width, height) in [(151, 389, 13, 49), (11, 344, 13, 49), (291, 346, 13, 49)]
(151, 389, 13, 49), {
(11, 344, 13, 49),
(291, 346, 13, 49),
] {
self.draw_active_table_region(x, y, width, height); self.draw_active_table_region(x, y, width, height);
} }
} }
@@ -605,12 +664,7 @@ impl App {
} }
if let Some(item) = self.attract.item_index() { if let Some(item) = self.attract.item_index() {
draw_texture( draw_texture(&self.assets.diamond[item], 123.0, 300.0, WHITE);
&self.assets.diamond[item],
123.0,
300.0,
WHITE,
);
} }
self.draw_intro_marquee(); self.draw_intro_marquee();
} }
@@ -815,7 +869,7 @@ impl App {
if game.special_hole_active() { if game.special_hole_active() {
self.draw_active_table_region(9, 14, 17, 17); 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( draw_texture_ex(
&self.assets.plunger, &self.assets.plunger,
314.0, 314.0,
@@ -857,15 +911,10 @@ impl App {
); );
} }
} }
if !game.claw.ball_suspended { for (index, ball) in game.balls.iter().enumerate() {
draw_texture( if game.ball_suspended(index) {
&self.assets.ball, continue;
game.ball.position.x - 8.0, }
game.ball.position.y - 8.0,
WHITE,
);
}
if let Some(ball) = game.secondary_ball {
draw_texture( draw_texture(
&self.assets.ball, &self.assets.ball,
ball.position.x - 8.0, ball.position.x - 8.0,
@@ -929,7 +978,15 @@ impl App {
} else if (66..=128).contains(&frame) { } else if (66..=128).contains(&frame) {
draw_texture_region(&self.assets.wheel, 79.0, 33.0, 91.0, 90.0, 180.0, 90.0); draw_texture_region(&self.assets.wheel, 79.0, 33.0, 91.0, 90.0, 180.0, 90.0);
if frame < 83 { if frame < 83 {
draw_texture_region(wide_a, 79.0, ((82 - frame) * 2) as f32, 91.0, 90.0, 0.0, 0.0); draw_texture_region(
wide_a,
79.0,
((82 - frame) * 2) as f32,
91.0,
90.0,
0.0,
0.0,
);
} else { } else {
let height = (128 - frame) * 2; let height = (128 - frame) * 2;
draw_texture_region( draw_texture_region(
@@ -1122,14 +1179,7 @@ impl App {
draw_centered("SETTINGS", 122.0, 30, BLACK); draw_centered("SETTINGS", 122.0, 30, BLACK);
let rows = [ let rows = [
format!("GRAPHICS DETAIL / SPEED: {}", self.saved.settings.speed), format!("GRAPHICS DETAIL / SPEED: {}", self.saved.settings.speed),
format!( format!("SOUND: {}", if self.sounds_enabled { "ON" } else { "OFF" }),
"SOUND: {}",
if self.sounds_enabled {
"ON"
} else {
"OFF"
}
),
format!("LANGUAGE: {}", self.saved.settings.language.label()), format!("LANGUAGE: {}", self.saved.settings.language.label()),
]; ];
for (index, row) in rows.iter().enumerate() { for (index, row) in rows.iter().enumerate() {
@@ -1171,6 +1221,15 @@ impl App {
let score = entry.score.to_string(); let score = entry.score.to_string();
let width = measure_text(&score, None, 16, 1.0).width; let width = measure_text(&score, None, 16, 1.0).width;
draw_text(&score, 460.0 - width, y, 16.0, BLACK); 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),
);
}
} }
} }
@@ -1181,7 +1240,13 @@ impl App {
draw_rectangle(x, y, 270.0, 124.0, Color::from_rgba(192, 192, 192, 255)); draw_rectangle(x, y, 270.0, 124.0, Color::from_rgba(192, 192, 192, 255));
draw_rectangle_lines(x, y, 270.0, 124.0, 2.0, WHITE); draw_rectangle_lines(x, y, 270.0, 124.0, 2.0, WHITE);
draw_rectangle_lines(x + 2.0, y + 2.0, 266.0, 120.0, 2.0, DARKGRAY); draw_rectangle_lines(x + 2.0, y + 2.0, 266.0, 120.0, 2.0, DARKGRAY);
draw_rectangle(x + 4.0, y + 4.0, 262.0, 20.0, Color::from_rgba(0, 0, 128, 255)); draw_rectangle(
x + 4.0,
y + 4.0,
262.0,
20.0,
Color::from_rgba(0, 0, 128, 255),
);
draw_text( draw_text(
"Congratulations! This is a Top Ten Score!", "Congratulations! This is a Top Ten Score!",
x + 9.0, x + 9.0,
@@ -1265,6 +1330,14 @@ impl App {
eprintln!("could not save settings and highscores: {error}"); eprintln!("could not save settings and highscores: {error}");
} }
} }
#[cfg(target_arch = "wasm32")]
fn update_remote_high_scores(&mut self) {
if let Some(scores) = self.persistence.take_high_scores() {
self.saved.high_scores = scores;
self.save();
}
}
} }
fn draw_panel(x: f32, y: f32, width: f32, height: f32) { fn draw_panel(x: f32, y: f32, width: f32, height: f32) {
@@ -1319,13 +1392,7 @@ mod tests {
#[test] #[test]
fn attract_timer_uses_the_selected_detail_callback() { fn attract_timer_uses_the_selected_detail_callback() {
for (detail, interval) in [ for (detail, interval) in [(1, 0.050), (2, 0.040), (3, 0.030), (4, 0.020), (5, 0.010)] {
(1, 0.050),
(2, 0.040),
(3, 0.030),
(4, 0.020),
(5, 0.010),
] {
let mut animation = AttractAnimation::default(); let mut animation = AttractAnimation::default();
animation.update(interval - 0.001, detail); animation.update(interval - 0.001, detail);
assert_eq!(animation.tick, 0); assert_eq!(animation.tick, 0);
+130 -73
View File
@@ -1,3 +1,7 @@
#[cfg(target_arch = "wasm32")]
use futures_util::future::join_all;
#[cfg(target_arch = "wasm32")]
use macroquad::experimental::coroutines::{Coroutine, start_coroutine};
use macroquad::{ use macroquad::{
audio::{PlaySoundParams, Sound, load_sound_from_bytes, play_sound, stop_sound}, audio::{PlaySoundParams, Sound, load_sound_from_bytes, play_sound, stop_sound},
prelude::{FilterMode, Image, Texture2D}, prelude::{FilterMode, Image, Texture2D},
@@ -24,6 +28,8 @@ pub struct Assets {
pub ball: Texture2D, pub ball: Texture2D,
pub digits: Texture2D, pub digits: Texture2D,
sounds: Vec<(u16, Sound)>, sounds: Vec<(u16, Sound)>,
#[cfg(target_arch = "wasm32")]
sound_loader: Option<Coroutine<Vec<(u16, Sound)>>>,
} }
impl Assets { impl Assets {
@@ -33,7 +39,8 @@ impl Assets {
let inactive_table = texture(include_bytes!("../assets/original/images/dat_00998.png")); let inactive_table = texture(include_bytes!("../assets/original/images/dat_00998.png"));
let loading = texture(include_bytes!("../assets/original/images/dat_00995.png")); let loading = texture(include_bytes!("../assets/original/images/dat_00995.png"));
let loading_progress = texture(include_bytes!("../assets/original/images/dat_00994.png")); let loading_progress = texture(include_bytes!("../assets/original/images/dat_00994.png"));
let highscore_background = texture(include_bytes!("../assets/original/images/dat_00993.png")); let highscore_background =
texture(include_bytes!("../assets/original/images/dat_00993.png"));
let help = [ let help = [
texture(include_bytes!("../assets/original/images/dat_01001.png")), texture(include_bytes!("../assets/original/images/dat_01001.png")),
texture(include_bytes!("../assets/original/images/dat_01002.png")), texture(include_bytes!("../assets/original/images/dat_01002.png")),
@@ -84,78 +91,18 @@ impl Assets {
let digits = let digits =
monochrome_texture(include_bytes!("../assets/original/images/bitmap_00500.png")); monochrome_texture(include_bytes!("../assets/original/images/bitmap_00500.png"));
let sound_bytes: [(u16, &[u8]); 16] = [ let sound_bytes = sound_bytes();
( #[cfg(target_arch = "wasm32")]
2001, let (sounds, sound_loader) = (
include_bytes!("../assets/original/audio/wav_02001.wav"), Vec::new(),
), Some(start_coroutine(
( async move { load_sounds(&sound_bytes).await },
2002, )),
include_bytes!("../assets/original/audio/wav_02002.wav"), );
), #[cfg(not(target_arch = "wasm32"))]
( let sounds = load_sounds(&sound_bytes).await;
2004, #[cfg(target_arch = "wasm32")]
include_bytes!("../assets/original/audio/wav_02004.wav"), macroquad::window::next_frame().await;
),
(
2006,
include_bytes!("../assets/original/audio/wav_02006.wav"),
),
(
2007,
include_bytes!("../assets/original/audio/wav_02007.wav"),
),
(
2008,
include_bytes!("../assets/original/audio/wav_02008.wav"),
),
(
2011,
include_bytes!("../assets/original/audio/wav_02011.wav"),
),
(
2012,
include_bytes!("../assets/original/audio/wav_02012.wav"),
),
(
2013,
include_bytes!("../assets/original/audio/wav_02013.wav"),
),
(
2015,
include_bytes!("../assets/original/audio/wav_02015.wav"),
),
(
2016,
include_bytes!("../assets/original/audio/wav_02016.wav"),
),
(
2017,
include_bytes!("../assets/original/audio/wav_02017.wav"),
),
(
2019,
include_bytes!("../assets/original/audio/wav_02019.wav"),
),
(
2020,
include_bytes!("../assets/original/audio/wav_02020.wav"),
),
(
2021,
include_bytes!("../assets/original/audio/wav_02021.wav"),
),
(
2022,
include_bytes!("../assets/original/audio/wav_02022.wav"),
),
];
let mut sounds = Vec::with_capacity(sound_bytes.len());
for (id, bytes) in sound_bytes {
if let Ok(sound) = load_sound_from_bytes(bytes).await {
sounds.push((id, sound));
}
}
Self { Self {
active_table, active_table,
@@ -178,9 +125,23 @@ impl Assets {
ball, ball,
digits, digits,
sounds, sounds,
#[cfg(target_arch = "wasm32")]
sound_loader,
} }
} }
#[cfg(target_arch = "wasm32")]
pub fn update(&mut self) {
let Some(loader) = self.sound_loader.as_ref() else {
return;
};
if !loader.is_done() {
return;
}
let loader = self.sound_loader.take().expect("sound loader must exist");
self.sounds = loader.retrieve().unwrap_or_default();
}
pub fn play(&self, id: u16, enabled: bool) { pub fn play(&self, id: u16, enabled: bool) {
if !enabled { if !enabled {
return; return;
@@ -204,6 +165,102 @@ impl Assets {
} }
} }
fn sound_bytes() -> [(u16, &'static [u8]); 16] {
[
(
2001,
include_bytes!("../assets/original/audio/wav_02001.wav"),
),
(
2002,
include_bytes!("../assets/original/audio/wav_02002.wav"),
),
(
2004,
include_bytes!("../assets/original/audio/wav_02004.wav"),
),
(
2006,
include_bytes!("../assets/original/audio/wav_02006.wav"),
),
(
2007,
include_bytes!("../assets/original/audio/wav_02007.wav"),
),
(
2008,
include_bytes!("../assets/original/audio/wav_02008.wav"),
),
(
2011,
include_bytes!("../assets/original/audio/wav_02011.wav"),
),
(
2012,
include_bytes!("../assets/original/audio/wav_02012.wav"),
),
(
2013,
include_bytes!("../assets/original/audio/wav_02013.wav"),
),
(
2015,
include_bytes!("../assets/original/audio/wav_02015.wav"),
),
(
2016,
include_bytes!("../assets/original/audio/wav_02016.wav"),
),
(
2017,
include_bytes!("../assets/original/audio/wav_02017.wav"),
),
(
2019,
include_bytes!("../assets/original/audio/wav_02019.wav"),
),
(
2020,
include_bytes!("../assets/original/audio/wav_02020.wav"),
),
(
2021,
include_bytes!("../assets/original/audio/wav_02021.wav"),
),
(
2022,
include_bytes!("../assets/original/audio/wav_02022.wav"),
),
]
}
async fn load_sounds(sound_bytes: &[(u16, &[u8])]) -> Vec<(u16, Sound)> {
#[cfg(target_arch = "wasm32")]
{
join_all(sound_bytes.iter().map(|(id, bytes)| async move {
load_sound_from_bytes(bytes)
.await
.ok()
.map(|sound| (*id, sound))
}))
.await
.into_iter()
.flatten()
.collect()
}
#[cfg(not(target_arch = "wasm32"))]
{
let mut sounds = Vec::with_capacity(sound_bytes.len());
for &(id, bytes) in sound_bytes {
if let Ok(sound) = load_sound_from_bytes(bytes).await {
sounds.push((id, sound));
}
}
sounds
}
}
fn texture(bytes: &[u8]) -> Texture2D { fn texture(bytes: &[u8]) -> Texture2D {
let texture = Texture2D::from_file_with_format(bytes, None); let texture = Texture2D::from_file_with_format(bytes, None);
texture.set_filter(FilterMode::Nearest); texture.set_filter(FilterMode::Nearest);
+5 -4
View File
@@ -77,15 +77,16 @@ mod tests {
fn zero_bounds_still_advance_the_seed() { fn zero_bounds_still_advance_the_seed() {
let mut random = BorlandRandom::new(7); let mut random = BorlandRandom::new(7);
assert_eq!(random.below(0), 0); assert_eq!(random.below(0), 0);
assert_eq!(random.seed(), 7_u32.wrapping_mul(MULTIPLIER).wrapping_add(1)); assert_eq!(
random.seed(),
7_u32.wrapping_mul(MULTIPLIER).wrapping_add(1)
);
} }
#[test] #[test]
fn unit_interval_is_the_exact_unsigned_seed_fraction() { fn unit_interval_is_the_exact_unsigned_seed_fraction() {
let mut random = BorlandRandom::new(0xfedc_ba98); let mut random = BorlandRandom::new(0xfedc_ba98);
let expected_seed = 0xfedc_ba98_u32 let expected_seed = 0xfedc_ba98_u32.wrapping_mul(MULTIPLIER).wrapping_add(1);
.wrapping_mul(MULTIPLIER)
.wrapping_add(1);
assert_eq!( assert_eq!(
random.unit_interval().to_bits(), random.unit_interval().to_bits(),
(f64::from(expected_seed) / TWO_TO_32).to_bits() (f64::from(expected_seed) / TWO_TO_32).to_bits()
+70 -29
View File
@@ -5,8 +5,7 @@ use crate::{original_physics::MilliVec, real48::Real48};
const SEARCH_RADIUS: i32 = 54_000; const SEARCH_RADIUS: i32 = 54_000;
const ONE: Real48 = Real48::from_bytes([0x81, 0, 0, 0, 0, 0]); const ONE: Real48 = Real48::from_bytes([0x81, 0, 0, 0, 0, 0]);
const TWO: Real48 = Real48::from_bytes([0x82, 0, 0, 0, 0, 0]); const TWO: Real48 = Real48::from_bytes([0x82, 0, 0, 0, 0, 0]);
const TWO_FIFTHS: Real48 = const TWO_FIFTHS: Real48 = Real48::from_bytes([0x7f, 0xcd, 0xcc, 0xcc, 0xcc, 0x4c]);
Real48::from_bytes([0x7f, 0xcd, 0xcc, 0xcc, 0xcc, 0x4c]);
const THOUSAND: Real48 = Real48::from_bytes([0x8a, 0, 0, 0, 0, 0x7a]); const THOUSAND: Real48 = Real48::from_bytes([0x8a, 0, 0, 0, 0, 0x7a]);
#[derive(Clone, Copy, Debug, PartialEq, Eq)] #[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -171,14 +170,11 @@ fn penetration(geometry: Geometry, delta: i32, ball: MilliVec) -> i32 {
.wrapping_add(43_000) .wrapping_add(43_000)
.wrapping_sub(geometry.pivot.y), .wrapping_sub(geometry.pivot.y),
); );
geometry geometry.pivot.y.wrapping_sub(
.pivot Real48::from_i32(numerator)
.y .divide(Real48::from_i32(edge_dx))
.wrapping_sub( .round_i32(),
Real48::from_i32(numerator) )
.divide(Real48::from_i32(edge_dx))
.round_i32(),
)
} else { } else {
geometry.positive_edge.y.wrapping_add(43_000) geometry.positive_edge.y.wrapping_add(43_000)
} }
@@ -299,8 +295,14 @@ mod tests {
let velocities = [ let velocities = [
MilliVec { x: 0, y: 2_000 }, MilliVec { x: 0, y: 2_000 },
MilliVec { x: 1_000, y: 2_000 }, MilliVec { x: 1_000, y: 2_000 },
MilliVec { x: -1_000, y: 2_000 }, MilliVec {
MilliVec { x: 2_700, y: -2_700 }, x: -1_000,
y: 2_000,
},
MilliVec {
x: 2_700,
y: -2_700,
},
]; ];
let mut hash = 14_695_981_039_346_656_037_u64; let mut hash = 14_695_981_039_346_656_037_u64;
let mut hits = 0_u32; let mut hits = 0_u32;
@@ -315,10 +317,10 @@ mod tests {
Real48::from_bytes([0x80, 0, 0, 0, 0, 0]), Real48::from_bytes([0x80, 0, 0, 0, 0, 0]),
3_800, 3_800,
); );
let (hit, velocity, movement) = response.map_or( let (hit, velocity, movement) = response
(0_u32, input_velocity, MilliVec::default()), .map_or((0_u32, input_velocity, MilliVec::default()), |response| {
|response| (1, response.velocity, response.movement), (1, response.velocity, response.movement)
); });
hits += hit; hits += hit;
for value in [ for value in [
i32_bits(x), i32_bits(x),
@@ -342,40 +344,73 @@ mod tests {
fn four_direction_vectors_match_the_reconstructed_c_harness() { fn four_direction_vectors_match_the_reconstructed_c_harness() {
let cases = [ let cases = [
( (
MilliVec { x: 104_000, y: 384_000 }, MilliVec {
x: 104_000,
y: 384_000,
},
MilliVec { x: 1_000, y: 2_000 }, MilliVec { x: 1_000, y: 2_000 },
-1, -1,
FlipperSide::Left, FlipperSide::Left,
Real48::from_bytes([0x80, 0, 0, 0, 0, 0]), Real48::from_bytes([0x80, 0, 0, 0, 0, 0]),
MilliVec { x: 1_266, y: 1_511 }, MilliVec { x: 1_266, y: 1_511 },
MilliVec { x: -6_703, y: -8_000 }, MilliVec {
x: -6_703,
y: -8_000,
},
), ),
( (
MilliVec { x: 209_000, y: 419_000 }, MilliVec {
MilliVec { x: -1_000, y: 2_000 }, x: 209_000,
y: 419_000,
},
MilliVec {
x: -1_000,
y: 2_000,
},
1, 1,
FlipperSide::Right, FlipperSide::Right,
Real48::from_bytes([0x80, 0, 0, 0, 0, 0x40]), Real48::from_bytes([0x80, 0, 0, 0, 0, 0x40]),
MilliVec { x: -737, y: 2_263 }, MilliVec { x: -737, y: 2_263 },
MilliVec { x: -5_578, y: 17_127 }, MilliVec {
x: -5_578,
y: 17_127,
},
), ),
( (
MilliVec { x: 104_000, y: 421_000 }, MilliVec {
x: 104_000,
y: 421_000,
},
MilliVec { x: 1_000, y: 2_000 }, MilliVec { x: 1_000, y: 2_000 },
1, 1,
FlipperSide::Left, FlipperSide::Left,
Real48::from_bytes([0x7f, 0, 0, 0, 0, 0]), Real48::from_bytes([0x7f, 0, 0, 0, 0, 0]),
MilliVec { x: 622, y: 2_320 }, MilliVec { x: 622, y: 2_320 },
MilliVec { x: 5_414, y: 20_193 }, MilliVec {
x: 5_414,
y: 20_193,
},
), ),
( (
MilliVec { x: 209_000, y: 385_000 }, MilliVec {
MilliVec { x: -1_000, y: 2_000 }, x: 209_000,
y: 385_000,
},
MilliVec {
x: -1_000,
y: 2_000,
},
-1, -1,
FlipperSide::Right, FlipperSide::Right,
Real48::ZERO, Real48::ZERO,
MilliVec { x: -1_280, y: 1_560 }, MilliVec {
MilliVec { x: 7_385, y: -9_000 }, x: -1_280,
y: 1_560,
},
MilliVec {
x: 7_385,
y: -9_000,
},
), ),
]; ];
for (ball, velocity, delta, side, response, expected_velocity, expected_movement) in cases { for (ball, velocity, delta, side, response, expected_velocity, expected_movement) in cases {
@@ -398,7 +433,10 @@ mod tests {
fn boundary_vectors_match_the_live_original_binary() { fn boundary_vectors_match_the_live_original_binary() {
for (ball, velocity, delta, side, expected_velocity, expected_movement) in [ for (ball, velocity, delta, side, expected_velocity, expected_movement) in [
( (
MilliVec { x: 100_000, y: 370_000 }, MilliVec {
x: 100_000,
y: 370_000,
},
MilliVec { x: 1_000, y: 2_000 }, MilliVec { x: 1_000, y: 2_000 },
-1, -1,
FlipperSide::Left, FlipperSide::Left,
@@ -406,7 +444,10 @@ mod tests {
MilliVec::default(), MilliVec::default(),
), ),
( (
MilliVec { x: 209_000, y: 419_000 }, MilliVec {
x: 209_000,
y: 419_000,
},
MilliVec { x: 1_000, y: 2_000 }, MilliVec { x: 1_000, y: 2_000 },
1, 1,
FlipperSide::Right, FlipperSide::Right,
+2235 -827
View File
@@ -1,20 +1,34 @@
use macroquad::prelude::{Rect, Vec2, vec2};
use crate::{ use crate::{
borland_random::BorlandRandom, borland_random::BorlandRandom,
flipper_physics::{FlipperSide, moving_flipper_response}, flipper_physics::{FlipperSide, moving_flipper_response},
geometry::Segment, geometry::Segment,
original_physics::{ original_physics::{
CollisionMaterial, CollisionResponse, GRAVITY_MILLI_PER_STEP, CollisionMaterial,
MAXIMUM_SPEED_MILLI_PER_STEP, MilliVec, StaticCollisionCandidate, CollisionResponse,
ball_collision_response, capture_collision_candidate, circle_collision_candidate_at, GRAVITY_MILLI_PER_STEP,
line_collision_candidate_at, milli_distance, path_intersects_circle, MAXIMUM_SPEED_MILLI_PER_STEP,
MilliVec,
StaticCollisionCandidate,
ball_collision_response,
capture_collision_candidate,
circle_collision_candidate_at,
line_collision_candidate_at,
milli_distance,
path_intersects_circle,
}, },
real48::Real48, real48::Real48,
table::{ table::{
BUMPERS, EFFECT_SENSOR, LOCK_HOLES, PASSIVE_CIRCLES, TARGET_SENSORS, WALLS, BUMPERS,
EFFECT_SENSOR,
LOCK_HOLES,
PASSIVE_CIRCLES,
SPECIAL_HOLE_SENSOR, SPECIAL_HOLE_SENSOR,
TARGET_SENSORS,
WALLS,
}, },
}; };
use macroquad::prelude::{Rect, Vec2, vec2};
const LEFT_FLIPPER_RAISED_TIP: Vec2 = Vec2::new(133.0, 377.0); const LEFT_FLIPPER_RAISED_TIP: Vec2 = Vec2::new(133.0, 377.0);
const RIGHT_FLIPPER_RAISED_TIP: Vec2 = Vec2::new(181.0, 376.0); const RIGHT_FLIPPER_RAISED_TIP: Vec2 = Vec2::new(181.0, 376.0);
@@ -32,6 +46,19 @@ const CLAW_FRAME_SECONDS: f32 = 0.030;
const DETAIL_TIMER_SECONDS: [f32; 5] = [0.050, 0.040, 0.030, 0.020, 0.010]; const DETAIL_TIMER_SECONDS: [f32; 5] = [0.050, 0.040, 0.030, 0.020, 0.010];
const DETAIL_SUBSTEPS: [u8; 5] = [5, 4, 3, 2, 1]; const DETAIL_SUBSTEPS: [u8; 5] = [5, 4, 3, 2, 1];
const CLAW_TERMINAL_FRAMES: [u8; 4] = [1, 6, 7, 18]; const CLAW_TERMINAL_FRAMES: [u8; 4] = [1, 6, 7, 18];
const CLAW_TABLE_TERMINAL_FRAMES: [u8; 3] = [1, 6, 7];
/// 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; const ORIGINAL_BALL_SPEED_PER_SECOND: f32 = 380.0;
#[allow(clippy::cast_possible_wrap)] #[allow(clippy::cast_possible_wrap)]
@@ -73,7 +100,7 @@ pub enum Event {
Nudge, Nudge,
Tilt, Tilt,
Drain, Drain,
HighScoreCandidate(u32), HighScoreCandidate { score: u32, crazy: bool },
StopSound, StopSound,
Sound(u16), Sound(u16),
} }
@@ -96,7 +123,7 @@ impl Event {
| Self::Nudge | Self::Nudge
| Self::Tilt | Self::Tilt
| Self::Drain | Self::Drain
| Self::HighScoreCandidate(_) | Self::HighScoreCandidate { .. }
| Self::StopSound => None, | Self::StopSound => None,
} }
} }
@@ -121,6 +148,12 @@ pub struct Claw {
pub frame: u8, pub frame: u8,
pub bank: ClawSpriteBank, pub bank: ClawSpriteBank,
pub ball_suspended: bool, pub ball_suspended: bool,
/// 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<(usize, bool)>,
target_frame: u8, target_frame: u8,
frame_accumulator: f32, frame_accumulator: f32,
} }
@@ -132,6 +165,8 @@ impl Default for Claw {
frame: 10, frame: 10,
bank: ClawSpriteBank::Opening, bank: ClawSpriteBank::Opening,
ball_suspended: false, ball_suspended: false,
captured_slot: None,
capture_entry: None,
target_frame: 10, target_frame: 10,
frame_accumulator: 0.0, frame_accumulator: 0.0,
} }
@@ -171,9 +206,18 @@ pub struct Player {
pub bumper_value: u32, pub bumper_value: u32,
pub diamond_segments: u8, pub diamond_segments: u8,
pub media_level: u8, pub media_level: u8,
crazy_mode: CrazyMode,
rules: RuleState, 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 { impl Default for Player {
fn default() -> Self { fn default() -> Self {
Self { Self {
@@ -185,6 +229,7 @@ impl Default for Player {
bumper_value: 1_000, bumper_value: 1_000,
diamond_segments: 0, diamond_segments: 0,
media_level: 0, media_level: 0,
crazy_mode: CrazyMode::Unused,
rules: RuleState::default(), rules: RuleState::default(),
} }
} }
@@ -227,6 +272,13 @@ enum BallDoubleState {
Active, Active,
} }
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
enum CrazyMode {
#[default]
Unused,
Used,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
enum SpecialHoleGate { enum SpecialHoleGate {
#[default] #[default]
@@ -250,6 +302,29 @@ enum BallAction {
Finish, 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)] #[derive(Clone, Copy, Debug)]
struct SensorScanResult { struct SensorScanResult {
action: BallAction, action: BallAction,
@@ -328,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)] #[derive(Clone, Debug)]
pub struct Game { pub struct Game {
pub players: Vec<Player>, pub players: Vec<Player>,
pub current_player: usize, pub current_player: usize,
pub ball: Ball, /// Every ball in the machine, in timer slot order. All balls run the
pub secondary_ball: Option<Ball>, /// 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 wheel_holes: [bool; 5],
pub top_targets: [bool; 3], pub top_targets: [bool; 3],
pub tilted: bool, pub tilted: bool,
@@ -368,6 +469,7 @@ pub struct Game {
} }
impl Game { impl Game {
#[cfg(not(target_arch = "wasm32"))]
pub fn new(player_count: usize) -> Self { pub fn new(player_count: usize) -> Self {
Self::new_with_seed(player_count, macroquad::rand::rand()) Self::new_with_seed(player_count, macroquad::rand::rand())
} }
@@ -376,8 +478,8 @@ impl Game {
Self { Self {
players: vec![Player::default(); player_count.clamp(1, 4)], players: vec![Player::default(); player_count.clamp(1, 4)],
current_player: 0, current_player: 0,
ball: Ball::default(), balls: vec![Ball::default()],
secondary_ball: None, spawned_balls: Vec::new(),
wheel_holes: [false; 5], wheel_holes: [false; 5],
top_targets: [false; 3], top_targets: [false; 3],
tilted: false, tilted: false,
@@ -446,15 +548,49 @@ impl Game {
true 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> { pub(crate) fn begin_claw_scenario(&mut self, terminal_frame: u8) -> Vec<Event> {
self.ball.in_launcher = false; self.balls[0].in_launcher = false;
self.ball.position = CLAW_TRIGGER_CENTER; self.balls[0].position = CLAW_TRIGGER_CENTER;
self.ball.velocity = Vec2::ZERO; self.balls[0].velocity = Vec2::ZERO;
let mut events = Vec::new(); let mut events = Vec::new();
self.begin_claw_capture(terminal_frame, &mut events); self.begin_claw_capture(terminal_frame, &mut events);
events events
} }
#[cfg(not(target_arch = "wasm32"))]
pub(crate) fn begin_effect_scenario(&mut self, effect: u8) { pub(crate) fn begin_effect_scenario(&mut self, effect: u8) {
self.target_effect = effect.min(7); self.target_effect = effect.min(7);
self.object_active[usize::from(EFFECT_SENSOR.id)] = self.target_effect != 0; self.object_active[usize::from(EFFECT_SENSOR.id)] = self.target_effect != 0;
@@ -464,9 +600,10 @@ impl Game {
} }
} }
#[cfg(not(target_arch = "wasm32"))]
pub(crate) fn begin_target_scenario(&mut self) { pub(crate) fn begin_target_scenario(&mut self) {
self.ball.in_launcher = false; self.balls[0].in_launcher = false;
self.ball.position = vec2(170.0, 230.0); self.balls[0].position = vec2(170.0, 230.0);
self.players[0].bumper_value = 6_000; self.players[0].bumper_value = 6_000;
self.wheel_holes = [true, false, true, false, false]; self.wheel_holes = [true, false, true, false, false];
self.target_rotation_state = Some(0); self.target_rotation_state = Some(0);
@@ -474,7 +611,7 @@ impl Game {
self.object_active[109] = false; self.object_active[109] = false;
} }
fn fire_launcher(&mut self) { fn fire_launcher(&mut self, index: usize) {
self.launcher_velocity_milli = self self.launcher_velocity_milli = self
.launcher_velocity_milli .launcher_velocity_milli
.wrapping_sub(LAUNCHER_RELEASE_IMPULSE_MILLI); .wrapping_sub(LAUNCHER_RELEASE_IMPULSE_MILLI);
@@ -483,10 +620,12 @@ impl Game {
let variation = self.random.below(3_800) / 40; let variation = self.random.below(3_800) / 40;
launch_velocity = -MAXIMUM_SPEED_MILLI_PER_STEP + i32::from(variation); launch_velocity = -MAXIMUM_SPEED_MILLI_PER_STEP + i32::from(variation);
} else if launch_velocity > -2_280 { } else if launch_velocity > -2_280 {
launch_velocity = launch_velocity.wrapping_sub(i32::from(self.random.below(3_800) / 40)); launch_velocity =
launch_velocity.wrapping_sub(i32::from(self.random.below(3_800) / 40));
} }
self.ball.in_launcher = false; let ball = &mut self.balls[index];
self.ball.velocity = MilliVec { ball.in_launcher = false;
ball.velocity = MilliVec {
x: 0, x: 0,
y: launch_velocity, y: launch_velocity,
} }
@@ -516,14 +655,15 @@ impl Game {
} else if self.tilted { } else if self.tilted {
self.flipper_release_latch[1] = true; self.flipper_release_latch[1] = true;
} }
self.flipper_inputs.left_raised = controls.left_flipper self.flipper_inputs.left_raised =
&& !self.tilted controls.left_flipper && !self.tilted && !self.flipper_release_latch[0];
&& !self.flipper_release_latch[0]; self.flipper_inputs.right_raised =
self.flipper_inputs.right_raised = controls.right_flipper controls.right_flipper && !self.tilted && !self.flipper_release_latch[1];
&& !self.tilted
&& !self.flipper_release_latch[1];
if self.ball.in_launcher && !self.tilted { // 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 let Some(launcher_ball) = self.launcher_ball() {
if controls.launch_down { if controls.launch_down {
if self.launcher_was_down { if self.launcher_was_down {
self.launcher_hold_seconds += frame_time.min(0.05); self.launcher_hold_seconds += frame_time.min(0.05);
@@ -544,9 +684,11 @@ impl Game {
self.launcher_charge = self.launcher_charge =
f32::from(i16::try_from(charge_milli).unwrap_or(3_800)) / f32::from(3_800_i16); f32::from(i16::try_from(charge_milli).unwrap_or(3_800)) / f32::from(3_800_i16);
} else if self.launcher_was_down { } else if self.launcher_was_down {
self.fire_launcher(); self.fire_launcher(launcher_ball);
events.push(Event::Launch); events.push(Event::Launch);
events.push(Event::Sound(2002)); if !self.tilted {
events.push(Event::Sound(2002));
}
} }
} }
if !self.tilted && controls.nudge != Nudge::None { if !self.tilted && controls.nudge != Nudge::None {
@@ -565,66 +707,34 @@ impl Game {
fn timer_tick(&mut self, elapsed: f32, substeps: u8, events: &mut Vec<Event>) { fn timer_tick(&mut self, elapsed: f32, substeps: u8, events: &mut Vec<Event>) {
self.update_claw(elapsed, events); 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_record_countdowns();
self.update_target_rotation(events); self.update_target_rotation(events);
} }
let panel_active = self.update_panel_completion(events); let panel_active = self.update_panel_completion(events);
if !self.claw.ball_suspended && !panel_active { // A held claw ball stays in the list, so it still counts as a live
let had_secondary_ball = self.secondary_ball.is_some(); // ball until the claw releases it or the others drain.
if had_secondary_ball { if self.balls.len() < 2 {
self.score_mode = ScoreMode::Multiball; self.score_mode = ScoreMode::Normal;
} }
for _ in 0..substeps { // The single-ball wheel replacement runs with its ball parked in the
if self.fixed_update(events) // launcher and freezes the table; balls still in play keep moving.
|| self.finished if !panel_active || self.balls.iter().any(|ball| !ball.in_launcher) {
|| self.claw.ball_suspended self.advance_ball_slots(substeps, events);
{
break;
}
}
if had_secondary_ball && self.secondary_ball.is_none() {
self.score_mode = ScoreMode::Normal;
}
if had_secondary_ball && !self.finished && !self.claw.ball_suspended {
if self.secondary_ball.is_some() {
for _ in 0..substeps {
if self.advance_secondary_ball(events) {
break;
}
}
} else {
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);
}
}
}
} }
self.pending_flipper_edges[0] = match ( self.pending_flipper_edges[0] =
self.flipper_inputs.left_raised, match (self.flipper_inputs.left_raised, self.flippers.left_raised) {
self.flippers.left_raised, (true, false) => -1,
) { (false, true) => 1,
(true, false) => -1, _ => 0,
(false, true) => 1, };
_ => 0, self.pending_flipper_edges[1] =
}; match (self.flipper_inputs.right_raised, self.flippers.right_raised) {
self.pending_flipper_edges[1] = match ( (true, false) => -1,
self.flipper_inputs.right_raised, (false, true) => 1,
self.flippers.right_raised, _ => 0,
) { };
(true, false) => -1,
(false, true) => 1,
_ => 0,
};
self.flippers = self.flipper_inputs; self.flippers = self.flipper_inputs;
for edge in self.pending_flipper_edges { for edge in self.pending_flipper_edges {
if edge != 0 { if edge != 0 {
@@ -638,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) { fn update_record_countdowns(&mut self) {
for countdown in &mut self.record_countdowns[1..] { for countdown in &mut self.record_countdowns[1..] {
if *countdown != 0 { if *countdown != 0 {
@@ -702,11 +875,15 @@ impl Game {
fn apply_nudge(&mut self, nudge: Nudge, events: &mut Vec<Event>) { fn apply_nudge(&mut self, nudge: Nudge, events: &mut Vec<Event>) {
const SCALAR: i32 = 15; const SCALAR: i32 = 15;
events.push(Event::Sound(2019)); 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 { match nudge {
Nudge::Left | Nudge::Right => { Nudge::Left | Nudge::Right => {
let amount = (50 - i32::from(self.random.below(20))) * SCALAR; let amount = (50 - i32::from(self.random.below(20))) * SCALAR;
let signed = if nudge == Nudge::Left { -amount } else { amount }; let signed = if nudge == Nudge::Left {
-amount
} else {
amount
};
velocity.x = velocity.x.wrapping_add(signed); velocity.x = velocity.x.wrapping_add(signed);
} }
Nudge::Center => { Nudge::Center => {
@@ -724,14 +901,13 @@ impl Game {
} }
Nudge::None => return, Nudge::None => return,
} }
if let Some(secondary) = &mut self.secondary_ball { let held = self.held_ball();
if self.balls.len() > 1 {
let slot_impulse = match nudge { let slot_impulse = match nudge {
Nudge::Left | Nudge::Right => { Nudge::Left | Nudge::Right => MilliVec {
MilliVec { x: if nudge == Nudge::Left { -600 } else { 600 },
x: if nudge == Nudge::Left { -600 } else { 600 }, y: 0,
y: 0, },
}
}
Nudge::Center => { Nudge::Center => {
let horizontal = (i32::from(self.random.below(21)) - 10) * SCALAR; let horizontal = (i32::from(self.random.below(21)) - 10) * SCALAR;
let vertical = -(i32::from(self.random.below(100)) + 50) * SCALAR; let vertical = -(i32::from(self.random.below(100)) + 50) * SCALAR;
@@ -742,16 +918,17 @@ impl Game {
} }
Nudge::None => MilliVec::default(), Nudge::None => MilliVec::default(),
}; };
let mut primary_slot = MilliVec::from_velocity_per_second(self.ball.velocity); for (index, ball) in self.balls.iter_mut().enumerate() {
primary_slot.x = primary_slot.x.wrapping_add(slot_impulse.x); if held == Some(index) {
primary_slot.y = primary_slot.y.wrapping_add(slot_impulse.y); continue;
self.ball.velocity = primary_slot.to_velocity_per_second(); }
let mut secondary_slot = MilliVec::from_velocity_per_second(secondary.velocity); let mut slot = MilliVec::from_velocity_per_second(ball.velocity);
secondary_slot.x = secondary_slot.x.wrapping_add(slot_impulse.x); slot.x = slot.x.wrapping_add(slot_impulse.x);
secondary_slot.y = secondary_slot.y.wrapping_add(slot_impulse.y); slot.y = slot.y.wrapping_add(slot_impulse.y);
secondary.velocity = secondary_slot.to_velocity_per_second(); ball.velocity = slot.to_velocity_per_second();
} else { }
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); self.tilt_counter = self.tilt_counter.wrapping_add(25);
@@ -769,65 +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)] #[allow(clippy::too_many_lines)]
fn fixed_update(&mut self, events: &mut Vec<Event>) -> bool { fn step_ball(&mut self, pass: BallPass, events: &mut Vec<Event>) -> BallStep {
if self.ball.in_launcher { let index = pass.index;
self.ball.position = LAUNCHER_POSITION; let mut ball = self.balls[index];
self.ball.velocity = Vec2::ZERO; if ball.in_launcher {
return true; 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 old_position = MilliVec::from_position(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 mut velocity = MilliVec::from_velocity_per_second(ball.velocity); let mut velocity = MilliVec::from_velocity_per_second(ball.velocity);
velocity.y += GRAVITY_MILLI_PER_STEP; velocity.y += GRAVITY_MILLI_PER_STEP;
velocity.clamp_speed(MAXIMUM_SPEED_MILLI_PER_STEP); velocity.clamp_speed(MAXIMUM_SPEED_MILLI_PER_STEP);
let speed_before_collision = milli_distance(velocity); let speed_before_collision = milli_distance(velocity);
if predicted_outside_board(old_position.add(velocity)) { if predicted_outside_board(old_position.add(velocity)) {
self.drain(events); return self.lose_ball(index, events);
return true;
} }
let (best_static, scan, predicted) = self.scan_ordered_records_for_ball( let (best_static, scan, predicted) = self.scan_ordered_records_for_ball(
&mut ball, &mut ball,
1, pass,
ball_count,
old_position, old_position,
&mut velocity, &mut velocity,
events, events,
); );
let mut best_collision = best_static let mut best_collision = best_static
.map(|(id, is_wall, candidate)| (id, is_wall, candidate.resolve(velocity, ball.spin))); .map(|(id, is_wall, candidate)| (id, is_wall, candidate.resolve(velocity, ball.spin)));
let mut transferred_secondary_velocity = None;
if let Some(secondary) = initial_secondary { // Ball-to-ball contact only competes when no static record was hit.
let other_position = MilliVec::from_position(secondary.position); // Objects 174 and 175 are the original's moving circle records for
let response = ball_collision_response( // slot one and every later slot.
old_position, let mut transferred_velocity = None;
velocity, if best_collision.is_none() {
ball.spin, for (other_index, other) in self.balls.iter().enumerate() {
other_position, if other_index == index || self.claw_holds(other_index) {
MilliVec::from_velocity_per_second(secondary.velocity), continue;
secondary.capture_age <= 0, }
); let other_position = MilliVec::from_position(other.position);
if predicted.x >= other_position.x.wrapping_sub(21_000) if predicted.x >= other_position.x.wrapping_sub(21_000)
&& predicted.x <= other_position.x.wrapping_add(21_000) && predicted.x <= other_position.x.wrapping_add(21_000)
&& predicted.y >= other_position.y.wrapping_sub(21_000) && predicted.y >= other_position.y.wrapping_sub(21_000)
&& predicted.y <= other_position.y.wrapping_add(21_000) && predicted.y <= other_position.y.wrapping_add(21_000)
&& let Some(response) = response && let Some(response) = ball_collision_response(
&& best_collision.is_none() old_position,
{ velocity,
transferred_secondary_velocity = Some(response.other_velocity); ball.spin,
best_collision = Some(( other_position,
175, MilliVec::from_velocity_per_second(other.velocity),
false, other.capture_age <= 0,
CollisionResponse { )
surface_distance: response.surface_distance, {
velocity: response.moving_velocity, transferred_velocity = Some((other_index, response.other_velocity));
spin: response.moving_spin, best_collision = Some((
auxiliary_fired: false, 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(); let collided = best_collision.is_some();
@@ -835,87 +1023,130 @@ impl Game {
ball.capture_age = 0; ball.capture_age = 0;
} }
let mut auxiliary_fired = false; 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; velocity = response.velocity;
ball.spin = response.spin; ball.spin = response.spin;
auxiliary_fired = response.auxiliary_fired; auxiliary_fired = response.auxiliary_fired;
self.last_collision_id = Some(object_id); self.last_collision_id = Some(object_id);
if object_id == 175 hit = Some((object_id, is_wall));
&& let (Some(secondary), Some(transferred)) =
(&mut self.secondary_ball, transferred_secondary_velocity)
{
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;
} }
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.x = 0;
velocity.y = 0; if speed_before_collision < 1_000 {
velocity.y = 0;
}
} }
velocity.clamp_speed(MAXIMUM_SPEED_MILLI_PER_STEP); velocity.clamp_speed(MAXIMUM_SPEED_MILLI_PER_STEP);
ball.position = old_position.add(velocity).to_position(); ball.position = old_position.add(velocity).to_position();
ball.velocity = velocity.to_velocity_per_second(); ball.velocity = velocity.to_velocity_per_second();
self.ball = ball; self.balls[index] = ball;
if let Some(wall_id) = hit_wall { match hit {
if wall_id == 2 { Some((2, true)) => {
let special_respawn = self.special_respawn_pending(); // The original continues the drained ball's response on the
let response_spin = self.ball.spin; // ball respawned in its slot.
self.drain(events); let respawn_in_place = self.balls.len() == 1 && self.special_respawn_pending(index);
if special_respawn { let response_spin = ball.spin;
let origin = MilliVec::from_position(self.ball.position); let step = self.lose_ball(index, events);
self.ball.position = origin.add(velocity).to_position(); if respawn_in_place {
self.ball.velocity = velocity.to_velocity_per_second(); let respawned = &mut self.balls[index];
self.ball.spin = response_spin; 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 { Some((25, true)) if speed_before_collision < 1_000 => {
self.ball = Ball::default(); return self.return_ball_to_launcher(index);
self.launcher_charge = 0.0;
self.launcher_was_down = false;
return true;
} }
self.apply_wall_rule(wall_id, events); Some((wall_id, true)) => {
if auxiliary_fired && matches!(wall_id, 55 | 74 | 107) { self.apply_wall_rule(wall_id, events);
events.push(Event::Sound(2019)); if auxiliary_fired && matches!(wall_id, 55 | 74 | 107) {
events.push(Event::Sound(2019));
}
} }
} Some((circle_id, false)) => self.apply_bumper_rule(circle_id, auxiliary_fired, events),
None => {}
if let Some(circle_id) = hit_circle {
self.apply_bumper_rule(circle_id, auxiliary_fired, events);
} }
match scan.action { match scan.action {
BallAction::Keep => {} BallAction::Keep => {}
BallAction::Suspend => return true, BallAction::Suspend => return BallStep::Stop,
BallAction::Reset => { BallAction::Reset => {
self.reset_ball_to_launcher(); self.reset_ball_to_launcher(index);
return true; return BallStep::Stop;
}
BallAction::Finish => {
self.drain(events);
return true;
} }
BallAction::Finish => return self.lose_ball(index, events),
BallAction::Remove => { BallAction::Remove => {
self.ball = self // A capture during multiball takes the ball off the table
.secondary_ball // without a drain. If the other balls already left during
.take() // this callback it was the last ball, which returns to the
.expect("a multiball capture must leave the other slot active"); // launcher exactly like a single-ball capture.
return true; 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( fn retain_static_range(
@@ -936,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( fn scan_ordered_records_for_ball(
&mut self, &mut self,
ball: &mut Ball, ball: &mut Ball,
ball_number: u16, pass: BallPass,
ball_count: u16,
old_position: MilliVec, old_position: MilliVec,
velocity: &mut MilliVec, velocity: &mut MilliVec,
events: &mut Vec<Event>, events: &mut Vec<Event>,
@@ -965,8 +1195,7 @@ impl Game {
ball.velocity = velocity.to_velocity_per_second(); ball.velocity = velocity.to_velocity_per_second();
match self.capture_record_step( match self.capture_record_step(
ball, ball,
ball_number, pass,
ball_count,
89, 89,
CLAW_TRIGGER_CENTER, CLAW_TRIGGER_CENTER,
CLAW_TRIGGER_RADIUS, CLAW_TRIGGER_RADIUS,
@@ -975,12 +1204,12 @@ impl Game {
&mut capture_candidate, &mut capture_candidate,
events, events,
) { ) {
CaptureStep::Complete if ball_count == 1 => { CaptureStep::Complete => {
let terminal_frame = self.next_claw_terminal_frame(); let terminal_frame =
self.begin_claw_capture_after_hold(ball, terminal_frame, events); 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; action = BallAction::Suspend;
} }
CaptureStep::Complete => action = BallAction::Remove,
CaptureStep::Outside | CaptureStep::Holding => {} CaptureStep::Outside | CaptureStep::Holding => {}
} }
*velocity = MilliVec::from_velocity_per_second(ball.velocity); *velocity = MilliVec::from_velocity_per_second(ball.velocity);
@@ -991,8 +1220,7 @@ impl Game {
ball.velocity = velocity.to_velocity_per_second(); ball.velocity = velocity.to_velocity_per_second();
if let Some(completed_action) = self.check_lock_holes( if let Some(completed_action) = self.check_lock_holes(
ball, ball,
ball_number, pass,
ball_count,
old_position, old_position,
predicted, predicted,
&mut capture_candidate, &mut capture_candidate,
@@ -1003,6 +1231,9 @@ impl Game {
*velocity = MilliVec::from_velocity_per_second(ball.velocity); *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); self.retain_static_range(old_position, predicted, *velocity, 134..=139, &mut best);
if old_position.y < 250_000 { if old_position.y < 250_000 {
ball.velocity = velocity.to_velocity_per_second(); ball.velocity = velocity.to_velocity_per_second();
@@ -1022,8 +1253,7 @@ impl Game {
} }
if let Some(completed_action) = self.check_special_hole( if let Some(completed_action) = self.check_special_hole(
ball, ball,
ball_number, pass,
ball_count,
old_position, old_position,
predicted, predicted,
&mut capture_candidate, &mut capture_candidate,
@@ -1034,7 +1264,6 @@ impl Game {
*velocity = MilliVec::from_velocity_per_second(ball.velocity); *velocity = MilliVec::from_velocity_per_second(ball.velocity);
let effect_triggered = self.check_effect_sensor( let effect_triggered = self.check_effect_sensor(
ball, ball,
ball_number,
old_position, old_position,
movement_from_prediction(old_position, predicted), movement_from_prediction(old_position, predicted),
events, events,
@@ -1070,13 +1299,7 @@ impl Game {
best = Some((id, false, candidate)); best = Some((id, false, candidate));
} }
ball.velocity = velocity.to_velocity_per_second(); ball.velocity = velocity.to_velocity_per_second();
( (best, SensorScanResult { action }, predicted)
best,
SensorScanResult {
action,
},
predicted,
)
} }
fn find_static_collision_candidate_in_range( fn find_static_collision_candidate_in_range(
@@ -1200,129 +1423,21 @@ 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 { fn advance_secondary_ball(&mut self, events: &mut Vec<Event>) -> bool {
self.advance_secondary_ball_slot(events, true) if self.balls.len() < 2 {
}
#[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 {
return true; return true;
}
let pass = BallPass {
index: 1,
number: 2,
count: 2,
}; };
let primary_position = self.ball.position; self.step_ball(pass, events) != BallStep::Continue
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
&& 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
} }
fn apply_bumper_rule( fn apply_bumper_rule(&mut self, object_id: u8, auxiliary_fired: bool, events: &mut Vec<Event>) {
&mut self,
object_id: u8,
auxiliary_fired: bool,
events: &mut Vec<Event>,
) {
if self.tilted || !BUMPERS.iter().any(|bumper| bumper.id == object_id) { if self.tilted || !BUMPERS.iter().any(|bumper| bumper.id == object_id) {
return; return;
} }
@@ -1461,30 +1576,30 @@ impl Game {
movement_velocity: MilliVec, movement_velocity: MilliVec,
events: &mut Vec<Event>, events: &mut Vec<Event>,
) -> BallAction { ) -> BallAction {
let ball_count = if self.secondary_ball.is_some() { 2 } else { 1 }; let count = u16::try_from(self.balls.len()).unwrap_or(u16::MAX);
let mut ball = self.ball; let pass = BallPass {
index: 0,
number: 1,
count,
};
let mut ball = self.balls[0];
let scan = self.check_sensor_objects_for_ball( let scan = self.check_sensor_objects_for_ball(
&mut ball, &mut ball,
1, pass,
ball_count,
old_position, old_position,
movement_velocity, movement_velocity,
events, events,
); );
match scan.action { match scan.action {
BallAction::Keep | BallAction::Suspend => self.ball = ball, BallAction::Keep | BallAction::Suspend => self.balls[0] = ball,
BallAction::Reset => self.reset_ball_to_launcher(), BallAction::Reset => self.reset_ball_to_launcher(0),
BallAction::Finish => { BallAction::Finish => {
self.ball = ball; self.balls[0] = ball;
self.drain(events); self.lose_ball(0, events);
}
BallAction::Remove => {
self.ball = self
.secondary_ball
.take()
.expect("a multiball capture must leave the other slot active");
} }
BallAction::Remove => self.remove_ball(0),
} }
self.publish_spawned_balls();
scan.action scan.action
} }
@@ -1492,8 +1607,7 @@ impl Game {
fn check_sensor_objects_for_ball( fn check_sensor_objects_for_ball(
&mut self, &mut self,
ball: &mut Ball, ball: &mut Ball,
ball_number: u16, pass: BallPass,
ball_count: u16,
old_position: MilliVec, old_position: MilliVec,
movement_velocity: MilliVec, movement_velocity: MilliVec,
events: &mut Vec<Event>, events: &mut Vec<Event>,
@@ -1504,11 +1618,7 @@ impl Game {
if !self.tilted if !self.tilted
&& (TARGET_SENSORS.into_iter().any(|sensor| { && (TARGET_SENSORS.into_iter().any(|sensor| {
self.object_active[usize::from(sensor.id)] self.object_active[usize::from(sensor.id)]
&& trigger_broadphase_contains( && trigger_broadphase_contains(predicted_position, sensor.center, sensor.radius)
predicted_position,
sensor.center,
sensor.radius,
)
}) || self.object_active[usize::from(EFFECT_SENSOR.id)] }) || self.object_active[usize::from(EFFECT_SENSOR.id)]
&& trigger_broadphase_contains( && trigger_broadphase_contains(
predicted_position, predicted_position,
@@ -1526,8 +1636,7 @@ impl Game {
if !self.claw.active { if !self.claw.active {
match self.capture_record_step( match self.capture_record_step(
ball, ball,
ball_number, pass,
ball_count,
89, 89,
CLAW_TRIGGER_CENTER, CLAW_TRIGGER_CENTER,
CLAW_TRIGGER_RADIUS, CLAW_TRIGGER_RADIUS,
@@ -1538,75 +1647,49 @@ impl Game {
) { ) {
CaptureStep::Holding | CaptureStep::Outside => {} CaptureStep::Holding | CaptureStep::Outside => {}
CaptureStep::Complete => { CaptureStep::Complete => {
if ball_count == 1 { let terminal_frame =
let terminal_frame = self.next_claw_terminal_frame(); self.next_claw_terminal_frame(self.claw_capture_restricted_for(pass.index));
self.begin_claw_capture_after_hold(ball, terminal_frame, events); self.begin_claw_capture_after_hold(ball, pass.index, terminal_frame, events);
action = BallAction::Suspend; action = BallAction::Suspend;
} else {
action = BallAction::Remove;
}
} }
} }
} }
if let Some(completed_action) = if let Some(completed_action) = self.check_lock_holes(
self.check_lock_holes( ball,
ball, pass,
ball_number, old_position,
ball_count, predicted_position,
old_position, &mut capture_candidate,
predicted_position, events,
&mut capture_candidate, ) {
events,
)
{
action = completed_action; action = completed_action;
} }
self.check_target_sensors( self.check_target_sensors(ball, old_position, movement_velocity, 140..=147, events);
if let Some(completed_action) = self.check_special_hole(
ball, ball,
pass,
old_position, old_position,
movement_velocity, predicted_position,
140..=147, &mut capture_candidate,
events, events,
); ) {
if let Some(completed_action) =
self.check_special_hole(
ball,
ball_number,
ball_count,
old_position,
predicted_position,
&mut capture_candidate,
events,
)
{
action = completed_action; action = completed_action;
} }
self.check_effect_sensor( self.check_effect_sensor(ball, old_position, movement_velocity, events);
ball, self.check_target_sensors(ball, old_position, movement_velocity, 150..=152, events);
ball_number, SensorScanResult { action }
old_position,
movement_velocity,
events,
);
self.check_target_sensors(
ball,
old_position,
movement_velocity,
150..=152,
events,
);
SensorScanResult {
action,
}
} }
#[allow(clippy::cast_possible_truncation, clippy::too_many_arguments)] #[allow(
clippy::cast_possible_truncation,
clippy::too_many_arguments,
clippy::too_many_lines
)]
fn capture_record_step( fn capture_record_step(
&mut self, &mut self,
ball: &mut Ball, ball: &mut Ball,
ball_number: u16, pass: BallPass,
ball_count: u16,
record_id: u8, record_id: u8,
center: Vec2, center: Vec2,
radius: f32, radius: f32,
@@ -1615,6 +1698,11 @@ impl Game {
best_capture: &mut Option<(u8, StaticCollisionCandidate)>, best_capture: &mut Option<(u8, StaticCollisionCandidate)>,
events: &mut Vec<Event>, events: &mut Vec<Event>,
) -> CaptureStep { ) -> CaptureStep {
let BallPass {
index: ball_index,
number: ball_number,
count: ball_count,
} = pass;
let current_position = MilliVec::from_position(ball.position); let current_position = MilliVec::from_position(ball.position);
let center_view = center; let center_view = center;
let center = MilliVec::from_position(center_view); let center = MilliVec::from_position(center_view);
@@ -1626,6 +1714,12 @@ impl Game {
|| predicted_position.y < center.y.wrapping_sub(broadphase_margin) || predicted_position.y < center.y.wrapping_sub(broadphase_margin)
|| predicted_position.y > center.y.wrapping_add(broadphase_margin) || predicted_position.y > center.y.wrapping_add(broadphase_margin)
{ {
if record_id == 89
&& ball.capture_age == 0
&& self.record_contacts[usize::from(record_id)] == 0
{
self.clear_claw_capture_entry_for(ball_index);
}
return CaptureStep::Outside; return CaptureStep::Outside;
} }
let dx = center.x.wrapping_sub(current_position.x); let dx = center.x.wrapping_sub(current_position.x);
@@ -1638,11 +1732,24 @@ impl Game {
let contact = self.record_contacts[contact_index]; let contact = self.record_contacts[contact_index];
let contact_allowed = let contact_allowed =
contact == 0 || (contact != 99 && ball_count == 1) || contact == ball_number; 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 if surface_distance < -11_000
&& contact_allowed && contact_allowed
&& !(record_id == 148 && self.special_hole_gate == SpecialHoleGate::Suppressed) && !(record_id == 148 && self.special_hole_gate == SpecialHoleGate::Suppressed)
{ {
if record_id == 89 && ball.capture_age == 0 && self.claw.capture_entry.is_none() {
// Latch the restriction when the ball first enters the deep
// capture zone, before the pull/hold phase can outlive the
// other active ball.
self.claw.capture_entry = Some((ball_index, ball_count > 1));
}
if ball.capture_age < 300 { if ball.capture_age < 300 {
let mut velocity = MilliVec::from_velocity_per_second(ball.velocity); let mut velocity = MilliVec::from_velocity_per_second(ball.velocity);
let stationary = center.x.wrapping_sub(previous_position.x).wrapping_abs() < 500 let stationary = center.x.wrapping_sub(previous_position.x).wrapping_abs() < 500
@@ -1685,21 +1792,39 @@ impl Game {
return CaptureStep::Complete; return CaptureStep::Complete;
} }
ball.capture_age = 0; ball.capture_age = 0;
self.record_contacts[contact_index] = if record_id == 148 { 2 } else { 99 }; self.record_contacts[contact_index] = if record_id == 89 && ball_count > 1 {
if ball_count > 1 { // Keep ownership with the held slot so its later release can
self.record_contacts[contact_index] = 2; // clear the claw contact before a fresh entry.
} ball_number
} else if record_id == 148 || ball_count > 1 {
2
} else {
99
};
return CaptureStep::Complete; return CaptureStep::Complete;
} }
if record_id == 89 && surface_distance >= -11_000 {
self.clear_claw_capture_entry_for(ball_index);
}
if surface_distance > -11_000 && contact == ball_number { if surface_distance > -11_000 && contact == ball_number {
self.record_contacts[contact_index] = 0; self.record_contacts[contact_index] = 0;
ball.capture_age = 0; ball.capture_age = 0;
} }
// 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 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) if surface_distance <= milli_distance(velocity)
&& surface_distance >= -11_000 && surface_distance >= -11_000
&& contact != 0 && contact != 0
&& !leaving_filled_hole
&& let Some(candidate) = capture_collision_candidate( && let Some(candidate) = capture_collision_candidate(
current_position, current_position,
velocity, velocity,
@@ -1718,18 +1843,34 @@ impl Game {
fn check_lock_holes( fn check_lock_holes(
&mut self, &mut self,
ball: &mut Ball, ball: &mut Ball,
ball_number: u16, pass: BallPass,
ball_count: u16,
old_position: MilliVec, old_position: MilliVec,
predicted_position: MilliVec, predicted_position: MilliVec,
best_capture: &mut Option<(u8, StaticCollisionCandidate)>, best_capture: &mut Option<(u8, StaticCollisionCandidate)>,
events: &mut Vec<Event>, events: &mut Vec<Event>,
) -> Option<BallAction> { ) -> 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() { for (index, sensor) in LOCK_HOLES.into_iter().enumerate() {
match self.capture_record_step( match self.capture_record_step(
ball, ball,
ball_number, pass,
ball_count,
sensor.id, sensor.id,
sensor.center, sensor.center,
sensor.radius, sensor.radius,
@@ -1741,7 +1882,7 @@ impl Game {
CaptureStep::Outside | CaptureStep::Holding => continue, CaptureStep::Outside | CaptureStep::Holding => continue,
CaptureStep::Complete => {} CaptureStep::Complete => {}
} }
return Some(self.complete_lock_hole(index, ball_count, events)); return Some(self.complete_lock_hole(index, pass.count, events));
} }
None None
} }
@@ -1753,7 +1894,17 @@ impl Game {
events: &mut Vec<Event>, events: &mut Vec<Event>,
) -> BallAction { ) -> BallAction {
self.wheel_holes[index] = true; self.wheel_holes[index] = true;
let filled = self.wheel_holes.iter().filter(|filled| **filled).count(); // Deliberate original-game divergence: a multiball capture normally
// leaves owner 2 here, which lets the surviving single ball capture
// the visibly occupied hole once more. Occupied wheel holes remain
// permanent in the clone regardless of which ball completed them.
self.record_contacts[129 + index] = 99;
// 1000:967a derives the award from all live type-three contact words,
// including another ball that is still settling into a lock hole.
let filled = self.record_contacts[129..=133]
.iter()
.filter(|contact| **contact != 0)
.count();
let shift = u32::try_from(filled.min(5)).unwrap_or(5); let shift = u32::try_from(filled.min(5)).unwrap_or(5);
let award = 5_000_u32 << shift; let award = 5_000_u32 << shift;
let player = &mut self.players[self.current_player]; let player = &mut self.players[self.current_player];
@@ -1765,9 +1916,13 @@ impl Game {
player.secondary_score = 0; player.secondary_score = 0;
player.score_multiplier = player.score_multiplier.wrapping_add(1); player.score_multiplier = player.score_multiplier.wrapping_add(1);
player.extra_balls = player.extra_balls.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.panel_frame = Some(0);
} }
self.score_mode = ScoreMode::Normal;
} }
events.push(Event::Lock); events.push(Event::Lock);
if ball_count == 1 { if ball_count == 1 {
@@ -1785,8 +1940,7 @@ impl Game {
fn check_special_hole( fn check_special_hole(
&mut self, &mut self,
ball: &mut Ball, ball: &mut Ball,
ball_number: u16, pass: BallPass,
ball_count: u16,
old_position: MilliVec, old_position: MilliVec,
predicted_position: MilliVec, predicted_position: MilliVec,
best_capture: &mut Option<(u8, StaticCollisionCandidate)>, best_capture: &mut Option<(u8, StaticCollisionCandidate)>,
@@ -1794,8 +1948,7 @@ impl Game {
) -> Option<BallAction> { ) -> Option<BallAction> {
match self.capture_record_step( match self.capture_record_step(
ball, ball,
ball_number, pass,
ball_count,
SPECIAL_HOLE_SENSOR.id, SPECIAL_HOLE_SENSOR.id,
SPECIAL_HOLE_SENSOR.center, SPECIAL_HOLE_SENSOR.center,
SPECIAL_HOLE_SENSOR.radius, SPECIAL_HOLE_SENSOR.radius,
@@ -1809,7 +1962,7 @@ impl Game {
} }
self.multiball_state = MultiballState::Ready; self.multiball_state = MultiballState::Ready;
events.push(Event::Wheel); events.push(Event::Wheel);
Some(if ball_count == 1 { Some(if pass.count == 1 {
if self.tilted { if self.tilted {
BallAction::Finish BallAction::Finish
} else { } else {
@@ -1823,7 +1976,6 @@ impl Game {
fn check_effect_sensor( fn check_effect_sensor(
&mut self, &mut self,
ball: &mut Ball, ball: &mut Ball,
ball_number: u16,
old_position: MilliVec, old_position: MilliVec,
movement_velocity: MilliVec, movement_velocity: MilliVec,
events: &mut Vec<Event>, events: &mut Vec<Event>,
@@ -1832,7 +1984,14 @@ impl Game {
return false; return false;
} }
let effect_index = usize::from(EFFECT_SENSOR.id); let effect_index = usize::from(EFFECT_SENSOR.id);
if self.object_active[effect_index] { let predicted_position = old_position.add(movement_velocity);
if self.object_active[effect_index]
&& trigger_broadphase_contains(
predicted_position,
EFFECT_SENSOR.center,
EFFECT_SENSOR.radius,
)
{
let touched = path_intersects_circle( let touched = path_intersects_circle(
old_position, old_position,
movement_velocity, movement_velocity,
@@ -1857,15 +2016,10 @@ impl Game {
self.add_score(transfer, events); self.add_score(transfer, events);
self.players[self.current_player].secondary_score = 0; self.players[self.current_player].secondary_score = 0;
} }
7 if self.record_contacts[usize::from(SPECIAL_HOLE_SENSOR.id)] != 0 // Release the ball parked in the special hole, however
&& self.record_contacts[usize::from(SPECIAL_HOLE_SENSOR.id)] != ball_number // many balls are already in play.
&& self.secondary_ball.is_none() 7 if self.special_hole_active() && self.spawned_balls.is_empty() => {
&& matches!( self.spawned_balls.push(Ball {
self.multiball_state,
MultiballState::Ready | MultiballState::Active
) =>
{
self.secondary_ball = Some(Ball {
position: vec2(17.0, 23.0), position: vec2(17.0, 23.0),
velocity: MilliVec { x: 0, y: 3_040 }.to_velocity_per_second(), velocity: MilliVec { x: 0, y: 3_040 }.to_velocity_per_second(),
in_launcher: false, in_launcher: false,
@@ -1898,17 +2052,27 @@ impl Game {
return false; return false;
} }
let mut triggered = false; let mut triggered = false;
let predicted_position = old_position.add(movement_velocity);
for sensor in TARGET_SENSORS { for sensor in TARGET_SENSORS {
if !record_ids.contains(&sensor.id) { if !record_ids.contains(&sensor.id) {
continue; continue;
} }
let contact_index = usize::from(sensor.id);
// The original scanner does not call a type-4 handler until the
// predicted point is inside that record's registered bounds. In
// multiball this also prevents an unrelated ball outside the
// corridor from clearing the ball that is currently inside it.
if !self.object_active[contact_index]
|| !trigger_broadphase_contains(predicted_position, sensor.center, sensor.radius)
{
continue;
}
let touched = path_intersects_circle( let touched = path_intersects_circle(
old_position, old_position,
movement_velocity, movement_velocity,
sensor.center, sensor.center,
sensor.radius, sensor.radius,
); );
let contact_index = usize::from(sensor.id);
let entered = touched && !self.trigger_flags[contact_index]; let entered = touched && !self.trigger_flags[contact_index];
self.trigger_flags[contact_index] = touched; self.trigger_flags[contact_index] = touched;
if !entered { if !entered {
@@ -1940,7 +2104,7 @@ impl Game {
ball.velocity = velocity.to_velocity_per_second(); 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] if self.record_contacts[129..=133]
.iter() .iter()
.all(|contact| *contact != 0) .all(|contact| *contact != 0)
@@ -1949,7 +2113,7 @@ impl Game {
} }
self.tilted = false; self.tilted = false;
self.tilt_counter = 0; self.tilt_counter = 0;
self.ball = Ball::default(); self.balls[index] = Ball::default();
self.launcher_charge = 0.0; self.launcher_charge = 0.0;
self.launcher_was_down = false; self.launcher_was_down = false;
self.launcher_hold_seconds = 0.0; self.launcher_hold_seconds = 0.0;
@@ -1999,10 +2163,7 @@ impl Game {
.round_i32() .round_i32()
.wrapping_neg(); .wrapping_neg();
let predicted = old_position.add(*velocity); let predicted = old_position.add(*velocity);
if predicted.x < min_x if predicted.x < min_x || predicted.x > max_x || predicted.y < min_y || predicted.y > max_y
|| predicted.x > max_x
|| predicted.y < min_y
|| predicted.y > max_y
{ {
self.object_active[object_id] = false; self.object_active[object_id] = false;
} }
@@ -2032,17 +2193,105 @@ impl Game {
self.target_effect.min(7) self.target_effect.min(7)
} }
fn next_claw_terminal_frame(&mut self) -> u8 { fn next_claw_terminal_frame(&mut self, restricted: bool) -> u8 {
CLAW_TERMINAL_FRAMES[usize::from(self.random.below(4))] if restricted {
// Frame 18 sends the released ball down the shooter lane. During
// a capture that began with two live balls, keep it on the table.
CLAW_TABLE_TERMINAL_FRAMES[usize::from(self.random.below(3))]
} else {
CLAW_TERMINAL_FRAMES[usize::from(self.random.below(4))]
}
}
fn held_ball(&self) -> Option<usize> {
self.claw
.ball_suspended
.then_some(self.claw.captured_slot)
.flatten()
}
fn claw_holds(&self, index: usize) -> bool {
self.held_ball() == Some(index)
}
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 == index)
{
self.claw.capture_entry = None;
}
}
/// 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;
}
match usize::from(*contact).cmp(&removed_number) {
std::cmp::Ordering::Equal => *contact = 0,
std::cmp::Ordering::Greater => *contact -= 1,
std::cmp::Ordering::Less => {}
}
}
}
/// 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) { fn apply_flipper_kicks(&mut self) {
let pending = self.pending_flipper_edges; let pending = self.pending_flipper_edges;
self.pending_flipper_edges.fill(0); self.pending_flipper_edges.fill(0);
if self.ball.in_launcher || self.tilted { if self.tilted {
return; return;
} }
let held = self.held_ball();
for (delta, side) in [ for (delta, side) in [
(i32::from(pending[0]), FlipperSide::Left), (i32::from(pending[0]), FlipperSide::Left),
(i32::from(pending[1]), FlipperSide::Right), (i32::from(pending[1]), FlipperSide::Right),
@@ -2050,13 +2299,15 @@ impl Game {
if delta == 0 { if delta == 0 {
continue; continue;
} }
apply_flipper_response_to_ball(&mut self.ball, delta, side); for (index, ball) in self.balls.iter_mut().enumerate() {
if let Some(secondary) = &mut self.secondary_ball { if !ball.in_launcher && held != Some(index) {
apply_flipper_response_to_ball(secondary, delta, side); apply_flipper_response_to_ball(ball, delta, side);
}
} }
} }
} }
#[cfg(not(target_arch = "wasm32"))]
fn begin_claw_capture(&mut self, terminal_frame: u8, events: &mut Vec<Event>) { fn begin_claw_capture(&mut self, terminal_frame: u8, events: &mut Vec<Event>) {
debug_assert!(CLAW_TERMINAL_FRAMES.contains(&terminal_frame)); debug_assert!(CLAW_TERMINAL_FRAMES.contains(&terminal_frame));
if self.claw.active { if self.claw.active {
@@ -2066,9 +2317,11 @@ impl Game {
self.claw.target_frame = terminal_frame; self.claw.target_frame = terminal_frame;
self.claw.bank = ClawSpriteBank::Closing; self.claw.bank = ClawSpriteBank::Closing;
self.claw.ball_suspended = true; self.claw.ball_suspended = true;
self.claw.captured_slot = Some(0);
self.claw.capture_entry = None;
self.claw.frame_accumulator = 0.0; self.claw.frame_accumulator = 0.0;
self.ball.velocity = Vec2::ZERO; self.balls[0].velocity = Vec2::ZERO;
self.ball.spin = Real48::ZERO; self.balls[0].spin = Real48::ZERO;
events.push(Event::ClawCapture); events.push(Event::ClawCapture);
events.push(Event::Sound(2015)); events.push(Event::Sound(2015));
} }
@@ -2076,6 +2329,7 @@ impl Game {
fn begin_claw_capture_after_hold( fn begin_claw_capture_after_hold(
&mut self, &mut self,
ball: &mut Ball, ball: &mut Ball,
index: usize,
terminal_frame: u8, terminal_frame: u8,
events: &mut Vec<Event>, events: &mut Vec<Event>,
) { ) {
@@ -2087,6 +2341,7 @@ impl Game {
self.claw.target_frame = terminal_frame; self.claw.target_frame = terminal_frame;
self.claw.bank = ClawSpriteBank::Closing; self.claw.bank = ClawSpriteBank::Closing;
self.claw.ball_suspended = true; self.claw.ball_suspended = true;
self.claw.captured_slot = Some(index);
self.claw.frame_accumulator = 0.0; self.claw.frame_accumulator = 0.0;
ball.velocity = Vec2::ZERO; ball.velocity = Vec2::ZERO;
ball.spin = Real48::ZERO; ball.spin = Real48::ZERO;
@@ -2098,6 +2353,14 @@ impl Game {
return; return;
} }
self.claw.frame_accumulator += dt; 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 { while self.claw.active && self.claw.frame_accumulator >= self.claw_frame_seconds {
self.claw.frame_accumulator -= self.claw_frame_seconds; self.claw.frame_accumulator -= self.claw_frame_seconds;
self.advance_claw(events); self.advance_claw(events);
@@ -2124,7 +2387,13 @@ impl Game {
let release_frame = self.claw.frame; let release_frame = self.claw.frame;
self.claw.target_frame = 10; self.claw.target_frame = 10;
self.claw.bank = ClawSpriteBank::Opening; self.claw.bank = ClawSpriteBank::Opening;
(self.ball.position, self.ball.velocity) = claw_release(release_frame); let release = claw_release(release_frame);
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; self.claw.ball_suspended = false;
events.push(Event::ClawRelease); events.push(Event::ClawRelease);
events.push(Event::Sound(2016)); events.push(Event::Sound(2016));
@@ -2158,27 +2427,63 @@ impl Game {
} }
} }
fn drain(&mut self, events: &mut Vec<Event>) { /// The ball at `index` drained. While other balls remain it simply
if let Some(remaining_ball) = self.secondary_ball.take() { /// leaves the table; the last ball either takes the special respawn or
self.ball = remaining_ball; /// ends the player's ball.
self.score_mode = ScoreMode::Normal; 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); 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 self.special_respawn_pending() { if special_respawn {
self.ball = Ball { self.balls[index] = self.release_parked_ball();
position: vec2(17.0, 23.0), return BallStep::Stop;
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;
} }
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;
}
}
/// 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.ball_double = BallDoubleState::Inactive;
self.multiball_state = MultiballState::Unavailable;
self.special_hole_gate = SpecialHoleGate::Enabled; self.special_hole_gate = SpecialHoleGate::Enabled;
let player = &mut self.players[self.current_player]; let player = &mut self.players[self.current_player];
if player.extra_balls > 0 { if player.extra_balls > 0 {
@@ -2187,21 +2492,23 @@ impl Game {
player.balls = player.balls.saturating_sub(1); player.balls = player.balls.saturating_sub(1);
} }
let high_score_candidate = 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(); self.save_current_rule_state();
events.push(Event::Drain); events.push(Event::Drain);
if !self.tilted { if !self.tilted {
events.push(Event::Sound(2008)); events.push(Event::Sound(2008));
} }
if let Some(score) = high_score_candidate { if let Some(candidate) = high_score_candidate {
events.push(Event::HighScoreCandidate(score)); events.push(candidate);
self.flipper_release_latch[0] |= self.flipper_inputs.left_raised; self.flipper_release_latch[0] |= self.flipper_inputs.left_raised;
self.flipper_release_latch[1] |= self.flipper_inputs.right_raised; self.flipper_release_latch[1] |= self.flipper_inputs.right_raised;
self.flipper_inputs = Flippers::default(); self.flipper_inputs = Flippers::default();
} }
self.tilted = false; self.tilted = false;
self.tilt_counter = 0; self.tilt_counter = 0;
self.secondary_ball = None;
self.score_mode = ScoreMode::Normal; self.score_mode = ScoreMode::Normal;
self.launcher_charge = 0.0; self.launcher_charge = 0.0;
self.launcher_was_down = false; self.launcher_was_down = false;
@@ -2214,7 +2521,7 @@ impl Game {
if self.players[next].balls > 0 || self.players[next].extra_balls > 0 { if self.players[next].balls > 0 || self.players[next].extra_balls > 0 {
self.current_player = next; self.current_player = next;
self.load_current_rule_state(); self.load_current_rule_state();
self.ball = Ball::default(); self.balls = vec![Ball::default()];
return; return;
} }
next = (next + 1) % self.players.len(); next = (next + 1) % self.players.len();
@@ -2222,10 +2529,21 @@ impl Game {
self.finished = true; self.finished = true;
} }
fn special_respawn_pending(&self) -> bool { /// Whether the ball at `leaving` is the last one in play while a ball is
self.secondary_ball.is_none() /// parked in the special hole. Balls waiting in the launcher are not in
&& self.score_mode == ScoreMode::Normal /// play; a ball effect seven is about to publish is.
&& matches!(self.multiball_state, MultiballState::Ready | MultiballState::Active) 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,
MultiballState::Ready | MultiballState::Active
)
} }
fn save_current_rule_state(&mut self) { fn save_current_rule_state(&mut self) {
@@ -2333,24 +2651,71 @@ fn claw_release(frame: u8) -> (Vec2, Vec2) {
(position, direction * ORIGINAL_BALL_SPEED_PER_SECOND) (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) { fn apply_flipper_response_to_ball(ball: &mut Ball, delta: i32, side: FlipperSide) {
let position = MilliVec::from_position(ball.position); let position = MilliVec::from_position(ball.position);
let velocity = MilliVec::from_velocity_per_second(ball.velocity); let velocity = MilliVec::from_velocity_per_second(ball.velocity);
if let Some(response) = if let Some(response) = moving_flipper_response(
moving_flipper_response( position,
position, velocity,
velocity, delta,
delta, side,
side, Real48::from_bytes([0x80, 0, 0, 0, 0, 0]),
Real48::from_bytes([0x80, 0, 0, 0, 0, 0]), MAXIMUM_SPEED_MILLI_PER_STEP,
MAXIMUM_SPEED_MILLI_PER_STEP, ) {
)
{
ball.position = position.add(response.movement).to_position(); ball.position = position.add(response.movement).to_position();
ball.velocity = response.velocity.to_velocity_per_second(); ball.velocity = response.velocity.to_velocity_per_second();
} }
} }
#[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)] #[allow(clippy::too_many_lines)]
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
@@ -2376,14 +2741,10 @@ mod tests {
events: &mut Vec<Event>, events: &mut Vec<Event>,
) { ) {
for _ in 0..=60 { for _ in 0..=60 {
game.ball.in_launcher = false; game.balls[0].in_launcher = false;
game.ball.position = center; game.balls[0].position = center;
game.ball.velocity = Vec2::ZERO; game.balls[0].velocity = Vec2::ZERO;
game.check_sensor_objects( game.check_sensor_objects(MilliVec::from_position(center), MilliVec::default(), events);
MilliVec::from_position(center),
MilliVec::default(),
events,
);
} }
} }
@@ -2395,13 +2756,13 @@ mod tests {
previous_position: MilliVec, previous_position: MilliVec,
events: &mut Vec<Event>, events: &mut Vec<Event>,
) -> CaptureStep { ) -> CaptureStep {
let mut ball = game.ball; let mut ball = game.balls[0];
let mut capture_candidate = None; let mut capture_candidate = None;
let predicted_position = previous_position.add(MilliVec::from_velocity_per_second(ball.velocity)); let predicted_position =
previous_position.add(MilliVec::from_velocity_per_second(ball.velocity));
let result = game.capture_record_step( let result = game.capture_record_step(
&mut ball, &mut ball,
1, BallPass::slot(1, if game.secondary().is_some() { 2 } else { 1 }),
if game.secondary_ball.is_some() { 2 } else { 1 },
record_id, record_id,
center, center,
radius, radius,
@@ -2410,10 +2771,327 @@ mod tests {
&mut capture_candidate, &mut capture_candidate,
events, events,
); );
game.ball = ball; game.balls[0] = ball;
result 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] #[test]
fn player_count_is_bounded() { fn player_count_is_bounded() {
assert_eq!(Game::new(0).players.len(), 1); assert_eq!(Game::new(0).players.len(), 1);
@@ -2440,8 +3118,8 @@ mod tests {
fn ball_starts_in_the_recovered_shooter_lane() { fn ball_starts_in_the_recovered_shooter_lane() {
let game = Game::new(1); let game = Game::new(1);
assert_eq!(game.ball.position, Vec2::new(325.0, 413.0)); assert_eq!(game.balls[0].position, Vec2::new(325.0, 413.0));
assert!(game.ball.in_launcher); assert!(game.balls[0].in_launcher);
} }
#[test] #[test]
@@ -2454,10 +3132,10 @@ mod tests {
let mut game = Game::new(1); let mut game = Game::new(1);
game.object_active.fill(false); game.object_active.fill(false);
game.object_active[usize::from(wall.id)] = true; game.object_active[usize::from(wall.id)] = true;
game.ball.in_launcher = false; game.balls[0].in_launcher = false;
game.ball.position = midpoint - normal; game.balls[0].position = midpoint - normal;
game.ball.velocity = MilliVec::from_velocity_per_second(normal * 200.0) game.balls[0].velocity =
.to_velocity_per_second(); MilliVec::from_velocity_per_second(normal * 200.0).to_velocity_per_second();
game.fixed_update(&mut Vec::new()); game.fixed_update(&mut Vec::new());
if game.last_collision_id != Some(wall.id) { if game.last_collision_id != Some(wall.id) {
missed_walls.push(wall.id); missed_walls.push(wall.id);
@@ -2469,16 +3147,19 @@ mod tests {
let mut game = Game::new(1); let mut game = Game::new(1);
game.object_active.fill(false); game.object_active.fill(false);
game.object_active[usize::from(circle.id)] = true; game.object_active[usize::from(circle.id)] = true;
game.ball.in_launcher = false; game.balls[0].in_launcher = false;
game.ball.position = circle.center + vec2(circle.contact_radius + 1.0, 0.0); game.balls[0].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].velocity = MilliVec { x: -2_000, y: 0 }.to_velocity_per_second();
game.fixed_update(&mut Vec::new()); game.fixed_update(&mut Vec::new());
if game.last_collision_id != Some(circle.id) { if game.last_collision_id != Some(circle.id) {
missed_circles.push(circle.id); missed_circles.push(circle.id);
} }
} }
assert!(missed_walls.is_empty(), "missed production wall records: {missed_walls:?}"); assert!(
missed_walls.is_empty(),
"missed production wall records: {missed_walls:?}"
);
assert!( assert!(
missed_circles.is_empty(), missed_circles.is_empty(),
"missed production circle records: {missed_circles:?}" "missed production circle records: {missed_circles:?}"
@@ -2495,18 +3176,18 @@ mod tests {
(5, 0.010, 1), (5, 0.010, 1),
] { ] {
let mut game = Game::new(1); let mut game = Game::new(1);
game.ball.in_launcher = false; game.balls[0].in_launcher = false;
game.ball.position = vec2(200.0, 250.0); game.balls[0].position = vec2(200.0, 250.0);
game.ball.velocity = Vec2::ZERO; game.balls[0].velocity = Vec2::ZERO;
game.object_active.fill(false); game.object_active.fill(false);
game.update(interval - 0.001, detail, Controls::default()); game.update(interval - 0.001, detail, Controls::default());
assert_eq!(game.ball.position, vec2(200.0, 250.0)); assert_eq!(game.balls[0].position, vec2(200.0, 250.0));
assert_eq!(game.ball.velocity, Vec2::ZERO); assert_eq!(game.balls[0].velocity, Vec2::ZERO);
game.update(0.001_1, detail, Controls::default()); game.update(0.001_1, detail, Controls::default());
let velocity = MilliVec::from_velocity_per_second(game.ball.velocity); let velocity = MilliVec::from_velocity_per_second(game.balls[0].velocity);
let position = MilliVec::from_position(game.ball.position); let position = MilliVec::from_position(game.balls[0].position);
assert_eq!(velocity.y, 15 * substeps); assert_eq!(velocity.y, 15 * substeps);
assert_eq!(position.y, 250_000 + 15 * substeps * (substeps + 1) / 2); assert_eq!(position.y, 250_000 + 15 * substeps * (substeps + 1) / 2);
} }
@@ -2515,9 +3196,9 @@ mod tests {
#[test] #[test]
fn detail_batch_stops_after_the_first_collision_like_integrate_one_ball() { fn detail_batch_stops_after_the_first_collision_like_integrate_one_ball() {
let mut game = Game::new(1); let mut game = Game::new(1);
game.ball.in_launcher = false; game.balls[0].in_launcher = false;
game.ball.position = vec2(270.0, 30.0); game.balls[0].position = vec2(270.0, 30.0);
game.ball.velocity = vec2(0.0, -380.0); game.balls[0].velocity = vec2(0.0, -380.0);
let mut expected = game.clone(); let mut expected = game.clone();
let mut expected_events = Vec::new(); let mut expected_events = Vec::new();
let mut stopped = false; let mut stopped = false;
@@ -2532,17 +3213,17 @@ mod tests {
let mut events = Vec::new(); let mut events = Vec::new();
game.timer_tick(0.050, 5, &mut events); game.timer_tick(0.050, 5, &mut events);
assert_eq!(game.ball.position, expected.ball.position); assert_eq!(game.balls[0].position, expected.balls[0].position);
assert_eq!(game.ball.velocity, expected.ball.velocity); assert_eq!(game.balls[0].velocity, expected.balls[0].velocity);
assert_eq!(events, expected_events); assert_eq!(events, expected_events);
} }
#[test] #[test]
fn effect_seven_spawn_waits_until_the_next_timer_callback() { fn effect_seven_spawn_waits_until_the_next_timer_callback() {
let mut game = Game::new_with_seed(1, 7); let mut game = Game::new_with_seed(1, 7);
game.ball.in_launcher = false; game.balls[0].in_launcher = false;
game.ball.position = EFFECT_SENSOR.center; game.balls[0].position = EFFECT_SENSOR.center;
game.ball.velocity = Vec2::ZERO; game.balls[0].velocity = Vec2::ZERO;
game.object_active.fill(false); game.object_active.fill(false);
game.object_active[usize::from(EFFECT_SENSOR.id)] = true; game.object_active[usize::from(EFFECT_SENSOR.id)] = true;
game.multiball_state = MultiballState::Ready; game.multiball_state = MultiballState::Ready;
@@ -2552,7 +3233,7 @@ mod tests {
game.timer_tick(0.050, 5, &mut Vec::new()); game.timer_tick(0.050, 5, &mut Vec::new());
let spawned = game let spawned = game
.secondary_ball .secondary()
.expect("the effect-seven request must create slot two after simulation"); .expect("the effect-seven request must create slot two after simulation");
assert_eq!(spawned.position, vec2(17.0, 23.0)); assert_eq!(spawned.position, vec2(17.0, 23.0));
assert_eq!( assert_eq!(
@@ -2565,30 +3246,30 @@ mod tests {
fn survivor_keeps_ball_two_identity_for_the_rest_of_the_callback() { fn survivor_keeps_ball_two_identity_for_the_rest_of_the_callback() {
let lock = LOCK_HOLES[0]; let lock = LOCK_HOLES[0];
let mut game = Game::new_with_seed(1, 7); let mut game = Game::new_with_seed(1, 7);
game.ball.in_launcher = false; game.balls[0].in_launcher = false;
game.ball.position = lock.center; game.balls[0].position = lock.center;
game.ball.velocity = Vec2::ZERO; game.balls[0].velocity = Vec2::ZERO;
game.ball.capture_age = 300; game.balls[0].capture_age = 300;
game.record_contacts[usize::from(lock.id)] = 1; game.record_contacts[usize::from(lock.id)] = 1;
game.secondary_ball = Some(Ball { game.set_secondary(Some(Ball {
position: EFFECT_SENSOR.center, position: EFFECT_SENSOR.center,
velocity: Vec2::ZERO, velocity: Vec2::ZERO,
in_launcher: false, in_launcher: false,
spin: Real48::ZERO, spin: Real48::ZERO,
capture_age: 0, capture_age: 0,
}); }));
game.object_active[usize::from(EFFECT_SENSOR.id)] = true; game.object_active[usize::from(EFFECT_SENSOR.id)] = true;
game.multiball_state = MultiballState::Ready; game.multiball_state = MultiballState::Ready;
game.target_effect = 7; game.target_effect = 7;
game.timer_tick(0.050, 5, &mut Vec::new()); 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_eq!(game.target_effect, 0);
assert!(!game.effect_target_active()); assert!(!game.effect_target_active());
assert_eq!(game.multiball_state, MultiballState::Ready); assert_eq!(game.multiball_state, MultiballState::Ready);
assert!(game.wheel_holes[0]); 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] #[test]
@@ -2603,14 +3284,14 @@ mod tests {
}, },
); );
assert!(game.ball.in_launcher); assert!(game.balls[0].in_launcher);
assert!(game.launcher_charge > 0.0); assert!(game.launcher_charge > 0.0);
let events = game.update(1.0 / 60.0, 3, Controls::default()); let events = game.update(1.0 / 60.0, 3, Controls::default());
assert!(events.contains(&Event::Launch)); assert!(events.contains(&Event::Launch));
assert!(!game.ball.in_launcher); assert!(!game.balls[0].in_launcher);
assert!(game.ball.velocity.y < 0.0); assert!(game.balls[0].velocity.y < 0.0);
} }
#[test] #[test]
@@ -2622,15 +3303,15 @@ mod tests {
}; };
quick.update(0.0, 3, held); quick.update(0.0, 3, held);
quick.update(0.0, 3, Controls::default()); quick.update(0.0, 3, Controls::default());
let quick_speed = -quick.ball.velocity.y; let quick_speed = -quick.balls[0].velocity.y;
assert_eq!( 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 } MilliVec { x: 0, y: -2_270 }
); );
let mut charged = Game::new_with_seed(1, 7); let mut charged = Game::new_with_seed(1, 7);
launch_ball(&mut charged, 60); 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); assert!(charged_speed > quick_speed + 80.0);
} }
@@ -2656,6 +3337,17 @@ mod tests {
assert_eq!(game.launcher_frame(), 10); assert_eq!(game.launcher_frame(), 10);
} }
#[test]
fn maximum_launch_uses_the_initial_random_seed_for_trajectory_variation() {
let mut first = Game::new_with_seed(1, 1);
let mut second = Game::new_with_seed(1, 2);
launch_ball(&mut first, 60);
launch_ball(&mut second, 60);
assert_ne!(first.balls[0].velocity, second.balls[0].velocity);
}
#[test] #[test]
fn charged_ball_clears_the_shooter_lane() { fn charged_ball_clears_the_shooter_lane() {
for detail in 1..=5 { for detail in 1..=5 {
@@ -2663,21 +3355,21 @@ mod tests {
launch_ball(&mut game, 60); launch_ball(&mut game, 60);
let mut entered_table = false; let mut entered_table = false;
let mut returned_to_launcher = 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 { for _ in 0..480 {
game.update(1.0 / 120.0, detail, Controls::default()); game.update(1.0 / 120.0, detail, Controls::default());
minimum_y = minimum_y.min(game.ball.position.y); minimum_y = minimum_y.min(game.balls[0].position.y);
if game.ball.position.x < 280.0 && game.ball.position.y > 30.0 { if game.balls[0].position.x < 280.0 && game.balls[0].position.y > 30.0 {
entered_table = true; entered_table = true;
} }
returned_to_launcher |= game.ball.in_launcher && !entered_table; returned_to_launcher |= game.balls[0].in_launcher && !entered_table;
} }
assert!( assert!(
entered_table, entered_table,
"detail {detail}: the shooter curve should guide the ball onto the table; position={:?}, velocity={:?}, minimum_y={minimum_y}", "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!( assert!(
!returned_to_launcher, !returned_to_launcher,
@@ -2689,20 +3381,54 @@ mod tests {
#[test] #[test]
fn shooter_stop_rearms_a_returning_ball() { fn shooter_stop_rearms_a_returning_ball() {
let mut game = Game::new(1); let mut game = Game::new(1);
game.ball.in_launcher = false; game.balls[0].in_launcher = false;
game.ball.position = Vec2::new(325.0, 410.0); game.balls[0].position = Vec2::new(325.0, 410.0);
game.ball.velocity = Vec2::new(0.0, 80.0); game.balls[0].velocity = Vec2::new(0.0, 80.0);
for _ in 0..8 { for _ in 0..8 {
game.update(1.0 / 120.0, 5, Controls::default()); game.update(1.0 / 120.0, 5, Controls::default());
if game.ball.in_launcher { if game.balls[0].in_launcher {
break; break;
} }
} }
assert!(game.ball.in_launcher); assert!(game.balls[0].in_launcher);
assert_eq!(game.ball.position, LAUNCHER_POSITION); assert_eq!(game.balls[0].position, LAUNCHER_POSITION);
assert_eq!(game.ball.velocity, Vec2::ZERO); 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] #[test]
@@ -2710,16 +3436,19 @@ mod tests {
let mut game = Game::new(1); let mut game = Game::new(1);
game.object_active.fill(false); game.object_active.fill(false);
game.object_active[25] = true; game.object_active[25] = true;
game.ball.in_launcher = false; game.balls[0].in_launcher = false;
game.ball.position = Vec2::new(320.0, 412.0); game.balls[0].position = Vec2::new(320.0, 412.0);
game.ball.velocity = MilliVec { x: 500, y: 1_000 }.to_velocity_per_second(); game.balls[0].velocity = MilliVec { x: 500, y: 1_000 }.to_velocity_per_second();
assert!(game.fixed_update(&mut Vec::new())); assert!(game.fixed_update(&mut Vec::new()));
assert!(!game.ball.in_launcher); assert!(!game.balls[0].in_launcher);
assert!(game.ball.velocity.y < 0.0); assert!(game.balls[0].velocity.y < 0.0);
assert_eq!(MilliVec::from_velocity_per_second(game.ball.velocity).x, 0); assert_eq!(
assert_eq!(MilliVec::from_position(game.ball.position).x, 320_000); MilliVec::from_velocity_per_second(game.balls[0].velocity).x,
0
);
assert_eq!(MilliVec::from_position(game.balls[0].position).x, 320_000);
} }
#[test] #[test]
@@ -2728,11 +3457,11 @@ mod tests {
game.tilted = true; game.tilted = true;
game.tilt_counter = 39; game.tilt_counter = 39;
game.reset_ball_to_launcher(); game.reset_ball_to_launcher(0);
assert!(!game.tilted); assert!(!game.tilted);
assert_eq!(game.tilt_counter, 0); assert_eq!(game.tilt_counter, 0);
assert!(game.ball.in_launcher); assert!(game.balls[0].in_launcher);
} }
#[test] #[test]
@@ -2802,9 +3531,10 @@ mod tests {
let mut special = Game::new(1); let mut special = Game::new(1);
special.ball_double = BallDoubleState::Active; special.ball_double = BallDoubleState::Active;
special.multiball_state = MultiballState::Ready; special.multiball_state = MultiballState::Ready;
special.record_contacts[148] = 2;
special.drain(&mut Vec::new()); special.drain(&mut Vec::new());
assert_eq!(special.ball_double, BallDoubleState::Active); 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] #[test]
@@ -2827,10 +3557,10 @@ mod tests {
fn tilted_type_three_completion_finishes_or_special_respawns() { fn tilted_type_three_completion_finishes_or_special_respawns() {
let mut lock = Game::new(1); let mut lock = Game::new(1);
lock.tilted = true; lock.tilted = true;
lock.ball.in_launcher = false; lock.balls[0].in_launcher = false;
lock.ball.position = LOCK_HOLES[0].center; lock.balls[0].position = LOCK_HOLES[0].center;
lock.ball.velocity = Vec2::ZERO; lock.balls[0].velocity = Vec2::ZERO;
lock.ball.capture_age = 300; lock.balls[0].capture_age = 300;
let mut lock_events = Vec::new(); let mut lock_events = Vec::new();
assert_eq!( assert_eq!(
lock.check_sensor_objects( lock.check_sensor_objects(
@@ -2842,17 +3572,17 @@ mod tests {
); );
assert_eq!(lock.player().balls, 2); assert_eq!(lock.player().balls, 2);
assert_eq!(lock.player().secondary_score, 10_000); assert_eq!(lock.player().secondary_score, 10_000);
assert!(lock.ball.in_launcher); assert!(lock.balls[0].in_launcher);
assert!(!lock.tilted); assert!(!lock.tilted);
assert!(lock_events.contains(&Event::Drain)); assert!(lock_events.contains(&Event::Drain));
assert!(!lock_events.contains(&Event::Sound(2008))); assert!(!lock_events.contains(&Event::Sound(2008)));
let mut special = Game::new(1); let mut special = Game::new(1);
special.tilted = true; special.tilted = true;
special.ball.in_launcher = false; special.balls[0].in_launcher = false;
special.ball.position = SPECIAL_HOLE_SENSOR.center; special.balls[0].position = SPECIAL_HOLE_SENSOR.center;
special.ball.velocity = Vec2::ZERO; special.balls[0].velocity = Vec2::ZERO;
special.ball.capture_age = 300; special.balls[0].capture_age = 300;
let balls = special.player().balls; let balls = special.player().balls;
let mut special_events = Vec::new(); let mut special_events = Vec::new();
assert_eq!( assert_eq!(
@@ -2864,14 +3594,16 @@ mod tests {
BallAction::Finish BallAction::Finish
); );
assert_eq!(special.player().balls, balls); 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!(special.tilted);
assert_eq!(special.multiball_state, MultiballState::Unavailable); assert_eq!(special.multiball_state, MultiballState::Unavailable);
assert_eq!(special.record_contacts[148], 0); assert_eq!(special.record_contacts[148], 0);
assert!(!special_events.contains(&Event::Drain)); assert!(!special_events.contains(&Event::Drain));
assert!(!special_events assert!(
.iter() !special_events
.any(|event| event.sound_resource().is_some())); .iter()
.any(|event| event.sound_resource().is_some())
);
} }
#[test] #[test]
@@ -2912,8 +3644,8 @@ mod tests {
assert!(game.effect_target_active()); assert!(game.effect_target_active());
game.target_effect = 3; game.target_effect = 3;
assert_eq!(game.player_effect(), 3); assert_eq!(game.player_effect(), 3);
game.ball.in_launcher = false; game.balls[0].in_launcher = false;
game.ball.position = EFFECT_SENSOR.center; game.balls[0].position = EFFECT_SENSOR.center;
game.check_sensor_objects( game.check_sensor_objects(
MilliVec::from_position(EFFECT_SENSOR.center), MilliVec::from_position(EFFECT_SENSOR.center),
MilliVec::default(), MilliVec::default(),
@@ -2976,7 +3708,7 @@ mod tests {
.into_iter() .into_iter()
.find(|sensor| sensor.id == 150) .find(|sensor| sensor.id == 150)
.expect("record 150 must be present"); .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; target_ball.position = sensor.center;
let mut target_events = Vec::new(); let mut target_events = Vec::new();
target_game.check_target_sensors( target_game.check_target_sensors(
@@ -3000,12 +3732,11 @@ mod tests {
let mut effect_game = Game::new_with_seed(1, 7); let mut effect_game = Game::new_with_seed(1, 7);
effect_game.players[0].score = 139_500; effect_game.players[0].score = 139_500;
effect_game.begin_effect_scenario(1); 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; effect_ball.position = EFFECT_SENSOR.center;
let mut effect_events = Vec::new(); let mut effect_events = Vec::new();
effect_game.check_effect_sensor( effect_game.check_effect_sensor(
&mut effect_ball, &mut effect_ball,
1,
MilliVec::from_position(EFFECT_SENSOR.center), MilliVec::from_position(EFFECT_SENSOR.center),
MilliVec::default(), MilliVec::default(),
&mut effect_events, &mut effect_events,
@@ -3045,8 +3776,7 @@ mod tests {
let (_, _, predicted) = game.scan_ordered_records_for_ball( let (_, _, predicted) = game.scan_ordered_records_for_ball(
&mut ball, &mut ball,
1, BallPass::slot(1, 1),
1,
old_position, old_position,
&mut velocity, &mut velocity,
&mut events, &mut events,
@@ -3124,15 +3854,15 @@ mod tests {
game.apply_wall_rule(84, &mut events); game.apply_wall_rule(84, &mut events);
assert_eq!(game.target_effect, 7); assert_eq!(game.target_effect, 7);
game.ball.in_launcher = false; game.balls[0].in_launcher = false;
game.ball.position = EFFECT_SENSOR.center; game.balls[0].position = EFFECT_SENSOR.center;
game.check_sensor_objects( game.check_sensor_objects(
MilliVec::from_position(EFFECT_SENSOR.center), MilliVec::from_position(EFFECT_SENSOR.center),
MilliVec::default(), MilliVec::default(),
&mut events, &mut events,
); );
let spawned = game let spawned = game
.secondary_ball .secondary()
.expect("effect seven should spawn a second ball"); .expect("effect seven should spawn a second ball");
assert_eq!(game.multiball_state, MultiballState::Active); assert_eq!(game.multiball_state, MultiballState::Active);
assert!(!game.special_hole_active()); assert!(!game.special_hole_active());
@@ -3143,24 +3873,22 @@ mod tests {
); );
game.advance_secondary_ball(&mut events); game.advance_secondary_ball(&mut events);
let moving_ball = game let moving_ball = game.secondary().expect("second ball should remain active");
.secondary_ball
.expect("second ball should remain active");
assert!(moving_ball.position.y > spawned.position.y); assert!(moving_ball.position.y > spawned.position.y);
let balls_before = game.player().balls; let balls_before = game.player().balls;
game.drain(&mut events); 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.player().balls, balls_before);
assert_eq!(game.ball.position, moving_ball.position); assert_eq!(game.balls[0].position, moving_ball.position);
events.clear(); events.clear();
game.drain(&mut events); game.drain(&mut events);
assert!(events.is_empty()); assert!(events.is_empty());
assert_eq!(game.player().balls, balls_before); 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!( 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 } MilliVec { x: 0, y: 3_040 }
); );
assert_eq!(game.multiball_state, MultiballState::Unavailable); assert_eq!(game.multiball_state, MultiballState::Unavailable);
@@ -3169,25 +3897,22 @@ mod tests {
assert_eq!(game.special_hole_gate, SpecialHoleGate::Suppressed); assert_eq!(game.special_hole_gate, SpecialHoleGate::Suppressed);
game.fixed_update(&mut events); game.fixed_update(&mut events);
assert!(!game.ball.in_launcher); assert!(!game.balls[0].in_launcher);
assert_eq!( assert_eq!(
MilliVec::from_position(game.ball.position), MilliVec::from_position(game.balls[0].position),
MilliVec { MilliVec {
x: 17_000, x: 17_000,
y: 26_055, y: 26_055,
} }
); );
assert_eq!( assert_eq!(
MilliVec::from_velocity_per_second(game.ball.velocity), MilliVec::from_velocity_per_second(game.balls[0].velocity),
MilliVec { MilliVec { x: 0, y: 3_055 }
x: 0,
y: 3_055,
}
); );
assert_eq!(game.special_hole_gate, SpecialHoleGate::Suppressed); assert_eq!(game.special_hole_gate, SpecialHoleGate::Suppressed);
game.ball.position = vec2(17.0, 31.0); game.balls[0].position = vec2(17.0, 31.0);
game.ball.velocity = Vec2::ZERO; game.balls[0].velocity = Vec2::ZERO;
game.fixed_update(&mut events); game.fixed_update(&mut events);
assert_eq!(game.special_hole_gate, SpecialHoleGate::Enabled); assert_eq!(game.special_hole_gate, SpecialHoleGate::Enabled);
} }
@@ -3202,14 +3927,14 @@ mod tests {
assert_eq!(game.target_effect, 7); assert_eq!(game.target_effect, 7);
assert!(game.special_hole_active()); assert!(game.special_hole_active());
game.ball.in_launcher = false; game.balls[0].in_launcher = false;
game.ball.position = EFFECT_SENSOR.center; game.balls[0].position = EFFECT_SENSOR.center;
game.check_sensor_objects( game.check_sensor_objects(
MilliVec::from_position(EFFECT_SENSOR.center), MilliVec::from_position(EFFECT_SENSOR.center),
MilliVec::default(), MilliVec::default(),
&mut events, &mut events,
); );
assert!(game.secondary_ball.is_some()); assert!(game.secondary().is_some());
assert_eq!(game.target_effect, 0); assert_eq!(game.target_effect, 0);
assert!(!game.special_hole_active()); assert!(!game.special_hole_active());
@@ -3218,7 +3943,11 @@ mod tests {
} }
assert_eq!(game.record_contacts[148], 0); assert_eq!(game.record_contacts[148], 0);
game.drain(&mut events); game.drain(&mut events);
assert!(game.secondary_ball.is_none()); assert!(game.secondary().is_none());
game.drain(&mut events);
assert!(game.balls[0].in_launcher);
assert_eq!(game.player().balls, 2);
assert_eq!(game.multiball_state, MultiballState::Unavailable);
events.clear(); events.clear();
game.apply_wall_rule(84, &mut events); game.apply_wall_rule(84, &mut events);
@@ -3227,38 +3956,36 @@ mod tests {
game.target_effect = 7; game.target_effect = 7;
game.trigger_flags[usize::from(EFFECT_SENSOR.id)] = false; 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( game.check_sensor_objects(
MilliVec::from_position(EFFECT_SENSOR.center), MilliVec::from_position(EFFECT_SENSOR.center),
MilliVec::default(), MilliVec::default(),
&mut events, &mut events,
); );
assert!(game.secondary_ball.is_none()); assert!(game.secondary().is_none());
} }
#[test] #[test]
fn record_two_response_continues_after_special_respawn_like_live_original() { fn record_two_response_continues_after_special_respawn_like_live_original() {
let mut game = Game::new(1); let mut game = Game::new(1);
game.multiball_state = MultiballState::Active; game.multiball_state = MultiballState::Active;
game.ball.in_launcher = false; game.record_contacts[148] = 2;
game.ball.position = vec2(157.0, 453.0); game.balls[0].in_launcher = false;
game.ball.velocity = MilliVec { x: 0, y: 3_000 }.to_velocity_per_second(); 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!(game.fixed_update(&mut Vec::new()));
assert_eq!( assert_eq!(
MilliVec::from_position(game.ball.position), MilliVec::from_position(game.balls[0].position),
MilliVec { MilliVec {
x: 16_698, x: 16_698,
y: 21_191, y: 21_191,
} }
); );
assert_eq!( assert_eq!(
MilliVec::from_velocity_per_second(game.ball.velocity), MilliVec::from_velocity_per_second(game.balls[0].velocity),
MilliVec { MilliVec { x: -302, y: -1_809 }
x: -302,
y: -1_809,
}
); );
assert_eq!(game.multiball_state, MultiballState::Unavailable); assert_eq!(game.multiball_state, MultiballState::Unavailable);
assert_eq!(game.special_hole_gate, SpecialHoleGate::Suppressed); assert_eq!(game.special_hole_gate, SpecialHoleGate::Suppressed);
@@ -3276,7 +4003,7 @@ mod tests {
assert!(events.is_empty()); assert!(events.is_empty());
assert_eq!(game.player().balls, balls); 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.multiball_state, MultiballState::Unavailable);
assert_eq!(game.record_contacts[148], 0); assert_eq!(game.record_contacts[148], 0);
assert_eq!(game.special_hole_gate, SpecialHoleGate::Suppressed); assert_eq!(game.special_hole_gate, SpecialHoleGate::Suppressed);
@@ -3289,7 +4016,7 @@ mod tests {
.find(|sensor| sensor.id == 150) .find(|sensor| sensor.id == 150)
.expect("record 150 must be present"); .expect("record 150 must be present");
let mut game = Game::new_with_seed(1, 7); 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 { let mut second = Ball {
position: sensor.center, position: sensor.center,
velocity: vec2(12.0, -34.0), velocity: vec2(12.0, -34.0),
@@ -3297,9 +4024,7 @@ mod tests {
spin: Real48::ZERO, spin: Real48::ZERO,
capture_age: 0, capture_age: 0,
}; };
let old_position = MilliVec::from_position( let old_position = MilliVec::from_position(sensor.center - vec2(sensor.radius + 1.0, 0.0));
sensor.center - vec2(sensor.radius + 1.0, 0.0),
);
let current_position = MilliVec::from_position(sensor.center); let current_position = MilliVec::from_position(sensor.center);
let movement = MilliVec { let movement = MilliVec {
x: current_position.x.wrapping_sub(old_position.x), x: current_position.x.wrapping_sub(old_position.x),
@@ -3310,8 +4035,7 @@ mod tests {
assert_eq!( assert_eq!(
game.check_sensor_objects_for_ball( game.check_sensor_objects_for_ball(
&mut second, &mut second,
2, BallPass::slot(2, 2),
2,
old_position, old_position,
movement, movement,
&mut events, &mut events,
@@ -3323,7 +4047,7 @@ mod tests {
assert!(game.top_targets[0]); assert!(game.top_targets[0]);
assert!(game.magnetic_field_active(153)); assert!(game.magnetic_field_active(153));
assert_eq!(game.player().score, sensor.score); 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)); assert_ne!(second.velocity, vec2(12.0, -34.0));
} }
@@ -3345,8 +4069,7 @@ mod tests {
assert_eq!( assert_eq!(
game.check_sensor_objects_for_ball( game.check_sensor_objects_for_ball(
&mut second, &mut second,
2, BallPass::slot(2, 2),
2,
MilliVec::from_position(EFFECT_SENSOR.center), MilliVec::from_position(EFFECT_SENSOR.center),
MilliVec::default(), MilliVec::default(),
&mut events, &mut events,
@@ -3355,7 +4078,7 @@ mod tests {
BallAction::Keep BallAction::Keep
); );
assert!(game.secondary_ball.is_none()); assert!(game.secondary().is_none());
assert_eq!(game.multiball_state, MultiballState::Ready); assert_eq!(game.multiball_state, MultiballState::Ready);
assert_eq!(game.target_effect, 0); assert_eq!(game.target_effect, 0);
assert!(!game.effect_target_active()); assert!(!game.effect_target_active());
@@ -3374,15 +4097,14 @@ mod tests {
spin: Real48::ZERO, spin: Real48::ZERO,
capture_age: 0, capture_age: 0,
}; };
game.secondary_ball = Some(secondary); game.set_secondary(Some(secondary));
let mut primary = game.ball; let mut primary = game.balls[0];
let mut events = Vec::new(); let mut events = Vec::new();
assert_eq!( assert_eq!(
game.check_sensor_objects_for_ball( game.check_sensor_objects_for_ball(
&mut primary, &mut primary,
1, BallPass::slot(1, 2),
2,
MilliVec::from_position(EFFECT_SENSOR.center), MilliVec::from_position(EFFECT_SENSOR.center),
MilliVec::default(), MilliVec::default(),
&mut events, &mut events,
@@ -3391,7 +4113,10 @@ mod tests {
BallAction::Keep BallAction::Keep
); );
assert_eq!(game.secondary_ball.map(|ball| ball.position), Some(secondary.position)); assert_eq!(
game.secondary().map(|ball| ball.position),
Some(secondary.position)
);
assert_eq!(game.multiball_state, MultiballState::Active); assert_eq!(game.multiball_state, MultiballState::Active);
assert_eq!(game.target_effect, 0); assert_eq!(game.target_effect, 0);
} }
@@ -3400,34 +4125,88 @@ mod tests {
fn multiball_capture_age_is_per_slot_and_removes_only_ball_two() { fn multiball_capture_age_is_per_slot_and_removes_only_ball_two() {
let sensor = LOCK_HOLES[0]; let sensor = LOCK_HOLES[0];
let mut game = Game::new(1); 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.record_contacts[usize::from(sensor.id)] = 2;
game.secondary_ball = Some(Ball { game.set_secondary(Some(Ball {
position: sensor.center, position: sensor.center,
velocity: Vec2::ZERO, velocity: Vec2::ZERO,
in_launcher: false, in_launcher: false,
spin: Real48::ZERO, spin: Real48::ZERO,
capture_age: 300, capture_age: 300,
}); }));
game.score_mode = ScoreMode::Multiball;
let mut events = Vec::new(); let mut events = Vec::new();
game.advance_secondary_ball(&mut events); game.advance_secondary_ball(&mut events);
assert!(game.secondary_ball.is_none()); assert!(game.secondary().is_none());
assert_eq!(game.ball.capture_age, 17); assert_eq!(game.score_mode, ScoreMode::Multiball);
assert_eq!(game.record_contacts[usize::from(sensor.id)], 2); assert_eq!(game.balls[0].capture_age, 17);
assert_eq!(game.record_contacts[usize::from(sensor.id)], 99);
assert!(game.wheel_holes[0]); assert!(game.wheel_holes[0]);
assert!(events.contains(&Event::Lock)); assert!(events.contains(&Event::Lock));
game.timer_tick(0.0, 0, &mut events);
assert_eq!(game.score_mode, ScoreMode::Normal);
}
#[test]
fn captured_ball_one_keeps_multiball_scoring_for_ball_two_slot_pass() {
let sensor = LOCK_HOLES[0];
let mut game = Game::new_with_seed(1, 7);
game.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.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;
let mut events = Vec::new();
game.timer_tick(0.01, 1, &mut events);
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);
game.timer_tick(0.0, 0, &mut events);
assert_eq!(game.score_mode, ScoreMode::Normal);
}
#[test]
fn active_multiball_advances_the_returning_claw_while_both_balls_live() {
let mut game = Game::new(1);
game.claw.active = true;
game.claw.frame = 1;
game.claw.target_frame = 10;
game.claw.bank = ClawSpriteBank::Opening;
game.claw.frame_accumulator = 0.0;
game.claw.ball_suspended = true;
game.claw.captured_slot = Some(0);
game.score_mode = ScoreMode::Multiball;
let mut events = Vec::new();
game.update_claw(CLAW_FRAME_SECONDS, &mut events);
assert_eq!(game.claw.frame, 2);
} }
#[test] #[test]
fn capturing_ball_one_during_multiball_promotes_ball_two() { fn capturing_ball_one_during_multiball_promotes_ball_two() {
let sensor = LOCK_HOLES[0]; let sensor = LOCK_HOLES[0];
let mut game = Game::new(1); let mut game = Game::new(1);
game.ball.in_launcher = false; game.balls[0].in_launcher = false;
game.ball.position = sensor.center; game.balls[0].position = sensor.center;
game.ball.velocity = Vec2::ZERO; game.balls[0].velocity = Vec2::ZERO;
game.ball.capture_age = 300; game.balls[0].capture_age = 300;
game.record_contacts[usize::from(sensor.id)] = 1; game.record_contacts[usize::from(sensor.id)] = 1;
let survivor = Ball { let survivor = Ball {
position: vec2(210.0, 240.0), position: vec2(210.0, 240.0),
@@ -3436,7 +4215,7 @@ mod tests {
spin: Real48::ZERO, spin: Real48::ZERO,
capture_age: 23, capture_age: 23,
}; };
game.secondary_ball = Some(survivor); game.set_secondary(Some(survivor));
let mut events = Vec::new(); let mut events = Vec::new();
game.check_sensor_objects( game.check_sensor_objects(
@@ -3445,26 +4224,51 @@ mod tests {
&mut events, &mut events,
); );
assert!(game.secondary_ball.is_none()); assert!(game.secondary().is_none());
assert_eq!(game.ball.position, survivor.position); assert_eq!(game.balls[0].position, survivor.position);
assert_eq!(game.ball.velocity, survivor.velocity); assert_eq!(game.balls[0].velocity, survivor.velocity);
assert_eq!(game.ball.capture_age, survivor.capture_age); assert_eq!(game.balls[0].capture_age, survivor.capture_age);
assert_eq!(game.record_contacts[usize::from(sensor.id)], 2); assert_eq!(game.record_contacts[usize::from(sensor.id)], 99);
assert!(game.wheel_holes[0]); assert!(game.wheel_holes[0]);
} }
#[test]
fn occupied_multiball_wheel_hole_rejects_the_surviving_ball() {
let sensor = LOCK_HOLES[0];
let mut game = Game::new(1);
game.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();
let action = game.check_sensor_objects(
MilliVec::from_position(sensor.center),
MilliVec::default(),
&mut events,
);
assert_eq!(action, BallAction::Keep);
assert_eq!(game.record_contacts[usize::from(sensor.id)], 99);
assert_eq!(game.player().secondary_score, 0);
assert!(!events.contains(&Event::Lock));
assert!(!game.balls[0].in_launcher);
}
#[test] #[test]
fn center_drain_advances_to_a_fresh_ball() { fn center_drain_advances_to_a_fresh_ball() {
let mut game = Game::new(1); let mut game = Game::new(1);
game.ball.in_launcher = false; game.balls[0].in_launcher = false;
game.ball.position = vec2(157.0, 450.0); game.balls[0].position = vec2(157.0, 450.0);
game.ball.velocity = vec2(0.0, 300.0); game.balls[0].velocity = vec2(0.0, 300.0);
let events = game.update(1.0 / 30.0, 5, Controls::default()); let events = game.update(1.0 / 30.0, 5, Controls::default());
assert!(events.contains(&Event::Drain)); assert!(events.contains(&Event::Drain));
assert_eq!(game.players[0].balls, 2); assert_eq!(game.players[0].balls, 2);
assert!(game.ball.in_launcher); assert!(game.balls[0].in_launcher);
} }
#[test] #[test]
@@ -3477,14 +4281,14 @@ mod tests {
] { ] {
let mut game = Game::new(1); let mut game = Game::new(1);
game.object_active.fill(false); game.object_active.fill(false);
game.ball.in_launcher = false; game.balls[0].in_launcher = false;
game.ball.position = position; game.balls[0].position = position;
game.ball.velocity = velocity.to_velocity_per_second(); game.balls[0].velocity = velocity.to_velocity_per_second();
let mut events = Vec::new(); let mut events = Vec::new();
assert!(game.fixed_update(&mut events)); assert!(game.fixed_update(&mut events));
assert!(game.ball.in_launcher); assert!(game.balls[0].in_launcher);
assert_eq!(game.player().balls, 2); assert_eq!(game.player().balls, 2);
assert!(events.contains(&Event::Drain)); assert!(events.contains(&Event::Drain));
} }
@@ -3494,8 +4298,8 @@ mod tests {
fn prediction_bounds_remove_only_the_escaping_multiball_slot() { fn prediction_bounds_remove_only_the_escaping_multiball_slot() {
let mut game = Game::new(1); let mut game = Game::new(1);
game.object_active.fill(false); game.object_active.fill(false);
game.ball.in_launcher = false; game.balls[0].in_launcher = false;
game.ball.position = vec2(200.0, 200.0); game.balls[0].position = vec2(200.0, 200.0);
let second = Ball { let second = Ball {
position: vec2(1.0, 200.0), position: vec2(1.0, 200.0),
velocity: MilliVec { x: -2_000, y: 0 }.to_velocity_per_second(), velocity: MilliVec { x: -2_000, y: 0 }.to_velocity_per_second(),
@@ -3503,15 +4307,14 @@ mod tests {
spin: Real48::ZERO, spin: Real48::ZERO,
capture_age: 0, capture_age: 0,
}; };
game.secondary_ball = Some(second); game.set_secondary(Some(second));
game.score_mode = ScoreMode::Multiball; game.score_mode = ScoreMode::Multiball;
assert!(game.advance_secondary_ball(&mut Vec::new())); 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.player().balls, 3);
assert_eq!(game.score_mode, ScoreMode::Normal); assert_eq!(game.score_mode, ScoreMode::Normal);
} }
#[test] #[test]
@@ -3555,7 +4358,10 @@ mod tests {
let mut events = Vec::new(); let mut events = Vec::new();
game.drain(&mut events); 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_eq!(game.current_player, 1);
assert!(!game.finished); assert!(!game.finished);
@@ -3563,61 +4369,64 @@ mod tests {
game.players[1].balls = 1; game.players[1].balls = 1;
game.players[1].score = 654_321; game.players[1].score = 654_321;
game.drain(&mut events); game.drain(&mut events);
assert!(events.contains(&Event::HighScoreCandidate(654_321))); assert!(events.contains(&Event::HighScoreCandidate {
score: 654_321,
crazy: false
}));
assert!(game.finished); assert!(game.finished);
} }
#[test] #[test]
fn fast_ball_cannot_tunnel_through_the_top_rail() { fn fast_ball_cannot_tunnel_through_the_top_rail() {
let mut game = Game::new(1); let mut game = Game::new(1);
game.ball.in_launcher = false; game.balls[0].in_launcher = false;
game.ball.position = vec2(270.0, 30.0); game.balls[0].position = vec2(270.0, 30.0);
game.ball.velocity = vec2(0.0, -380.0); game.balls[0].velocity = vec2(0.0, -380.0);
for _ in 0..5 { for _ in 0..5 {
game.fixed_update(&mut Vec::new()); game.fixed_update(&mut Vec::new());
} }
assert!(game.ball.position.y >= 15.0); assert!(game.balls[0].position.y >= 15.0);
assert!(game.ball.velocity.y > 0.0); assert!(game.balls[0].velocity.y > 0.0);
} }
#[test] #[test]
fn relative_record_57_matches_live_two_substep_transition() { fn relative_record_57_matches_live_two_substep_transition() {
let mut game = Game::new(1); let mut game = Game::new(1);
game.ball.in_launcher = false; game.balls[0].in_launcher = false;
game.ball.position = vec2(72.530, 354.750); game.balls[0].position = vec2(72.530, 354.750);
game.ball.velocity = MilliVec { x: 3_000, y: 0 }.to_velocity_per_second(); game.balls[0].velocity = MilliVec { x: 3_000, y: 0 }.to_velocity_per_second();
let mut events = Vec::new(); let mut events = Vec::new();
assert!(!game.fixed_update(&mut events)); assert!(!game.fixed_update(&mut events));
assert_eq!( assert_eq!(
MilliVec::from_position(game.ball.position), MilliVec::from_position(game.balls[0].position),
MilliVec { MilliVec {
x: 75_530, x: 75_530,
y: 354_765, y: 354_765,
} }
); );
assert_eq!( 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 } MilliVec { x: 3_000, y: 15 }
); );
assert!(game.fixed_update(&mut events)); assert!(game.fixed_update(&mut events));
assert_eq!(game.last_collision_id, Some(57)); assert_eq!(game.last_collision_id, Some(57));
assert_eq!( assert_eq!(
game.ball.spin.bytes(), game.balls[0].spin.bytes(),
[0x83, 0x5d, 0x8f, 0xc2, 0xf5, 0xa8] [0x83, 0x5d, 0x8f, 0xc2, 0xf5, 0xa8]
); );
assert_eq!( assert_eq!(
MilliVec::from_position(game.ball.position), MilliVec::from_position(game.balls[0].position),
MilliVec { MilliVec {
x: 77_369, x: 77_369,
y: 356_597, y: 356_597,
} }
); );
assert_eq!( 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 } MilliVec { x: 1_839, y: 1_832 }
); );
} }
@@ -3625,50 +4434,53 @@ mod tests {
#[test] #[test]
fn relative_record_72_matches_live_two_substep_transition() { fn relative_record_72_matches_live_two_substep_transition() {
let mut game = Game::new(1); let mut game = Game::new(1);
game.ball.in_launcher = false; game.balls[0].in_launcher = false;
game.ball.position = vec2(238.348, 356.810); game.balls[0].position = vec2(238.348, 356.810);
game.ball.velocity = MilliVec { x: -3_000, y: 0 }.to_velocity_per_second(); game.balls[0].velocity = MilliVec { x: -3_000, y: 0 }.to_velocity_per_second();
let mut events = Vec::new(); let mut events = Vec::new();
assert!(!game.fixed_update(&mut events)); assert!(!game.fixed_update(&mut events));
assert_eq!( assert_eq!(
MilliVec::from_position(game.ball.position), MilliVec::from_position(game.balls[0].position),
MilliVec { MilliVec {
x: 235_348, x: 235_348,
y: 356_825, y: 356_825,
} }
); );
assert_eq!( 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 } MilliVec { x: -3_000, y: 15 }
); );
assert!(game.fixed_update(&mut events)); assert!(game.fixed_update(&mut events));
assert_eq!(game.last_collision_id, Some(72)); assert_eq!(game.last_collision_id, Some(72));
assert_eq!( assert_eq!(
game.ball.spin.bytes(), game.balls[0].spin.bytes(),
[0x83, 0x86, 0xeb, 0x51, 0xb8, 0x2e] [0x83, 0x86, 0xeb, 0x51, 0xb8, 0x2e]
); );
assert_eq!( assert_eq!(
MilliVec::from_position(game.ball.position), MilliVec::from_position(game.balls[0].position),
MilliVec { MilliVec {
x: 233_253, x: 233_253,
y: 358_494, y: 358_494,
} }
); );
assert_eq!( 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 } MilliVec {
x: -2_095,
y: 1_669
}
); );
} }
#[test] #[test]
fn retained_collision_candidate_clears_the_slot_capture_age() { fn retained_collision_candidate_clears_the_slot_capture_age() {
let mut game = Game::new(1); let mut game = Game::new(1);
game.ball.in_launcher = false; game.balls[0].in_launcher = false;
game.ball.position = vec2(270.0, 30.0); game.balls[0].position = vec2(270.0, 30.0);
game.ball.velocity = vec2(0.0, -380.0); game.balls[0].velocity = vec2(0.0, -380.0);
game.ball.capture_age = 42; game.balls[0].capture_age = 42;
for _ in 0..5 { for _ in 0..5 {
if game.fixed_update(&mut Vec::new()) { if game.fixed_update(&mut Vec::new()) {
@@ -3676,7 +4488,7 @@ mod tests {
} }
} }
assert_eq!(game.ball.capture_age, 0); assert_eq!(game.balls[0].capture_age, 0);
} }
#[test] #[test]
@@ -3761,9 +4573,9 @@ mod tests {
weak.object_active.fill(false); weak.object_active.fill(false);
weak.object_active[51] = true; weak.object_active[51] = true;
weak.players[0].bumper_value = 2_000; weak.players[0].bumper_value = 2_000;
weak.ball.in_launcher = false; weak.balls[0].in_launcher = false;
weak.ball.position = vec2(165.0, 171.1); weak.balls[0].position = vec2(165.0, 171.1);
weak.ball.velocity = vec2(0.0, -20.0); weak.balls[0].velocity = vec2(0.0, -20.0);
let mut weak_events = Vec::new(); let mut weak_events = Vec::new();
assert!(weak.fixed_update(&mut weak_events)); assert!(weak.fixed_update(&mut weak_events));
@@ -3771,9 +4583,9 @@ mod tests {
fast.object_active.fill(false); fast.object_active.fill(false);
fast.object_active[51] = true; fast.object_active[51] = true;
fast.players[0].bumper_value = 2_000; fast.players[0].bumper_value = 2_000;
fast.ball.in_launcher = false; fast.balls[0].in_launcher = false;
fast.ball.position = vec2(165.0, 172.015); fast.balls[0].position = vec2(165.0, 172.015);
fast.ball.velocity = vec2(0.0, -198.5); fast.balls[0].velocity = vec2(0.0, -198.5);
let mut tilted = fast.clone(); let mut tilted = fast.clone();
tilted.tilted = true; tilted.tilted = true;
let mut fast_events = Vec::new(); let mut fast_events = Vec::new();
@@ -3787,11 +4599,11 @@ mod tests {
assert!(fast_events.contains(&Event::Sound(2006))); assert!(fast_events.contains(&Event::Sound(2006)));
assert_eq!(weak.record_countdown(51), 5); assert_eq!(weak.record_countdown(51), 5);
assert_eq!(fast.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.player().score, 0);
assert_eq!(tilted.record_countdown(51), 0); assert_eq!(tilted.record_countdown(51), 0);
assert!(!tilted_events.contains(&Event::Sound(2006))); 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] #[test]
@@ -3815,9 +4627,9 @@ mod tests {
game.flippers.left_raised = true; game.flippers.left_raised = true;
game.flippers.right_raised = true; game.flippers.right_raised = true;
let score = game.player().score; let score = game.player().score;
game.ball.in_launcher = false; game.balls[0].in_launcher = false;
game.ball.position = vec2(201.0, 285.0); game.balls[0].position = vec2(201.0, 285.0);
game.ball.velocity = Vec2::ZERO; game.balls[0].velocity = Vec2::ZERO;
game.update( game.update(
1.0 / 30.0, 1.0 / 30.0,
5, 5,
@@ -3928,8 +4740,7 @@ mod tests {
#[test] #[test]
fn raised_flipper_tips_use_their_asymmetric_swept_bounds() { fn raised_flipper_tips_use_their_asymmetric_swept_bounds() {
for (object_id, center) in [(67_u8, vec2(133.0, 377.0)), (82, vec2(181.0, 376.0))] for (object_id, center) in [(67_u8, vec2(133.0, 377.0)), (82, vec2(181.0, 376.0))] {
{
let mut game = Game::new(1); let mut game = Game::new(1);
game.object_active.fill(false); game.object_active.fill(false);
game.object_active[usize::from(object_id)] = true; game.object_active[usize::from(object_id)] = true;
@@ -4003,21 +4814,27 @@ mod tests {
#[test] #[test]
fn raising_flipper_uses_the_reconstructed_moving_response() { fn raising_flipper_uses_the_reconstructed_moving_response() {
let mut game = Game::new(1); let mut game = Game::new(1);
game.ball.in_launcher = false; game.balls[0].in_launcher = false;
game.ball.position = vec2(125.0, 390.0); game.balls[0].position = vec2(125.0, 390.0);
game.ball.velocity = Vec2::ZERO; game.balls[0].velocity = Vec2::ZERO;
game.pending_flipper_edges[0] = -1; game.pending_flipper_edges[0] = -1;
game.apply_flipper_kicks(); game.apply_flipper_kicks();
let velocity_after_edge = game.ball.velocity; let velocity_after_edge = game.balls[0].velocity;
assert_eq!(game.ball.position, vec2(138.124, 355.0)); assert_eq!(game.balls[0].position, vec2(138.124, 355.0));
let raw_velocity = MilliVec::from_velocity_per_second(velocity_after_edge); let raw_velocity = MilliVec::from_velocity_per_second(velocity_after_edge);
assert_eq!(raw_velocity, MilliVec { x: 2_209, y: -5_891 }); assert_eq!(
raw_velocity,
MilliVec {
x: 2_209,
y: -5_891
}
);
assert_eq!(game.pending_flipper_edges, [0, 0]); assert_eq!(game.pending_flipper_edges, [0, 0]);
game.apply_flipper_kicks(); game.apply_flipper_kicks();
assert_eq!( assert_eq!(
game.ball.velocity, velocity_after_edge, game.balls[0].velocity, velocity_after_edge,
"holding a raised flipper must not add another impulse" "holding a raised flipper must not add another impulse"
); );
} }
@@ -4026,16 +4843,16 @@ mod tests {
fn returning_flipper_uses_the_raised_record_geometry() { fn returning_flipper_uses_the_raised_record_geometry() {
let mut game = Game::new(1); let mut game = Game::new(1);
game.flippers.left_raised = true; game.flippers.left_raised = true;
game.ball.in_launcher = false; game.balls[0].in_launcher = false;
game.ball.position = vec2(110.0, 410.0); game.balls[0].position = vec2(110.0, 410.0);
game.ball.velocity = vec2(100.0, 200.0); game.balls[0].velocity = vec2(100.0, 200.0);
let mut events = Vec::new(); let mut events = Vec::new();
game.timer_tick(0.030, 0, &mut events); game.timer_tick(0.030, 0, &mut events);
assert_eq!(events, [Event::FlipperMove, Event::Sound(2021)]); 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!(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]); assert_eq!(game.pending_flipper_edges, [0, 0]);
} }
@@ -4053,7 +4870,7 @@ mod tests {
#[test] #[test]
fn nudge_and_tilt_emit_the_original_ordered_sound_sequence() { fn nudge_and_tilt_emit_the_original_ordered_sound_sequence() {
let mut game = Game::new_with_seed(1, 7); let mut game = Game::new_with_seed(1, 7);
game.ball.in_launcher = false; game.balls[0].in_launcher = false;
let controls = Controls { let controls = Controls {
nudge: Nudge::Right, nudge: Nudge::Right,
..Controls::default() ..Controls::default()
@@ -4064,7 +4881,7 @@ mod tests {
[Event::Sound(2019), Event::Nudge] [Event::Sound(2019), Event::Nudge]
); );
assert_eq!( assert_eq!(
MilliVec::from_velocity_per_second(game.ball.velocity), MilliVec::from_velocity_per_second(game.balls[0].velocity),
MilliVec { x: 690, y: 0 } MilliVec { x: 690, y: 0 }
); );
assert_eq!(game.tilt_counter, 25); assert_eq!(game.tilt_counter, 25);
@@ -4089,7 +4906,7 @@ mod tests {
); );
assert_eq!(events, [Event::Sound(2019), Event::Nudge]); assert_eq!(events, [Event::Sound(2019), Event::Nudge]);
assert_eq!( assert_eq!(
MilliVec::from_velocity_per_second(game.ball.velocity), MilliVec::from_velocity_per_second(game.balls[0].velocity),
MilliVec { x: -84, y: -660 } MilliVec { x: -84, y: -660 }
); );
assert_eq!(game.tilt_counter, 25); assert_eq!(game.tilt_counter, 25);
@@ -4102,35 +4919,35 @@ mod tests {
#[test] #[test]
fn tilt_counter_decays_once_per_selected_detail_callback() { fn tilt_counter_decays_once_per_selected_detail_callback() {
for (detail, interval) in [ for (detail, interval) in [(1, 0.050), (2, 0.040), (3, 0.030), (4, 0.020), (5, 0.010)] {
(1, 0.050),
(2, 0.040),
(3, 0.030),
(4, 0.020),
(5, 0.010),
] {
let mut game = Game::new(1); let mut game = Game::new(1);
game.tilt_counter = 2; game.tilt_counter = 2;
game.update(interval - 0.001, detail, Controls::default()); game.update(interval - 0.001, detail, Controls::default());
assert_eq!(game.tilt_counter, 2, "detail {detail} decayed before its callback"); assert_eq!(
game.tilt_counter, 2,
"detail {detail} decayed before its callback"
);
game.update(0.001_1, detail, Controls::default()); game.update(0.001_1, detail, Controls::default());
assert_eq!(game.tilt_counter, 1, "detail {detail} did not decay at its callback"); assert_eq!(
game.tilt_counter, 1,
"detail {detail} did not decay at its callback"
);
} }
} }
#[test] #[test]
fn multiball_nudge_consumes_active_draws_then_updates_both_saved_slots() { fn multiball_nudge_consumes_active_draws_then_updates_both_saved_slots() {
let mut game = Game::new_with_seed(1, 7); let mut game = Game::new_with_seed(1, 7);
game.ball.in_launcher = false; game.balls[0].in_launcher = false;
game.ball.velocity = MilliVec { x: 100, y: 200 }.to_velocity_per_second(); game.balls[0].velocity = MilliVec { x: 100, y: 200 }.to_velocity_per_second();
game.secondary_ball = Some(Ball { game.set_secondary(Some(Ball {
position: vec2(200.0, 200.0), position: vec2(200.0, 200.0),
velocity: MilliVec { x: -300, y: 400 }.to_velocity_per_second(), velocity: MilliVec { x: -300, y: 400 }.to_velocity_per_second(),
in_launcher: false, in_launcher: false,
spin: Real48::ZERO, spin: Real48::ZERO,
capture_age: 0, capture_age: 0,
}); }));
let mut reference = BorlandRandom::new(7); let mut reference = BorlandRandom::new(7);
let _discarded_active_x = reference.real48(); let _discarded_active_x = reference.real48();
let _discarded_active_y = reference.below(20); let _discarded_active_y = reference.below(20);
@@ -4143,13 +4960,13 @@ mod tests {
game.apply_nudge(Nudge::Center, &mut Vec::new()); game.apply_nudge(Nudge::Center, &mut Vec::new());
assert_eq!( assert_eq!(
MilliVec::from_velocity_per_second(game.ball.velocity), MilliVec::from_velocity_per_second(game.balls[0].velocity),
MilliVec { MilliVec {
x: 100 + shared_impulse.x, x: 100 + shared_impulse.x,
y: 200 + shared_impulse.y, 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!( assert_eq!(
MilliVec::from_velocity_per_second(secondary.velocity), MilliVec::from_velocity_per_second(secondary.velocity),
MilliVec { MilliVec {
@@ -4203,10 +5020,10 @@ mod tests {
#[test] #[test]
fn claw_capture_holds_releases_and_returns_in_original_frame_order() { fn claw_capture_holds_releases_and_returns_in_original_frame_order() {
let mut game = Game::new(1); let mut game = Game::new(1);
game.ball.in_launcher = false; game.balls[0].in_launcher = false;
game.ball.position = CLAW_TRIGGER_CENTER; game.balls[0].position = CLAW_TRIGGER_CENTER;
game.ball.velocity = vec2(120.0, -40.0); game.balls[0].velocity = vec2(120.0, -40.0);
let held_position = game.ball.position; let held_position = game.balls[0].position;
let mut events = Vec::new(); let mut events = Vec::new();
game.begin_claw_capture(6, &mut events); game.begin_claw_capture(6, &mut events);
@@ -4214,10 +5031,10 @@ mod tests {
assert_eq!(game.claw.frame, 10); assert_eq!(game.claw.frame, 10);
assert_eq!(game.claw.bank, ClawSpriteBank::Closing); assert_eq!(game.claw.bank, ClawSpriteBank::Closing);
assert!(game.claw.ball_suspended); 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); 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); assert_eq!(game.claw.frame, 10);
for expected_frame in [9, 8, 7, 6] { for expected_frame in [9, 8, 7, 6] {
@@ -4231,9 +5048,9 @@ mod tests {
assert_eq!(game.claw.frame, 6); assert_eq!(game.claw.frame, 6);
assert_eq!(game.claw.bank, ClawSpriteBank::Opening); assert_eq!(game.claw.bank, ClawSpriteBank::Opening);
assert!(!game.claw.ball_suspended); assert!(!game.claw.ball_suspended);
assert_eq!(game.ball.position, vec2(270.0, 94.0)); assert_eq!(game.balls[0].position, vec2(270.0, 94.0));
assert!((game.ball.velocity.x - -152.0).abs() < 0.001); assert!((game.balls[0].velocity.x - -152.0).abs() < 0.001);
assert!((game.ball.velocity.y - 228.0).abs() < 0.001); assert!((game.balls[0].velocity.y - 228.0).abs() < 0.001);
for expected_frame in [7, 8, 9, 10] { for expected_frame in [7, 8, 9, 10] {
game.update_claw(CLAW_FRAME_SECONDS, &mut events); game.update_claw(CLAW_FRAME_SECONDS, &mut events);
@@ -4245,6 +5062,504 @@ mod tests {
assert!(!game.claw.active); assert!(!game.claw.active);
} }
#[test]
fn multiball_claw_capture_keeps_the_ball_and_uses_only_table_releases() {
let mut game = Game::new_with_seed(1, 7);
game.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);
let mut events = Vec::new();
assert_eq!(
game.check_sensor_objects(claw_position, MilliVec::default(), &mut events),
BallAction::Keep
);
assert_eq!(game.claw.capture_entry, Some((0, true)));
assert_eq!(
game.check_sensor_objects(claw_position, MilliVec::default(), &mut events),
BallAction::Suspend
);
assert!(game.claw.active);
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().expect("survivor must remain").position;
game.timer_tick(CLAW_FRAME_SECONDS, 1, &mut events);
assert_ne!(
game.secondary().expect("survivor must remain").position,
survivor_before
);
while game.claw.active {
game.update_claw(CLAW_FRAME_SECONDS, &mut events);
}
assert!(events.contains(&Event::ClawRelease));
assert!(!game.ball_suspended(0));
assert!(game.secondary().is_some());
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.balls[0].position),
MilliVec::default(),
&mut events,
),
BallAction::Keep
);
assert_eq!(game.record_contacts[89], 0);
}
#[test]
fn multiball_claw_restriction_is_latched_before_the_other_ball_drains() {
let mut game = Game::new_with_seed(1, 7);
game.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);
let mut events = Vec::new();
assert_eq!(
game.check_sensor_objects(claw_position, MilliVec::default(), &mut events),
BallAction::Keep
);
game.set_secondary(None);
game.score_mode = ScoreMode::Normal;
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((0, true)));
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((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.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();
assert_eq!(
game.check_sensor_objects(
MilliVec::from_position(CLAW_TRIGGER_CENTER),
MilliVec::default(),
&mut events,
),
BallAction::Keep
);
assert_eq!(game.claw.capture_entry, Some((0, true)));
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,
BallPass::slot(2, 2),
secondary_position,
MilliVec::default(),
&mut events,
)
.action,
BallAction::Keep
);
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.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();
assert_eq!(
game.check_sensor_objects(
MilliVec::from_position(CLAW_TRIGGER_CENTER),
MilliVec::default(),
&mut events,
),
BallAction::Keep
);
assert_eq!(game.claw.capture_entry, Some((0, true)));
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.balls[0].position),
MilliVec::default(),
&mut events,
),
BallAction::Keep
);
assert_eq!(game.claw.capture_entry, None);
game.set_secondary(None);
game.score_mode = ScoreMode::Normal;
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),
MilliVec::default(),
&mut events,
),
BallAction::Keep
);
assert_eq!(game.claw.capture_entry, Some((0, false)));
}
#[test]
fn nonoverlapping_two_ball_claw_approaches_keep_capture_ownership() {
// Exercise the sequential solver from non-overlapping cardinal
// approaches around the sub-8 px deep zone. Ball contact begins at
// 17 px, so a second ball must be resolved before both can settle.
let capture_center = CLAW_TRIGGER_CENTER - vec2(0.0, 2.0);
let directions = [
vec2(1.0, 0.0),
vec2(0.0, 1.0),
vec2(-1.0, 0.0),
vec2(0.0, -1.0),
];
let radii = [10.0, 18.0];
let speeds = [100.0, 300.0];
let mut captures = 0;
for first_direction in directions {
for second_direction in directions {
for first_radius in radii {
for second_radius in radii {
let first_position = capture_center + first_direction * first_radius;
let second_position = capture_center + second_direction * second_radius;
if first_position.distance(second_position) < 17.0 {
continue;
}
for first_speed in speeds {
for second_speed in speeds {
let mut game = Game::new_with_seed(1, 7);
game.balls[0] = Ball {
position: first_position,
velocity: (capture_center - first_position).normalize()
* first_speed,
in_launcher: false,
spin: Real48::ZERO,
capture_age: 0,
};
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();
for _ in 0..100 {
game.timer_tick(0.01, 1, &mut events);
if game.claw.active {
let Some((owner, true)) = game.claw.capture_entry else {
break;
};
captures += 1;
assert_eq!(
Some(owner),
game.claw.captured_slot,
"positions {first_position:?}/{second_position:?}, speeds {first_speed}/{second_speed}"
);
break;
}
if game.secondary().is_none() || game.finished {
break;
}
}
}
}
}
}
}
}
assert!(captures > 0, "the sweep must exercise claw captures");
}
#[test]
fn secondary_multiball_ball_can_be_held_by_the_claw_without_hiding_primary() {
let mut game = Game::new_with_seed(1, 7);
game.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.take_secondary().expect("secondary ball");
assert_eq!(
game.check_sensor_objects_for_ball(
&mut secondary,
BallPass::slot(2, 2),
claw_position,
MilliVec::default(),
&mut events,
)
.action,
BallAction::Keep
);
game.set_secondary(Some(secondary));
let mut secondary = game.take_secondary().expect("secondary ball");
assert_eq!(
game.check_sensor_objects_for_ball(
&mut secondary,
BallPass::slot(2, 2),
claw_position,
MilliVec::default(),
&mut events,
)
.action,
BallAction::Suspend
);
game.set_secondary(Some(secondary));
assert!(!game.ball_suspended(0));
assert!(game.ball_suspended(1));
assert!(matches!(game.claw.target_frame, 1 | 6 | 7));
let primary_before = game.balls[0].position;
game.timer_tick(CLAW_FRAME_SECONDS, 1, &mut events);
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.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);
let mut events = Vec::new();
assert_eq!(
game.check_sensor_objects(claw_position, MilliVec::default(), &mut events),
BallAction::Keep
);
assert_eq!(
game.check_sensor_objects(claw_position, MilliVec::default(), &mut events),
BallAction::Suspend
);
let balls_before = game.player().balls;
game.timer_tick(CLAW_FRAME_SECONDS, 1, &mut events);
assert!(game.secondary().is_none());
assert_eq!(game.player().balls, balls_before);
assert_eq!(game.score_mode, ScoreMode::Normal);
assert!(game.claw.ball_suspended);
while game.claw.ball_suspended {
game.update_claw(CLAW_FRAME_SECONDS, &mut events);
}
assert!(events.contains(&Event::ClawRelease));
assert!(!game.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.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.take_secondary().expect("secondary ball");
assert_eq!(
game.check_sensor_objects_for_ball(
&mut secondary,
BallPass::slot(2, 2),
claw_position,
MilliVec::default(),
&mut events,
)
.action,
BallAction::Keep
);
game.set_secondary(Some(secondary));
let mut secondary = game.take_secondary().expect("secondary ball");
assert_eq!(
game.check_sensor_objects_for_ball(
&mut secondary,
BallPass::slot(2, 2),
claw_position,
MilliVec::default(),
&mut events,
)
.action,
BallAction::Suspend
);
game.set_secondary(Some(secondary));
assert_eq!(game.record_contacts[89], 2);
assert_eq!(game.claw.capture_entry, Some((1, true)));
game.timer_tick(CLAW_FRAME_SECONDS, 1, &mut events);
assert!(game.secondary().is_none());
assert!(game.ball_suspended(0));
assert_eq!(game.record_contacts[89], 1);
assert_eq!(game.claw.capture_entry, Some((0, true)));
assert_eq!(game.score_mode, ScoreMode::Normal);
assert!(events.contains(&Event::Drain));
}
#[test]
fn unrestricted_claw_capture_retains_the_launcher_release_choice() {
let mut saw_launcher_release = false;
for seed in 0..64 {
let mut game = Game::new_with_seed(1, seed);
if game.next_claw_terminal_frame(false) == 18 {
saw_launcher_release = true;
break;
}
}
assert!(saw_launcher_release);
}
#[test]
fn claw_reentry_after_multiball_uses_the_current_ball_count() {
let mut game = Game::new_with_seed(1, 7);
game.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);
let mut events = Vec::new();
assert_eq!(
game.check_sensor_objects(claw_position, MilliVec::default(), &mut events),
BallAction::Keep
);
assert_eq!(
game.check_sensor_objects(claw_position, MilliVec::default(), &mut events),
BallAction::Suspend
);
assert_eq!(game.claw.capture_entry, Some((0, true)));
game.set_secondary(None);
game.score_mode = ScoreMode::Normal;
while game.claw.active {
game.update_claw(CLAW_FRAME_SECONDS, &mut events);
}
let unrestricted_seed = (0..64)
.find(|seed| {
let mut candidate = Game::new_with_seed(1, *seed);
candidate.next_claw_terminal_frame(false) == 18
})
.expect("a seed must select the launcher release");
game.random = BorlandRandom::new(unrestricted_seed);
game.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((0, false)));
assert_eq!(
game.check_sensor_objects(claw_position, MilliVec::default(), &mut events),
BallAction::Suspend
);
assert_eq!(game.claw.target_frame, 18);
}
#[test] #[test]
fn claw_frame_cadence_follows_the_five_original_timer_choices() { fn claw_frame_cadence_follows_the_five_original_timer_choices() {
for (detail, frame_seconds) in DETAIL_TIMER_SECONDS.into_iter().enumerate() { for (detail, frame_seconds) in DETAIL_TIMER_SECONDS.into_iter().enumerate() {
@@ -4264,26 +5579,26 @@ mod tests {
#[test] #[test]
fn claw_uses_the_original_circular_trigger_not_a_broad_rectangle() { fn claw_uses_the_original_circular_trigger_not_a_broad_rectangle() {
let mut game = Game::new(1); let mut game = Game::new(1);
game.ball.in_launcher = false; game.balls[0].in_launcher = false;
game.ball.position = CLAW_TRIGGER_CENTER + vec2(CLAW_TRIGGER_RADIUS + 0.1, 0.0); game.balls[0].position = CLAW_TRIGGER_CENTER + vec2(CLAW_TRIGGER_RADIUS + 0.1, 0.0);
game.check_sensor_objects( game.check_sensor_objects(
MilliVec::from_position(game.ball.position), MilliVec::from_position(game.balls[0].position),
MilliVec::default(), MilliVec::default(),
&mut Vec::new(), &mut Vec::new(),
); );
assert!(!game.claw.active); assert!(!game.claw.active);
game.ball.position = CLAW_TRIGGER_CENTER; game.balls[0].position = CLAW_TRIGGER_CENTER;
let mut events = Vec::new(); let mut events = Vec::new();
game.check_sensor_objects( game.check_sensor_objects(
MilliVec::from_position(game.ball.position), MilliVec::from_position(game.balls[0].position),
MilliVec::default(), MilliVec::default(),
&mut events, &mut events,
); );
assert!(!game.claw.active); assert!(!game.claw.active);
assert_eq!(events, [Event::Sound(2015)]); assert_eq!(events, [Event::Sound(2015)]);
game.check_sensor_objects( game.check_sensor_objects(
MilliVec::from_position(game.ball.position), MilliVec::from_position(game.balls[0].position),
MilliVec::default(), MilliVec::default(),
&mut events, &mut events,
); );
@@ -4295,9 +5610,9 @@ mod tests {
#[test] #[test]
fn top_sensor_uses_the_original_score_and_contact_latch() { fn top_sensor_uses_the_original_score_and_contact_latch() {
let mut game = Game::new_with_seed(1, 7); let mut game = Game::new_with_seed(1, 7);
game.ball.in_launcher = false; game.balls[0].in_launcher = false;
game.ball.position = vec2(205.0, 55.0); game.balls[0].position = vec2(205.0, 55.0);
game.ball.velocity = vec2(100.0, 200.0); game.balls[0].velocity = vec2(100.0, 200.0);
let mut events = Vec::new(); let mut events = Vec::new();
game.check_sensor_objects( game.check_sensor_objects(
@@ -4311,7 +5626,7 @@ mod tests {
assert!(!game.object_active[6]); assert!(!game.object_active[6]);
assert!(!game.object_active[154]); assert!(!game.object_active[154]);
assert_eq!( 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 } MilliVec { x: 1_012, y: 1_926 }
); );
@@ -4334,19 +5649,19 @@ mod tests {
assert!(!game.object_active[153]); assert!(!game.object_active[153]);
game.check_sensor_objects( game.check_sensor_objects(
MilliVec::from_position(game.ball.position), MilliVec::from_position(game.balls[0].position),
MilliVec::default(), MilliVec::default(),
&mut events, &mut events,
); );
assert_eq!(game.player().score, 500, "contact must score only once"); assert_eq!(game.player().score, 500, "contact must score only once");
game.ball.position = vec2(205.0, 70.0); game.balls[0].position = vec2(205.0, 62.0);
game.check_sensor_objects( game.check_sensor_objects(
MilliVec::from_position(game.ball.position), MilliVec::from_position(game.balls[0].position),
MilliVec::default(), MilliVec::default(),
&mut events, &mut events,
); );
game.ball.position = vec2(205.0, 55.0); game.balls[0].position = vec2(205.0, 55.0);
game.check_sensor_objects( game.check_sensor_objects(
MilliVec::from_position(vec2(205.0, 60.0)), MilliVec::from_position(vec2(205.0, 60.0)),
MilliVec { x: 0, y: -5_000 }, MilliVec { x: 0, y: -5_000 },
@@ -4355,6 +5670,47 @@ mod tests {
assert_eq!(game.player().score, 1_000); assert_eq!(game.player().score, 1_000);
} }
#[test]
fn type_four_latch_is_not_cleared_by_a_ball_outside_its_broadphase() {
let sensor = TARGET_SENSORS
.into_iter()
.find(|sensor| sensor.id == 140)
.expect("record 140 must be present");
let mut game = Game::new(1);
let mut primary = game.balls[0];
primary.position = sensor.center;
let mut events = Vec::new();
game.check_target_sensors(
&mut primary,
MilliVec::from_position(sensor.center),
MilliVec::default(),
140..=147,
&mut events,
);
assert_eq!(game.player().score, sensor.score);
let mut unrelated = primary;
unrelated.position = vec2(200.0, 200.0);
let unrelated_position = MilliVec::from_position(unrelated.position);
game.check_target_sensors(
&mut unrelated,
unrelated_position,
MilliVec::default(),
140..=147,
&mut events,
);
game.check_target_sensors(
&mut primary,
MilliVec::from_position(sensor.center),
MilliVec::default(),
140..=147,
&mut events,
);
assert_eq!(game.player().score, sensor.score);
}
#[test] #[test]
fn magnetic_rectangles_use_old_position_for_entry_and_prediction_for_exit() { fn magnetic_rectangles_use_old_position_for_entry_and_prediction_for_exit() {
let mut game = Game::new_with_seed(1, 7); let mut game = Game::new_with_seed(1, 7);
@@ -4408,10 +5764,10 @@ mod tests {
game.object_active.fill(false); game.object_active.fill(false);
game.object_active[5] = true; game.object_active[5] = true;
game.object_active[6] = true; game.object_active[6] = true;
game.ball.in_launcher = false; game.balls[0].in_launcher = false;
game.ball.position = vec2(149.0, 430.0); game.balls[0].position = vec2(149.0, 430.0);
game.ball.velocity = MilliVec { x: -2_000, y: 0 }.to_velocity_per_second(); game.balls[0].velocity = MilliVec { x: -2_000, y: 0 }.to_velocity_per_second();
let old_position = MilliVec::from_position(game.ball.position); let old_position = MilliVec::from_position(game.balls[0].position);
let mut expected_game = game.clone(); let mut expected_game = game.clone();
let mut expected_motion = MilliVec { x: -2_000, y: 15 }; let mut expected_motion = MilliVec { x: -2_000, y: 15 };
@@ -4427,7 +5783,7 @@ mod tests {
assert!(expected_game.apply_magnetic_record(6, old_position, &mut expected_motion)); assert!(expected_game.apply_magnetic_record(6, old_position, &mut expected_motion));
let expected_response = candidate let expected_response = candidate
.2 .2
.resolve(expected_motion, expected_game.ball.spin); .resolve(expected_motion, expected_game.balls[0].spin);
let mut expected_velocity = expected_response.velocity; let mut expected_velocity = expected_response.velocity;
expected_velocity.clamp_speed(MAXIMUM_SPEED_MILLI_PER_STEP); expected_velocity.clamp_speed(MAXIMUM_SPEED_MILLI_PER_STEP);
@@ -4435,7 +5791,7 @@ mod tests {
assert_eq!(game.last_collision_id, Some(5)); assert_eq!(game.last_collision_id, Some(5));
assert_eq!( assert_eq!(
MilliVec::from_velocity_per_second(game.ball.velocity), MilliVec::from_velocity_per_second(game.balls[0].velocity),
expected_velocity expected_velocity
); );
assert_eq!(game.random.seed(), expected_game.random.seed()); assert_eq!(game.random.seed(), expected_game.random.seed());
@@ -4444,14 +5800,14 @@ mod tests {
#[test] #[test]
fn wheel_holes_accumulate_and_transfer_the_per_player_secondary_score() { fn wheel_holes_accumulate_and_transfer_the_per_player_secondary_score() {
let mut game = Game::new(1); let mut game = Game::new(1);
game.ball.in_launcher = false; game.balls[0].in_launcher = false;
let mut events = Vec::new(); let mut events = Vec::new();
let expected_bonus = [10_000, 30_000, 70_000, 150_000, 0]; let expected_bonus = [10_000, 30_000, 70_000, 150_000, 0];
for (index, sensor) in LOCK_HOLES.into_iter().enumerate() { for (index, sensor) in LOCK_HOLES.into_iter().enumerate() {
complete_stationary_type_three_capture(&mut game, sensor.center, &mut events); complete_stationary_type_three_capture(&mut game, sensor.center, &mut events);
assert_eq!(game.player().secondary_score, expected_bonus[index]); 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, 310_000);
assert_eq!(game.player().score_multiplier, 2); assert_eq!(game.player().score_multiplier, 2);
@@ -4462,11 +5818,7 @@ mod tests {
5 5
); );
complete_stationary_type_three_capture( complete_stationary_type_three_capture(&mut game, SPECIAL_HOLE_SENSOR.center, &mut events);
&mut game,
SPECIAL_HOLE_SENSOR.center,
&mut events,
);
assert!(game.wheel_holes.iter().all(|filled| *filled)); assert!(game.wheel_holes.iter().all(|filled| *filled));
assert_eq!(game.multiball_state, MultiballState::Ready); assert_eq!(game.multiball_state, MultiballState::Ready);
assert_eq!(game.record_contacts[148], 2); assert_eq!(game.record_contacts[148], 2);
@@ -4481,7 +5833,24 @@ mod tests {
} }
#[test] #[test]
fn fifth_multiball_lock_defers_panel_and_caps_survivor_reaward() { fn wheel_award_counts_another_ball_settling_in_a_different_hole() {
let mut game = Game::new(1);
game.record_contacts[129] = 1;
game.record_contacts[130] = 2;
let mut events = Vec::new();
assert_eq!(
game.complete_lock_hole(1, 2, &mut events),
BallAction::Remove
);
assert_eq!(game.player().secondary_score, 20_000);
assert!(!game.wheel_holes[0]);
assert!(game.wheel_holes[1]);
}
#[test]
fn fifth_multiball_lock_starts_the_panel_and_caps_survivor_reaward() {
let mut game = Game::new(1); let mut game = Game::new(1);
game.wheel_holes = [true, true, true, true, false]; game.wheel_holes = [true, true, true, true, false];
game.record_contacts[129..=133].fill(2); game.record_contacts[129..=133].fill(2);
@@ -4496,11 +5865,8 @@ mod tests {
assert_eq!(game.player().score, 620_000); assert_eq!(game.player().score, 620_000);
assert_eq!(game.player().secondary_score, 0); assert_eq!(game.player().secondary_score, 0);
assert_eq!(game.player().score_multiplier, 2); assert_eq!(game.player().score_multiplier, 2);
assert_eq!(game.panel_frame, None); assert_eq!(game.panel_frame, 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; game.score_mode = ScoreMode::Normal;
assert_eq!( assert_eq!(
@@ -4517,10 +5883,10 @@ mod tests {
fn type_three_capture_pulls_holds_and_publishes_contact_sentinels() { fn type_three_capture_pulls_holds_and_publishes_contact_sentinels() {
let sensor = LOCK_HOLES[0]; let sensor = LOCK_HOLES[0];
let mut game = Game::new(1); let mut game = Game::new(1);
game.ball.in_launcher = false; game.balls[0].in_launcher = false;
game.ball.position = sensor.center + vec2(5.0, 0.0); game.balls[0].position = sensor.center + vec2(5.0, 0.0);
game.ball.velocity = vec2(100.0, 0.0); game.balls[0].velocity = vec2(100.0, 0.0);
let previous = MilliVec::from_position(game.ball.position); let previous = MilliVec::from_position(game.balls[0].position);
let mut events = Vec::new(); let mut events = Vec::new();
assert_eq!( assert_eq!(
@@ -4535,14 +5901,14 @@ mod tests {
CaptureStep::Holding CaptureStep::Holding
); );
assert_eq!( assert_eq!(
MilliVec::from_velocity_per_second(game.ball.velocity), MilliVec::from_velocity_per_second(game.balls[0].velocity),
MilliVec { x: 750, y: -150 } MilliVec { x: 750, y: -150 }
); );
assert_eq!(game.record_contacts[usize::from(sensor.id)], 0); 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.balls[0].position = sensor.center;
game.ball.velocity = Vec2::ZERO; game.balls[0].velocity = Vec2::ZERO;
assert_eq!( assert_eq!(
capture_primary_record_step( capture_primary_record_step(
&mut game, &mut game,
@@ -4555,10 +5921,10 @@ mod tests {
CaptureStep::Holding CaptureStep::Holding
); );
assert_eq!(game.record_contacts[usize::from(sensor.id)], 1); 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)]); assert_eq!(events, [Event::Sound(2015)]);
game.ball.capture_age = 300; game.balls[0].capture_age = 300;
assert_eq!( assert_eq!(
capture_primary_record_step( capture_primary_record_step(
&mut game, &mut game,
@@ -4571,7 +5937,7 @@ mod tests {
CaptureStep::Complete CaptureStep::Complete
); );
assert_eq!(game.record_contacts[usize::from(sensor.id)], 99); 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] #[test]
@@ -4580,10 +5946,10 @@ mod tests {
let mut game = Game::new(1); let mut game = Game::new(1);
game.object_active.fill(false); game.object_active.fill(false);
game.object_active[usize::from(sensor.id)] = true; game.object_active[usize::from(sensor.id)] = true;
game.ball.in_launcher = false; game.balls[0].in_launcher = false;
game.ball.position = sensor.center + vec2(7.5, 0.0); game.balls[0].position = sensor.center + vec2(7.5, 0.0);
game.ball.velocity = MilliVec { x: 3_800, y: 0 }.to_velocity_per_second(); game.balls[0].velocity = MilliVec { x: 3_800, y: 0 }.to_velocity_per_second();
let old_position = MilliVec::from_position(game.ball.position); let old_position = MilliVec::from_position(game.balls[0].position);
let mut expected_velocity = MilliVec { x: 3_800, y: 15 }; let mut expected_velocity = MilliVec { x: 3_800, y: 15 };
expected_velocity.clamp_speed(MAXIMUM_SPEED_MILLI_PER_STEP); expected_velocity.clamp_speed(MAXIMUM_SPEED_MILLI_PER_STEP);
let damping = Real48::from_bytes([0x80, 0x66, 0x66, 0x66, 0x66, 0x66]); let damping = Real48::from_bytes([0x80, 0x66, 0x66, 0x66, 0x66, 0x66]);
@@ -4599,11 +5965,11 @@ mod tests {
assert!(!game.fixed_update(&mut Vec::new())); assert!(!game.fixed_update(&mut Vec::new()));
assert_eq!( assert_eq!(
MilliVec::from_velocity_per_second(game.ball.velocity), MilliVec::from_velocity_per_second(game.balls[0].velocity),
expected_velocity expected_velocity
); );
assert_eq!( assert_eq!(
MilliVec::from_position(game.ball.position), MilliVec::from_position(game.balls[0].position),
old_position.add(expected_velocity) old_position.add(expected_velocity)
); );
} }
@@ -4615,18 +5981,18 @@ mod tests {
game.object_active.fill(false); game.object_active.fill(false);
game.object_active[usize::from(sensor.id)] = true; game.object_active[usize::from(sensor.id)] = true;
game.record_contacts[usize::from(sensor.id)] = 99; game.record_contacts[usize::from(sensor.id)] = 99;
game.ball.in_launcher = false; game.balls[0].in_launcher = false;
game.ball.position = sensor.center + vec2(17.0, 0.0); game.balls[0].position = sensor.center + vec2(17.0, 0.0);
game.ball.velocity = MilliVec { x: -3_000, y: 0 }.to_velocity_per_second(); game.balls[0].velocity = MilliVec { x: -3_000, y: 0 }.to_velocity_per_second();
let old_position = MilliVec::from_position(game.ball.position); let old_position = MilliVec::from_position(game.balls[0].position);
assert!(game.fixed_update(&mut Vec::new())); 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!(velocity.x > 0, "the type-three rim must reflect the ball");
assert_eq!(game.last_collision_id, Some(sensor.id)); assert_eq!(game.last_collision_id, Some(sensor.id));
assert_eq!( assert_eq!(
MilliVec::from_position(game.ball.position), MilliVec::from_position(game.balls[0].position),
old_position.add(velocity) old_position.add(velocity)
); );
} }
@@ -4638,9 +6004,9 @@ mod tests {
game.object_active.fill(false); game.object_active.fill(false);
game.object_active[usize::from(sensor.id)] = true; game.object_active[usize::from(sensor.id)] = true;
game.record_contacts[usize::from(sensor.id)] = 1; game.record_contacts[usize::from(sensor.id)] = 1;
game.ball.in_launcher = false; game.balls[0].in_launcher = false;
game.ball.position = sensor.center + vec2(23.0, 0.0); game.balls[0].position = sensor.center + vec2(23.0, 0.0);
game.ball.velocity = MilliVec { x: 3_800, y: 0 }.to_velocity_per_second(); game.balls[0].velocity = MilliVec { x: 3_800, y: 0 }.to_velocity_per_second();
game.fixed_update(&mut Vec::new()); game.fixed_update(&mut Vec::new());
@@ -4656,12 +6022,12 @@ mod tests {
let mut game = Game::new_with_seed(1, 7); let mut game = Game::new_with_seed(1, 7);
game.object_active.fill(false); game.object_active.fill(false);
game.object_active[usize::from(sensor.id)] = true; game.object_active[usize::from(sensor.id)] = true;
game.ball.in_launcher = false; game.balls[0].in_launcher = false;
game.ball.position = vec2(205.0, 58.0); game.balls[0].position = vec2(205.0, 58.0);
game.ball.velocity = MilliVec { x: 0, y: -2_000 }.to_velocity_per_second(); game.balls[0].velocity = MilliVec { x: 0, y: -2_000 }.to_velocity_per_second();
let old_position = MilliVec::from_position(game.ball.position); let old_position = MilliVec::from_position(game.balls[0].position);
let mut expected_game = game.clone(); 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_ball.velocity = MilliVec { x: 0, y: -1_985 }.to_velocity_per_second();
expected_game.randomize_trigger_velocity(&mut expected_ball); expected_game.randomize_trigger_velocity(&mut expected_ball);
let expected_velocity = MilliVec::from_velocity_per_second(expected_ball.velocity); let expected_velocity = MilliVec::from_velocity_per_second(expected_ball.velocity);
@@ -4670,11 +6036,11 @@ mod tests {
assert_eq!(game.player().score, 500); assert_eq!(game.player().score, 500);
assert_eq!( assert_eq!(
MilliVec::from_velocity_per_second(game.ball.velocity), MilliVec::from_velocity_per_second(game.balls[0].velocity),
expected_velocity expected_velocity
); );
assert_eq!( assert_eq!(
MilliVec::from_position(game.ball.position), MilliVec::from_position(game.balls[0].position),
old_position.add(expected_velocity) old_position.add(expected_velocity)
); );
assert_eq!(game.random.seed(), expected_game.random.seed()); assert_eq!(game.random.seed(), expected_game.random.seed());
@@ -4696,7 +6062,11 @@ mod tests {
assert_eq!(game.panel_frame, None); assert_eq!(game.panel_frame, None);
assert_eq!(game.wheel_holes, [false; 5]); assert_eq!(game.wheel_holes, [false; 5]);
assert!(game.record_contacts[129..=133].iter().all(|contact| *contact == 0)); assert!(
game.record_contacts[129..=133]
.iter()
.all(|contact| *contact == 0)
);
assert_eq!( assert_eq!(
events, events,
[ [
@@ -4716,7 +6086,7 @@ mod tests {
#[test] #[test]
fn target_rotation_uses_six_callbacks_and_rotates_player_state() { fn target_rotation_uses_six_callbacks_and_rotates_player_state() {
let mut game = Game::new(1); 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.wheel_holes = [true, false, true, false, false];
game.record_contacts[129..=133].copy_from_slice(&[11, 22, 33, 44, 55]); game.record_contacts[129..=133].copy_from_slice(&[11, 22, 33, 44, 55]);
game.target_rotation_state = Some(0); game.target_rotation_state = Some(0);
@@ -4742,7 +6112,7 @@ mod tests {
game.timer_tick(0.030, 0, &mut Vec::new()); game.timer_tick(0.030, 0, &mut Vec::new());
assert_eq!(game.target_rotation_state, Some(2)); 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()); game.timer_tick(0.030, 0, &mut Vec::new());
assert_eq!(game.target_rotation_state, Some(3)); assert_eq!(game.target_rotation_state, Some(3));
@@ -4758,9 +6128,13 @@ mod tests {
game.record_countdowns[51] = 5; game.record_countdowns[51] = 5;
game.timer_tick(0.030, 0, &mut Vec::new()); game.timer_tick(0.030, 0, &mut Vec::new());
assert_eq!(game.record_countdown(51), 5, "launcher idle pauses countdowns"); assert_eq!(
game.record_countdown(51),
5,
"launcher idle pauses countdowns"
);
game.ball.in_launcher = false; game.balls[0].in_launcher = false;
for expected in (0..5).rev() { for expected in (0..5).rev() {
game.timer_tick(0.030, 0, &mut Vec::new()); game.timer_tick(0.030, 0, &mut Vec::new());
assert_eq!(game.record_countdown(51), expected); assert_eq!(game.record_countdown(51), expected);
@@ -4802,9 +6176,9 @@ mod tests {
fn drain_clears_latched_tilt() { fn drain_clears_latched_tilt() {
let mut game = Game::new(1); let mut game = Game::new(1);
game.tilted = true; game.tilted = true;
game.ball.in_launcher = false; game.balls[0].in_launcher = false;
game.ball.position = vec2(157.0, 454.0); game.balls[0].position = vec2(157.0, 454.0);
game.ball.velocity = vec2(0.0, 200.0); game.balls[0].velocity = vec2(0.0, 200.0);
let held = Controls { let held = Controls {
left_flipper: true, left_flipper: true,
@@ -4813,7 +6187,7 @@ mod tests {
let events = game.update(0.030, 3, held); let events = game.update(0.030, 3, held);
assert!(!game.tilted); assert!(!game.tilted);
assert!(game.ball.in_launcher); assert!(game.balls[0].in_launcher);
assert!(!events.contains(&Event::Sound(2008))); assert!(!events.contains(&Event::Sound(2008)));
assert!(game.flipper_release_latch[0]); assert!(game.flipper_release_latch[0]);
game.update(0.0, 3, held); game.update(0.0, 3, held);
@@ -4874,6 +6248,40 @@ mod tests {
} }
assert!(game.tilted); assert!(game.tilted);
assert!(game.ball.in_launcher); assert!(game.balls[0].in_launcher);
}
#[test]
fn tilted_waiting_ball_can_still_be_launched() {
let mut game = Game::new_with_seed(1, 7);
for _ in 0..2 {
game.update(
0.0,
3,
Controls {
nudge: Nudge::Right,
..Controls::default()
},
);
}
assert!(game.tilted);
assert!(game.balls[0].in_launcher);
game.update(
0.0,
3,
Controls {
launch_down: true,
..Controls::default()
},
);
assert!(game.launcher_frame() > 0);
let events = game.update(0.0, 3, Controls::default());
assert!(events.contains(&Event::Launch));
assert!(!events.contains(&Event::Sound(2002)));
assert!(!game.balls[0].in_launcher);
assert!(game.tilted);
} }
} }
+16 -10
View File
@@ -7,11 +7,13 @@ mod geometry;
mod original_physics; mod original_physics;
mod persistence; mod persistence;
mod real48; mod real48;
#[cfg(not(target_arch = "wasm32"))]
mod simulation; mod simulation;
mod table; mod table;
use app::App; use app::App;
use macroquad::prelude::*; use macroquad::prelude::*;
#[cfg(not(target_arch = "wasm32"))]
use simulation::{Request, Scenario, Simulation, usage}; use simulation::{Request, Scenario, Simulation, usage};
const WINDOW_WIDTH: i32 = 640; const WINDOW_WIDTH: i32 = 640;
@@ -30,18 +32,21 @@ fn window_conf() -> Conf {
#[macroquad::main(window_conf)] #[macroquad::main(window_conf)]
async fn main() { async fn main() {
let request = match Request::parse(std::env::args()) { #[cfg(not(target_arch = "wasm32"))]
Ok(request) => request, {
Err(error) => { let request = match Request::parse(std::env::args()) {
eprintln!("{error}"); Ok(request) => request,
Err(error) => {
eprintln!("{error}");
return;
}
};
if let Some(request) = request {
if let Err(error) = run_simulation(request).await {
eprintln!("{error}\n\n{}", usage());
}
return; return;
} }
};
if let Some(request) = request {
if let Err(error) = run_simulation(request).await {
eprintln!("{error}\n\n{}", usage());
}
return;
} }
let mut app = App::load().await; let mut app = App::load().await;
@@ -51,6 +56,7 @@ async fn main() {
} }
} }
#[cfg(not(target_arch = "wasm32"))]
async fn run_simulation(request: Request) -> Result<(), String> { async fn run_simulation(request: Request) -> Result<(), String> {
let mut simulation = Simulation::new(request.scenario, request.seed); let mut simulation = Simulation::new(request.scenario, request.seed);
simulation.advance_to(request.target_step); simulation.advance_to(request.target_step);
+31 -38
View File
@@ -73,11 +73,7 @@ impl CollisionMaterial {
} }
} }
pub const fn circle( pub const fn circle(normal_rebound: f64, tangent_coupling: f64, normal_kick: f64) -> Self {
normal_rebound: f64,
tangent_coupling: f64,
normal_kick: f64,
) -> Self {
Self { Self {
normal_rebound, normal_rebound,
tangent_coupling, tangent_coupling,
@@ -173,8 +169,7 @@ impl MilliVec {
// `maximum / speed` is mathematically equivalent, but can differ by // `maximum / speed` is mathematically equivalent, but can differ by
// one millipixel because each Real48 operation is rounded // one millipixel because each Real48 operation is rounded
// independently. // independently.
let excess = Real48::from_i32(speed.wrapping_sub(maximum)) let excess = Real48::from_i32(speed.wrapping_sub(maximum)).divide(Real48::from_i32(speed));
.divide(Real48::from_i32(speed));
self.x = self self.x = self
.x .x
.wrapping_sub(Real48::from_i32(self.x).multiply(excess).round_i32()); .wrapping_sub(Real48::from_i32(self.x).multiply(excess).round_i32());
@@ -262,11 +257,7 @@ fn tangent_velocity(velocity: MilliVec, normal_x: Real48, normal_y: Real48, leng
.round_i32() .round_i32()
} }
fn cross_at_endpoint( fn cross_at_endpoint(point: MilliVec, current: MilliVec, predicted: MilliVec) -> i32 {
point: MilliVec,
current: MilliVec,
predicted: MilliVec,
) -> i32 {
predicted predicted
.x .x
.wrapping_sub(point.x) .wrapping_sub(point.x)
@@ -383,9 +374,7 @@ pub fn line_collision_candidate_at(
let vertical_units = normal_y.divide(THOUSAND); let vertical_units = normal_y.divide(THOUSAND);
let distance = Real48::from_i32(old_position.x.wrapping_sub(start.x)) let distance = Real48::from_i32(old_position.x.wrapping_sub(start.x))
.multiply(vertical_units) .multiply(vertical_units)
.subtract( .subtract(Real48::from_i32(old_position.y.wrapping_sub(start.y)).multiply(horizontal_units))
Real48::from_i32(old_position.y.wrapping_sub(start.y)).multiply(horizontal_units),
)
.divide(length_units); .divide(length_units);
let distance = if distance.compare(ZERO).is_lt() { let distance = if distance.compare(ZERO).is_lt() {
ZERO.subtract(distance) ZERO.subtract(distance)
@@ -525,10 +514,7 @@ pub fn capture_collision_candidate(
let radius_milli = (radius * 1_000.0).round() as i32; let radius_milli = (radius * 1_000.0).round() as i32;
let delta = MilliVec { let delta = MilliVec {
x: center.x.wrapping_sub(old_position.x), x: center.x.wrapping_sub(old_position.x),
y: center y: center.y.wrapping_sub(old_position.y).wrapping_sub(2_000),
.y
.wrapping_sub(old_position.y)
.wrapping_sub(2_000),
}; };
let surface_distance = milli_distance(delta).wrapping_sub(radius_milli); let surface_distance = milli_distance(delta).wrapping_sub(radius_milli);
let normal_x = Real48::from_i32(center.y.wrapping_sub(old_position.y)); let normal_x = Real48::from_i32(center.y.wrapping_sub(old_position.y));
@@ -541,9 +527,7 @@ pub fn capture_collision_candidate(
return None; return None;
} }
let normal_velocity = normal_velocity(velocity, normal_x, normal_y, length); let normal_velocity = normal_velocity(velocity, normal_x, normal_y, length);
if normal_velocity >= 0 if normal_velocity >= 0 || normal_velocity.wrapping_abs() < surface_distance.wrapping_abs() {
|| normal_velocity.wrapping_abs() < surface_distance.wrapping_abs()
{
return None; return None;
} }
Some(StaticCollisionCandidate { Some(StaticCollisionCandidate {
@@ -681,8 +665,14 @@ mod tests {
#[test] #[test]
fn float_views_roundtrip_every_gameplay_velocity_millipixel() { fn float_views_roundtrip_every_gameplay_velocity_millipixel() {
for value in -10_000..=10_000 { for value in -10_000..=10_000 {
let milli = MilliVec { x: value, y: -value }; let milli = MilliVec {
assert_eq!(MilliVec::from_velocity_per_second(milli.to_velocity_per_second()), milli); x: value,
y: -value,
};
assert_eq!(
MilliVec::from_velocity_per_second(milli.to_velocity_per_second()),
milli
);
} }
} }
@@ -697,8 +687,7 @@ mod tests {
if speed <= maximum { if speed <= maximum {
continue; continue;
} }
let excess = Real48::from_i32(speed - maximum) let excess = Real48::from_i32(speed - maximum).divide(Real48::from_i32(speed));
.divide(Real48::from_i32(speed));
let expected = MilliVec { let expected = MilliVec {
x: x.wrapping_sub(Real48::from_i32(x).multiply(excess).round_i32()), x: x.wrapping_sub(Real48::from_i32(x).multiply(excess).round_i32()),
y: y.wrapping_sub(Real48::from_i32(y).multiply(excess).round_i32()), y: y.wrapping_sub(Real48::from_i32(y).multiply(excess).round_i32()),
@@ -716,7 +705,10 @@ mod tests {
break; break;
} }
} }
assert!(mismatches.is_empty(), "speed-clamp mismatches: {mismatches:?}"); assert!(
mismatches.is_empty(),
"speed-clamp mismatches: {mismatches:?}"
);
} }
#[test] #[test]
@@ -783,7 +775,9 @@ mod tests {
assert_eq!(candidate.surface_distance, -800); assert_eq!(candidate.surface_distance, -800);
assert_ne!( assert_ne!(
candidate.resolve(MilliVec { x: 1_000, y: 0 }, Real48::ZERO).velocity, candidate
.resolve(MilliVec { x: 1_000, y: 0 }, Real48::ZERO)
.velocity,
MilliVec { x: 1_000, y: 0 } MilliVec { x: 1_000, y: 0 }
); );
} }
@@ -887,16 +881,15 @@ mod tests {
fn surface_distance_orders_candidate_contacts() { fn surface_distance_orders_candidate_contacts() {
let old = MilliVec::default(); let old = MilliVec::default();
let velocity = MilliVec { x: 10_000, y: 0 }; let velocity = MilliVec { x: 10_000, y: 0 };
let near = let near = line_collision_response(
line_collision_response( old,
old, velocity,
velocity, vec2(2.0, 1.0),
vec2(2.0, 1.0), vec2(2.0, -1.0),
vec2(2.0, -1.0), CollisionMaterial::line(0.6, 0.1),
CollisionMaterial::line(0.6, 0.1), Real48::ZERO,
Real48::ZERO, )
) .expect("near rail should be crossed");
.expect("near rail should be crossed");
let far = line_collision_response( let far = line_collision_response(
old, old,
velocity, velocity,
+82 -11
View File
@@ -1,6 +1,12 @@
use std::io;
#[cfg(not(target_arch = "wasm32"))]
use std::{fs, path::PathBuf};
#[cfg(not(target_arch = "wasm32"))]
use directories::ProjectDirs; use directories::ProjectDirs;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::{fs, io, path::PathBuf}; #[cfg(target_arch = "wasm32")]
use tdkpin_web_storage::{queue_high_score, queue_save, take_high_scores, take_loaded};
const ORIGINAL_HIGHSCORES: &[u8] = include_bytes!("../assets/original/HISCORES.DAT"); const ORIGINAL_HIGHSCORES: &[u8] = include_bytes!("../assets/original/HISCORES.DAT");
const ORIGINAL_INI: &str = include_str!("../../original/TDKPIN.INI"); const ORIGINAL_INI: &str = include_str!("../../original/TDKPIN.INI");
@@ -56,6 +62,10 @@ impl Default for Settings {
pub struct HighScore { pub struct HighScore {
pub name: String, pub name: String,
pub score: u32, 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)] #[derive(Clone, Debug, Deserialize, Serialize)]
@@ -74,10 +84,15 @@ impl Default for SavedData {
} }
} }
#[cfg(not(target_arch = "wasm32"))]
pub struct Persistence { pub struct Persistence {
path: PathBuf, path: PathBuf,
} }
#[cfg(target_arch = "wasm32")]
pub struct Persistence;
#[cfg(not(target_arch = "wasm32"))]
impl Persistence { impl Persistence {
pub fn new() -> Self { pub fn new() -> Self {
let path = ProjectDirs::from("com", "kiwi-hamburg", "TDK Pinball Machine").map_or_else( let path = ProjectDirs::from("com", "kiwi-hamburg", "TDK Pinball Machine").map_or_else(
@@ -91,11 +106,7 @@ impl Persistence {
fs::read_to_string(&self.path) fs::read_to_string(&self.path)
.ok() .ok()
.and_then(|text| serde_json::from_str(&text).ok()) .and_then(|text| serde_json::from_str(&text).ok())
.unwrap_or_else(|| SavedData { .unwrap_or_else(native_default_saved_data)
settings: load_adjacent_original_settings()
.unwrap_or_else(|| parse_original_settings(ORIGINAL_INI)),
high_scores: parse_original_high_scores(ORIGINAL_HIGHSCORES),
})
} }
pub fn save(&self, data: &SavedData) -> io::Result<()> { pub fn save(&self, data: &SavedData) -> io::Result<()> {
@@ -117,6 +128,47 @@ impl Persistence {
} }
} }
#[cfg(target_arch = "wasm32")]
impl Persistence {
pub fn new() -> Self {
Self
}
#[allow(clippy::unused_self)]
pub fn load(&self) -> SavedData {
take_loaded()
.and_then(|bytes| serde_json::from_slice(&bytes).ok())
.unwrap_or_default()
}
#[allow(clippy::unused_self)]
pub fn save(&self, data: &SavedData) -> io::Result<()> {
let encoded = serde_json::to_vec(data).map_err(io::Error::other)?;
queue_save(encoded);
Ok(())
}
pub fn take_high_scores(&self) -> Option<Vec<HighScore>> {
take_high_scores().and_then(|bytes| serde_json::from_slice(&bytes).ok())
}
pub fn submit_high_score(&self, entry: &HighScore) {
if let Ok(encoded) = serde_json::to_vec(entry) {
queue_high_score(encoded);
}
}
}
#[cfg(not(target_arch = "wasm32"))]
fn native_default_saved_data() -> SavedData {
SavedData {
settings: load_adjacent_original_settings()
.unwrap_or_else(|| parse_original_settings(ORIGINAL_INI)),
high_scores: parse_original_high_scores(ORIGINAL_HIGHSCORES),
}
}
#[cfg(not(target_arch = "wasm32"))]
fn load_adjacent_original_settings() -> Option<Settings> { fn load_adjacent_original_settings() -> Option<Settings> {
let executable = std::env::current_exe().ok()?; let executable = std::env::current_exe().ok()?;
let exact_name = executable.with_extension("INI"); let exact_name = executable.with_extension("INI");
@@ -154,7 +206,10 @@ pub fn parse_original_settings(text: &str) -> Settings {
_ => Language::English, _ => Language::English,
}; };
} else if key.trim().eq_ignore_ascii_case("Speed") { } else if key.trim().eq_ignore_ascii_case("Speed") {
settings.speed = u8::try_from(value).ok().filter(|speed| (1..=5).contains(speed)).unwrap_or(3); settings.speed = u8::try_from(value)
.ok()
.filter(|speed| (1..=5).contains(speed))
.unwrap_or(3);
} else if key.trim().eq_ignore_ascii_case("Sound") { } else if key.trim().eq_ignore_ascii_case("Sound") {
settings.sounds = value != 0; settings.sounds = value != 0;
} }
@@ -193,6 +248,7 @@ pub fn parse_original_high_scores(bytes: &[u8]) -> Vec<HighScore> {
HighScore { HighScore {
name: String::from_utf8_lossy(&name_bytes[..length]).into_owned(), name: String::from_utf8_lossy(&name_bytes[..length]).into_owned(),
score, score,
crazy: false,
} }
}) })
.collect() .collect()
@@ -249,14 +305,16 @@ mod tests {
let settings = parse_original_settings(ORIGINAL_INI); let settings = parse_original_settings(ORIGINAL_INI);
assert_eq!(settings.speed, 3); assert_eq!(settings.speed, 3);
assert_eq!(settings.language, Language::German); assert_eq!(settings.language, Language::German);
assert!(settings.sounds, "the original reads singular Sound, not Sounds"); assert!(
settings.sounds,
"the original reads singular Sound, not Sounds"
);
} }
#[test] #[test]
fn original_ini_values_are_case_insensitive_and_clamped() { fn original_ini_values_are_case_insensitive_and_clamped() {
let settings = parse_original_settings( let settings =
"[settings]\nlanguage=5\nSPEED=9\nSound=0\nSounds=1\n", parse_original_settings("[settings]\nlanguage=5\nSPEED=9\nSound=0\nSounds=1\n");
);
assert_eq!(settings.speed, 3); assert_eq!(settings.speed, 3);
assert_eq!(settings.language, Language::Spanish); assert_eq!(settings.language, Language::Spanish);
assert!(!settings.sounds); assert!(!settings.sounds);
@@ -282,6 +340,7 @@ mod tests {
HighScore { HighScore {
name: "TEST".to_owned(), name: "TEST".to_owned(),
score: 6_537_393, score: 6_537_393,
crazy: false,
}, },
); );
assert_eq!(scores.len(), 10); assert_eq!(scores.len(), 10);
@@ -296,12 +355,23 @@ mod tests {
HighScore { HighScore {
name: "WRAPPED".to_owned(), name: "WRAPPED".to_owned(),
score: u32::MAX, score: u32::MAX,
crazy: false,
}, },
); );
assert!(!scores.iter().any(|entry| entry.name == "WRAPPED")); assert!(!scores.iter().any(|entry| entry.name == "WRAPPED"));
assert!(!qualifies_high_score(&scores, u32::MAX)); 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] #[test]
fn original_dialog_allows_an_accepted_empty_name() { fn original_dialog_allows_an_accepted_empty_name() {
let mut scores = parse_original_high_scores(ORIGINAL_HIGHSCORES); let mut scores = parse_original_high_scores(ORIGINAL_HIGHSCORES);
@@ -310,6 +380,7 @@ mod tests {
HighScore { HighScore {
name: String::new(), name: String::new(),
score: 6_537_393, score: 6_537_393,
crazy: false,
}, },
); );
+7 -13
View File
@@ -167,8 +167,7 @@ impl Real48 {
} }
let mut numerator = self.significand(); let mut numerator = self.significand();
let denominator = right.significand(); let denominator = right.significand();
let mut exponent = let mut exponent = i16::from(self.exponent()) - i16::from(right.exponent()) + EXPONENT_BIAS;
i16::from(self.exponent()) - i16::from(right.exponent()) + EXPONENT_BIAS;
if numerator < denominator { if numerator < denominator {
numerator <<= 1; numerator <<= 1;
exponent -= 1; exponent -= 1;
@@ -254,16 +253,8 @@ impl Real48 {
assert!(highest_bit < 32, "Real48 integer overflow"); assert!(highest_bit < 32, "Real48 integer overflow");
let significand = self.significand(); let significand = self.significand();
let shift = i16::try_from(FRACTION_BITS).unwrap_or(39) - highest_bit; let shift = i16::try_from(FRACTION_BITS).unwrap_or(39) - highest_bit;
let mut magnitude = if shift >= 64 { let mut magnitude = if shift >= 64 { 0 } else { significand >> shift };
0 if round && shift > 0 && shift <= 40 && (significand >> (shift - 1)) & 1 != 0 {
} else {
significand >> shift
};
if round
&& shift > 0
&& shift <= 40
&& (significand >> (shift - 1)) & 1 != 0
{
magnitude += 1; magnitude += 1;
} }
let limit = if self.negative() { let limit = if self.negative() {
@@ -344,7 +335,10 @@ mod tests {
assert_eq!(HALF.round_i32(), 1); assert_eq!(HALF.round_i32(), 1);
assert_eq!(Real48::from_bytes([0x80, 0, 0, 0, 0, 0x80]).round_i32(), -1); assert_eq!(Real48::from_bytes([0x80, 0, 0, 0, 0, 0x80]).round_i32(), -1);
assert_eq!(ONE.compare(TWO), Ordering::Less); assert_eq!(ONE.compare(TWO), Ordering::Less);
assert_eq!(Real48::from_i32(-2).compare(Real48::from_i32(-1)), Ordering::Less); assert_eq!(
Real48::from_i32(-2).compare(Real48::from_i32(-1)),
Ordering::Less
);
assert_eq!(TWO.sqrt().bytes(), [0x81, 0xfa, 0x33, 0xf3, 0x04, 0x35]); assert_eq!(TWO.sqrt().bytes(), [0x81, 0xfa, 0x33, 0xf3, 0x04, 0x35]);
assert_eq!( assert_eq!(
ONE_AND_HALF.sqrt().bytes(), ONE_AND_HALF.sqrt().bytes(),
+77 -48
View File
@@ -1,11 +1,13 @@
use crate::game::{ClawSpriteBank, Controls, Event, Game};
use serde::Serialize;
use std::{ use std::{
fs, fs,
path::{Path, PathBuf}, path::{Path, PathBuf},
str::FromStr, str::FromStr,
}; };
use serde::Serialize;
use crate::game::{ClawSpriteBank, Controls, Event, Game};
pub const SIMULATION_HZ: u32 = 120; pub const SIMULATION_HZ: u32 = 120;
const SIMULATION_HZ_F64: f64 = 120.0; const SIMULATION_HZ_F64: f64 = 120.0;
const SIMULATION_DT: f32 = 1.0 / 120.0; const SIMULATION_DT: f32 = 1.0 / 120.0;
@@ -27,6 +29,7 @@ pub enum Scenario {
Claw6, Claw6,
Claw7, Claw7,
Claw18, Claw18,
Crazy,
} }
impl Scenario { impl Scenario {
@@ -46,6 +49,7 @@ impl Scenario {
Self::Claw6 => "claw-6", Self::Claw6 => "claw-6",
Self::Claw7 => "claw-7", Self::Claw7 => "claw-7",
Self::Claw18 => "claw-18", Self::Claw18 => "claw-18",
Self::Crazy => "crazy",
} }
} }
@@ -64,7 +68,8 @@ impl Scenario {
| Self::Launcher | Self::Launcher
| Self::Flippers | Self::Flippers
| Self::Panel | Self::Panel
| Self::Targets => None, | Self::Targets
| Self::Crazy => None,
} }
} }
} }
@@ -88,8 +93,9 @@ impl FromStr for Scenario {
"claw-6" => Ok(Self::Claw6), "claw-6" => Ok(Self::Claw6),
"claw-7" => Ok(Self::Claw7), "claw-7" => Ok(Self::Claw7),
"claw-18" => Ok(Self::Claw18), "claw-18" => Ok(Self::Claw18),
"crazy" => Ok(Self::Crazy),
_ => Err(format!( _ => 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"
)), )),
} }
} }
@@ -173,7 +179,7 @@ fn seconds_to_step(value: &str) -> Result<u64, String> {
} }
pub const fn usage() -> &'static str { 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)] #[derive(Debug, Serialize, PartialEq)]
@@ -181,8 +187,8 @@ pub struct Snapshot {
pub scenario: &'static str, pub scenario: &'static str,
pub step: u64, pub step: u64,
pub seconds: f64, pub seconds: f64,
pub ball: BallSnapshot, /// Every ball in slot order, including one held by the claw.
pub secondary_ball: Option<BallSnapshot>, pub balls: Vec<BallSnapshot>,
pub claw: ClawSnapshot, pub claw: ClawSnapshot,
pub flippers: FlipperSnapshot, pub flippers: FlipperSnapshot,
pub launcher_charge: f32, pub launcher_charge: f32,
@@ -235,6 +241,9 @@ impl Simulation {
if scenario == Scenario::Effect7 { if scenario == Scenario::Effect7 {
game.begin_effect_scenario(7); game.begin_effect_scenario(7);
} }
if scenario == Scenario::Crazy {
game.add_crazy_balls();
}
let initial_events = scenario let initial_events = scenario
.claw_terminal() .claw_terminal()
.map_or_else(Vec::new, |terminal| game.begin_claw_scenario(terminal)); .map_or_else(Vec::new, |terminal| game.begin_claw_scenario(terminal));
@@ -296,20 +305,18 @@ impl Simulation {
scenario: self.scenario.name(), scenario: self.scenario.name(),
step: self.step, step: self.step,
seconds: f64::from(step) / SIMULATION_HZ_F64, seconds: f64::from(step) / SIMULATION_HZ_F64,
ball: BallSnapshot { balls: self
x: self.game.ball.position.x, .game
y: self.game.ball.position.y, .balls
velocity_x: self.game.ball.velocity.x, .iter()
velocity_y: self.game.ball.velocity.y, .map(|ball| BallSnapshot {
in_launcher: self.game.ball.in_launcher, x: ball.position.x,
}, y: ball.position.y,
secondary_ball: self.game.secondary_ball.map(|ball| BallSnapshot { velocity_x: ball.velocity.x,
x: ball.position.x, velocity_y: ball.velocity.y,
y: ball.position.y, in_launcher: ball.in_launcher,
velocity_x: ball.velocity.x, })
velocity_y: ball.velocity.y, .collect(),
in_launcher: ball.in_launcher,
}),
claw: ClawSnapshot { claw: ClawSnapshot {
active: self.game.claw.active, active: self.game.claw.active,
frame: self.game.claw.frame, frame: self.game.claw.frame,
@@ -331,7 +338,7 @@ impl Simulation {
} }
fn autoplay_controls(&mut self) -> Controls { 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; let launch_down = self.autoplay_launcher_frames < 132;
self.autoplay_launcher_frames += 1; self.autoplay_launcher_frames += 1;
Controls { Controls {
@@ -340,11 +347,15 @@ impl Simulation {
} }
} else { } else {
self.autoplay_launcher_frames = 0; 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 { Controls {
left_flipper: self.game.ball.position.y > 340.0 left_flipper: near_flipper(0.0..175.0),
&& self.game.ball.position.x < 175.0, right_flipper: near_flipper(140.0..f32::INFINITY),
right_flipper: self.game.ball.position.y > 340.0
&& self.game.ball.position.x > 140.0,
..Controls::default() ..Controls::default()
} }
} }
@@ -376,9 +387,8 @@ fn controls_for(scenario: Scenario, step: u64) -> Controls {
| Scenario::Claw1 | Scenario::Claw1
| Scenario::Claw6 | Scenario::Claw6
| Scenario::Claw7 | Scenario::Claw7
| Scenario::Claw18 => { | Scenario::Claw18
Controls::default() | Scenario::Crazy => Controls::default(),
}
} }
} }
@@ -448,16 +458,12 @@ mod tests {
assert!(event_count("Target") >= 1); assert!(event_count("Target") >= 1);
assert!(event_count("Bumper") >= 1); assert!(event_count("Bumper") >= 1);
assert!(simulation.trace.iter().all(|snapshot| { assert!(simulation.trace.iter().all(|snapshot| {
snapshot.ball.x.is_finite() snapshot.balls.iter().all(|ball| {
&& snapshot.ball.y.is_finite() ball.x.is_finite()
&& snapshot.ball.velocity_x.is_finite() && ball.y.is_finite()
&& snapshot.ball.velocity_y.is_finite() && ball.velocity_x.is_finite()
&& snapshot.secondary_ball.as_ref().is_none_or(|ball| { && ball.velocity_y.is_finite()
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() { fn launcher_scenario_holds_for_one_second_then_releases() {
let mut simulation = Simulation::new(Scenario::Launcher, 1); let mut simulation = Simulation::new(Scenario::Launcher, 1);
simulation.advance_to(60); 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)); assert!((0.19..=0.21).contains(&simulation.game.launcher_charge));
simulation.advance_to(121); 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())); assert!(simulation.trace[121].events.contains(&"Launch".to_owned()));
} }
@@ -507,9 +513,24 @@ mod tests {
.iter() .iter()
.flat_map(|snapshot| snapshot.events.iter().map(String::as_str)) .flat_map(|snapshot| snapshot.events.iter().map(String::as_str))
.collect::<Vec<_>>(); .collect::<Vec<_>>();
assert_eq!(events.iter().filter(|event| **event == "StopSound").count(), 3); assert_eq!(
assert_eq!(events.iter().filter(|event| **event == "Sound(2013)").count(), 4); events.iter().filter(|event| **event == "StopSound").count(),
assert_eq!(events.iter().filter(|event| **event == "Sound(2012)").count(), 2); 3
);
assert_eq!(
events
.iter()
.filter(|event| **event == "Sound(2013)")
.count(),
4
);
assert_eq!(
events
.iter()
.filter(|event| **event == "Sound(2012)")
.count(),
2
);
} }
#[test] #[test]
@@ -521,7 +542,10 @@ mod tests {
simulation.advance_to(22); simulation.advance_to(22);
assert_eq!(simulation.game.target_rotation_state, Some(6)); assert_eq!(simulation.game.target_rotation_state, Some(6));
assert_eq!(simulation.game.wheel_holes, [false, true, false, false, true]); assert_eq!(
simulation.game.wheel_holes,
[false, true, false, false, true]
);
simulation.advance_to(26); simulation.advance_to(26);
assert_eq!(simulation.game.target_rotation_state, None); assert_eq!(simulation.game.target_rotation_state, None);
} }
@@ -561,10 +585,15 @@ mod tests {
.filter(|event| **event == "ClawRelease") .filter(|event| **event == "ClawRelease")
.count(); .count();
assert!(captures >= 1, "{} must enter the claw", scenario.name()); assert!(captures >= 1, "{} must enter the claw", scenario.name());
assert_eq!(releases, 1, "{} must release its seeded capture", scenario.name()); assert_eq!(
releases,
1,
"{} must release its seeded capture",
scenario.name()
);
assert!(captures >= releases); assert!(captures >= releases);
assert!(simulation.game.ball.position.is_finite()); assert!(simulation.game.balls[0].position.is_finite());
assert!(simulation.game.ball.velocity.is_finite()); assert!(simulation.game.balls[0].velocity.is_finite());
} }
} }
} }
+6 -2
View File
@@ -9,9 +9,10 @@
//! each object's bounds by `param_26`. They must not be enlarged again by the //! each object's bounds by `param_26`. They must not be enlarged again by the
//! radius of the rendered ball. //! radius of the rendered ball.
use crate::geometry::Segment;
use macroquad::prelude::Vec2; use macroquad::prelude::Vec2;
use crate::geometry::Segment;
#[derive(Clone, Copy, Debug)] #[derive(Clone, Copy, Debug)]
pub struct TableSegment { pub struct TableSegment {
pub id: u8, pub id: u8,
@@ -425,7 +426,10 @@ mod tests {
.iter() .iter()
.find(|wall| wall.id == id) .find(|wall| wall.id == id)
.expect("relative line record must be present"); .expect("relative line record must be present");
assert_eq!((wall.segment.start, wall.segment.end), (expected_start, expected_end)); assert_eq!(
(wall.segment.start, wall.segment.end),
(expected_start, expected_end)
);
} }
for (id, expected_center) in [ for (id, expected_center) in [
(54, Vec2::new(53.0, 292.0)), (54, Vec2::new(53.0, 292.0)),
+26
View File
@@ -0,0 +1,26 @@
# TDK Pinball Machine web build
Build and serve the browser version from this directory with:
```sh
just web-serve
```
The game is compiled for `wasm32-unknown-unknown` and loaded into a fixed
640x460 canvas centered on the black page by `index.html`. Browser settings and
a fallback copy of the high-score table are saved in `localStorage`; native
builds continue to use their normal per-user save file.
The page includes the official Macroquad browser loader locally. The WASM
bootstrap is local as well, so the page does not require inline or third-party
JavaScript. The web page must be served over HTTP rather than opened directly
from a `file:` URL.
For a strict Content Security Policy, allow
`script-src 'self' 'wasm-unsafe-eval'` for the browser's WebAssembly compilation
and `connect-src 'self'` for the local WASM and optional high-score API
requests.
When the same-origin `/api/highscores` endpoint is available, the browser loads
and submits the shared top-ten table there. The small Axum service and an nginx
proxy example are in [highscore-server](../highscore-server/).
+3
View File
@@ -0,0 +1,3 @@
"use strict";
load("tdkpin-rs.wasm");
+53
View File
@@ -0,0 +1,53 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1, viewport-fit=cover"
/>
<meta
name="description"
content="Play the reconstructed 1995 TDK Pinball Machine in your browser."
/>
<title>TDK Pinball Machine 1.00</title>
<style>
:root {
color-scheme: dark;
background: #000;
}
html,
body {
width: 100%;
height: 100%;
margin: 0;
min-width: 640px;
min-height: 460px;
overflow: auto;
background: #000;
}
body {
display: grid;
place-items: center;
}
#glcanvas {
display: block;
width: 640px;
height: 460px;
background: #000;
outline: none;
image-rendering: pixelated;
}
</style>
</head>
<body>
<canvas id="glcanvas" tabindex="1" aria-label="TDK Pinball Machine"></canvas>
<noscript>This game needs JavaScript enabled.</noscript>
<script src="./mq_js_bundle.js"></script>
<script src="./storage.js"></script>
<script src="./bootstrap.js"></script>
</body>
</html>
+3
View File
@@ -0,0 +1,3 @@
"use strict";const version=2,canvas=document.querySelector("#glcanvas");var gl,clipboard=null,wasm_memory,animation_frame_timeout,FS,GL,Module,wasm_exports,emscripten_shaders_hack,importObject,plugins=[],high_dpi=!1,blocking_event_loop=!1;function init_webgl(e){if(e==1){gl=canvas.getContext("webgl");function t(e){var t=e.getExtension("OES_vertex_array_object");t?(e.createVertexArray=function(){return t.createVertexArrayOES()},e.deleteVertexArray=function(e){t.deleteVertexArrayOES(e)},e.bindVertexArray=function(e){t.bindVertexArrayOES(e)},e.isVertexArray=function(e){return t.isVertexArrayOES(e)}):alert("Unable to get OES_vertex_array_object extension")}function n(e){var t=e.getExtension("ANGLE_instanced_arrays");t&&(e.vertexAttribDivisor=function(e,n){t.vertexAttribDivisorANGLE(e,n)},e.drawArraysInstanced=function(e,n,s,o){t.drawArraysInstancedANGLE(e,n,s,o)},e.drawElementsInstanced=function(e,n,s,o,i){t.drawElementsInstancedANGLE(e,n,s,o,i)})}function s(e){var t=e.getExtension("EXT_disjoint_timer_query");t&&(e.createQuery=function(){return t.createQueryEXT()},e.beginQuery=function(e,n){return t.beginQueryEXT(e,n)},e.endQuery=function(e){return t.endQueryEXT(e)},e.deleteQuery=function(e){t.deleteQueryEXT(e)},e.getQueryObject=function(e,n){return t.getQueryObjectEXT(e,n)})}function o(e){var t=e.getExtension("WEBGL_draw_buffers");t&&(e.drawBuffers=function(e){return t.drawBuffersWEBGL(e)})}try{gl.getExtension("EXT_shader_texture_lod"),gl.getExtension("OES_standard_derivatives")}catch(e){console.warn(e)}t(gl),n(gl),s(gl),o(gl),gl.getExtension("WEBGL_depth_texture")==null&&alert("Cant initialize WEBGL_depth_texture extension")}else gl=canvas.getContext("webgl2");gl===null&&alert("Unable to initialize WebGL. Your browser or machine may not support it.")}canvas.focus(),canvas.requestPointerLock=canvas.requestPointerLock||canvas.mozRequestPointerLock||function(){},document.exitPointerLock=document.exitPointerLock||document.mozExitPointerLock||function(){};function assert(e,t){e==!1&&alert(t)}function getArray(e,t,n){return new t(wasm_memory.buffer,e,n)}function UTF8ToString(e,t){let i=new Uint8Array(wasm_memory.buffer,e);for(var n,a,r,c,s=0,l=s+t,o="";!(s>=l);){if(n=i[s++],!n)return o;if(!(n&128)){o+=String.fromCharCode(n);continue}if(a=i[s++]&63,(n&224)==192){o+=String.fromCharCode((n&31)<<6|a);continue}r=i[s++]&63,(n&240)==224?n=(n&15)<<12|a<<6|r:((n&248)!=240&&console.warn("Invalid UTF-8 leading byte 0x"+n.toString(16)+" encountered when deserializing a UTF-8 string on the asm.js/wasm heap to a JS string!"),n=(n&7)<<18|a<<12|r<<6|i[s++]&63),n<65536?o+=String.fromCharCode(n):(c=n-65536,o+=String.fromCharCode(55296|c>>10,56320|c&1023))}return o}function stringToUTF8(e,t,n,s){for(var o,r,c=n,i=n+s,a=0;a<e.length;++a)if(o=e.charCodeAt(a),o>=55296&&o<=57343&&(r=e.charCodeAt(++a),o=65536+((o&1023)<<10)|r&1023),o<=127){if(n>=i)break;t[n++]=o}else if(o<=2047){if(n+1>=i)break;t[n++]=192|o>>6,t[n++]=128|o&63}else if(o<=65535){if(n+2>=i)break;t[n++]=224|o>>12,t[n++]=128|o>>6&63,t[n++]=128|o&63}else{if(n+3>=i)break;o>=2097152&&console.warn("Invalid Unicode code point 0x"+o.toString(16)+" encountered when serializing a JS string to an UTF-8 string on the asm.js/wasm heap! (Valid unicode code points should be in range 0-0x1FFFFF)."),t[n++]=240|o>>18,t[n++]=128|o>>12&63,t[n++]=128|o>>6&63,t[n++]=128|o&63}return n-c}FS={loaded_files:[],unique_id:0},GL={counter:1,buffers:[],mappedBuffers:{},programs:[],framebuffers:[],renderbuffers:[],textures:[],uniforms:[],shaders:[],vaos:[],timerQueries:[],contexts:{},programInfos:{},getNewId:function(e){for(var n=GL.counter++,t=e.length;t<n;t++)e[t]=null;return n},validateGLObjectID:function(e,t,n,s){t!=0&&(e[t]===null?console.error(n+" called with an already deleted "+s+" ID "+t+"!"):e[t]||console.error(n+" called with an invalid "+s+" ID "+t+"!"))},getSource:function(e,t,n,s){for(var a,i="",o=0;o<t;++o)a=s==0?void 0:getArray(s+o*4,Uint32Array,1)[0],i+=UTF8ToString(getArray(n+o*4,Uint32Array,1)[0],a);return i},populateUniformTable:function(e){GL.validateGLObjectID(GL.programs,e,"populateUniformTable","program");for(var t,n,s,i,a,l,o=GL.programs[e],r=GL.programInfos[e]={uniforms:{},maxUniformLength:0,maxAttributeLength:-1,maxUniformBlockNameLength:-1},d=r.uniforms,u=gl.getProgramParameter(o,35718),c=0;c<u;++c)if(i=gl.getActiveUniform(o,c),t=i.name,r.maxUniformLength=Math.max(r.maxUniformLength,t.length+1),t.slice(-1)=="]"&&(t=t.slice(0,t.lastIndexOf("["))),n=gl.getUniformLocation(o,t),n){s=GL.getNewId(GL.uniforms),d[t]=[i.size,s],GL.uniforms[s]=n;for(a=1;a<i.size;++a)l=t+"["+a+"]",n=gl.getUniformLocation(o,l),s=GL.getNewId(GL.uniforms),GL.uniforms[s]=n}}};function _glGenObject(e,t,n,s,o){for(var i,a,r=0;r<e;r++)i=gl[n](),a=i&&GL.getNewId(s),i?(i.name=a,s[a]=i):(console.error("GL_INVALID_OPERATION"),GL.recordError(1282),alert("GL_INVALID_OPERATION in "+o+": GLctx."+n+" returned null - most likely GL context is lost!")),getArray(t+r*4,Int32Array,1)[0]=a}function _webglGet(e,t,n){if(!t){consolLine truncated
`,o=o.replace(/gl_FragColor/g,"GL_FragColor")),o.indexOf("attribute")!=-1?(o=o.replace(/attribute/g,"in"),o=o.replace(/varying/g,"out")):o=o.replace(/varying/g,"in"),o=o.replace(/textureCubeLodEXT/g,"textureCubeLod"),o=o.replace(/texture2DLodEXT/g,"texture2DLod"),o=o.replace(/texture2DProjLodEXT/g,"texture2DProjLod"),o=o.replace(/texture2DGradEXT/g,"texture2DGrad"),o=o.replace(/texture2DProjGradEXT/g,"texture2DProjGrad"),o=o.replace(/textureCubeGradEXT/g,"textureCubeGrad"),o=o.replace(/textureCube/g,"texture"),o=o.replace(/texture1D/g,"texture"),o=o.replace(/texture2D/g,"texture"),o=o.replace(/texture3D/g,"texture"),o=o.replace(/#version 100/g,`#version 300 es
`+i)),gl.shaderSource(GL.shaders[e],o)},glGetProgramInfoLog:function(e,t,n,s){GL.validateGLObjectID(GL.programs,e,"glGetProgramInfoLog","program");var o,i=gl.getProgramInfoLog(GL.programs[e]);assert(i!==null);let a=getArray(s,Uint8Array,t);for(o=0;o<t;o++)a[o]=i.charCodeAt(o)},glGetString:function(e){var t=gl.getParameter(e).toString(),n=t.length+1,s=wasm_exports.allocate_vec_u8(n),o=new Uint8Array(wasm_memory.buffer,s,n);return o[t.length]=0,stringToUTF8(t,o,0,n),s},glCompileShader:function(e){GL.validateGLObjectID(GL.shaders,e,"glCompileShader","shader"),gl.compileShader(GL.shaders[e])},glGetShaderiv:function(e,t,n){if(assert(n),GL.validateGLObjectID(GL.shaders,e,"glGetShaderiv","shader"),t==35716){var s,i,o=gl.getShaderInfoLog(GL.shaders[e]);assert(o!==null),getArray(n,Int32Array,1)[0]=o.length+1}else t==35720?(s=gl.getShaderSource(GL.shaders[e]),i=s===null||s.length==0?0:s.length+1,getArray(n,Int32Array,1)[0]=i):getArray(n,Int32Array,1)[0]=gl.getShaderParameter(GL.shaders[e],t)},glGetShaderInfoLog:function(e,t,n,s){GL.validateGLObjectID(GL.shaders,e,"glGetShaderInfoLog","shader");var o,i=gl.getShaderInfoLog(GL.shaders[e]);assert(i!==null);let a=getArray(s,Uint8Array,t);for(o=0;o<t;o++)a[o]=i.charCodeAt(o)},glVertexAttribDivisor:function(e,t){gl.vertexAttribDivisor(e,t)},glDrawArraysInstanced:function(e,t,n,s){gl.drawArraysInstanced(e,t,n,s)},glDrawElementsInstanced:function(e,t,n,s,o){gl.drawElementsInstanced(e,t,n,s,o)},glDeleteShader:function(e){var t=GL.shaders[e];if(t==null)return;gl.deleteShader(t),GL.shaders[e]=null},glDeleteProgram:function(e){var t=GL.programs[e];if(t==null)return;gl.deleteProgram(t),GL.programs[e]=null},glDeleteBuffers:function(e,t){for(var n,o,s=0;s<e;s++){if(o=getArray(t+s*4,Uint32Array,1)[0],n=GL.buffers[o],!n)continue;gl.deleteBuffer(n),n.name=0,GL.buffers[o]=null}},glDeleteFramebuffers:function(e,t){for(var n,o,s=0;s<e;s++){if(o=getArray(t+s*4,Uint32Array,1)[0],n=GL.framebuffers[o],!n)continue;gl.deleteFramebuffer(n),n.name=0,GL.framebuffers[o]=null}},glDeleteTextures:function(e,t){for(var n,o,s=0;s<e;s++){if(o=getArray(t+s*4,Uint32Array,1)[0],n=GL.textures[o],!n)continue;gl.deleteTexture(n),n.name=0,GL.textures[o]=null}},glGenQueries:function(e,t){_glGenObject(e,t,"createQuery",GL.timerQueries,"glGenQueries")},glDeleteQueries:function(e){for(var n,o,s=0;s<e;s++){if(o=getArray(textures+s*4,Uint32Array,1)[0],n=GL.timerQueries[o],!n)continue;gl.deleteQuery(n),n.name=0,GL.timerQueries[o]=null}},glBeginQuery:function(e,t){GL.validateGLObjectID(GL.timerQueries,t,"glBeginQuery","id"),gl.beginQuery(e,GL.timerQueries[t])},glEndQuery:function(e){gl.endQuery(e)},glGetQueryObjectiv:function(e,t,n){GL.validateGLObjectID(GL.timerQueries,e,"glGetQueryObjectiv","id");let s=gl.getQueryObject(GL.timerQueries[e],t);getArray(n,Uint32Array,1)[0]=s},glGetQueryObjectui64v:function(e,t,n){GL.validateGLObjectID(GL.timerQueries,e,"glGetQueryObjectui64v","id");let o=gl.getQueryObject(GL.timerQueries[e],t),s=getArray(n,Uint32Array,2);s[0]=o,s[1]=(o-s[0])/4294967296},glGenerateMipmap:function(e){gl.generateMipmap(e)},setup_canvas_size:function(e){window.high_dpi=e,resize(canvas)},run_animation_loop:function(e){canvas.onmousemove=function(e){var t=mouse_relative_position(e.clientX,e.clientY),n=t.x,s=t.y;wasm_exports.mouse_move(Math.floor(n),Math.floor(s)),(e.movementX!=0||e.movementY!=0)&&wasm_exports.raw_mouse_move(Math.floor(e.movementX),Math.floor(e.movementY))},canvas.onmousedown=function(e){var t=mouse_relative_position(e.clientX,e.clientY),n=t.x,s=t.y,o=into_sapp_mousebutton(e.button);wasm_exports.mouse_down(n,s,o)},canvas.addEventListener("wheel",function(e){e.preventDefault(),wasm_exports.mouse_wheel(-e.deltaX,-e.deltaY)}),canvas.onmouseup=function(e){var t=mouse_relative_position(e.clientX,e.clientY),n=t.x,s=t.y,o=into_sapp_mousebutton(e.button);wasm_exports.mouse_up(n,s,o)},canvas.onkeydown=function(e){var n,t=into_sapp_keycode(e.code);switch(t){case 32:case 262:case 263:case 264:case 265:case 290:case 291:case 292:case 293:case 294:case 295:case 296:case 297:case 298:case 299:case 259:case 258:case 39:case 47:e.preventDefault();break}n=0,e.ctrlKey&&(n|=SAPP_MODIFIER_CTRL),e.shiftKey&&(n|=SAPP_MODIFIER_SHIFT),e.altKey&&(n|=SAPP_MODIFIER_ALT),wasm_exports.key_down(t,n,e.repeat),(t==32||t==39||t==47)&&wasm_exports.key_press(t)},canvas.onkeyup=function(e){var n=into_sapp_keycode(e.code),t=0;e.ctrlKey&&(t|=SAPP_MODIFIER_CTRL),e.shiftKey&&(t|=SAPP_MODIFIER_SHIFT),e.altKey&&(t|=SAPP_MODIFIER_ALT),wasm_exports.key_up(n,t)},canvas.onkeypress=function(e){var t=into_sapp_keycode(e.code);let n=t==261||e.ctrlKey;n==!1&&wasm_exports.key_press(e.charCode)},canvas.addEventListener("touchstart",function(e){e.preventDefault();for(const t of e.changedTouches){let n=mouse_relative_position(t.clientX,t.clientY);wasm_exports.touch(SAPP_EVENTTYPE_TOUCHES_BEGAN,t.identifier,n.x,n.y)}}),canvas.addEventListener("touchend",function(e){e.preventDefault();for(const t of e.changedTouches){let n=mouse_relative_position( Line truncated
+193
View File
@@ -0,0 +1,193 @@
"use strict";
(function registerStoragePlugin() {
const storageKey = "tdkpin.save.v1";
const encoder = new TextEncoder();
const decoder = new TextDecoder();
const highScoreApi = "api/highscores";
const highScoreRetryInitialDelayMs = 250;
const highScoreRetryMaxDelayMs = 30_000;
let lastRevision = 0;
let lastHighScoreRevision = 0;
let highScoreRequestInFlight = false;
let highScoreGeneration = 0;
let highScoreSubmissionWarningShown = false;
let highScoreRetryDelayMs = highScoreRetryInitialDelayMs;
let highScoreRetryAt = 0;
function browserStorage() {
try {
return window.localStorage;
} catch (_error) {
return null;
}
}
function sendSavedDataToRust() {
wasm_exports.tdkpin_browser_storage_clear();
const storage = browserStorage();
let saved = null;
try {
saved = storage?.getItem(storageKey);
} catch (_error) {
saved = null;
}
if (saved !== null && saved !== undefined) {
for (const byte of encoder.encode(saved)) {
wasm_exports.tdkpin_browser_storage_push(byte);
}
}
wasm_exports.tdkpin_browser_storage_finish();
}
function flushRustSave() {
const revision = wasm_exports.tdkpin_browser_storage_save_revision();
if (revision === lastRevision) {
return;
}
const bytes = new Uint8Array(
wasm_exports.tdkpin_browser_storage_save_length(),
);
for (let index = 0; index < bytes.length; index += 1) {
bytes[index] = wasm_exports.tdkpin_browser_storage_save_byte(index);
}
const storage = browserStorage();
try {
storage?.setItem(storageKey, decoder.decode(bytes));
} catch (_error) {
// Private browsing or a full quota should not stop the game loop.
}
wasm_exports.tdkpin_browser_storage_save_ack();
lastRevision = revision;
}
function deliverHighScores(json) {
wasm_exports.tdkpin_browser_high_scores_clear();
for (const byte of encoder.encode(json)) {
wasm_exports.tdkpin_browser_high_scores_push(byte);
}
wasm_exports.tdkpin_browser_high_scores_finish();
}
async function fetchHighScores() {
const generation = ++highScoreGeneration;
try {
const response = await fetch(highScoreApi, {
cache: "no-store",
headers: { Accept: "application/json" },
});
if (!response.ok) {
throw new Error(`high-score request failed (${response.status})`);
}
const json = await response.text();
if (generation === highScoreGeneration) {
deliverHighScores(json);
}
} catch (error) {
console.warn("shared high scores unavailable; using local scores", error);
}
}
function retryAfterDelay(response) {
if (response.status !== 429 && response.status !== 503) {
return null;
}
const value = response.headers.get("Retry-After")?.trim();
if (!value) {
return null;
}
const seconds = Number(value);
if (Number.isFinite(seconds) && seconds >= 0) {
const delay = seconds * 1000;
return Number.isFinite(delay) ? delay : null;
}
const timestamp = Date.parse(value);
return Number.isFinite(timestamp)
? Math.max(0, timestamp - Date.now())
: null;
}
function scheduleHighScoreRetry(retryAfterMs) {
const jitteredDelay = highScoreRetryDelayMs * (0.5 + Math.random());
const delay =
retryAfterMs === null ? jitteredDelay : retryAfterMs + jitteredDelay;
highScoreRetryAt = Date.now() + delay;
highScoreRetryDelayMs = Math.min(
highScoreRetryDelayMs * 2,
highScoreRetryMaxDelayMs,
);
}
function resetHighScoreRetry() {
highScoreRetryDelayMs = highScoreRetryInitialDelayMs;
highScoreRetryAt = 0;
}
async function flushHighScoreSubmission() {
if (highScoreRequestInFlight) {
return;
}
if (Date.now() < highScoreRetryAt) {
return;
}
const revision = wasm_exports.tdkpin_browser_high_score_revision();
if (revision === lastHighScoreRevision) {
return;
}
const bytes = new Uint8Array(
wasm_exports.tdkpin_browser_high_score_length(),
);
for (let index = 0; index < bytes.length; index += 1) {
bytes[index] = wasm_exports.tdkpin_browser_high_score_byte(index);
}
highScoreRequestInFlight = true;
let retryAfterMs = null;
try {
const response = await fetch(highScoreApi, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: decoder.decode(bytes),
keepalive: true,
});
if (!response.ok) {
retryAfterMs = retryAfterDelay(response);
throw new Error(`high-score submission failed (${response.status})`);
}
const json = await response.text();
highScoreGeneration += 1;
deliverHighScores(json);
wasm_exports.tdkpin_browser_high_score_ack(revision);
lastHighScoreRevision = revision;
highScoreSubmissionWarningShown = false;
resetHighScoreRetry();
} catch (error) {
scheduleHighScoreRetry(retryAfterMs);
if (!highScoreSubmissionWarningShown) {
console.warn("shared high-score submission failed; will retry", error);
highScoreSubmissionWarningShown = true;
}
} finally {
highScoreRequestInFlight = false;
}
}
function onInit() {
sendSavedDataToRust();
lastRevision = wasm_exports.tdkpin_browser_storage_save_revision();
void fetchHighScores();
window.setInterval(flushRustSave, 50);
window.setInterval(() => void flushHighScoreSubmission(), 50);
window.addEventListener("beforeunload", flushRustSave);
}
miniquad_add_plugin({
name: "tdkpin_storage",
on_init: onInit,
version: 1,
});
})();
BIN
View File
Binary file not shown.
+8
View File
@@ -0,0 +1,8 @@
[package]
name = "tdkpin-web-storage"
version = "1.0.0"
edition = "2024"
publish = false
[lib]
path = "src/lib.rs"
+134
View File
@@ -0,0 +1,134 @@
#![allow(unsafe_code)]
use std::{
cell::{Cell, RefCell},
mem,
};
thread_local! {
static LOADED_BYTES: RefCell<Vec<u8>> = const { RefCell::new(Vec::new()) };
static LOADED_HIGH_SCORES: RefCell<Vec<u8>> = const { RefCell::new(Vec::new()) };
static PENDING_SAVE: RefCell<Option<Vec<u8>>> = const { RefCell::new(None) };
static PENDING_HIGH_SCORE: RefCell<Option<Vec<u8>>> = const { RefCell::new(None) };
static SAVE_REVISION: Cell<u32> = const { Cell::new(0) };
static HIGH_SCORE_REVISION: Cell<u32> = const { Cell::new(0) };
}
pub fn take_loaded() -> Option<Vec<u8>> {
let bytes = LOADED_BYTES.with(|loaded| mem::take(&mut *loaded.borrow_mut()));
(!bytes.is_empty()).then_some(bytes)
}
pub fn queue_save(bytes: Vec<u8>) {
PENDING_SAVE.with(|pending| *pending.borrow_mut() = Some(bytes));
SAVE_REVISION.with(|revision| revision.set(revision.get().wrapping_add(1)));
}
pub fn take_high_scores() -> Option<Vec<u8>> {
let bytes = LOADED_HIGH_SCORES.with(|loaded| mem::take(&mut *loaded.borrow_mut()));
(!bytes.is_empty()).then_some(bytes)
}
pub fn queue_high_score(bytes: Vec<u8>) {
PENDING_HIGH_SCORE.with(|pending| *pending.borrow_mut() = Some(bytes));
HIGH_SCORE_REVISION.with(|revision| revision.set(revision.get().wrapping_add(1)));
}
#[unsafe(no_mangle)]
pub extern "C" fn tdkpin_storage_crate_version() -> u32 {
1
}
#[unsafe(no_mangle)]
pub extern "C" fn tdkpin_browser_storage_clear() {
LOADED_BYTES.with(|loaded| loaded.borrow_mut().clear());
}
#[unsafe(no_mangle)]
pub extern "C" fn tdkpin_browser_storage_push(byte: u32) {
if let Ok(byte) = u8::try_from(byte) {
LOADED_BYTES.with(|loaded| loaded.borrow_mut().push(byte));
}
}
#[unsafe(no_mangle)]
pub extern "C" fn tdkpin_browser_storage_finish() {}
#[unsafe(no_mangle)]
pub extern "C" fn tdkpin_browser_high_scores_clear() {
LOADED_HIGH_SCORES.with(|loaded| loaded.borrow_mut().clear());
}
#[unsafe(no_mangle)]
pub extern "C" fn tdkpin_browser_high_scores_push(byte: u32) {
if let Ok(byte) = u8::try_from(byte) {
LOADED_HIGH_SCORES.with(|loaded| loaded.borrow_mut().push(byte));
}
}
#[unsafe(no_mangle)]
pub extern "C" fn tdkpin_browser_high_scores_finish() {}
#[unsafe(no_mangle)]
pub extern "C" fn tdkpin_browser_storage_save_revision() -> u32 {
SAVE_REVISION.with(Cell::get)
}
#[unsafe(no_mangle)]
pub extern "C" fn tdkpin_browser_storage_save_length() -> u32 {
PENDING_SAVE.with(|pending| {
pending
.borrow()
.as_ref()
.map_or(0, |bytes| u32::try_from(bytes.len()).unwrap_or(u32::MAX))
})
}
#[unsafe(no_mangle)]
pub extern "C" fn tdkpin_browser_storage_save_byte(index: u32) -> u32 {
PENDING_SAVE.with(|pending| {
pending
.borrow()
.as_ref()
.and_then(|bytes| bytes.get(usize::try_from(index).ok()?))
.map_or(0, |byte| u32::from(*byte))
})
}
#[unsafe(no_mangle)]
pub extern "C" fn tdkpin_browser_storage_save_ack() {
PENDING_SAVE.with(|pending| *pending.borrow_mut() = None);
}
#[unsafe(no_mangle)]
pub extern "C" fn tdkpin_browser_high_score_revision() -> u32 {
HIGH_SCORE_REVISION.with(Cell::get)
}
#[unsafe(no_mangle)]
pub extern "C" fn tdkpin_browser_high_score_length() -> u32 {
PENDING_HIGH_SCORE.with(|pending| {
pending
.borrow()
.as_ref()
.map_or(0, |bytes| u32::try_from(bytes.len()).unwrap_or(u32::MAX))
})
}
#[unsafe(no_mangle)]
pub extern "C" fn tdkpin_browser_high_score_byte(index: u32) -> u32 {
PENDING_HIGH_SCORE.with(|pending| {
pending
.borrow()
.as_ref()
.and_then(|bytes| bytes.get(usize::try_from(index).ok()?))
.map_or(0, |byte| u32::from(*byte))
})
}
#[unsafe(no_mangle)]
pub extern "C" fn tdkpin_browser_high_score_ack(revision: u32) {
if HIGH_SCORE_REVISION.with(Cell::get) == revision {
PENDING_HIGH_SCORE.with(|pending| *pending.borrow_mut() = None);
}
}