Commit Graph
147 Commits
Author SHA1 Message Date
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 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 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
ddidderr ee58525011 chore(release): prepare v1.0.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
Promote the accumulated reconstruction and gameplay fixes to the first stable
release. Refresh the lockfile with cargo update and express direct dependency
requirements using the requested major or 0.x compatibility ranges.

Test Plan:
- `cargo update --locked` -- passed; 0 packages required updates
- `just test` -- passed (135 tests)
- `just clippy` -- passed
- `just build-production` -- passed
- `cargo metadata --locked --format-version 1 --no-deps` -- passed
- `git diff --cached --check` -- passed
2026-08-29 09:44:59 +02:00
ddidderr 36f8c38d62 fix(multiball): clear stale release-ball state
The release-ball effect was inferred from the persistent multiball state,
which remains active while two balls are in play. After the first release this
left the top-left special-hole indicator lit and caused later sprite hits to
arm another release. Use record 148's contact state as the release prerequisite
and show the special-hole indicator only while that pre-release state is ready.

Test Plan:
- `cargo test --workspace --all-targets --all-features` -- passed (135 tests)
- `cargo clippy --workspace --all-targets --all-features -- -D warnings` -- passed
- `cargo build --profile production` -- passed
- `git diff --check` -- passed
2026-08-29 09:36:33 +02:00
ddidderr fa3f168467 fix(flippers): match original moving-hit geometry
The clone's moving-flipper gate used the cross-product operands in the
opposite order, mirroring and narrowing the hit wedge. Right-flipper release
also used record 81's first endpoint even though the original reads its second
endpoint. Correct both geometry paths, retain the recovered swept tip bounds,
and add live-binary boundary vectors plus a dense transition regression.

Test Plan:
- `cargo test --workspace --all-targets --all-features` -- passed (134 tests)
- `cargo clippy --workspace --all-targets --all-features -- -D warnings` -- passed
- `cargo build --profile production` -- passed
- `LSAN_OPTIONS=detect_leaks=0 ASAN_OPTIONS=detect_leaks=0 bash original/tools/test_reconstructed_c.sh` -- passed
- `python3 original/tools/audit_reconstruction.py --require-complete` -- passed
- `git diff --cached --check` -- passed
2026-08-29 09:15:25 +02:00
ddidderr faf66f1ac0 docs(audit): record final player-experience parity
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
Add the requirement-by-requirement audit for the Rust rewrite. It ties visual,
audio, input, physics, timer, rule, multiball, player, persistence, random, and
build claims to the preserved binary, complete readable C, focused tests,
deterministic framebuffers, and stopped-Wine differential probes.

The audit distinguishes native-window and portable-storage substitutions from
gameplay semantics, records both relative-slingshot position/velocity/spin
probes, and lists the reproducible final gates used for the completion decision.

Test Plan:
- `cargo build --profile production` -- passed
- `cargo test --workspace --all-targets --all-features` -- 125 passed
- `cargo clippy --workspace --all-targets --all-features -- -D warnings` -- passed
- Windows and macOS `cargo check` targets -- passed
- `bash original/tools/test_reconstructed_c.sh` -- passed
- `python3 original/tools/audit_reconstruction.py --require-complete` -- passed with zero incomplete or unclassified units
- original executable, timer DLL, and preserved raw hashes -- matched
- `rumdl check --flavor commonmark README.md RECONSTRUCTION.md PARITY_AUDIT.md CHANGELOG.md` -- passed
- `git diff --cached --check` -- passed
2026-08-23 20:57:16 +02:00
ddidderr 7633ef9990 fix(attract): restore all remainder-driven cycles
The same raw Div32 pattern used by the item animation appears in every nested
idle phase: after division by 32, 40, or 16, the binary copies the remainder
from CX:BX before the second division. Readable C and Rust used quotients for
targets, word quads, the record strip, and the four-point chase, turning short
repeating chases into slow one-shot progressions.

Use `%32/2`, `%32/4`, `%40/4`, and `%16/4` for those cycles alongside the
already corrected `%144/8` items. Preserve the first-cycle phase-zero delay,
then repeat each incremental overlay state exactly. Add C and Rust coverage at
phase activation, reversal, deactivation, and wrap boundaries.

Test Plan:
- raw instruction review at `1000:01b7-063b` -- all remainder transfers confirmed
- `cargo test --workspace --all-targets --all-features` -- 125 passed
- `cargo clippy --workspace --all-targets --all-features -- -D warnings` -- passed
- `bash original/tools/test_reconstructed_c.sh` -- passed
- `python3 original/tools/audit_reconstruction.py --require-complete` -- passed with zero incomplete or unclassified units
- `cargo run -- --simulate attract --step 145 --screenshot /tmp/tdkpin-attract-remainder.png` -- passed; visually inspected at 640x460
- `rumdl check --flavor commonmark RECONSTRUCTION.md CHANGELOG.md` -- passed
- `git diff --cached --check` -- passed
2026-08-23 20:53:12 +02:00
ddidderr dd0299c30b fix(attract): use remainder-driven item phases
Raw 1000:061c-063b divides the idle tick by 144, copies the remainder from
CX:BX into AX:DX, and only then divides by 8. Readable C and Rust instead used
an unbounded quotient equivalent to tick/1152, making DAT801-809 advance far
too slowly and suggesting a nonexistent negative long-idle index.

Use `(tick % 144) / 8` to repeat item states 0,1..9,8..1 every 144 callbacks.
Remove the disproven long-idle status crops and restore the ten-minute simulator
ceiling. Extend the C and Rust phase tests across the forward, reverse, and wrap
boundaries.

Test Plan:
- raw instruction review at `1000:061c-063b` -- remainder transfer confirmed
- `cargo test --workspace --all-targets --all-features` -- 125 passed
- `cargo clippy --workspace --all-targets --all-features -- -D warnings` -- passed
- `bash original/tools/test_reconstructed_c.sh` -- passed
- `python3 original/tools/audit_reconstruction.py --require-complete` -- passed with zero incomplete or unclassified units
- `rumdl check --flavor commonmark RECONSTRUCTION.md CHANGELOG.md` -- passed
- `git diff --cached --check` -- passed
2026-08-23 20:50:05 +02:00
ddidderr 5b8351ca1f fix(ui): reproduce long-idle item index wrap
The attract item animation computes `18 - phase` as a signed value without a
lower bound. At phases 19 through 21, the subsequent 16-bit handle index wraps
backward from the DAT801 table into DAT703, DAT702, and DAT701, copying each
bitmap's upper 68x61 region into the item panel. Rust stopped drawing after
phase 18 and hid this observable long-idle behavior.

Render those three adjacent status crops and return to the inactive panel for
later phases. Raise the deterministic simulation ceiling to 100,000 frames so
the first wrapped phase at 10.94 minutes can be captured and inspected.

Test Plan:
- `cargo test --workspace --all-targets --all-features` -- 125 passed
- `cargo clippy --workspace --all-targets --all-features -- -D warnings` -- passed
- `rumdl check --flavor commonmark RECONSTRUCTION.md CHANGELOG.md` -- passed
- `cargo run -- --simulate attract --step 78797 --screenshot /tmp/tdkpin-attract-wrap-19.png` -- passed; DAT703 crop visually inspected at 640x460
- `git diff --cached --check` -- passed
2026-08-23 20:45:54 +02:00
ddidderr db0ec0bdc4 fix(random): continue Borland seed across games
The original RandSeed is program-global and is initialized once at startup.
Rust stored the exact Borland generator inside Game but discarded its final
state at game over and seeded every following game from Macroquad again,
breaking stream continuity across high-score/attract flow.

Expose the current seed, retain it when the App consumes a finished game, and
construct the next game from that value. Attract, high-score, and portable UI
continue to consume no gameplay draws.

Test Plan:
- `cargo test --workspace --all-targets --all-features` -- 125 passed
- `cargo clippy --workspace --all-targets --all-features -- -D warnings` -- passed
- `rumdl check --flavor commonmark RECONSTRUCTION.md CHANGELOG.md` -- passed
- `git diff --cached --check` -- passed
2026-08-23 20:43:10 +02:00
ddidderr 2a17fbeeef docs(audit): align validation claims with current evidence
The reconstruction ledger still described an obsolete function count, claimed
local Windows/macOS cross-target checks despite those standard libraries being
absent, and attributed lock, claw, drain, and repeated-launch coverage to the
current autoplay trace. Those statements were stronger than the current
artifacts prove after the corrected trajectory.

Record the final C ledger counts, distinguish configured native CI from local
Linux production validation, add both stopped-Wine slingshot probes, and route
claw/panel/lock/drain evidence to the deterministic and focused tests that
actually cover it.

Test Plan:
- `rumdl check --flavor commonmark RECONSTRUCTION.md` -- passed
- `git diff --cached --check` -- passed
2026-08-23 20:41:06 +02:00
ddidderr 0c4c18dbea fix(input): honor auxiliary-window key gate
Both original key handlers return without processing gameplay keys while any of
the four child-window slots is occupied. Rust continued to toggle sound through
Help, HighScore, and name-entry UI and used F1 to close Help, behavior the main
Win16 window cannot perform through an active child.

Limit recovered F1 and F12 handling to the attract and playing screens. The
portable Enter/Escape Help return remains available as an explicit modern
control without bypassing the original main-window gate.

Test Plan:
- `cargo test --workspace --all-targets --all-features` -- 125 passed
- `cargo clippy --workspace --all-targets --all-features -- -D warnings` -- passed
- `rumdl check --flavor commonmark RECONSTRUCTION.md CHANGELOG.md` -- passed
- `git diff --cached --check` -- passed
2026-08-23 20:39:08 +02:00
ddidderr 1dff82aef6 fix(input): dispatch release-handler actions on key-up
The original key-release handler owns add-player, all three nudge actions, and
F12 sound toggling. Rust dispatched those actions on the initial key press,
which changed launcher/player timing and applied nudges before the player
released the key.

Use Macroquad's key-up edges for every recovered release-handler action. Keep
F1 on key-down, flippers as held key bytes, and the launcher as press/repeat
plus release, matching their separate binary paths.

Test Plan:
- `cargo test --workspace --all-targets --all-features` -- 125 passed
- `cargo clippy --workspace --all-targets --all-features -- -D warnings` -- passed
- `rumdl check --flavor commonmark RECONSTRUCTION.md CHANGELOG.md` -- passed
- `git diff --cached --check` -- passed
2026-08-23 20:38:31 +02:00
ddidderr 26b7a9ecf3 fix(input): match original low-byte key scans
The Win16 handlers mask the keyboard scan to its low byte. Main and keypad
Enter therefore both produce the right-flipper scan 0x1c, both Ctrl keys
produce the left-flipper scan 0x1d, and scan 0x1b is the physical
main-keyboard plus/right-bracket position accepted alongside keypad plus. Rust
omitted main Enter and scan 0x1b, and incorrectly assigned Right Ctrl to the
right flipper.

Map the native keys to those recovered scan semantics. Keep A/D, arrows, and
Equal only as explicit modern aliases and document the distinction.

Test Plan:
- `cargo test --workspace --all-targets --all-features` -- 125 passed
- `cargo clippy --workspace --all-targets --all-features -- -D warnings` -- passed
- `rumdl check --flavor commonmark README.md RECONSTRUCTION.md CHANGELOG.md` -- passed
- `git diff --cached --check` -- passed
2026-08-23 20:37:45 +02:00
ddidderr 505bf0417a fix(rules): finish tilted type-three captures
The original capture handler does not request a launcher reset when a single
ball completes a type-three record during Tilt. It calls the normal ball-end
state machine instead. Rust returned Reset for every single-ball capture,
granting tilted lock-hole captures a free ball and bypassing record 148's
special-respawn decision.

Add an explicit Finish action for tilted single-ball type-three completions.
Lock holes now consume the ball with the tilted drain cue suppressed; record
148 still publishes its required contact first, so the same finish call can
consume it through the special-respawn branch without using a marker.

Test Plan:
- `cargo test --workspace --all-targets --all-features` -- 125 passed
- `cargo clippy --workspace --all-targets --all-features -- -D warnings` -- passed
- `rumdl check --flavor commonmark RECONSTRUCTION.md CHANGELOG.md` -- passed
- `git diff --cached --check` -- passed
2026-08-23 20:36:29 +02:00
ddidderr c1790c7ff6 fix(multiball): start completed panel on type-three reset
The original reset helper checks contact words 129 through 133 and starts the
DAT600 panel whenever all five are nonzero. This matters after the fifth lock is
captured during multiball: the panel is initially deferred, but a later
single-ball type-three reset, including record 148, starts it even without a
second lock-hole award. Rust only started the panel from its lock completion
helper.

Mirror the five-contact gate in the shared type-three launcher reset. Extend the
multiball completion regression to prove both the special-hole reset path and
the separately capped survivor recapture path.

Test Plan:
- `cargo test --workspace --all-targets --all-features` -- 124 passed
- `cargo clippy --workspace --all-targets --all-features -- -D warnings` -- passed
- `rumdl check --flavor commonmark RECONSTRUCTION.md CHANGELOG.md` -- passed
- `git diff --cached --check` -- passed
2026-08-23 20:34:34 +02:00