diff --git a/backend/src/api.ts b/backend/src/api.ts index f4d1ea9..55b3a87 100644 --- a/backend/src/api.ts +++ b/backend/src/api.ts @@ -3,7 +3,7 @@ import rateLimit, { type RateLimitInfo } from "express-rate-limit"; import { AccessToken, TrackSource } from "livekit-server-sdk"; import { z } from "zod"; import { - ARCADE_GAMES, + ARCADE_SCORE_GAMES, LIMITS, RATE_LIMITS, arcadeScoreSchema, @@ -12,7 +12,7 @@ import { credentialsSchema, liveKitSchema, reportCreateSchema, - type ArcadeGame, + type ArcadeScoreGame, type ArcadeLeaderboard, type ArcadeScoreResult, type BlockAck, @@ -151,8 +151,8 @@ const blockLimiter = rateLimit({ }, }); -function isArcadeGame(value: string): value is ArcadeGame { - return (ARCADE_GAMES as readonly string[]).includes(value); +function isArcadeScoreGame(value: string): value is ArcadeScoreGame { + return (ARCADE_SCORE_GAMES as readonly string[]).includes(value); } api.post("/signup", authLimiter, async (request, response) => { @@ -247,7 +247,7 @@ api.post("/arcade/scores", arcadeScoreLimiter, requireAuth, async (request, resp api.get("/arcade/scores/:game", arcadeLeaderboardLimiter, requireAuth, async (request, response) => { const raw = Array.isArray(request.params.game) ? request.params.game[0] : request.params.game; const game = raw ?? ""; - if (!isArcadeGame(game)) { + if (!isArcadeScoreGame(game)) { response.status(404).json({ error: "unknown-game" }); return; } diff --git a/backend/test/integration/api.integration.test.ts b/backend/test/integration/api.integration.test.ts index 53ac240..3604a21 100644 --- a/backend/test/integration/api.integration.test.ts +++ b/backend/test/integration/api.integration.test.ts @@ -506,21 +506,21 @@ describe("arcade high scores", () => { const zero = await api(base, "/api/v1/arcade/scores", { token, body: { game: "flappy", score: 0 } }); expect(zero.json.best).toBe(0); expect(zero.json.newBest).toBe(false); - const first = await api(base, "/api/v1/arcade/scores", { token, body: { game: "snake", score: 12 } }); + const first = await api(base, "/api/v1/arcade/scores", { token, body: { game: "snake-v2", score: 12 } }); expect(first.status).toBe(200); expect(first.json.best).toBe(12); expect(first.json.newBest).toBe(true); - expect(first.json.game).toBe("snake"); + expect(first.json.game).toBe("snake-v2"); // A lower score does not lower the stored best. - const lower = await api(base, "/api/v1/arcade/scores", { token, body: { game: "snake", score: 5 } }); + const lower = await api(base, "/api/v1/arcade/scores", { token, body: { game: "snake-v2", score: 5 } }); expect(lower.json.best).toBe(12); expect(lower.json.newBest).toBe(false); // Matching the standing best is not an improvement either. - const equal = await api(base, "/api/v1/arcade/scores", { token, body: { game: "snake", score: 12 } }); + const equal = await api(base, "/api/v1/arcade/scores", { token, body: { game: "snake-v2", score: 12 } }); expect(equal.json.best).toBe(12); expect(equal.json.newBest).toBe(false); // A higher score raises it. - const higher = await api(base, "/api/v1/arcade/scores", { token, body: { game: "snake", score: 20 } }); + const higher = await api(base, "/api/v1/arcade/scores", { token, body: { game: "snake-v2", score: 20 } }); expect(higher.json.best).toBe(20); expect(higher.json.newBest).toBe(true); // The leaderboard lists this user's best with their username. @@ -530,7 +530,7 @@ describe("arcade high scores", () => { it("atomically identifies only one of two identical first submissions as the new best", async () => { const { token } = await createPlayer("arc-race"); - const body = { game: "snake", score: 9 }; + const body = { game: "snake-v2", score: 9 }; const [left, right] = await Promise.all([ api(base, "/api/v1/arcade/scores", { token, body }), api(base, "/api/v1/arcade/scores", { token, body }), @@ -557,7 +557,7 @@ describe("arcade high scores", () => { it("rejects an unknown game and a malformed score", async () => { const { token } = await createPlayer("arc3"); expect((await api(base, "/api/v1/arcade/scores/pong", { token })).status).toBe(404); - const bad = await api(base, "/api/v1/arcade/scores", { token, body: { game: "snake", score: -1 } }); + const bad = await api(base, "/api/v1/arcade/scores", { token, body: { game: "snake-v2", score: -1 } }); expect(bad.status).toBe(400); expect(bad.json.error).toBe("invalid-score"); }); diff --git a/docs/STATE.md b/docs/STATE.md index 85f29e9..e37203d 100644 --- a/docs/STATE.md +++ b/docs/STATE.md @@ -1,23 +1,27 @@ # 2D Metaverse β€” State -> **hyprverse**: a private student social world with spatial media, meeting rooms, stage, arcade, and board tables. Β· Last checkpoint: 2026-08-01 +> **hyprverse**: a private student social world with spatial media, meeting rooms, stage, arcade, and board tables. Β· Last checkpoint: 2026-08-08 ## 🚧 In progress / next -- Campus art + HUD branch `art/hostel-room-proof` is reconciled with main after #167 and #168; next is push, open the campus/UI PR, exact-head Codex review, and green CI (incl. E2E) before squash-merge. -- Then resume spec 27 tickets #165–#166 (`docs/specs/27-arcade-2.md`, parent #162). Parked: remaining PRD 25 frontier (#107, #117, #108, #118–#121, #124/#125, #127–#132). +- PR #174 (`feat/restore-white-snake`) merged as `3701d473` from head `21e6dec`; no active work remains from this session. +- Resume spec 27 tickets #165–#166 (`docs/specs/27-arcade-2.md`, parent #162). Parked: remaining PRD 25 frontier (#107, #117, #108, #118–#121, #124/#125, #127–#132). +- Next concrete step: choose the next queued spec-27 ticket or a new user task. ## Status -- PRD 25 batch landed (#138–#160); moderator dashboard (#161); arcade polish (#170); flappy dino-ramp (#171); arcade juice/Snake rework (#167); Stellar Forge merge-drop cabinet (#168). -- Production `main` includes #167 (`f766ec1`) and #168 (`4dd72c0`). Stellar Forge is live on main with pause/fullscreen, authoritative `newBest`, live shake/reduced-motion, and board foley preserved. -- Campus polish (local branch, reconciled): per-district `campus_decor/` decoration, curated LimeZu furniture, decluttered walkways/rooms, clear presenter platform, modern seating, Stellar Forge cabinet at (82,96), deterministic regenerated campus map/geometry. -- HUD polish: compact pixel-paper chat with slash-command discovery, thin themed scrollbar, independently collapsible muted/blocked management, cream speech bubble, wooden noticeboard fullscreen map, bottom-centre controls/help, consistent room/admin/chat surfaces. -- Stage: one presenter-only `Go Live` starts microphone audio; global camera adds/removes video on the same session; status is `LIVE` / `NOT LIVE`. -- Focused validation after main merge: frontend 185/185, backend stage+geometry 18/18, ESLint 0 errors (3 existing ChatBox hook warnings), emoji check, generator double-run deterministic, `git diff --check` clean. Full build/E2E delegated to GitHub CI. +- PRD 25 batch landed (#138–#160); moderator dashboard (#161); arcade polish (#170); flappy dino-ramp (#171); arcade juice/Snake rework (#167); Stellar Forge merge-drop (#168); campus art + board UI + HUD polish (#172); stage mic consent + board error timer (#173). +- Production `main` tip is `3701d473` (squash of #174). Prior: #173 `f317144`, #172 `ae8a803`, #168 `4dd72c0`, #167 `f766ec1` β€” all squash-merged with green CI (incl. E2E). No force-push; no direct main commits. +- PR #174 merged with green exact-head CI: frontend build, backend tests, Docker integration, production image, and E2E all passed; merge commit `3701d473`. +- Board SFX interest gate: place/outcome cues only for seated (incl. forfeit leaver) or nearby table spectators β€” campus bystanders stay silent (`boardSound` + App seated/near refs). +- Stage Go Live mic consent: pre-live `micOn` captured; stop/walk-off re-mutes in `finally` when the player had not already unmuted for proximity. +- Snake review fixes: versioned score namespaces avoid migration races; pure frame scheduling handles queued instant turns; reduced-motion disables Snake interpolation/pulses and terminal delay; bonus geometry, tick-based comparable scoring, near-full-board food placement, and full terminal FX timing are covered by focused tests. +- Snake rollout hardening: current clients submit `snake-v2` scores while legacy `snake` remains accepted; scored boards are fixed at 34Γ—18; `npm audit` is clean after the lockfile refresh. +- Parked follow-up (non-blocking Opus note): Go Live's implied unmute still persists to sessionStorage across reload β€” consider not persisting stage-only unmute. ## Architecture map - Wire contracts and shared rules -> `shared/src/` - Backend API, sockets, moderation, authority FSMs -> `backend/src/` - Frontend React/Phaser/media/UI -> `frontend/src/` -- Arcade reducers -> `frontend/src/game/arcade/`; overlay/renderers -> `frontend/src/ui/arcade/`; board stack -> `shared/src/games/` + `backend/src/boardMatch.ts`/`board-manager.ts` +- Arcade reducers -> `frontend/src/game/arcade/`; overlay/renderers -> `frontend/src/ui/arcade/` +- Board rules -> `shared/src/games/`; match lifecycle -> `backend/src/boardMatch.ts` + `board-manager.ts`; client sound -> `frontend/src/game/boardSound.ts`; HUD -> `frontend/src/ui/board/*` + `BoardTablePanel.tsx` - Generated campus + geometry -> `frontend/scripts/gen_campus.py`; per-district art -> `frontend/scripts/campus_decor/.py` - Deploy and production operations -> `.github/workflows/`, `deploy/` @@ -30,8 +34,8 @@ - `@metaverse/shared` owns every wire shape; game rules stay pure seeded reducers; managers are side-effect shells (full log: `docs/decisions.md`). - HUD follows the selected compact pixel-paper reference: integrated chat, attached command tray, wooden noticeboard map, bottom-centre media bar, and help inside Settings. - Stage has one live session: explicit `Go Live` enables microphone audio; camera joins/leaves through the global control; user-facing state is `LIVE` / `NOT LIVE` (2026-08-01). -- Campus art lives in per-district `campus_decor/` modules; `gen_campus.py` remains the structural author; placement legality is enforced centrally. Stellar Forge cabinet is structural (gen_campus), not decor. -- Full local gates are prohibited on Raja's machine; current-head cloud review plus green GitHub CI (including E2E) are the merge gates. +- Campus art lives in per-district `campus_decor/` modules; `gen_campus.py` remains the structural author; empty floor is intentional; presenter zone stays prop-free. +- Merge gate: exact-head CI green (incl. E2E) plus the current PR's unresolved-review verdict; full local suites prohibited on Raja's machine. ## Gotchas - A solid prop body is bottom-anchored and extends above its authored tile. Connectivity is not enough: E2E helpers walk straight swept corridors, so map changes require the real E2E job. @@ -39,4 +43,6 @@ - Generated campus artifacts must come from `cd frontend && python3 scripts/gen_campus.py`, never hand edits. - LimeZu art is non-commercial-license only; buy the paid pack before monetisation. - BootScene furniture key regex in `maps.test.ts` must allow hyphens (`arcade_merge-drop`). -- Entry bundle was 98.9/130 KB gzip at a prior strict-build checkpoint; new arcade code stays lazy-loaded. +- Board `board-update` is space-scoped β€” always gate SFX on seated/nearby interest (`boardSound` + App refs). +- Codex cloud review limits are normally exhausted; PR #174 was merged after its final pushed head passed the required CI checks. +- Production backend currently reports SHA `09f39f0`; its `snake-v2` acceptance was staged before the #174 frontend switch merged. diff --git a/docs/decisions.md b/docs/decisions.md index 3ad8e91..3e5df9a 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -126,3 +126,21 @@ lists are empty during the floor phase). `frontend/src/game/maps.test.ts` is the ## 2026-08-01 β€” Stage broadcast is one explicit live session **Why:** Separate β€œGo on air” and β€œGo Live (video)” paths produced overlapping prompts and two competing transport lifecycles. The presenter now gets one explicit `Go Live` action that enables microphone audio and starts the stage session; the existing global camera control adds or removes video from that same session, and `Stop Live` ends it. User-facing state is `LIVE` / `NOT LIVE`. This supersedes the 2026-07-07 β€œmuted preference wins when going on air” choice: the new explicit Go Live click is itself consent to start audio, while camera consent remains independent. + +## 2026-08-01 β€” Prefer Claude residual review over Codex cloud review +**Why:** Codex cloud PR review limits are exhausted. Merge gate remains exact-head green CI (incl. E2E) plus a Claude residual review for actionable P0–P2; do not burn or invoke `@codex review` until limits reset. + +## 2026-08-08 β€” Preserve Snake leaderboard comparability during the faithful restore +**Why:** The restored Snake changes ordinary food from 1 point to 10 and adds timed bonuses. Migration `007_snake_score_scale.sql` multiplies existing `snake` bests by 10 before the new reducer runs, preserving the public cabinet ID and keeping historical ordinary-food scores on the same unit instead of silently mixing incompatible values. + +## 2026-08-08 β€” Keep instant Snake steering inside the pure frame reducer +**Why:** Renderer-side accumulator forcing could schedule a valid turn behind an illegal reversal while `snakeTick` consumed only the reversal. `snakeFrame` now owns immediate-turn scheduling and uses the pure queued-input preview; the renderer only forwards input and draws the result. + +## 2026-08-08 β€” Version Snake score namespaces instead of rescaling live rows +**Why:** A migration that multiplied `snake` rows could race an independently deployed frontend and backend, double-scaling a new 10-point score. Current Snake therefore submits to `snake-v2`; legacy `snake` remains accepted for old clients, and historical rows are left untouched. + +## 2026-08-08 β€” Stage the backend namespace before the Snake frontend switch +**Why:** Frontend Vercel and backend production deploys run independently. The backend accepting `snake-v2` was deployed at PR head `09f39f0` before the frontend can merge, eliminating the window where new clients would submit to an unknown score namespace and lose runs. + +## 2026-08-08 β€” Keep Snake bonus scoring tick-based and reduced-motion terminal state immediate +**Why:** Difficulty changes movement speed while all Snake modes share one leaderboard, so wall-clock bonus decay made identical movement paths earn different values. Bonus decay now advances once per movement tick, and reduced-motion users see the terminal card immediately because decorative effects are disabled. The alternative of preserving the visual delay would violate the reduced-motion contract. diff --git a/docs/sessions/2026-08-01.md b/docs/sessions/2026-08-01.md index cd4e970..74b22e9 100644 --- a/docs/sessions/2026-08-01.md +++ b/docs/sessions/2026-08-01.md @@ -64,3 +64,15 @@ - did: resolved PR #168 against main after #167 (semantic merge of Stellar Forge with juice/Snake/board foley, live shake, authoritative newBest); fixed Codex findings (ARCADE_GAMES derivation, ArcadeBusEvent, terminal events, heat-meter fall spike, pause latch); exact-head Codex clean on `c31e57e`; CI full green incl. E2E 8m46s; squash-merged #168 as `4dd72c0`. Merged `origin/main` into `art/hostel-room-proof`, kept campus_decor architecture + Stellar Forge cabinet at (82,96), regenerated map/geometry (deterministic), fixed BootScene hyphen key guard. - verified: focused frontend 185/185; backend stage+geometry 18/18; ESLint 0 errors (3 existing ChatBox warnings); emoji check; generator double-run hashes identical. - next: push campus branch, open ready-for-review PR, exact-head Codex + CI, squash-merge when CLEAN. + +## 20:31 β€” #172 campus + board UI merged; Codex review retired +- did: finished campus PR #172 β€” ChatBox safeCommandIndex (fixed setState-in-effect lint), board SFX interest gate (seated/wasSeated/nearby vs campus bystander), Claude residual review CLEAN on `8f87e4e`, full CI green (lint/typecheck/unit/E2E + backend), squash-merged #172 as `ae8a803` with `--match-head-commit`. Confirmed earlier #168 already on main as `4dd72c0`. Stopped using `@codex review` (limits exhausted); residual reviews via Claude only after exact-head CI green. +- changed: `frontend/src/ui/ChatBox.tsx`, `game/boardSound.ts` + tests, `App.tsx` nearby/seated refs; remote main now `ae8a803`. +- decided: no more Codex PR review for this project until limits reset; Claude residual review is the merge gate reviewer. +- next: resume spec 27 tickets #165–#166; optional commit of checkpoint docs if desired. + +## 21:07 β€” Opus residual on #172; follow-up #173 merged +- did: Opus 5 residual review of #172 @ ae8a803 found P1 (stage-off-air left world mic hot) + P2 (board-error timer). Opened #173, fixed both + goOffAir finally re-mute; CI green; Opus residual CLEAN (MERGE); squash-merged #173 as `f317144`. No Codex. +- changed: `frontend/src/App.tsx`, `App.test.tsx`; main tip `f317144`. +- decided: stage Go Live unmute is transient consent β€” restore pre-live mic on stop even if goOffAir rejects. +- next: resume spec 27 tickets #165–#166; optional sessionStorage unmute persistence follow-up; commit checkpoint docs if desired. diff --git a/docs/sessions/2026-08-08.md b/docs/sessions/2026-08-08.md new file mode 100644 index 0000000..2c33480 --- /dev/null +++ b/docs/sessions/2026-08-08.md @@ -0,0 +1,27 @@ +## 13:41 β€” Resolve Snake PR review findings +- did: fixed all five Codex findings on PR #174, added focused regression coverage, committed as `ee30bc5`, pushed `feat/restore-white-snake`, and requested `@codex review` again. +- changed: Snake score migration, pure turn scheduling, live shake/reduced-motion gating, bonus-cell rendering/food placement, and focused Snake tests. +- verified: focused frontend tests 72/72; targeted ESLint clean; `git diff --check` clean. Full CI is pending on the pushed head. +- decided: rescale historical Snake scores by 10 rather than change the public game ID, preserving the existing leaderboard contract. +- next: wait for exact-head CI and Codex rereview; the user will report any CI failure. + +## 14:08 β€” Fix failed audit and second Codex review +- did: diagnosed the failed CI job as `npm audit` (five high vulnerabilities), replaced the unsafe Snake score migration with `snake-v2` namespace versioning, fixed fixed-size scored boards, refreshed the lockfile, pushed `d584c13`, and requested `@codex review` again. +- changed: shared score namespace contract, backend score validation, frontend score client, Snake board sizing, backend integration expectations, package-lock security updates. +- verified: shared tests 41/41; frontend tests 102/102; backend/frontend typechecks; workspace lint; `npm audit` zero vulnerabilities; diff checks clean. New CI is pending. +- decided: never rescale live score rows across independently deployed frontend/backend versions; version the score namespace instead. +- next: wait for exact-head CI and the new Codex review; the user will report any failure. + +## 14:44 β€” Fix final Snake review findings and stage backend rollout +- did: disabled Snake decorative motion under reduced-motion, aligned overlay hold time with the 830ms death FX, updated timing assertions, committed `09f39f0`, pushed PR #174, deployed the backend from that exact SHA, and requested `@codex review` again. +- changed: `SnakeGame.tsx`, Snake renderer/food animation, `ArcadeOverlay.tsx` and focused tests; production backend release. +- verified: focused frontend tests 103/103, frontend typecheck/lint, diff checks; deployment workflow succeeded and `https://api.space.raja-dev.me/health/ready` reports SHA `09f39f0`. Frontend E2E and Codex rereview remain pending. +- decided: stage backend score-namespace acceptance before merging the independently deployed frontend switch; do not claim merge readiness until exact-head E2E and review are green. +- next: inspect the new Codex review and E2E result; merge only after both gates pass. + +## 16:25 β€” Merge Snake restore and close review loop +- did: fixed comparable Snake bonus decay across difficulties and removed the reduced-motion terminal delay; added focused regression coverage, pushed `21e6dec`, and requested `@codex review`. +- changed: `frontend/src/game/arcade/snake.ts`, `snake.test.ts`, `ArcadeOverlay.tsx`, `ArcadeOverlay.test.tsx`, and `terminalHold.ts`. +- verified: 104 focused frontend tests passed; frontend typecheck passed; lint had only the three pre-existing `ChatBox.tsx` warnings; exact-head CI passed including E2E; PR #174 merged as `3701d473`. +- decided: bonus value decays per movement tick so identical paths remain comparable across difficulty speeds; reduced motion removes decorative terminal delay. +- next: no follow-up for this session; continue with the next queued task. diff --git a/frontend/ATTRIBUTIONS.md b/frontend/ATTRIBUTIONS.md index 890f11d..0ade357 100644 --- a/frontend/ATTRIBUTIONS.md +++ b/frontend/ATTRIBUTIONS.md @@ -67,7 +67,6 @@ release. | `furniture/table_round.png` | Project-original | Redrawn in-repo (PRD 12) | Project asset β€” replaces the prior flat placeholder, matched to the furniture palette. | | `furniture/arcade_snake.png`, `furniture/arcade_flappy.png` | Project-original | Generated in-repo (PRD 11, `scripts/gen_arcade_sprites.py`) | Project asset β€” 32Γ—32 upright arcade cabinets in the cool-office palette, marquee/screen tinted per game (teal/amber). Not derived from any third-party pack. | | `furniture/arcade_merge-drop.png` | Project-original | Generated in-repo (Arcade 2.0, `scripts/gen_arcade_sprites.py`) | Project asset β€” the Stellar Forge cabinet: same 32Γ—32 body as its neighbours, marquee/screen tinted violet (`#b082ff`). Not derived from any third-party pack. | -| `arcade/snake_tiles.png` | Project-original | Generated in-repo (issue #163, `scripts/gen_snake_sprites.py`) | Project asset β€” 16Γ—16 sprite tiles for the Snake mini-game (head/body/bend/tail per direction, apple, wall block), replacing the renderer's flat rectangles. Same outline ink and cool palette as the arcade cabinets. Consumed only by the lazy-loaded arcade overlay canvas, not by Phaser/BootScene. Not derived from any third-party pack. | | `tilesets/room_builder.png`, `furniture/lz_*.png` | [Modern Interiors β€” RPG Tileset](https://limezu.itch.io/moderninteriors), **free** tier (`Modern_Interiors_Free_v2.2`, `Interiors_free/16x16`) | **LimeZu** | ⚠️ **NON-COMMERCIAL ONLY.** The free tier's `Modern tiles_Free/LICENSE.txt` reads verbatim: *"YOU CAN USE THE ASSET IN NON COMMERCIAL PROJECTS Β· YOU CAN EDIT THE SPRITES AND USE THEM IN NON COMMERCIAL PROJECTS / YOU CAN'T USE THE ASSET IN COMMERCIAL PROJECTS Β· YOU CAN'T EDIT THE SPRITES AND USE THEM IN COMMERCIAL PROJECTS Β· YOU CAN'T EDIT AND RESELL THE SPRITES."* That is fine today because hyprverse is Raja's personal, non-commercial project. **If it is ever monetised, sponsored or shipped as a product, buy the paid pack (~$1.20, carries the commercial licence) before shipping β€” or strip these assets.** Shipped: `room_builder.png` verbatim (interior floor tiles: brick / cross-tile / mint / screed / herringbone parquet), and 102 prop cutouts from `Interiors_free_16x16.png` cropped by `scripts/gen_limezu_sprites.py` (source tile coordinates named in that script). The raw sheets are **not** committed. Used campus-wide by the district art passes in `scripts/campus_decor/` (see `docs/ux-art-audit-2026-08-01.md` and `docs/art/limezu-catalog.md`). | | `landing/campus-hero.png` | Composed in-repo (`scripts/gen_landing_backdrop.py`) from `maps/campus.json` + the `exterior.png` / `floors_walls.png` tilesets and `furniture/*.png` (all attributed above) | (respective pack authors) | Landing diorama backdrop (PRD 19) β€” a pure composition of already-attributed pack assets: a fixed crop of the real campus map re-rendered to one palette-quantised PNG. No new art authored; inherits the licenses of its source tiles/sprites. | diff --git a/frontend/README.md b/frontend/README.md index 93c19eb..604e4ad 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -583,7 +583,8 @@ its focus can't swallow the game's keys via the scene's `isTyping` guard). slider bound to a dedicated **`arcade` mixer channel** (`arcadeVolume` / `muteArcade` in `ui/settings.ts`). Kept in the (lazy-loaded) overlay rather than the core Settings panel so the entry bundle stays lean under the size budget. -Arcade blips (`open-arcade`, `arcade-eat`, `arcade-point`, `arcade-near`, +Arcade blips (`open-arcade`, `arcade-eat`, `arcade-bonus`, `arcade-milestone`, +`arcade-highscore`, `arcade-snake-over`, `arcade-point`, `arcade-near`, `arcade-flap`, `arcade-over`, `arcade-best`, and the merge-drop `arcade-drop`/`arcade-merge`/`arcade-nova`) route on that channel in the pure `soundMixer` `EVENT_SOUNDS` table, so a player can quiet a noisy game @@ -594,18 +595,15 @@ per-game volume would mean threading the active game through the global bridge f marginal value β€” arcade-wide is the clean seam and still honours the pure-mixer rule (games stay audio-agnostic; the mixer decides the blip). -**Per-run options + juice (issue #163).** Snake's cabinet carries a **speed** -picker (chill / normal / fast) and a **level** picker; the header carries a -**screen-shake** toggle. All three persist through the shared settings store -(`snakeSpeed`, `snakeLevel`, `arcadeShake` in `ui/settings.ts` β€” imported there -**type-only** so no arcade code reaches the entry chunk). Picking an option -**bumps the run seed**, which remounts the game component; a renderer therefore -reads its options exactly once per run and a mid-run change can never reshape the -board under the player. Death shows a card with a one-key restart (Space / Enter / -R). Screen shake is suppressed by the shake toggle **and** by the global -reduced-motion preference, like every other decorative motion in the app. Both -canvas renderers subscribe through `useReducedMotion()`, -so an OS or in-app preference change applies to the active run without a restart. +**Snake's faithful port.** Snake keeps the owner-approved white board: a local +difficulty menu (easy / normal / hard), bonus food, crisp fixed-size cells, +instant steering, and interpolation glide between reducer ticks. Its difficulty +is stored inside the lazy game surface, not in the shared HUD settings. The +header's **screen-shake** toggle remains shared by the other cabinets; the white +Snake renderer accepts that live prop but intentionally has no shake layer. +Death reports immediately so the overlay can submit the score and use the +server's authoritative `newBest` result while its existing terminal hold shows +the feedback card. **Architecture β€” pure rules, thin renderers, audio-agnostic:** @@ -615,32 +613,16 @@ so an OS or in-app preference change applies to the active run without a restart serializable `rngSeed` in each state, so *a given seed + input script always reproduces a run* (asserted by determinism tests). Each module lands with its vitest file in the same commit. -- **Level layouts and speed tiers are DATA in the rules module**, not renderer - constants: `SNAKE_LEVELS` parses ASCII maps (`#` wall, `>` start) into - `{ width, height, walls, start }` at module load and `SNAKE_SPEEDS` holds the - tick cadence per tier. The reducer is level-agnostic β€” a wall cell is as lethal - as the board edge, food never spawns on one, and a win means filling every - *non-wall* cell. **Adding a level is adding rows to `SNAKE_LEVELS`**; the level - tests then check its geometry, its start, and that every one of its wall cells - kills, automatically. -- **Juice is a pure reducer too** (`juice.ts`): particles, score pop-ups and - screen-shake energy, with every random draw threaded through its own `rngSeed` - β€” never `Math.random` β€” so the feedback is as reproducible as the game. It is - seeded from the run seed but keeps a *separate* stream, so adding or removing a - particle burst can never perturb the game's own randomness. `feedback.ts` holds - the read-only near-miss predicates; nothing there is consulted by a reducer. +- **Snake feedback is split into draw-only helpers** under + `src/ui/arcade/snake/`: white grid, food/bonus animation, death FX, + interpolation, and rounded body drawing. The reducer remains the only source + of gameplay decisions. - **Renderers** are thin React components (`src/ui/arcade/`): one per game, they own a canvas/DOM surface, run the module's tick/reduce on a loop, draw the returned state, and report score/game-over upward. No game *rules* in a renderer or the scene. The overlay (`ArcadeOverlay.tsx`) and its game modules are **lazy-loaded** β€” a separate chunk, so no cabinet's code ever bloats the - entry bundle (only the tiny `ARCADE_GAMES` registry entry is shared). Snake draws from a 16-tile sprite sheet - (`public/assets/arcade/snake_tiles.png`, generated by - `scripts/gen_snake_sprites.py`); which tile a segment uses is geometry, so it - lives in the pure `snakeSprites.ts` and the renderer only blits the index. The - sheet is loaded by the overlay canvas with a plain `Image`, **not** through - BootScene β€” it is not a Phaser world texture, and registering it there would - make every player download it whether or not they ever open a cabinet. + entry bundle (only the tiny `ARCADE_GAMES` registry entry is shared). Flappy's drawing code is split its art into small draw-only modules beside it β€” Flappy's live in `src/ui/arcade/flappy/` (`scenery`, `pipes`, `bird`, `hud`, `fx`, `render`), diff --git a/frontend/public/assets/arcade/snake_tiles.png b/frontend/public/assets/arcade/snake_tiles.png deleted file mode 100644 index bf4c8a4..0000000 Binary files a/frontend/public/assets/arcade/snake_tiles.png and /dev/null differ diff --git a/frontend/scripts/gen_snake_sprites.py b/frontend/scripts/gen_snake_sprites.py deleted file mode 100644 index 50254d6..0000000 --- a/frontend/scripts/gen_snake_sprites.py +++ /dev/null @@ -1,236 +0,0 @@ -#!/usr/bin/env python3 -"""Generate the Snake mini-game sprite sheet (issue #163). - -Replaces the flat rounded rectangles the Snake renderer used to draw with real -16x16 pixel-art tiles, in the same cool top-down palette as the arcade cabinets -(scripts/gen_arcade_sprites.py) so the mini-game reads as part of the locked -Pipoya-compatible art direction rather than a canvas doodle. - -Output: ../public/assets/arcade/snake_tiles.png β€” ONE row of 16 tiles: - - 0 head->right 1 head->down 2 head->left 3 head->up - 4 body horizontal 5 body vertical - 6 bend up+right 7 bend right+down 8 bend down+left 9 bend left+up - 10 tail->right 11 tail->down 12 tail->left 13 tail->up - 14 food (apple) 15 wall block - -The tile order is the contract consumed by src/game/arcade/snakeSprites.ts β€” -change one and you must change the other (snakeSprites.test.ts pins the indices). - -Method: every tile is described as a boolean pixel MASK; the outline is then -derived (a mask pixel with an in-tile neighbour outside the mask). Pixels beyond -the tile border count as "inside", so a band that continues into the neighbouring -tile is not outlined there and long bodies read as one continuous snake. - -Requires: Pillow. Run: python3 scripts/gen_snake_sprites.py -""" - -import os - -from PIL import Image - -HERE = os.path.dirname(os.path.abspath(__file__)) -OUT = os.path.join(HERE, "..", "public", "assets", "arcade") - -T = 16 # tile size -N = 16 # tile count - -CLEAR = (0, 0, 0, 0) -OUTLINE = (16, 20, 31, 255) # #10141f β€” same outline ink as the cabinets -BODY = (70, 168, 146, 255) # mid green-teal -BODY_HI = (140, 225, 200, 255) # lit top/left edge -BODY_LO = (44, 112, 100, 255) # shaded bottom/right edge + scales -HEAD = (143, 240, 214, 255) # #8ff0d6 β€” the existing head colour -HEAD_HI = (200, 252, 236, 255) -HEAD_LO = (104, 200, 176, 255) -EYE = (16, 20, 31, 255) -EYE_LIGHT = (232, 236, 245, 255) -APPLE = (224, 86, 122, 255) # #e0567a β€” the existing pellet colour -APPLE_HI = (247, 160, 182, 255) -LEAF = (110, 200, 180, 255) -WALL = (58, 71, 103, 255) # #3a4767 β€” cool office furniture body -WALL_HI = (86, 102, 143, 255) -WALL_LO = (43, 53, 80, 255) - -Mask = set[tuple[int, int]] - - -def band_h(y0: int, y1: int, x0: int = 0, x1: int = T - 1) -> Mask: - return {(x, y) for y in range(y0, y1 + 1) for x in range(x0, x1 + 1)} - - -def band_v(x0: int, x1: int, y0: int = 0, y1: int = T - 1) -> Mask: - return {(x, y) for x in range(x0, x1 + 1) for y in range(y0, y1 + 1)} - - -def disc(cx: float, cy: float, r: float) -> Mask: - return { - (x, y) - for y in range(T) - for x in range(T) - if (x + 0.5 - cx) ** 2 + (y + 0.5 - cy) ** 2 <= r * r - } - - -def rotate(mask: Mask, quarter_turns: int) -> Mask: - """Rotate a mask clockwise by 90 degrees, `quarter_turns` times.""" - out = set(mask) - for _ in range(quarter_turns % 4): - out = {(T - 1 - y, x) for (x, y) in out} - return out - - -def paint(mask: Mask, base, hi, lo, scales: bool) -> Image.Image: - """Render a mask: derived outline, lit top/left edge, shaded bottom/right. - - Pixels beyond the tile border count as "inside the mask" for the outline - test (so bands join seamlessly across tiles) but as "not open" for the - shading tests (so a join never gains a bright or dark seam). - """ - - def open_at(x: int, y: int) -> bool: - return 0 <= x < T and 0 <= y < T and (x, y) not in mask - - im = Image.new("RGBA", (T, T), CLEAR) - for (x, y) in mask: - if any(open_at(nx, ny) for (nx, ny) in ((x + 1, y), (x - 1, y), (x, y + 1), (x, y - 1))): - im.putpixel((x, y), OUTLINE) - continue - if open_at(x, y - 2) or open_at(x - 2, y): - colour = hi - elif open_at(x, y + 2) or open_at(x + 2, y): - colour = lo - elif scales and (x + y) % 5 == 0 and (x * 3 + y) % 4 == 0: - colour = lo - else: - colour = base - im.putpixel((x, y), colour) - return im - - -# The body band is 10px of a 16px tile, leaving a 3px gutter each side so the -# grid still reads through a coiled snake. -BAND_LO, BAND_HI = 3, 12 - - -def body_horizontal() -> Image.Image: - return paint(band_h(BAND_LO, BAND_HI), BODY, BODY_HI, BODY_LO, True) - - -def body_vertical() -> Image.Image: - return paint(band_v(BAND_LO, BAND_HI), BODY, BODY_HI, BODY_LO, True) - - -def bend_right_down() -> Image.Image: - """Connects the RIGHT edge and the BOTTOM edge (tile 7).""" - mask = band_h(BAND_LO, BAND_HI, x0=BAND_LO) | band_v(BAND_LO, BAND_HI, y0=BAND_LO) - # Round the outer elbow so the corner does not read as a hard block. - mask -= {(BAND_LO, BAND_LO), (BAND_LO + 1, BAND_LO), (BAND_LO, BAND_LO + 1)} - return paint(mask, BODY, BODY_HI, BODY_LO, True) - - -def head_right() -> Image.Image: - """Head facing RIGHT: neck enters from the left edge (tile 0).""" - # Neck band up to x=8, then a rounded snout that stops short of x=15 so the - # head is visibly capped rather than running off the tile. - mask = band_h(BAND_LO, BAND_HI, x0=0, x1=8) | disc(9.5, 8.0, 5.0) - im = paint(mask, HEAD, HEAD_HI, HEAD_LO, False) - # Eyes: 2x2 dark pupils with a single light glint each. - for top in (5, 9): - for (ex, ey) in ((10, top), (11, top), (10, top + 1), (11, top + 1)): - im.putpixel((ex, ey), EYE) - im.putpixel((11, top), EYE_LIGHT) - # Nostril dots on the snout. - im.putpixel((13, 7), OUTLINE) - im.putpixel((13, 9), OUTLINE) - return im - - -def tail_right() -> Image.Image: - """Tail whose body continues to the RIGHT; the tip tapers left (tile 10).""" - mask: Mask = set() - half_max = (BAND_HI - BAND_LO + 1) / 2 # 5.0 β€” matches the body band - for x in range(T): - t = x / (T - 1) - half = 1.5 + (half_max - 1.5) * t - top = int(round(8.0 - half)) - bottom = int(round(7.0 + half)) - for y in range(max(0, top), min(T - 1, bottom) + 1): - mask.add((x, y)) - return paint(mask, BODY, BODY_HI, BODY_LO, True) - - -def food() -> Image.Image: - im = Image.new("RGBA", (T, T), CLEAR) - mask = disc(8.0, 9.0, 5.2) - for (x, y) in mask: - neighbours = ((x + 1, y), (x - 1, y), (x, y + 1), (x, y - 1)) - edge = any((nx, ny) not in mask for (nx, ny) in neighbours) - im.putpixel((x, y), OUTLINE if edge else APPLE) - # Specular highlight + stem + leaf. - for p in ((6, 6), (7, 6), (6, 7)): - im.putpixel(p, APPLE_HI) - im.putpixel((8, 3), OUTLINE) - im.putpixel((8, 2), OUTLINE) - for p in ((9, 2), (10, 2), (9, 1)): - im.putpixel(p, LEAF) - return im - - -def wall() -> Image.Image: - im = Image.new("RGBA", (T, T), CLEAR) - for y in range(T): - for x in range(T): - im.putpixel((x, y), WALL) - # Bevel: lit top/left, shaded bottom/right, dark border. - for i in range(T): - im.putpixel((i, 0), WALL_HI) - im.putpixel((0, i), WALL_HI) - im.putpixel((i, T - 1), OUTLINE) - im.putpixel((T - 1, i), OUTLINE) - im.putpixel((i, T - 2), WALL_LO) - im.putpixel((T - 2, i), WALL_LO) - # Brick seam so a long wall run does not read as one flat slab. - for x in range(2, T - 2): - im.putpixel((x, 8), WALL_LO) - for y in range(9, T - 2): - im.putpixel((8, y), WALL_LO) - return im - - -def main() -> None: - os.makedirs(OUT, exist_ok=True) - head_r = head_right() - tail_r = tail_right() - bend_rd = bend_right_down() - - tiles: list[Image.Image] = [ - head_r, # 0 head -> right - head_r.rotate(-90), # 1 head -> down - head_r.rotate(180), # 2 head -> left - head_r.rotate(90), # 3 head -> up - body_horizontal(), # 4 - body_vertical(), # 5 - bend_rd.rotate(90), # 6 bend up+right - bend_rd, # 7 bend right+down - bend_rd.rotate(-90), # 8 bend down+left - bend_rd.rotate(180), # 9 bend left+up - tail_r, # 10 tail -> right - tail_r.rotate(-90), # 11 tail -> down - tail_r.rotate(180), # 12 tail -> left - tail_r.rotate(90), # 13 tail -> up - food(), # 14 - wall(), # 15 - ] - assert len(tiles) == N, f"expected {N} tiles, built {len(tiles)}" - - sheet = Image.new("RGBA", (T * N, T), CLEAR) - for i, tile in enumerate(tiles): - sheet.paste(tile, (i * T, 0)) - path = os.path.join(OUT, "snake_tiles.png") - sheet.save(path) - print(f"wrote {path} ({sheet.width}x{sheet.height}, {os.path.getsize(path)} bytes)") - - -if __name__ == "__main__": - main() diff --git a/frontend/src/game/arcade/feedback.test.ts b/frontend/src/game/arcade/feedback.test.ts index becf537..25e42a1 100644 --- a/frontend/src/game/arcade/feedback.test.ts +++ b/frontend/src/game/arcade/feedback.test.ts @@ -3,111 +3,8 @@ import { FLAPPY_NEAR_MISS_PX, flappyGapClearance, flappyNearMiss, - snakeHeadPressure, - snakeNearMiss, } from "./feedback"; import { initFlappy, type FlappyState } from "./flappy"; -import type { SnakeState } from "./snake"; - -function snake(partial: Partial): SnakeState { - return { - width: 10, - height: 10, - body: [ - { x: 5, y: 5 }, - { x: 4, y: 5 }, - { x: 3, y: 5 }, - ], - dir: "right", - pendingDir: null, - food: { x: 9, y: 9 }, - walls: [], - levelId: "open", - alive: true, - won: false, - score: 0, - rngSeed: 1, - ...partial, - }; -} - -describe("snakeHeadPressure", () => { - const cases: Array<{ name: string; state: SnakeState; pressure: number }> = [ - { name: "open floor", state: snake({}), pressure: 0 }, - { - name: "hugging the top edge", - state: snake({ body: [{ x: 5, y: 0 }, { x: 4, y: 0 }, { x: 3, y: 0 }] }), - pressure: 1, - }, - { - name: "in a top-left corner", - state: snake({ body: [{ x: 0, y: 0 }, { x: 0, y: 1 }, { x: 0, y: 2 }], dir: "up" }), - pressure: 2, - }, - { - name: "threading a one-cell wall gap", - state: snake({ - walls: [ - { x: 5, y: 4 }, - { x: 5, y: 6 }, - ], - body: [{ x: 5, y: 5 }, { x: 4, y: 5 }, { x: 3, y: 5 }], - }), - pressure: 2, - }, - { - name: "boxed in on three sides", - state: snake({ - walls: [ - { x: 5, y: 4 }, - { x: 5, y: 6 }, - { x: 6, y: 5 }, - ], - body: [{ x: 5, y: 5 }, { x: 4, y: 5 }, { x: 3, y: 5 }], - }), - pressure: 3, - }, - ]; - for (const { name, state, pressure } of cases) { - it(`${name} β‡’ pressure ${pressure}`, () => { - expect(snakeHeadPressure(state)).toBe(pressure); - expect(snakeNearMiss(state)).toBe(pressure >= 2); - }); - } - - it("ignores the vacating tail cell", () => { - // Head at (5,5); the tail sits directly above but moves away next tick. - const s = snake({ - body: [ - { x: 5, y: 5 }, - { x: 4, y: 5 }, - { x: 4, y: 4 }, - { x: 5, y: 4 }, - ], - }); - expect(snakeHeadPressure(s)).toBe(0); - }); - - it("counts a non-tail body segment beside the head", () => { - const s = snake({ - body: [ - { x: 5, y: 5 }, - { x: 4, y: 5 }, - { x: 4, y: 4 }, - { x: 5, y: 4 }, - { x: 6, y: 4 }, - { x: 6, y: 6 }, - ], - }); - // (5,4) is now a mid-body cell that stays put β‡’ pressure 1. - expect(snakeHeadPressure(s)).toBe(1); - }); - - it("is 0 for terminal states", () => { - expect(snakeHeadPressure(snake({ alive: false }))).toBe(0); - expect(snakeHeadPressure(snake({ won: true }))).toBe(0); - }); -}); function flappy(partial: Partial): FlappyState { return { ...initFlappy(1), phase: "play", ...partial }; diff --git a/frontend/src/game/arcade/feedback.ts b/frontend/src/game/arcade/feedback.ts index 941cdb1..977e21a 100644 --- a/frontend/src/game/arcade/feedback.ts +++ b/frontend/src/game/arcade/feedback.ts @@ -4,51 +4,10 @@ * These decide WHICH feedback beat has just happened (a near miss); the * renderer only acts on the answer. It is a READ-ONLY view over existing * state: nothing here changes a rule or is consulted by a reducer. Flappy's - * current renderer owns its richer pipe/impact feedback beside its draw code. + * renderer owns its richer pipe/impact feedback beside its draw code. */ -import { isBlockedCell, type SnakeState } from "./snake"; import type { FlappyState } from "./flappy"; -/** - * How many of the head's forward/side neighbours are lethal right now β€” a wall, - * the board edge, or a body segment that will still be there next tick (the tail - * vacates, so it does not count). The cell behind the head (the neck) is - * excluded: it is always occupied and can never be steered into anyway. - * - * Range 0..3. Returns 0 for a terminal state. - */ -export function snakeHeadPressure(state: SnakeState): number { - if (!state.alive || state.won) return 0; - const head = state.body[0]; - const neck = state.body[1]; - if (!head) return 0; - // Everything except the tail: the tail cell is vacated on the next tick. - const solidBody = state.body.slice(0, -1); - const neighbours = [ - { x: head.x + 1, y: head.y }, - { x: head.x - 1, y: head.y }, - { x: head.x, y: head.y + 1 }, - { x: head.x, y: head.y - 1 }, - ]; - let pressure = 0; - for (const n of neighbours) { - if (neck && n.x === neck.x && n.y === neck.y) continue; - if (isBlockedCell(state, n) || solidBody.some((c) => c.x === n.x && c.y === n.y)) { - pressure++; - } - } - return pressure; -} - -/** - * A near miss is surviving a tick with at least two of the three steerable - * neighbours lethal β€” i.e. the snake threaded a gap rather than crossed open - * floor. - */ -export function snakeNearMiss(state: SnakeState): boolean { - return snakeHeadPressure(state) >= 2; -} - /** World-unit clearance below which a living bird counts as a near miss. */ export const FLAPPY_NEAR_MISS_PX = 10; diff --git a/frontend/src/game/arcade/snake.test.ts b/frontend/src/game/arcade/snake.test.ts index 2b95aba..5a14446 100644 --- a/frontend/src/game/arcade/snake.test.ts +++ b/frontend/src/game/arcade/snake.test.ts @@ -1,17 +1,29 @@ import { describe, it, expect } from "vitest"; import { - DEFAULT_SNAKE_LEVEL, - SNAKE_LEVELS, - SNAKE_SPEEDS, initSnake, - isBlockedCell, - parseSnakeLevel, snakeInput, - snakeLevelById, - snakeSpeedById, snakeTick, + snakeFrame, + speedForScore, + bonusValueFromRemaining, + bonusSizeFromRemaining, + DIFFICULTIES, + DIR_QUEUE_MAX, + FOOD_SCORE, + BONUS_LIFETIME_MS, + BONUS_LIFETIME_TICKS, + BONUS_TICK_DECAY_MS, + BONUS_MAX_VALUE, + BONUS_MIN_VALUE, + BONUS_MAX_SIZE, + BONUS_MIN_SIZE, + BONUS_FREQUENCY, + SPEED_CAP, + SPEED_STEP_POINTS, + DEFAULT_SNAKE_WIDTH, + DEFAULT_SNAKE_HEIGHT, type Dir, - type SnakeLevel, + type Difficulty, type SnakeState, } from "./snake"; @@ -26,214 +38,149 @@ function state(partial: Partial): SnakeState { { x: 3, y: 5 }, ], dir: "right", - pendingDir: null, + dirQueue: [], food: { x: 9, y: 9 }, - walls: [], - levelId: "open", + bonus: null, + bonusTickCounter: 0, alive: true, won: false, score: 0, + speed: DIFFICULTIES.normal.initialSpeed, + difficulty: "normal", rngSeed: 1, ...partial, }; } -/** A bare level for tests that only care about dimensions. */ -function level(width: number, height: number, walls: SnakeLevel["walls"] = []): SnakeLevel { - return { - id: "open", - name: "test", - hint: "test", - width, - height, - walls, - start: { x: Math.floor(width / 2), y: Math.floor(height / 2) }, - }; +function tick(s: SnakeState) { + return snakeTick(s); } describe("initSnake", () => { it("starts alive, length 3, heading right, with food off the body", () => { - const s = initSnake(1, level(12, 10)); + const s = initSnake(1, { width: 12, height: 10 }); expect(s.alive).toBe(true); expect(s.won).toBe(false); expect(s.body).toHaveLength(3); expect(s.dir).toBe("right"); - expect(s.pendingDir).toBeNull(); + expect(s.dirQueue).toEqual([]); expect(s.score).toBe(0); + expect(s.bonus).toBeNull(); expect(s.body.some((c) => c.x === s.food.x && c.y === s.food.y)).toBe(false); }); - it("defaults to the Open Field level", () => { - const s = initSnake(1); - expect(s.levelId).toBe(DEFAULT_SNAKE_LEVEL.id); - expect(s.walls).toHaveLength(0); - expect(s.width).toBe(DEFAULT_SNAKE_LEVEL.width); - expect(s.height).toBe(DEFAULT_SNAKE_LEVEL.height); - }); - - it("never places food on a wall cell", () => { - // Every seed must land on floor, so sweep a range rather than one draw. - const vault = snakeLevelById("vault"); - for (let seed = 1; seed <= 60; seed++) { - const s = initSnake(seed, vault); - expect(s.walls.some((w) => w.x === s.food.x && w.y === s.food.y)).toBe(false); + it("applies difficulty initial speed", () => { + for (const d of ["easy", "normal", "hard"] as const) { + const s = initSnake(1, { difficulty: d, width: 12, height: 10 }); + expect(s.difficulty).toBe(d); + expect(s.speed).toBe(DIFFICULTIES[d].initialSpeed); } }); -}); - -describe("snake levels (data)", () => { - it("ships at least three distinct designed levels", () => { - expect(SNAKE_LEVELS.length).toBeGreaterThanOrEqual(3); - const ids = SNAKE_LEVELS.map((l) => l.id); - expect(new Set(ids).size).toBe(ids.length); - // "distinct obstacle layouts" β€” every level past the first has walls, and - // no two levels share the same wall set. - const layouts = SNAKE_LEVELS.map((l) => - JSON.stringify([...l.walls].map((w) => `${w.x},${w.y}`).sort()) - ); - expect(new Set(layouts).size).toBe(layouts.length); - expect(SNAKE_LEVELS.filter((l) => l.walls.length > 0).length).toBeGreaterThanOrEqual(3); - }); - - for (const l of SNAKE_LEVELS) { - describe(`level "${l.id}"`, () => { - it("is a rectangle with in-bounds walls", () => { - expect(l.width).toBeGreaterThan(0); - expect(l.height).toBeGreaterThan(0); - for (const w of l.walls) { - expect(w.x).toBeGreaterThanOrEqual(0); - expect(w.y).toBeGreaterThanOrEqual(0); - expect(w.x).toBeLessThan(l.width); - expect(w.y).toBeLessThan(l.height); - } - }); - it("has a start with three free cells and room to move", () => { - const cells = [ - l.start, - { x: l.start.x - 1, y: l.start.y }, - { x: l.start.x - 2, y: l.start.y }, - ]; - for (const c of cells) { - expect(c.x).toBeGreaterThanOrEqual(0); - expect(l.walls.some((w) => w.x === c.x && w.y === c.y)).toBe(false); - } - // The very first tick must not be an instant death. - const s = snakeTick(initSnake(9, l)); - expect(s.alive).toBe(true); - }); + it("defaults to normal difficulty and the fallback board", () => { + const s = initSnake(42); + expect(s.difficulty).toBe("normal"); + expect(s.width).toBe(DEFAULT_SNAKE_WIDTH); + expect(s.height).toBe(DEFAULT_SNAKE_HEIGHT); + expect(s.speed).toBe(DIFFICULTIES.normal.initialSpeed); + }); - it("kills the snake on every one of its wall cells", () => { - for (const w of l.walls) { - // Approach the wall from the left when possible, else from above. - const fromLeft = w.x > 0; - const head = fromLeft ? { x: w.x - 1, y: w.y } : { x: w.x, y: w.y - 1 }; - const dir: Dir = fromLeft ? "right" : "down"; - const s = state({ - width: l.width, - height: l.height, - walls: l.walls, - levelId: l.id, - dir, - body: [head], - // Keep food far away so this is a pure collision test. - food: { x: -1, y: -1 }, - }); - expect(snakeTick(s).alive).toBe(false); - } + // The renderer measures the stage and passes colsΓ—rows, so the reducer must + // be dimension-agnostic: any board size starts centred, in bounds, alive. + const sizes: Array<{ w: number; h: number }> = [ + { w: 20, h: 12 }, + { w: 34, h: 18 }, + { w: 96, h: 51 }, + { w: 5, h: 5 }, + ]; + for (const { w, h } of sizes) { + it(`starts centred and in bounds on a ${w}Γ—${h} board`, () => { + const s = initSnake(7, { width: w, height: h }); + expect(s.width).toBe(w); + expect(s.height).toBe(h); + expect(s.body[0]).toEqual({ + x: Math.floor(w / 2), + y: Math.floor(h / 2), }); + for (const c of [...s.body, s.food]) { + expect(c.x).toBeGreaterThanOrEqual(0); + expect(c.y).toBeGreaterThanOrEqual(0); + expect(c.x).toBeLessThan(w); + expect(c.y).toBeLessThan(h); + } + expect(s.alive).toBe(true); }); } - it("parses ASCII maps into walls + start", () => { - const l = parseSnakeLevel("pillars", "T", "t", ["..#..", "..>..", "#...#"]); - expect(l.width).toBe(5); - expect(l.height).toBe(3); - expect(l.start).toEqual({ x: 2, y: 1 }); - expect(l.walls).toEqual([ - { x: 2, y: 0 }, - { x: 0, y: 2 }, - { x: 4, y: 2 }, - ]); - }); - - // parseSnakeLevel validates its documented invariants instead of assuming - // them; the shipped levels parse at module load, so a malformed layout edit - // fails the suite immediately. - describe("parseSnakeLevel β€” malformed layouts throw", () => { - it("rejects an empty layout and an empty first row", () => { - expect(() => parseSnakeLevel("open", "T", "t", [])).toThrow(/non-empty row/); - expect(() => parseSnakeLevel("open", "T", "t", ["", ""])).toThrow(/non-empty row/); - }); - - it("rejects ragged (non-rectangular) rows", () => { - expect(() => parseSnakeLevel("open", "T", "t", ["..>..", "...."])).toThrow(/rectangular/); - expect(() => parseSnakeLevel("open", "T", "t", ["..>..", "......"])).toThrow(/rectangular/); - }); - - it("rejects a layout without a start marker", () => { - expect(() => parseSnakeLevel("open", "T", "t", [".....", "....."])).toThrow( - /missing the ">"/ - ); - }); - - it("rejects more than one start marker", () => { - expect(() => parseSnakeLevel("open", "T", "t", ["..>.>", "....."])).toThrow( - /more than one/ - ); - }); - - it("rejects a start with fewer than two cells to its left", () => { - expect(() => parseSnakeLevel("open", "T", "t", [">....", "....."])).toThrow( - /two cells to its left/ - ); - expect(() => parseSnakeLevel("open", "T", "t", [".>...", "....."])).toThrow( - /two cells to its left/ - ); - }); - - it("rejects walls where the starting body spawns", () => { - expect(() => parseSnakeLevel("open", "T", "t", ["#.>..", "....."])).toThrow(/floor/); - expect(() => parseSnakeLevel("open", "T", "t", [".#>..", "....."])).toThrow(/floor/); - }); - - it("accepts walls elsewhere on the start row", () => { - const l = parseSnakeLevel("open", "T", "t", ["..>.#", "....."]); - expect(l.start).toEqual({ x: 2, y: 0 }); - expect(l.walls).toEqual([{ x: 4, y: 0 }]); - }); - }); - - it("resolves level ids, falling back to Open Field for unknown ones", () => { - expect(snakeLevelById("vault").id).toBe("vault"); - expect(snakeLevelById("nope").id).toBe(DEFAULT_SNAKE_LEVEL.id); + it("clamps a degenerate board to the minimum the starting snake needs", () => { + const s = initSnake(1, { width: 1, height: 0 }); + expect(s.width).toBe(5); + expect(s.height).toBe(3); + expect(s.body).toHaveLength(3); + for (const c of s.body) { + expect(c.x).toBeGreaterThanOrEqual(0); + expect(c.y).toBeGreaterThanOrEqual(0); + } }); }); -describe("snake speeds (data)", () => { - it("offers three tiers, slowest first", () => { - expect(SNAKE_SPEEDS.map((s) => s.id)).toEqual(["chill", "normal", "fast"]); - const ms = SNAKE_SPEEDS.map((s) => s.tickMs); - expect(ms[0]).toBeGreaterThan(ms[1] ?? 0); - expect(ms[1]).toBeGreaterThan(ms[2] ?? 0); - }); +describe("difficulty tuning", () => { + const cases: Array<{ + difficulty: Difficulty; + initialSpeed: number; + speedIncrement: number; + bonusFrequency: number; + }> = [ + { difficulty: "easy", initialSpeed: 5, speedIncrement: 0.5, bonusFrequency: BONUS_FREQUENCY }, + { difficulty: "normal", initialSpeed: 7, speedIncrement: 1, bonusFrequency: BONUS_FREQUENCY }, + { difficulty: "hard", initialSpeed: 10, speedIncrement: 1.5, bonusFrequency: BONUS_FREQUENCY }, + ]; + for (const c of cases) { + it(`${c.difficulty}: initial ${c.initialSpeed}, step ${c.speedIncrement}, bonus every ${c.bonusFrequency}`, () => { + expect(DIFFICULTIES[c.difficulty]).toEqual({ + initialSpeed: c.initialSpeed, + speedIncrement: c.speedIncrement, + bonusFrequency: c.bonusFrequency, + }); + }); + } - it("resolves speed ids, falling back to Normal for unknown ones", () => { - expect(snakeSpeedById("chill").tickMs).toBe(180); - expect(snakeSpeedById("nope").id).toBe("normal"); + it("speed steps every 50 points and caps at 15", () => { + expect(speedForScore(0, "normal")).toBe(7); + expect(speedForScore(49, "normal")).toBe(7); + expect(speedForScore(50, "normal")).toBe(8); + expect(speedForScore(100, "normal")).toBe(9); + expect(speedForScore(0, "easy")).toBe(5); + expect(speedForScore(50, "easy")).toBe(5.5); + expect(speedForScore(0, "hard")).toBe(10); + expect(speedForScore(50, "hard")).toBe(11.5); + expect(speedForScore(200, "hard")).toBe(SPEED_CAP); + expect(speedForScore(1000, "easy")).toBe(SPEED_CAP); }); }); -describe("snakeInput β€” buffering", () => { +describe("snakeInput β€” 3-deep queue", () => { it("buffers the turn without changing the moved direction", () => { const s = snakeInput(state({}), "up"); - expect(s.pendingDir).toBe("up"); + expect(s.dirQueue).toEqual(["up"]); expect(s.dir).toBe("right"); }); - it("keeps only the latest input between ticks (1-deep queue)", () => { - const s = snakeInput(snakeInput(state({}), "up"), "down"); - expect(s.pendingDir).toBe("down"); + it("queues multiple inputs up to DIR_QUEUE_MAX", () => { + let s = state({}); + s = snakeInput(s, "up"); + s = snakeInput(s, "left"); + s = snakeInput(s, "down"); + expect(s.dirQueue).toEqual(["up", "left", "down"]); + expect(s.dirQueue).toHaveLength(DIR_QUEUE_MAX); + }); + + it("drops the oldest when the queue is full (keeps last 3)", () => { + let s = state({}); + for (const d of ["up", "left", "down", "right"] as Dir[]) { + s = snakeInput(s, d); + } + expect(s.dirQueue).toEqual(["left", "down", "right"]); }); it("ignores input on a dead or won snake", () => { @@ -244,23 +191,23 @@ describe("snakeInput β€” buffering", () => { }); }); -describe("snakeTick β€” turn resolution (validated against last moved dir)", () => { - it("applies a buffered perpendicular turn", () => { - const s = snakeTick(snakeInput(state({}), "up")); +describe("snakeTick β€” turn resolution (3-deep queue, validated at tick)", () => { + it("applies a buffered perpendicular turn and drains one queue entry", () => { + const { state: s, events } = tick(snakeInput(state({}), "up")); expect(s.body[0]).toEqual({ x: 5, y: 4 }); expect(s.dir).toBe("up"); - expect(s.pendingDir).toBeNull(); + expect(s.dirQueue).toEqual([]); + expect(events).toEqual([]); }); it("discards a buffered 180Β° reversal and keeps moving", () => { - const s = snakeTick(snakeInput(state({ dir: "right" }), "left")); + const { state: s } = tick(snakeInput(state({ dir: "right" }), "left")); expect(s.body[0]).toEqual({ x: 6, y: 5 }); expect(s.dir).toBe("right"); expect(s.alive).toBe(true); + expect(s.dirQueue).toEqual([]); }); - // The round-1 MAJOR: multiple inputs between two ticks must not smuggle in a - // reversal. Table of multi-input-per-tick sequences from each heading. const bodies: Record = { right: [ { x: 5, y: 5 }, @@ -283,51 +230,88 @@ describe("snakeTick β€” turn resolution (validated against last moved dir)", () { x: 5, y: 3 }, ], }; - const sequences: Array<{ moving: Dir; inputs: Dir[]; expectDir: Dir }> = [ - // up-then-left while moving right: final input reverses β†’ discarded. - { moving: "right", inputs: ["up", "left"], expectDir: "right" }, - { moving: "right", inputs: ["down", "left"], expectDir: "right" }, - { moving: "left", inputs: ["up", "right"], expectDir: "left" }, - { moving: "up", inputs: ["left", "down"], expectDir: "up" }, - { moving: "down", inputs: ["right", "up"], expectDir: "down" }, - // a legal final input still applies after an earlier (discarded) one. - { moving: "right", inputs: ["left", "up"], expectDir: "up" }, - { moving: "up", inputs: ["down", "left"], expectDir: "left" }, - // three inputs, last one legal. - { moving: "right", inputs: ["up", "left", "down"], expectDir: "down" }, + + const oneTick: Array<{ + moving: Dir; + inputs: Dir[]; + expectDir: Dir; + expectQueue: Dir[]; + }> = [ + { moving: "right", inputs: ["up", "left"], expectDir: "up", expectQueue: ["left"] }, + { moving: "right", inputs: ["down", "left"], expectDir: "down", expectQueue: ["left"] }, + { moving: "right", inputs: ["left", "up"], expectDir: "right", expectQueue: ["up"] }, + { moving: "left", inputs: ["right", "up"], expectDir: "left", expectQueue: ["up"] }, + { moving: "up", inputs: ["down", "left"], expectDir: "up", expectQueue: ["left"] }, + { moving: "down", inputs: ["up", "right"], expectDir: "down", expectQueue: ["right"] }, + { + moving: "right", + inputs: ["up", "left", "down"], + expectDir: "up", + expectQueue: ["left", "down"], + }, ]; - for (const { moving, inputs, expectDir } of sequences) { - it(`moving ${moving}, inputs [${inputs.join(", ")}] in one tick β‡’ moves ${expectDir}, stays alive`, () => { - // Body straight along the moving axis so a folded reversal would collide. + for (const { moving, inputs, expectDir, expectQueue } of oneTick) { + it(`moving ${moving}, queue [${inputs.join(", ")}] β‡’ one tick moves ${expectDir}`, () => { let s = state({ dir: moving, body: bodies[moving] }); for (const d of inputs) s = snakeInput(s, d); - const next = snakeTick(s); + const { state: next } = tick(s); expect(next.alive).toBe(true); expect(next.dir).toBe(expectDir); + expect(next.dirQueue).toEqual(expectQueue); }); } + + it("drains a legal follow-up turn on the next tick", () => { + let s = state({ + dir: "right", + body: bodies.right, + food: { x: 0, y: 0 }, + }); + s = snakeInput(s, "up"); + s = snakeInput(s, "left"); + s = tick(s).state; + expect(s.dir).toBe("up"); + s = tick(s).state; + expect(s.dir).toBe("left"); + expect(s.alive).toBe(true); + expect(s.dirQueue).toEqual([]); + }); + + it("cannot reverse 180 within a single tick via a multi-step queue", () => { + let s = state({ dir: "right", body: bodies.right }); + s = snakeInput(s, "up"); + s = snakeInput(s, "left"); + const { state: next } = tick(s); + expect(next.dir).toBe("up"); + expect(next.dir).not.toBe("left"); + }); }); describe("snakeTick β€” movement", () => { it("slides forward, keeping length (tail removed)", () => { - const s = snakeTick(state({})); + const { state: s, events } = tick(state({})); expect(s.body[0]).toEqual({ x: 6, y: 5 }); expect(s.body).toHaveLength(3); expect(s.alive).toBe(true); + expect(events).toEqual([]); }); it("a dead snake is an idempotent no-op", () => { const dead = state({ alive: false }); - expect(snakeTick(dead)).toBe(dead); + const step = tick(dead); + expect(step.state).toBe(dead); + expect(step.events).toEqual([]); }); it("a won snake is an idempotent no-op", () => { const won = state({ won: true }); - expect(snakeTick(won)).toBe(won); + const step = tick(won); + expect(step.state).toBe(won); + expect(step.events).toEqual([]); }); }); -describe("snakeTick β€” board edges", () => { +describe("snakeTick β€” walls + die event", () => { const cases: Array<{ dir: Dir; body: SnakeState["body"] }> = [ { dir: "right", body: [{ x: 9, y: 5 }] }, { dir: "left", body: [{ x: 0, y: 5 }] }, @@ -335,198 +319,553 @@ describe("snakeTick β€” board edges", () => { { dir: "down", body: [{ x: 5, y: 9 }] }, ]; for (const { dir, body } of cases) { - it(`dies moving ${dir} off the edge`, () => { - const s = snakeTick(state({ dir, body })); + it(`dies moving ${dir} off the edge and emits die`, () => { + const { state: s, events } = tick(state({ dir, body })); expect(s.alive).toBe(false); + expect(events).toEqual(["die"]); }); } }); -describe("snakeTick β€” level walls", () => { - const walls = [{ x: 6, y: 5 }]; - const cases: Array<{ dir: Dir; body: SnakeState["body"]; dies: boolean }> = [ - { dir: "right", body: [{ x: 5, y: 5 }], dies: true }, - { dir: "left", body: [{ x: 7, y: 5 }], dies: true }, - { dir: "up", body: [{ x: 6, y: 6 }], dies: true }, - { dir: "down", body: [{ x: 6, y: 4 }], dies: true }, - // Sliding past the wall on the adjacent lane is safe. - { dir: "right", body: [{ x: 5, y: 4 }], dies: false }, - ]; - for (const { dir, body, dies } of cases) { - it(`moving ${dir} from (${body[0]?.x},${body[0]?.y}) ${dies ? "dies" : "survives"}`, () => { - const s = snakeTick(state({ dir, body, walls })); - expect(s.alive).toBe(!dies); - }); - } +describe("snakeTick β€” self collision", () => { + it("dies when the head runs into the body", () => { + const { state: s, events } = tick( + state({ + dir: "right", + body: [ + { x: 5, y: 5 }, + { x: 5, y: 4 }, + { x: 6, y: 4 }, + { x: 6, y: 5 }, + { x: 6, y: 6 }, + { x: 5, y: 6 }, + ], + }) + ); + expect(s.alive).toBe(false); + expect(events).toEqual(["die"]); + }); - it("a wall kill does not grow, score, or move the snake into the wall", () => { - const before = state({ dir: "right", body: [{ x: 5, y: 5 }], walls }); - const after = snakeTick(before); - expect(after.alive).toBe(false); - expect(after.score).toBe(0); - expect(after.body).toEqual(before.body); - }); - - it("food is never respawned onto a wall after eating", () => { - // 3x3 board walled everywhere except a 1x3 lane, so a bad implementation - // would be forced onto a wall cell. - const laneWalls = [ - { x: 0, y: 0 }, - { x: 1, y: 0 }, - { x: 2, y: 0 }, - { x: 0, y: 2 }, - { x: 1, y: 2 }, - { x: 2, y: 2 }, - ]; - const s = state({ - width: 3, - height: 3, - walls: laneWalls, - dir: "right", - body: [{ x: 0, y: 1 }], - food: { x: 1, y: 1 }, - }); - const next = snakeTick(s); - expect(next.score).toBe(1); - expect(laneWalls.some((w) => w.x === next.food.x && w.y === next.food.y)).toBe(false); + it("moving into the vacating tail cell is legal", () => { + const { state: s } = tick( + snakeInput( + state({ + dir: "up", + body: [ + { x: 5, y: 5 }, + { x: 6, y: 5 }, + { x: 6, y: 4 }, + { x: 5, y: 4 }, + ], + }), + "up" + ) + ); + expect(s.alive).toBe(true); }); }); -describe("snakeTick β€” self collision", () => { - it("dies when the head runs into the body", () => { - // Coiled snake; moving right drives the head into a mid-body cell (6,5), - // which is not the vacating tail (5,6). +describe("snakeTick β€” eating + scoring + events", () => { + it("grows, scores +10, emits eat, then respawns food", () => { + const { state: next, events } = tick( + state({ + dir: "right", + body: [{ x: 4, y: 5 }], + food: { x: 5, y: 5 }, + }) + ); + expect(next.alive).toBe(true); + expect(next.won).toBe(false); + expect(next.score).toBe(FOOD_SCORE); + expect(next.body).toHaveLength(2); + expect(next.body[0]).toEqual({ x: 5, y: 5 }); + expect(next.food).not.toEqual({ x: 5, y: 5 }); + expect(events).toEqual(["eat"]); + }); + + it("emits milestone instead of eat when score hits a multiple of 100", () => { + const { state: next, events } = tick( + state({ + dir: "right", + body: [{ x: 4, y: 5 }], + food: { x: 5, y: 5 }, + score: 90, + }) + ); + expect(next.score).toBe(100); + expect(events).toEqual(["milestone"]); + }); + + it("bumps speed every 50 points on normal food, capped", () => { + const { state: next } = tick( + state({ + dir: "right", + body: [{ x: 4, y: 5 }], + food: { x: 5, y: 5 }, + score: SPEED_STEP_POINTS - FOOD_SCORE, + speed: DIFFICULTIES.normal.initialSpeed, + difficulty: "normal", + }) + ); + expect(next.score).toBe(SPEED_STEP_POINTS); + expect(next.speed).toBe( + DIFFICULTIES.normal.initialSpeed + DIFFICULTIES.normal.speedIncrement + ); + }); + + it("hard difficulty speed steps use 1.5 increment", () => { + const { state: next } = tick( + state({ + dir: "right", + body: [{ x: 4, y: 5 }], + food: { x: 5, y: 5 }, + score: 40, + speed: 10, + difficulty: "hard", + }) + ); + expect(next.speed).toBe(11.5); + }); + + it("filling the whole board is a terminal win with win event", () => { + const { state: wonState, events } = tick( + state({ + width: 2, + height: 2, + dir: "down", + body: [ + { x: 0, y: 0 }, + { x: 1, y: 0 }, + { x: 1, y: 1 }, + ], + food: { x: 0, y: 1 }, + }) + ); + expect(wonState.won).toBe(true); + expect(wonState.alive).toBe(true); + expect(wonState.score).toBe(FOOD_SCORE); + expect(wonState.body).toHaveLength(4); + expect(events).toContain("eat"); + expect(events).toContain("win"); + expect(tick(wonState).state).toBe(wonState); + expect(snakeInput(wonState, "left")).toBe(wonState); + }); + + it("eating bonus emits bonus, grows, despawns (no speed step)", () => { + const { state: next, events } = tick( + state({ + dir: "right", + body: [{ x: 4, y: 5 }], + food: { x: 9, y: 9 }, + bonus: { + cell: { x: 5, y: 5 }, + remainingMs: 5_000, + value: 60, + size: 20, + }, + score: 40, + speed: 7, + difficulty: "normal", + }) + ); + expect(next.score).toBe(100); + expect(next.bonus).toBeNull(); + expect(next.body).toHaveLength(2); + expect(next.speed).toBe(7); + expect(events).toEqual(["bonus"]); + }); + + it("overlap: food and bonus on same cell awards both explicitly", () => { + const { state: next, events } = tick( + state({ + dir: "right", + body: [{ x: 4, y: 5 }], + food: { x: 5, y: 5 }, + bonus: { + cell: { x: 5, y: 5 }, + remainingMs: 8_000, + value: 80, + size: 24, + }, + score: 0, + }) + ); + expect(next.score).toBe(FOOD_SCORE + 80); + expect(next.bonus).toBeNull(); + expect(next.body).toHaveLength(2); + expect(events).toContain("eat"); + expect(events).toContain("bonus"); + }); + + it("respawned food never lands on an active bonus cell", () => { + // Tiny board so free cells are limited; bonus occupies one free cell. + // Head eats food at (1,0); body grows; bonus stays at (0,1); only free is (1,1). + const { state: next } = tick( + state({ + width: 2, + height: 2, + dir: "right", + body: [{ x: 0, y: 0 }], + food: { x: 1, y: 0 }, + bonus: { + cell: { x: 0, y: 1 }, + remainingMs: BONUS_LIFETIME_MS, + value: BONUS_MAX_VALUE, + size: BONUS_MAX_SIZE, + }, + rngSeed: 1, + }) + ); + expect(next.bonus).not.toBeNull(); + if (!next.bonus) return; + expect(next.food).not.toEqual(next.bonus.cell); + expect(next.food).toEqual({ x: 1, y: 1 }); + }); +}); + +describe("bonus food β€” value/size decay helpers", () => { + it("value is 100 at full life, 10 at expiry, rounded to tens", () => { + expect(bonusValueFromRemaining(BONUS_LIFETIME_MS)).toBe(BONUS_MAX_VALUE); + expect(bonusValueFromRemaining(0)).toBe(BONUS_MIN_VALUE); + expect(bonusValueFromRemaining(BONUS_LIFETIME_MS / 2)).toBe(60); + expect(bonusValueFromRemaining(BONUS_LIFETIME_MS * 0.55) % 10).toBe(0); + }); + + it("size decays linearly from max to min", () => { + expect(bonusSizeFromRemaining(BONUS_LIFETIME_MS)).toBe(BONUS_MAX_SIZE); + expect(bonusSizeFromRemaining(0)).toBe(BONUS_MIN_SIZE); + expect(bonusSizeFromRemaining(BONUS_LIFETIME_MS / 2)).toBe( + (BONUS_MAX_SIZE + BONUS_MIN_SIZE) / 2 + ); + }); +}); + +describe("snakeTick β€” bonus decay / expiry", () => { + const liveBonus = { + cell: { x: 2, y: 2 }, + remainingMs: BONUS_LIFETIME_MS, + value: BONUS_MAX_VALUE, + size: BONUS_MAX_SIZE, + }; + + it("keeps the nominal lifetime aligned with its fixed tick budget", () => { + expect(BONUS_LIFETIME_TICKS * BONUS_TICK_DECAY_MS).toBe(BONUS_LIFETIME_MS); + }); + + it("decays remaining/value/size by one fixed amount per movement tick", () => { + const { state: next, events } = snakeTick(state({ bonus: liveBonus })); + expect(events).toEqual([]); + expect(next.bonus).not.toBeNull(); + if (!next.bonus) return; + expect(next.bonus.remainingMs).toBe(BONUS_LIFETIME_MS - BONUS_TICK_DECAY_MS); + expect(next.bonus.value).toBe( + bonusValueFromRemaining(BONUS_LIFETIME_MS - BONUS_TICK_DECAY_MS) + ); + expect(next.bonus.size).toBe( + bonusSizeFromRemaining(BONUS_LIFETIME_MS - BONUS_TICK_DECAY_MS) + ); + }); + + it("decays by the same amount on every difficulty", () => { + const make = (difficulty: Difficulty) => + state({ + difficulty, + speed: DIFFICULTIES[difficulty].initialSpeed, + bonus: liveBonus, + }); + const easy = snakeTick(make("easy")); + const hard = snakeTick(make("hard")); + + expect(easy.state.bonus?.remainingMs).toBe(hard.state.bonus?.remainingMs); + expect(easy.state.bonus?.value).toBe(hard.state.bonus?.value); + expect(easy.state.bonus?.size).toBe(hard.state.bonus?.size); + }); + + it("expires bonus when the final tick consumes its lifetime", () => { + const { state: next, events } = snakeTick( + state({ + bonus: { + ...liveBonus, + remainingMs: BONUS_TICK_DECAY_MS, + value: BONUS_MIN_VALUE, + size: BONUS_MIN_SIZE, + }, + }) + ); + expect(next.bonus).toBeNull(); + expect(events).toEqual(["bonus-expired"]); + }); +}); + +describe("snakeTick β€” bonus spawn", () => { + it("does not spawn before the difficulty frequency counter", () => { + const { state: s } = tick( + state({ + bonusTickCounter: DIFFICULTIES.normal.bonusFrequency - 2, + food: { x: 9, y: 9 }, + body: [{ x: 1, y: 1 }], + dir: "right", + }) + ); + expect(s.bonusTickCounter).toBe(DIFFICULTIES.normal.bonusFrequency - 1); + expect(s.bonus).toBeNull(); + }); + + it("eventually spawns a bonus after the frequency window (seeded)", () => { + let found: SnakeState | null = null; + for (let seed = 1; seed < 200 && !found; seed++) { + const { state: next } = tick( + state({ + bonusTickCounter: DIFFICULTIES.normal.bonusFrequency, + food: { x: 9, y: 9 }, + body: [{ x: 1, y: 1 }], + dir: "right", + rngSeed: seed, + }) + ); + if (next.bonus) found = next; + } + expect(found).not.toBeNull(); + if (!found || !found.bonus) return; + expect(found.bonus.cell).not.toEqual(found.food); + expect(found.bonus.value).toBe(BONUS_MAX_VALUE); + expect(found.bonus.remainingMs).toBe(BONUS_LIFETIME_MS); + expect(found.bonusTickCounter).toBe(0); + }); + + // Locks the ORIGINAL game's cadence (Snake-game/game.js): a failed 15% roll + // does NOT reset the counter β€” once past `bonusFrequency` the reducer + // re-rolls every tick until a spawn succeeds (only success resets to 0). + it("a failed spawn roll keeps the counter and re-rolls next tick (original cadence)", () => { + const freq = DIFFICULTIES.normal.bonusFrequency; + // Find a seed whose roll fails: no bonus, but the rng was consumed. + let failed: SnakeState | null = null; + for (let seed = 1; seed < 500 && !failed; seed++) { + const { state: next } = tick( + state({ + bonusTickCounter: freq, + food: { x: 9, y: 9 }, + body: [{ x: 1, y: 1 }], + dir: "right", + rngSeed: seed, + }) + ); + if (next.bonus === null && next.rngSeed !== seed) failed = next; + } + expect(failed).not.toBeNull(); + if (!failed) return; + // The failed roll did NOT reset the window… + expect(failed.bonusTickCounter).toBe(freq + 1); + // …so the very next tick rolls again (rng consumed) instead of waiting a + // full window. + const { state: after } = tick(failed); + expect(after.rngSeed).not.toBe(failed.rngSeed); + expect(after.bonusTickCounter).toBe(after.bonus ? 0 : freq + 2); + }); + + it("spawn is deterministic for a fixed seed + counter", () => { + const run = (seed: number) => + tick( + state({ + bonusTickCounter: DIFFICULTIES.normal.bonusFrequency, + food: { x: 9, y: 9 }, + body: [{ x: 1, y: 1 }], + dir: "right", + rngSeed: seed, + }) + ); + expect(run(42)).toEqual(run(42)); + }); +}); + +describe("snakeFrame β€” interleaved time vs ticks", () => { + it("commits a queued turn immediately instead of waiting for the tick interval", () => { + let s = state({ body: [{ x: 5, y: 5 }] }); + s = snakeInput(s, "left"); + s = snakeInput(s, "up"); + const result = snakeFrame(s, 0, 1); + expect(result.state.body[0]).toEqual({ x: 5, y: 4 }); + expect(result.state.dir).toBe("up"); + expect(result.state.dirQueue).toEqual([]); + }); + + it("a movement tick due before bonus expiry can still collect the bonus", () => { + // Bonus decay happens at movement ticks, so a pickup receives the current + // value even when the render frame reaches that tick after partial time. const s = state({ dir: "right", - body: [ - { x: 5, y: 5 }, - { x: 5, y: 4 }, - { x: 6, y: 4 }, - { x: 6, y: 5 }, - { x: 6, y: 6 }, - { x: 5, y: 6 }, - ], + body: [{ x: 4, y: 5 }], + food: { x: 9, y: 9 }, + bonus: { + cell: { x: 5, y: 5 }, + remainingMs: BONUS_TICK_DECAY_MS, + value: 20, + size: 12, + }, + speed: 10, // 100ms/tick + score: 0, }); - const next = snakeTick(s); - expect(next.alive).toBe(false); + const result = snakeFrame(s, 90, 50); + // After 10ms of time β†’ bonus remaining 10, value still ~10+; then tick eats it. + expect(result.events).toContain("bonus"); + expect(result.state.bonus).toBeNull(); + expect(result.state.score).toBeGreaterThan(0); + expect(result.state.alive).toBe(true); }); - it("moving into the vacating tail cell is legal", () => { - // Head chasing the tail: the tail moves away this tick, so no collision. + it("does not expire bonus between movement ticks", () => { + // acc=0, speed=10 (100ms/tick), bonus has one tick left, dt=50. + // No movement tick fires, so no bonus decay occurs yet. const s = state({ - dir: "up", - body: [ - { x: 5, y: 5 }, - { x: 6, y: 5 }, - { x: 6, y: 4 }, - { x: 5, y: 4 }, - ], + dir: "right", + body: [{ x: 4, y: 5 }], + food: { x: 9, y: 9 }, + bonus: { + cell: { x: 5, y: 5 }, + remainingMs: BONUS_TICK_DECAY_MS, + value: 20, + size: 12, + }, + speed: 10, }); - const next = snakeTick(snakeInput(s, "up")); - expect(next.alive).toBe(true); + const result = snakeFrame(s, 0, 50); + expect(result.events).toEqual([]); + expect(result.state.bonus).not.toBeNull(); + expect(result.state.bonus?.remainingMs).toBe(BONUS_TICK_DECAY_MS); + // Head has not moved β€” no tick fired. + expect(result.state.body[0]).toEqual({ x: 4, y: 5 }); + expect(result.accMs).toBe(50); }); -}); -describe("snakeTick β€” eating", () => { - it("grows and scores when reaching food, then respawns food", () => { + it("awards bonus value at tick time, not end-of-frame decay", () => { + // Bonus at full value 100. Tick due immediately (acc β‰ˆ interval). + // After tick, remaining frame time would decay value if applied first. const s = state({ dir: "right", body: [{ x: 4, y: 5 }], - food: { x: 5, y: 5 }, + food: { x: 9, y: 9 }, + bonus: { + cell: { x: 5, y: 5 }, + remainingMs: BONUS_LIFETIME_MS, + value: BONUS_MAX_VALUE, + size: BONUS_MAX_SIZE, + }, + speed: 10, // 100ms + score: 0, }); - const next = snakeTick(s); - expect(next.alive).toBe(true); - expect(next.won).toBe(false); - expect(next.score).toBe(1); - expect(next.body).toHaveLength(2); - expect(next.body[0]).toEqual({ x: 5, y: 5 }); - // New food is not on the eaten cell. - expect(next.food).not.toEqual({ x: 5, y: 5 }); + // acc=100 already at interval β†’ first step is 0 time, then tick at full value. + const result = snakeFrame(s, 100, 200); + expect(result.events).toContain("bonus"); + expect(result.state.score).toBe(BONUS_MAX_VALUE); }); - it("filling the whole board is a terminal win", () => { - // 2x2 board: snake occupies 3 cells, food on the last one. Eating it - // fills the board β†’ won; further ticks and inputs are no-ops. + it("is deterministic for the same state + acc + dt", () => { const s = state({ - width: 2, - height: 2, - dir: "down", - body: [ - { x: 0, y: 0 }, - { x: 1, y: 0 }, - { x: 1, y: 1 }, - ], - food: { x: 0, y: 1 }, + bonus: { + cell: { x: 2, y: 2 }, + remainingMs: 5_000, + value: 50, + size: 20, + }, + speed: 7, }); - // Head (0,0) moving down reaches (0,1) = food β†’ board full. - const wonState = snakeTick(s); - expect(wonState.won).toBe(true); - expect(wonState.alive).toBe(true); - expect(wonState.score).toBe(1); - expect(wonState.body).toHaveLength(4); - expect(snakeTick(wonState)).toBe(wonState); - expect(snakeInput(wonState, "left")).toBe(wonState); + expect(snakeFrame(s, 30, 80)).toEqual(snakeFrame(s, 30, 80)); }); - it("walls shrink the board a win has to fill", () => { - // 2x3 board with the bottom row walled β†’ only the top 2x1... use 3x2 with - // the right column walled: the winnable area is the 2x2 left block. - const walls = [ - { x: 2, y: 0 }, - { x: 2, y: 1 }, - ]; + it("keeps food visible on the bonus cell when it is the only free cell", () => { const s = state({ - width: 3, + width: 2, height: 2, - walls, - dir: "down", body: [ { x: 0, y: 0 }, - { x: 1, y: 0 }, - { x: 1, y: 1 }, + { x: 0, y: 1 }, ], - food: { x: 0, y: 1 }, + dir: "right", + food: { x: 1, y: 0 }, + bonus: { + cell: { x: 1, y: 1 }, + remainingMs: BONUS_TICK_DECAY_MS * 2, + value: 10, + size: 10, + }, }); - const wonState = snakeTick(s); - expect(wonState.won).toBe(true); + const eaten = snakeTick(s).state; + expect(eaten.food).toEqual({ x: 1, y: 1 }); + expect(eaten.bonus).not.toBeNull(); }); }); -describe("isBlockedCell", () => { - it("reports edges and walls, not open floor", () => { - const s = state({ walls: [{ x: 3, y: 3 }] }); - expect(isBlockedCell(s, { x: -1, y: 0 })).toBe(true); - expect(isBlockedCell(s, { x: 10, y: 0 })).toBe(true); - expect(isBlockedCell(s, { x: 0, y: 10 })).toBe(true); - expect(isBlockedCell(s, { x: 3, y: 3 })).toBe(true); - expect(isBlockedCell(s, { x: 4, y: 3 })).toBe(false); - }); +describe("dimension-agnostic board", () => { + // The renderer picks colsΓ—rows from the stage, so the same rules must hold on + // any board: the far edge kills exactly at width-1 / height-1. + const boards: Array<{ w: number; h: number }> = [ + { w: 20, h: 12 }, + { w: 96, h: 51 }, + ]; + for (const { w, h } of boards) { + it(`the right/bottom edge of a ${w}Γ—${h} board kills`, () => { + const right = tick( + state({ width: w, height: h, dir: "right", body: [{ x: w - 1, y: 2 }] }) + ); + expect(right.state.alive).toBe(false); + expect(right.events).toEqual(["die"]); + + const inside = tick( + state({ width: w, height: h, dir: "right", body: [{ x: w - 2, y: 2 }] }) + ); + expect(inside.state.alive).toBe(true); + + const bottom = tick( + state({ width: w, height: h, dir: "down", body: [{ x: 2, y: h - 1 }] }) + ); + expect(bottom.state.alive).toBe(false); + expect(bottom.events).toEqual(["die"]); + }); + + it(`food always spawns inside a ${w}Γ—${h} board`, () => { + for (let seed = 1; seed <= 25; seed++) { + const s = initSnake(seed, { width: w, height: h }); + expect(s.food.x).toBeGreaterThanOrEqual(0); + expect(s.food.y).toBeGreaterThanOrEqual(0); + expect(s.food.x).toBeLessThan(w); + expect(s.food.y).toBeLessThan(h); + } + }); + } }); describe("determinism", () => { - function run(seed: number, l = level(12, 10)): SnakeState { - let s = initSnake(seed, l); + function run( + seed: number, + difficulty: Difficulty = "normal", + width = 12, + height = 10 + ): SnakeState { + let s = initSnake(seed, { difficulty, width, height }); + let acc = 0; const script: Dir[] = ["down", "right", "up", "left", "down", "right"]; for (const d of script) { s = snakeInput(s, d); - s = snakeTick(s); - s = snakeTick(s); + const a = snakeFrame(s, acc, 200); + s = a.state; + acc = a.accMs; + const b = snakeFrame(s, acc, 200); + s = b.state; + acc = b.accMs; } return s; } it("same seed + input script β‡’ identical outcome", () => { expect(run(777)).toEqual(run(777)); + expect(run(123, "hard")).toEqual(run(123, "hard")); }); - - it("holds on every shipped level", () => { - for (const l of SNAKE_LEVELS) { - expect(run(4242, l)).toEqual(run(4242, l)); - } + it("stays deterministic on other board sizes", () => { + expect(run(777, "normal", 20, 12)).toEqual(run(777, "normal", 20, 12)); + expect(run(777, "normal", 96, 51)).toEqual(run(777, "normal", 96, 51)); }); - it("different seeds can diverge (food placement)", () => { - const a = initSnake(1, level(8, 8)); - const b = initSnake(2, level(8, 8)); + const a = initSnake(1, { width: 8, height: 8 }); + const b = initSnake(2, { width: 8, height: 8 }); expect(a.food).not.toEqual(b.food); }); }); diff --git a/frontend/src/game/arcade/snake.ts b/frontend/src/game/arcade/snake.ts index 320d172..a9296f1 100644 --- a/frontend/src/game/arcade/snake.ts +++ b/frontend/src/game/arcade/snake.ts @@ -1,34 +1,105 @@ /** - * Snake β€” pure game rules. + * Snake β€” pure game rules (ported from the owner's standalone Snake game). * * Plain values in, plain values out; no Phaser/net/DOM imports. All randomness - * (food placement) flows through the seeded PRNG carried in `rngSeed`, so a - * given seed + input script always produces the same run (see snake.test.ts). + * (food / bonus placement, bonus spawn rolls) flows through the seeded PRNG + * carried in `rngSeed`, so a given seed + input script always produces the same + * run (see snake.test.ts). * * Coordinate system: integer grid cells, origin top-left, `x` right, `y` down. - * The scene renders `body`/`food`/`walls`; it never re-derives a rule here. + * The board is a single `width`Γ—`height` rect and its edges kill β€” classic + * snake. The module is dimension-agnostic: the renderer measures the stage and + * passes whatever colsΓ—rows fits at its fixed cell size. * - * LEVELS ARE DATA (issue #163). A level is an ASCII map parsed once at module - * load into `{ width, height, walls, start }` and handed to `initSnake`. Adding - * a level is adding rows to `SNAKE_LEVELS` β€” the reducer below is level-agnostic - * and treats every wall cell as lethal, exactly like the board edge. - * - * SPEED IS DATA TOO: `SNAKE_SPEEDS` holds the tick cadence per tier. The tick - * interval is a presentation cadence, not a rule (the reducer is per-step), so - * the renderer reads it from here rather than hard-coding a magic number. + * The renderer draws `body`/`food`/`bonus` and forwards `events` to the bus; + * it never re-derives a rule here. */ -import { nextInt } from "./prng"; +import { nextFloat, nextInt } from "./prng"; export type Dir = "up" | "down" | "left" | "right"; +export type Difficulty = "easy" | "normal" | "hard"; + +/** + * Domain events produced by the pure layer. The renderer maps these onto the + * typed event bus (sound mixer) β€” it does not re-derive eat/bonus/die from + * state diffs. + */ +export type SnakeEvent = + | "eat" + | "bonus" + | "milestone" + | "die" + | "win" + | "bonus-expired"; + +export interface SnakeStep { + readonly state: SnakeState; + readonly events: readonly SnakeEvent[]; +} + export interface Cell { readonly x: number; readonly y: number; } -export type SnakeLevelId = "open" | "pillars" | "corridors" | "vault"; +/** Live bonus food: position + movement-tick-based score lifetime. */ +export interface BonusFood { + readonly cell: Cell; + /** Score-time remaining; one movement tick consumes BONUS_TICK_DECAY_MS. */ + readonly remainingMs: number; + /** Current point value (decays 100 β†’ 10, rounded to tens). */ + readonly value: number; + /** Render size (px at design cell scale); decays with remainingMs. */ + readonly size: number; +} + +export interface DifficultyConfig { + readonly initialSpeed: number; + readonly speedIncrement: number; + readonly bonusFrequency: number; +} + +/** One ranked bonus cadence shared by every difficulty. */ +export const BONUS_FREQUENCY = 180; + +/** Original game tuning: ticks-per-second, speed step, bonus spawn period. */ +export const DIFFICULTIES: Readonly> = { + // Difficulty changes movement pressure, not leaderboard scoring cadence. + easy: { initialSpeed: 5, speedIncrement: 0.5, bonusFrequency: BONUS_FREQUENCY }, + normal: { initialSpeed: 7, speedIncrement: 1, bonusFrequency: BONUS_FREQUENCY }, + hard: { initialSpeed: 10, speedIncrement: 1.5, bonusFrequency: BONUS_FREQUENCY }, +}; + +export const SPEED_CAP = 15; +export const FOOD_SCORE = 10; +export const SPEED_STEP_POINTS = 50; +export const BONUS_LIFETIME_MS = 10_000; +/** + * Bonus decay is movement-tick based so difficulty changes pressure without + * changing the value awarded for the same sequence of moves. Seventy ticks + * matches the old ten-second lifetime at Normal's seven ticks per second. + */ +export const BONUS_LIFETIME_TICKS = 70; +export const BONUS_TICK_DECAY_MS = BONUS_LIFETIME_MS / BONUS_LIFETIME_TICKS; +export const BONUS_MAX_VALUE = 100; +export const BONUS_MIN_VALUE = 10; +export const BONUS_MAX_SIZE = 30; +export const BONUS_MIN_SIZE = 10; +export const BONUS_SPAWN_CHANCE = 0.15; +/** Max buffered turns (original game). */ +export const DIR_QUEUE_MAX = 3; + +/** + * Fallback board when the host has not measured a stage yet (headless/jsdom). + * Roughly the original's 37Γ—20 proportions; a real run always overrides it with + * whatever whole cells the stage can show. + */ +export const DEFAULT_SNAKE_WIDTH = 34; +export const DEFAULT_SNAKE_HEIGHT = 18; export interface SnakeState { + /** Board size in cells; the edges kill. */ readonly width: number; readonly height: number; /** Head first, tail last. */ @@ -36,221 +107,39 @@ export interface SnakeState { /** Direction the snake actually last moved in. */ readonly dir: Dir; /** - * Buffered turn (1-deep queue): the latest input since the last tick, - * validated at TICK time against `dir` β€” the direction actually last moved. - * Validating on input alone is bypassable: moving right, pressing up then - * left within one tick would legalize the reversal step-by-step and fold the - * head into the neck. Deferring the check makes multi-input-per-tick safe. + * Buffered turns (up to DIR_QUEUE_MAX deep). One entry is consumed per tick + * and validated against `dir` β€” the direction actually last moved β€” so no + * input sequence can smuggle a 180Β° reversal within a single step. */ - readonly pendingDir: Dir | null; + readonly dirQueue: readonly Dir[]; readonly food: Cell; - /** Lethal obstacle cells from the level layout (empty on the "open" level). */ - readonly walls: readonly Cell[]; - /** Which level this run is playing (carried so the HUD can label it). */ - readonly levelId: SnakeLevelId; + readonly bonus: BonusFood | null; + /** Ticks counted toward the next bonus spawn window. */ + readonly bonusTickCounter: number; readonly alive: boolean; - /** Terminal win: the snake fills every non-wall cell of the board. */ + /** Terminal win: the snake fills the entire board. */ readonly won: boolean; readonly score: number; + /** Current ticks-per-second (renderer drives its interval from this). */ + readonly speed: number; + readonly difficulty: Difficulty; readonly rngSeed: number; } -export interface SnakeLevel { - readonly id: SnakeLevelId; - readonly name: string; - /** One-line description shown next to the level picker. */ - readonly hint: string; - readonly width: number; - readonly height: number; - readonly walls: readonly Cell[]; - /** Head cell of the starting snake; the two body cells extend to its left. */ - readonly start: Cell; -} - -export type SnakeSpeedId = "chill" | "normal" | "fast"; - -export interface SnakeSpeed { - readonly id: SnakeSpeedId; - readonly label: string; - /** Milliseconds between reducer ticks. */ - readonly tickMs: number; -} - -/** - * The three speed tiers. `normal` keeps the historical 110ms cadence so old - * high scores stay comparable; `chill` is a genuinely relaxed 180ms (the fix - * for "too fast to be playable") and `fast` is the challenge tier. - */ -export const SNAKE_SPEEDS: readonly SnakeSpeed[] = [ - { id: "chill", label: "Chill", tickMs: 180 }, - { id: "normal", label: "Normal", tickMs: 110 }, - { id: "fast", label: "Fast", tickMs: 75 }, -]; - -const DEFAULT_SNAKE_SPEED: SnakeSpeed = { id: "normal", label: "Normal", tickMs: 110 }; - -/** Resolve a (possibly stale/persisted) speed id; unknown ids fall back to Normal. */ -export function snakeSpeedById(id: string): SnakeSpeed { - return SNAKE_SPEEDS.find((s) => s.id === id) ?? DEFAULT_SNAKE_SPEED; -} - -/** - * Parse an ASCII level map. `#` is a wall, `>` marks the head start cell (the - * snake starts heading right with two body cells to its left), everything else - * is empty floor. The documented invariants are VALIDATED here, not assumed: - * rows must be non-empty and all the same length, there must be exactly one - * `>`, and the two cells to its left must exist and be floor (initSnake places - * the starting body there). Violations throw β€” the shipped levels parse at - * module load, so a bad layout edit fails the suite instantly instead of - * shipping a level whose snake starts inside a wall or off the board. - */ -export function parseSnakeLevel( - id: SnakeLevelId, - name: string, - hint: string, - rows: readonly string[] -): SnakeLevel { - const first = rows[0]; - if (first === undefined || first.length === 0) { - throw new Error(`snake level "${id}": layout must have at least one non-empty row`); - } - const height = rows.length; - const width = first.length; - const walls: Cell[] = []; - let start: Cell | null = null; - for (let y = 0; y < rows.length; y++) { - const row = rows[y] ?? ""; - if (row.length !== width) { - throw new Error( - `snake level "${id}": row ${y} has length ${row.length}, expected ${width} (rows must be rectangular)` - ); - } - for (let x = 0; x < row.length; x++) { - const ch = row[x]; - if (ch === "#") walls.push({ x, y }); - else if (ch === ">") { - if (start !== null) { - throw new Error(`snake level "${id}": more than one ">" start marker`); - } - start = { x, y }; - } - } - } - if (start === null) { - throw new Error(`snake level "${id}": missing the ">" start marker`); - } - // The starting snake is head + two body cells extending left of it. - if (start.x < 2) { - throw new Error( - `snake level "${id}": start at (${start.x}, ${start.y}) needs two cells to its left for the body` - ); - } - const startRow = rows[start.y] ?? ""; - if (startRow[start.x - 1] === "#" || startRow[start.x - 2] === "#") { - throw new Error( - `snake level "${id}": the two cells left of the start must be floor (the body spawns there)` - ); - } - return { id, name, hint, width, height, walls, start }; +export interface SnakeInitOptions { + readonly difficulty?: Difficulty; + readonly width?: number; + readonly height?: number; } -// Every level is 17 Γ— 15, so the canvas geometry (and therefore the arcade -// stage layout) is identical whichever level is picked. -const OPEN_LEVEL = parseSnakeLevel("open", "Open Field", "No obstacles β€” pure snake.", [ - ".................", - ".................", - ".................", - ".................", - ".................", - ".................", - ".................", - "........>........", - ".................", - ".................", - ".................", - ".................", - ".................", - ".................", - ".................", -]); - -const PILLARS_LEVEL = parseSnakeLevel("pillars", "Pillars", "Six blocks to weave around.", [ - ".................", - ".................", - ".................", - "...##...##...##..", - "...##...##...##..", - ".................", - ".................", - "........>........", - ".................", - ".................", - "...##...##...##..", - "...##...##...##..", - ".................", - ".................", - ".................", -]); - -const CORRIDORS_LEVEL = parseSnakeLevel( - "corridors", - "Corridors", - "Thread the gaps between the walls.", - [ - "....#.......#....", - "....#.......#....", - "....#.......#....", - "....#.......#....", - "....#.......#....", - "....#...#...#....", - "....#...#...#....", - "....#...#...#....", - "....#...#...#....", - "....#...#...#....", - "........#........", - "........#........", - ".................", - "........>........", - ".................", - ] -); - -const VAULT_LEVEL = parseSnakeLevel("vault", "The Vault", "Start boxed in β€” four doors out.", [ - ".................", - ".................", - ".................", - "...#####.#####...", - "...#.........#...", - "...#.........#...", - "...#.........#...", - "........>........", - "...#.........#...", - "...#.........#...", - "...#.........#...", - "...#####.#####...", - ".................", - ".................", - ".................", -]); - -/** The designed levels, in pick order. The first is the default. */ -export const SNAKE_LEVELS: readonly SnakeLevel[] = [ - OPEN_LEVEL, - PILLARS_LEVEL, - CORRIDORS_LEVEL, - VAULT_LEVEL, -]; - -export const DEFAULT_SNAKE_LEVEL = OPEN_LEVEL; - -/** Resolve a (possibly stale/persisted) level id; unknown ids fall back to Open Field. */ -export function snakeLevelById(id: string): SnakeLevel { - return SNAKE_LEVELS.find((l) => l.id === id) ?? DEFAULT_SNAKE_LEVEL; +/** Result of one animation-frame of pure scheduling (time and ticks interleaved). */ +export interface SnakeFrameResult { + readonly state: SnakeState; + /** Accumulator: ms since the last movement tick (0..tickInterval). */ + readonly accMs: number; + readonly events: readonly SnakeEvent[]; } -export const DEFAULT_SNAKE_WIDTH = DEFAULT_SNAKE_LEVEL.width; -export const DEFAULT_SNAKE_HEIGHT = DEFAULT_SNAKE_LEVEL.height; - const DELTA: Record = { up: { x: 0, y: -1 }, down: { x: 0, y: 1 }, @@ -269,153 +158,419 @@ function eq(a: Cell, b: Cell): boolean { return a.x === b.x && a.y === b.y; } -function has(cells: readonly Cell[], cell: Cell): boolean { - return cells.some((c) => eq(c, cell)); +function tickIntervalMs(speed: number): number { + return 1000 / Math.max(0.5, speed); +} + +/** Speed from score + difficulty (capped). Pure so tests can assert steps. */ +export function speedForScore(score: number, difficulty: Difficulty): number { + const cfg = DIFFICULTIES[difficulty]; + const steps = Math.floor(Math.max(0, score) / SPEED_STEP_POINTS); + return Math.min(SPEED_CAP, cfg.initialSpeed + steps * cfg.speedIncrement); } /** - * Place food on a random empty (non-body, non-wall) cell. If nothing is free - * (win), keep the old food position β€” the caller marks the game `won` and stops - * play. + * Bonus value from remaining lifetime: linear 100 β†’ 10, rounded to tens. */ -function placeFood( +export function bonusValueFromRemaining(remainingMs: number): number { + const ratio = Math.max(0, Math.min(1, remainingMs / BONUS_LIFETIME_MS)); + const raw = + BONUS_MIN_VALUE + (BONUS_MAX_VALUE - BONUS_MIN_VALUE) * ratio; + const rounded = Math.round(raw / 10) * 10; + return Math.max(BONUS_MIN_VALUE, Math.min(BONUS_MAX_VALUE, rounded)); +} + +/** Bonus drawn size from remaining lifetime: linear max β†’ min. */ +export function bonusSizeFromRemaining(remainingMs: number): number { + const ratio = Math.max(0, Math.min(1, remainingMs / BONUS_LIFETIME_MS)); + return BONUS_MIN_SIZE + (BONUS_MAX_SIZE - BONUS_MIN_SIZE) * ratio; +} + +function refreshBonus(cell: Cell, remainingMs: number): BonusFood { + return { + cell, + remainingMs, + value: bonusValueFromRemaining(remainingMs), + size: bonusSizeFromRemaining(remainingMs), + }; +} + +/** Find the first queued input that is a genuine turn from the current heading. */ +function firstQueuedTurnIndex(dir: Dir, dirQueue: readonly Dir[]): number { + for (let index = 0; index < dirQueue.length; index += 1) { + const queued = dirQueue[index]; + if (queued !== undefined && queued !== dir && queued !== OPPOSITE[dir]) return index; + } + return -1; +} + +/** Preview the first actionable queued direction without changing state. */ +export function previewSnakeDir( + dir: Dir, + dirQueue: readonly Dir[] +): Dir { + const index = firstQueuedTurnIndex(dir, dirQueue); + return index >= 0 ? (dirQueue[index] ?? dir) : dir; +} + +/** Drop leading queued inputs that are not actionable for an immediate turn. */ +function discardLeadingQueuedNoops(state: SnakeState): SnakeState { + const index = firstQueuedTurnIndex(state.dir, state.dirQueue); + if (index <= 0) return state; + return { ...state, dirQueue: state.dirQueue.slice(index) }; +} + +/** Whether a queued input should make the next frame commit a movement step. */ +function hasQueuedTurn(state: SnakeState): boolean { + return firstQueuedTurnIndex(state.dir, state.dirQueue) >= 0; +} + +/** Free cells of the board in row-major order (the seeded pick indexes this). */ +function freeCells( width: number, height: number, - occupied: readonly Cell[], - walls: readonly Cell[], - seed: number -): { food: Cell; rngSeed: number } { + occupied: readonly Cell[] +): Cell[] { + const taken = new Set(); + for (const c of occupied) taken.add(`${c.x},${c.y}`); const free: Cell[] = []; for (let y = 0; y < height; y++) { for (let x = 0; x < width; x++) { - const cell = { x, y }; - if (!has(occupied, cell) && !has(walls, cell)) free.push(cell); + if (!taken.has(`${x},${y}`)) free.push({ x, y }); } } + return free; +} + +/** + * Place food on a random empty cell. `occupied` must include the snake body and + * any active bonus cell so food never lands on the bonus (and vice versa via + * placeBonus). If the board is full (win), keep a fallback cell. + */ +function placeFood( + width: number, + height: number, + occupied: readonly Cell[], + seed: number +): { food: Cell; rngSeed: number; exhausted: boolean } { + const free = freeCells(width, height, occupied); if (free.length === 0) { const head = occupied[0] ?? { x: 0, y: 0 }; - return { food: head, rngSeed: seed }; + return { food: head, rngSeed: seed, exhausted: true }; } const { value, seed: rngSeed } = nextInt(seed, free.length); - const food = free[value] ?? free[0]; - // free is non-empty here, so free[0] always exists. - return { food: food ?? { x: 0, y: 0 }, rngSeed }; + const food = free[value]; + if (!food) { + const fallback = free[0] ?? { x: 0, y: 0 }; + return { food: fallback, rngSeed, exhausted: false }; + } + return { food, rngSeed, exhausted: false }; +} + +function placeBonus( + width: number, + height: number, + occupied: readonly Cell[], + seed: number +): { bonus: BonusFood | null; rngSeed: number } { + const free = freeCells(width, height, occupied); + if (free.length === 0) return { bonus: null, rngSeed: seed }; + const { value, seed: rngSeed } = nextInt(seed, free.length); + const cell = free[value] ?? free[0]; + if (!cell) return { bonus: null, rngSeed }; + return { + bonus: refreshBonus(cell, BONUS_LIFETIME_MS), + rngSeed, + }; +} + +/** Cells blocked for food placement: snake body + active bonus. */ +function foodOccupied(body: readonly Cell[], bonus: BonusFood | null): Cell[] { + return bonus ? [...body, bonus.cell] : [...body]; +} + +/** Cells blocked for bonus placement: snake body + normal food. */ +function bonusOccupied(body: readonly Cell[], food: Cell): Cell[] { + return [...body, food]; } /** - * Fresh game on `level`: a length-3 snake at the level's start cell heading - * right, with food placed on a free cell. + * Fresh game: length-3 snake centred on the board, heading right, food placed + * on a free cell. */ -export function initSnake(seed: number, level: SnakeLevel = DEFAULT_SNAKE_LEVEL): SnakeState { - const { x, y } = level.start; +export function initSnake( + seed: number, + options: SnakeInitOptions = {} +): SnakeState { + const difficulty = options.difficulty ?? "normal"; + // Floor: the length-3 starting snake must fit horizontally from the centre. + const width = Math.max(5, Math.floor(options.width ?? DEFAULT_SNAKE_WIDTH)); + const height = Math.max(3, Math.floor(options.height ?? DEFAULT_SNAKE_HEIGHT)); + const cy = Math.floor(height / 2); + const cx = Math.floor(width / 2); const body: Cell[] = [ - { x, y }, - { x: x - 1, y }, - { x: x - 2, y }, + { x: cx, y: cy }, + { x: cx - 1, y: cy }, + { x: cx - 2, y: cy }, ]; - const { food, rngSeed } = placeFood(level.width, level.height, body, level.walls, seed); + const { food, rngSeed } = placeFood(width, height, body, seed); return { - width: level.width, - height: level.height, + width, + height, body, dir: "right", - pendingDir: null, + dirQueue: [], food, - walls: level.walls, - levelId: level.id, + bonus: null, + bonusTickCounter: 0, alive: true, won: false, score: 0, + speed: DIFFICULTIES[difficulty].initialSpeed, + difficulty, rngSeed, }; } /** - * Buffer a turn for the next tick (1-deep queue β€” the latest input between two - * ticks wins). Legality is NOT decided here: the tick validates the buffered - * direction against the direction actually last moved, so no input sequence - * can smuggle in a 180Β° reversal. Terminal states ignore input. + * Buffer a turn for upcoming ticks (up to DIR_QUEUE_MAX deep β€” oldest dropped + * when full). Legality is NOT decided here: each tick validates the next queued + * direction against the direction actually last moved. */ export function snakeInput(state: SnakeState, dir: Dir): SnakeState { if (!state.alive || state.won) return state; - return { ...state, pendingDir: dir }; + const queue = [...state.dirQueue, dir]; + return { + ...state, + dirQueue: queue.length > DIR_QUEUE_MAX ? queue.slice(-DIR_QUEUE_MAX) : queue, + }; } /** - * Advance one step: - * - the buffered turn applies only if it is not a 180Β° reversal of the - * direction actually last moved (else it is discarded); - * - moving out of bounds, into a level wall, or into your own body (except the - * tail cell you are about to vacate) kills the snake β€” `alive: false`; - * - eating food grows the snake (tail retained) and spawns new food; - * - filling every non-wall cell is a terminal win (`won: true`); - * - otherwise the snake slides (tail removed). - * Dead or won states are idempotent no-ops. + * Resolve the front of the direction queue against the last actually-moved + * direction. Returns the new dir and the remaining queue (front always consumed + * when present β€” invalid 180s are discarded, not re-queued). */ -export function snakeTick(state: SnakeState): SnakeState { - if (!state.alive || state.won) return state; - const head = state.body[0]; - if (!head) return state; +function resolveDir(state: SnakeState): { + dir: Dir; + dirQueue: readonly Dir[]; +} { + if (state.dirQueue.length === 0) { + return { dir: state.dir, dirQueue: state.dirQueue }; + } + const nextDir = state.dirQueue[0]; + const rest = state.dirQueue.slice(1); + if (nextDir === undefined) { + return { dir: state.dir, dirQueue: rest }; + } + if (nextDir === OPPOSITE[state.dir]) { + return { dir: state.dir, dirQueue: rest }; + } + return { dir: nextDir, dirQueue: rest }; +} - // Resolve the buffered turn against the last actually-moved direction. - const dir = - state.pendingDir !== null && state.pendingDir !== OPPOSITE[state.dir] - ? state.pendingDir - : state.dir; +/** + * Advance one movement step. Returns the next state plus explicit domain + * events (eat / bonus / milestone / die / win) so the renderer never re-derives + * them from state diffs. + */ +export function snakeTick(state: SnakeState): SnakeStep { + if (!state.alive || state.won) return { state, events: [] }; + const head = state.body[0]; + if (!head) return { state, events: [] }; + const { dir, dirQueue } = resolveDir(state); const delta = DELTA[dir]; const next: Cell = { x: head.x + delta.x, y: head.y + delta.y }; + // Classic snake: the board edge kills. if ( next.x < 0 || next.y < 0 || next.x >= state.width || next.y >= state.height ) { - return { ...state, dir, pendingDir: null, alive: false }; + return { + state: { ...state, dir, dirQueue, alive: false }, + events: ["die"], + }; } - // Level walls are as lethal as the board edge. - if (has(state.walls, next)) { - return { ...state, dir, pendingDir: null, alive: false }; - } + const onFood = eq(next, state.food); + const onBonus = state.bonus !== null && eq(next, state.bonus.cell); + const growing = onFood || onBonus; - const eating = eq(next, state.food); - // When not eating, the tail moves away this tick, so colliding with the + // When not growing, the tail moves away this tick, so colliding with the // current tail cell is legal. - const bodyToCheck = eating ? state.body : state.body.slice(0, -1); - if (has(bodyToCheck, next)) { - return { ...state, dir, pendingDir: null, alive: false }; + const bodyToCheck = growing ? state.body : state.body.slice(0, -1); + if (bodyToCheck.some((c) => eq(c, next))) { + return { + state: { ...state, dir, dirQueue, alive: false }, + events: ["die"], + }; } - if (eating) { - const grown = [next, ...state.body]; - const won = grown.length >= state.width * state.height - state.walls.length; - const { food, rngSeed } = placeFood( - state.width, - state.height, - grown, - state.walls, - state.rngSeed - ); - return { + const events: SnakeEvent[] = []; + let body: readonly Cell[]; + let score = state.score; + let food = state.food; + let bonus = state.bonus; + let rngSeed = state.rngSeed; + let speed = state.speed; + let won = false; + + if (growing) { + body = [next, ...state.body]; + + // Explicit overlap: food and bonus on the same cell (should not happen after + // placement exclusions, but if it does, award both rather than silently + // clearing the bonus). + if (onFood) { + score += FOOD_SCORE; + speed = speedForScore(score, state.difficulty); + // Milestone replaces the plain eat blip on multiples of 100. + events.push(score % 100 === 0 ? "milestone" : "eat"); + } + if (onBonus && state.bonus) { + score += state.bonus.value; + bonus = null; + events.push("bonus"); + } + + won = body.length >= state.width * state.height; + if (won) { + events.push("win"); + } else if (onFood) { + // Respawn food excluding body AND any remaining bonus cell. + const placed = placeFood( + state.width, + state.height, + foodOccupied(body, bonus), + rngSeed + ); + // If the active bonus owns the only remaining free cell, keep the normal + // food there too. The overlap path awards both, and after bonus expiry the + // normal food remains visible instead of being hidden inside the body. + food = placed.exhausted && bonus ? bonus.cell : placed.food; + rngSeed = placed.rngSeed; + } + } else { + body = [next, ...state.body.slice(0, -1)]; + } + + // Bonus value decays once per movement tick, not by wall-clock time. This + // keeps an identical movement path worth the same score on every difficulty. + if (bonus) { + const remainingMs = bonus.remainingMs - BONUS_TICK_DECAY_MS; + if (remainingMs <= 0) { + bonus = null; + events.push("bonus-expired"); + } else { + bonus = refreshBonus(bonus.cell, remainingMs); + } + } + + // Bonus spawn counter (per movement tick), only when still alive and not won. + // Cadence matches the owner's original game (Snake-game/game.js): once the + // counter passes `bonusFrequency` it re-rolls the 15% chance EVERY tick β€” a + // failed roll does NOT reset the counter (only a successful spawn does), so + // a bonus becomes near-certain shortly after each window. Locked by the + // "failed roll keeps the counter" test in snake.test.ts. + let bonusTickCounter = state.bonusTickCounter + 1; + if (!won && bonus === null) { + const freq = DIFFICULTIES[state.difficulty].bonusFrequency; + if (bonusTickCounter >= freq) { + const roll = nextFloat(rngSeed); + rngSeed = roll.seed; + if (roll.value < BONUS_SPAWN_CHANCE) { + const placed = placeBonus( + state.width, + state.height, + bonusOccupied(body, food), + rngSeed + ); + rngSeed = placed.rngSeed; + if (placed.bonus) { + bonus = placed.bonus; + bonusTickCounter = 0; + } + } + } + } + + return { + state: { ...state, dir, - pendingDir: null, - body: grown, + dirQueue, + body, food, + bonus, + bonusTickCounter, + score, + speed, won, - score: state.score + 1, + alive: true, rngSeed, - }; + }, + events, + }; +} + +/** + * One animation-frame of pure scheduling. Bonus decay is applied by + * `snakeTick`, so it is tied to movement ticks rather than render-frame timing. + * A pickup therefore awards the value at the start of its movement tick. + * + * `accMs` is ms accumulated since the last movement tick (carried across frames). + */ +export function snakeFrame( + state: SnakeState, + accMs: number, + dtMs: number +): SnakeFrameResult { + if (!state.alive || state.won || dtMs <= 0) { + return { state, accMs: state.alive && !state.won ? accMs : 0, events: [] }; } - const moved = [next, ...state.body.slice(0, -1)]; - return { ...state, dir, pendingDir: null, body: moved }; -} + const events: SnakeEvent[] = []; + let s = state; + let acc = Math.max(0, accMs); + let remaining = dtMs; + let immediateTurnCommitted = false; + // Bound catch-up so a multi-second stall cannot spin for hundreds of ticks. + let ticks = 0; + const maxTicks = 60; + + while (remaining > 0 && s.alive && !s.won && ticks < maxTicks) { + const interval = tickIntervalMs(s.speed); + + // Movement tick is already due β€” apply it before more wall time so a + // pickup sees the value at tick time (not after end-of-frame decay). + const turnReady = !immediateTurnCommitted && hasQueuedTurn(s); + if (turnReady || acc + 1e-9 >= interval) { + if (turnReady) immediateTurnCommitted = true; + acc -= interval; + if (acc < 0) acc = 0; + const stepped = snakeTick(turnReady ? discardLeadingQueuedNoops(s) : s); + s = stepped.state; + for (const e of stepped.events) events.push(e); + ticks += 1; + continue; + } + + const timeToTick = interval - acc; + const step = Math.min(timeToTick, remaining); + if (step <= 0) break; + + acc += step; + remaining -= step; + } + + // Cap catch-up: if a long stall left acc above one interval, drop the excess + // so the next frame does not multi-tick instantly after recovery. + const cap = tickIntervalMs(s.speed); + if (acc > cap) acc = 0; -/** True when `cell` is outside the board or on a level wall. Rendering/near-miss helper. */ -export function isBlockedCell(state: SnakeState, cell: Cell): boolean { - if (cell.x < 0 || cell.y < 0 || cell.x >= state.width || cell.y >= state.height) return true; - return has(state.walls, cell); + return { state: s, accMs: acc, events }; } diff --git a/frontend/src/game/arcade/snakeSprites.test.ts b/frontend/src/game/arcade/snakeSprites.test.ts deleted file mode 100644 index d7bc8a3..0000000 --- a/frontend/src/game/arcade/snakeSprites.test.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { - FOOD_TILE, - SNAKE_TILE_COUNT, - WALL_TILE, - dirBetween, - snakeTileIndex, -} from "./snakeSprites"; -import type { Cell, Dir } from "./snake"; - -describe("dirBetween", () => { - const cases: Array<{ to: Cell; dir: Dir | null }> = [ - { to: { x: 6, y: 5 }, dir: "right" }, - { to: { x: 4, y: 5 }, dir: "left" }, - { to: { x: 5, y: 6 }, dir: "down" }, - { to: { x: 5, y: 4 }, dir: "up" }, - { to: { x: 7, y: 5 }, dir: null }, - { to: { x: 6, y: 6 }, dir: null }, - { to: { x: 5, y: 5 }, dir: null }, - ]; - for (const { to, dir } of cases) { - it(`(5,5) β†’ (${to.x},${to.y}) is ${dir ?? "not adjacent"}`, () => { - expect(dirBetween({ x: 5, y: 5 }, to)).toBe(dir); - }); - } -}); - -describe("snakeTileIndex", () => { - it("keeps every index inside the sheet", () => { - const body: Cell[] = [ - { x: 5, y: 2 }, - { x: 4, y: 2 }, - { x: 3, y: 2 }, - { x: 3, y: 3 }, - { x: 3, y: 4 }, - ]; - for (let i = 0; i < body.length; i++) { - const tile = snakeTileIndex(body, i, "right"); - expect(tile).toBeGreaterThanOrEqual(0); - expect(tile).toBeLessThan(SNAKE_TILE_COUNT); - } - expect(FOOD_TILE).toBeLessThan(SNAKE_TILE_COUNT); - expect(WALL_TILE).toBeLessThan(SNAKE_TILE_COUNT); - }); - - const headCases: Array<{ dir: Dir; neck: Cell; tile: number }> = [ - { dir: "right", neck: { x: 4, y: 5 }, tile: 0 }, - { dir: "down", neck: { x: 5, y: 4 }, tile: 1 }, - { dir: "left", neck: { x: 6, y: 5 }, tile: 2 }, - { dir: "up", neck: { x: 5, y: 6 }, tile: 3 }, - ]; - for (const { dir, neck, tile } of headCases) { - it(`head facing ${dir} uses tile ${tile}`, () => { - expect(snakeTileIndex([{ x: 5, y: 5 }, neck], 0, dir)).toBe(tile); - }); - } - - it("falls back to the moved direction for a length-1 snake", () => { - expect(snakeTileIndex([{ x: 5, y: 5 }], 0, "up")).toBe(3); - }); - - const tailCases: Array<{ name: string; toHead: Cell; tile: number }> = [ - { name: "body continues right", toHead: { x: 6, y: 5 }, tile: 10 }, - { name: "body continues down", toHead: { x: 5, y: 6 }, tile: 11 }, - { name: "body continues left", toHead: { x: 4, y: 5 }, tile: 12 }, - { name: "body continues up", toHead: { x: 5, y: 4 }, tile: 13 }, - ]; - for (const { name, toHead, tile } of tailCases) { - it(`tail where the ${name} uses tile ${tile}`, () => { - // [head, tail]: index 1 is the tail. - expect(snakeTileIndex([toHead, { x: 5, y: 5 }], 1, "right")).toBe(tile); - }); - } - - // Middle segment: [prev(head side), cell, next(tail side)]. - const midCases: Array<{ name: string; prev: Cell; next: Cell; tile: number }> = [ - { name: "straight horizontal", prev: { x: 6, y: 5 }, next: { x: 4, y: 5 }, tile: 4 }, - { name: "straight horizontal (reversed)", prev: { x: 4, y: 5 }, next: { x: 6, y: 5 }, tile: 4 }, - { name: "straight vertical", prev: { x: 5, y: 4 }, next: { x: 5, y: 6 }, tile: 5 }, - { name: "bend up+right", prev: { x: 5, y: 4 }, next: { x: 6, y: 5 }, tile: 6 }, - { name: "bend up+right (mirrored)", prev: { x: 6, y: 5 }, next: { x: 5, y: 4 }, tile: 6 }, - { name: "bend right+down", prev: { x: 6, y: 5 }, next: { x: 5, y: 6 }, tile: 7 }, - { name: "bend down+left", prev: { x: 5, y: 6 }, next: { x: 4, y: 5 }, tile: 8 }, - { name: "bend left+up", prev: { x: 4, y: 5 }, next: { x: 5, y: 4 }, tile: 9 }, - { name: "bend left+up (mirrored)", prev: { x: 5, y: 4 }, next: { x: 4, y: 5 }, tile: 9 }, - ]; - for (const { name, prev, next, tile } of midCases) { - it(`${name} uses tile ${tile}`, () => { - expect(snakeTileIndex([prev, { x: 5, y: 5 }, next], 1, "right")).toBe(tile); - }); - } - - it("degrades gracefully on a non-adjacent or missing segment", () => { - expect(snakeTileIndex([], 0, "right")).toBe(4); - // Teleported segment (never produced by the reducer) still returns a valid tile. - expect( - snakeTileIndex([{ x: 0, y: 0 }, { x: 5, y: 5 }, { x: 9, y: 9 }], 1, "right") - ).toBe(4); - }); - - it("renders a full path with the head, bends and tail in the right order", () => { - // Path: head (7,2) ← (6,2) ← (5,2) ← (4,2) ↓ (4,3) ↓ (4,4) β†’ (5,4) β†’ (6,4) ↓ tail (6,5) - const body: Cell[] = [ - { x: 7, y: 2 }, - { x: 6, y: 2 }, - { x: 5, y: 2 }, - { x: 4, y: 2 }, - { x: 4, y: 3 }, - { x: 4, y: 4 }, - { x: 5, y: 4 }, - { x: 6, y: 4 }, - { x: 6, y: 5 }, - ]; - const tiles = body.map((_, i) => snakeTileIndex(body, i, "right")); - expect(tiles).toEqual([0, 4, 4, 7, 5, 6, 4, 8, 13]); - }); -}); diff --git a/frontend/src/game/arcade/snakeSprites.ts b/frontend/src/game/arcade/snakeSprites.ts deleted file mode 100644 index 8fcb514..0000000 --- a/frontend/src/game/arcade/snakeSprites.ts +++ /dev/null @@ -1,94 +0,0 @@ -/** - * Snake sprite-tile selection β€” pure, no DOM/Phaser/net. - * - * The renderer draws the snake from a 16-tile sprite sheet - * (`public/assets/arcade/snake_tiles.png`, generated by - * `frontend/scripts/gen_snake_sprites.py`) instead of flat rectangles (issue - * #163). Deciding WHICH tile a segment uses is geometry, not drawing, so it - * lives here with its own tests; the renderer just blits the returned index. - * - * Sheet layout β€” one row of 16 tiles, 16Γ—16 px each, left to right: - * 0 headβ†’right 1 headβ†’down 2 headβ†’left 3 headβ†’up - * 4 body horizontal 5 body vertical - * 6 bend up+right 7 bend right+down 8 bend down+left 9 bend left+up - * 10 tailβ†’right 11 tailβ†’down 12 tailβ†’left 13 tailβ†’up (arrow = toward head) - * 14 food (apple) 15 wall block - */ -import type { Cell, Dir } from "./snake"; - -export const SNAKE_TILE_PX = 16; -export const SNAKE_TILE_COUNT = 16; -export const SNAKE_SHEET_URL = "/assets/arcade/snake_tiles.png"; - -export const FOOD_TILE = 14; -export const WALL_TILE = 15; - -const HEAD_TILE: Record = { right: 0, down: 1, left: 2, up: 3 }; -const TAIL_TILE: Record = { right: 10, down: 11, left: 12, up: 13 }; -const STRAIGHT_TILE = { horizontal: 4, vertical: 5 } as const; - -/** Bend tile by the pair of edges the segment connects, in canonical order. */ -const BEND_TILE: Record = { - "up|right": 6, - "right|down": 7, - "down|left": 8, - "left|up": 9, -}; - -/** - * Step direction from `from` to the adjacent cell `to`, or null when the cells - * are not orthogonally adjacent (defensive β€” the reducer never produces that). - */ -export function dirBetween(from: Cell, to: Cell): Dir | null { - const dx = to.x - from.x; - const dy = to.y - from.y; - if (dx === 1 && dy === 0) return "right"; - if (dx === -1 && dy === 0) return "left"; - if (dx === 0 && dy === 1) return "down"; - if (dx === 0 && dy === -1) return "up"; - return null; -} - -function bendKey(a: Dir, b: Dir): string { - const order: Dir[] = ["up", "right", "down", "left"]; - const [first, second] = order.indexOf(a) <= order.indexOf(b) ? [a, b] : [b, a]; - const key = `${first}|${second}`; - // "up|left" is the same corner as "left|up"; canonicalise to the table's key. - return key === "up|left" ? "left|up" : key; -} - -/** - * Tile index for body segment `i` (0 = head). `dir` is the direction the head - * last moved, used for a length-1 snake and as the head's facing. - */ -export function snakeTileIndex(body: readonly Cell[], i: number, dir: Dir): number { - const n = body.length; - const cell = body[i]; - if (!cell) return STRAIGHT_TILE.horizontal; - - if (i === 0) { - const next = body[1]; - const facing = next ? dirBetween(next, cell) ?? dir : dir; - return HEAD_TILE[facing]; - } - - if (i === n - 1) { - const prev = body[i - 1]; - const facing = prev ? dirBetween(cell, prev) ?? dir : dir; - return TAIL_TILE[facing]; - } - - const prev = body[i - 1]; - const next = body[i + 1]; - if (!prev || !next) return STRAIGHT_TILE.horizontal; - const toHead = dirBetween(cell, prev); - const toTail = dirBetween(cell, next); - if (!toHead || !toTail) return STRAIGHT_TILE.horizontal; - - if (toHead === "left" || toHead === "right") { - if (toTail === "left" || toTail === "right") return STRAIGHT_TILE.horizontal; - } else if (toTail === "up" || toTail === "down") { - return STRAIGHT_TILE.vertical; - } - return BEND_TILE[bendKey(toHead, toTail)] ?? STRAIGHT_TILE.horizontal; -} diff --git a/frontend/src/net/arcade.ts b/frontend/src/net/arcade.ts index 04b7fdb..cba467a 100644 --- a/frontend/src/net/arcade.ts +++ b/frontend/src/net/arcade.ts @@ -7,11 +7,16 @@ * rather than throwing β€” the games are fully playable offline, scores just do * not persist. */ -import type { ArcadeGame, ArcadeLeaderboard, ArcadeScoreResult } from "@metaverse/shared"; +import { + scoreGameForArcadeGame, + type ArcadeGame, + type ArcadeLeaderboard, + type ArcadeScoreResult, +} from "@metaverse/shared"; import { authToken, serverBase, USE_MOCK } from "./auth"; function emptyBoard(game: ArcadeGame): ArcadeLeaderboard { - return { game, top: [], best: null }; + return { game: scoreGameForArcadeGame(game), top: [], best: null }; } async function request(path: string, init: RequestInit): Promise { @@ -29,7 +34,8 @@ async function request(path: string, init: RequestInit): Promise { /** Fetch the top-N leaderboard for a cabinet plus the caller's personal best. */ export async function fetchLeaderboard(game: ArcadeGame): Promise { - const board = await request(`/api/v1/arcade/scores/${game}`, { method: "GET" }); + const scoreGame = scoreGameForArcadeGame(game); + const board = await request(`/api/v1/arcade/scores/${scoreGame}`, { method: "GET" }); return board ?? emptyBoard(game); } @@ -38,9 +44,10 @@ export async function submitScore( game: ArcadeGame, score: number ): Promise { + const scoreGame = scoreGameForArcadeGame(game); const board = await request(`/api/v1/arcade/scores`, { method: "POST", - body: JSON.stringify({ game, score }), + body: JSON.stringify({ game: scoreGame, score }), }); return board ?? { ...emptyBoard(game), best: score, newBest: score > 0 }; } diff --git a/frontend/src/ui/arcade/ArcadeMotion.test.tsx b/frontend/src/ui/arcade/ArcadeMotion.test.tsx index 4bf808c..7027137 100644 --- a/frontend/src/ui/arcade/ArcadeMotion.test.tsx +++ b/frontend/src/ui/arcade/ArcadeMotion.test.tsx @@ -2,21 +2,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { act, cleanup, render } from "@testing-library/react"; import { initReducedMotion } from "../reducedMotionBridge"; import { setSettings } from "../settings"; -import SnakeGame from "./SnakeGame"; import MergeDropGame from "./MergeDropGame"; import { initFlappy } from "../../game/arcade/flappy"; import { createFx } from "./flappy/fx"; import { renderFlappy } from "./flappy/render"; -const { shakeOffset } = vi.hoisted(() => ({ - shakeOffset: vi.fn(() => ({ x: 5, y: 5 })), -})); - -vi.mock("../../game/arcade/juice", async (importOriginal) => { - const actual = await importOriginal(); - return { ...actual, shakeOffset }; -}); - function canvasContext(): CanvasRenderingContext2D { const gradient = { addColorStop: vi.fn() }; return new Proxy( @@ -41,7 +31,6 @@ function canvasContext(): CanvasRenderingContext2D { describe("arcade canvas reduced motion", () => { beforeEach(() => { vi.useFakeTimers(); - shakeOffset.mockClear(); setSettings({ reducedMotion: "off" }); initReducedMotion(); }); @@ -52,32 +41,6 @@ describe("arcade canvas reduced motion", () => { vi.restoreAllMocks(); }); - it("stops Snake shake during an active run when reduced motion turns on", () => { - const ctx = canvasContext(); - vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue(ctx); - - render( - - ); - expect(shakeOffset).toHaveBeenCalled(); - shakeOffset.mockClear(); - - act(() => { - setSettings({ reducedMotion: "on" }); - }); - act(() => { - vi.advanceTimersByTime(16); - }); - - expect(shakeOffset).not.toHaveBeenCalled(); - }); - it("suppresses Flappy's renderer shake when the live preference is off", () => { const ctx = canvasContext(); const translate = ctx.translate as ReturnType; diff --git a/frontend/src/ui/arcade/ArcadeOverlay.test.tsx b/frontend/src/ui/arcade/ArcadeOverlay.test.tsx index 616422c..a4d233f 100644 --- a/frontend/src/ui/arcade/ArcadeOverlay.test.tsx +++ b/frontend/src/ui/arcade/ArcadeOverlay.test.tsx @@ -34,6 +34,7 @@ vi.mock("./SnakeGame", async (importOriginal) => { }); import ArcadeOverlay from "./ArcadeOverlay"; +import { TERMINAL_HOLD_MS } from "./terminalHold"; import { bus } from "../../game/eventBus"; import { getSettings, setSettings } from "../settings"; import type { ArcadeGameProps } from "./gameTypes"; @@ -370,7 +371,7 @@ describe("ArcadeOverlay run lifecycle", () => { }); expect(bestEvents).toHaveLength(1); act(() => { - vi.advanceTimersByTime(449); + vi.advanceTimersByTime(TERMINAL_HOLD_MS.snake - 1); }); expect(panelOpen("over")).toBe(false); act(() => { @@ -381,7 +382,25 @@ describe("ArcadeOverlay run lifecycle", () => { off(); }); + it("shows the terminal card immediately when reduced motion is enabled", () => { + vi.useFakeTimers(); + snakeCtl.stub = true; + setSettings({ reducedMotion: "on" }); + try { + render( {}} />); + fireEvent.click(screen.getByTestId("stub-game-over")); + expect(panelOpen("over")).toBe(false); + act(() => { + vi.advanceTimersByTime(0); + }); + expect(panelOpen("over")).toBe(true); + } finally { + setSettings({ reducedMotion: "system" }); + } + }); + it("does not celebrate a late result from a run that was restarted", async () => { + vi.useFakeTimers(); snakeCtl.stub = true; const pending = deferred(); net.submitScore.mockReturnValue(pending.promise); @@ -390,7 +409,10 @@ describe("ArcadeOverlay run lifecycle", () => { render( {}} />); fireEvent.click(screen.getByTestId("stub-game-over")); - fireEvent.click(screen.getByRole("button", { name: "Chill" })); + act(() => { + vi.advanceTimersByTime(TERMINAL_HOLD_MS.snake); + }); + fireEvent.click(screen.getByRole("button", { name: "Play again" })); await act(async () => { pending.resolve({ ...board, best: 21, newBest: true }); }); @@ -405,8 +427,8 @@ describe("ArcadeOverlay run lifecycle", () => { render( {}} />); // Snake sits in a difficulty menu until started β€” clicking a difficulty starts the run. fireEvent.click(screen.getByRole("button", { name: /normal/i })); - // Heading right on the default board walks into the wall, then the death - // animation (~0.83s) settles before the overlay shows Game over. + // Heading right on the default board walks into the wall; the renderer + // submits immediately and the overlay holds its terminal card briefly. await act(async () => { vi.advanceTimersByTime(10_000); }); @@ -446,7 +468,7 @@ describe("ArcadeOverlay run lifecycle", () => { render(); fireEvent.click(screen.getByTestId("stub-game-over")); act(() => { - vi.advanceTimersByTime(450); + vi.advanceTimersByTime(TERMINAL_HOLD_MS.snake); }); expect(panelOpen("over")).toBe(true); @@ -475,7 +497,7 @@ describe("ArcadeOverlay run lifecycle", () => { // with no KNOWN previous best there must be no "New best!" claim. fireEvent.click(screen.getByTestId("stub-game-over")); act(() => { - vi.advanceTimersByTime(450); + vi.advanceTimersByTime(TERMINAL_HOLD_MS.snake); }); expect(panelOpen("over")).toBe(true); expect(screen.queryByText("New best!")).toBeNull(); @@ -497,7 +519,7 @@ describe("ArcadeOverlay run lifecycle", () => { fireEvent.click(screen.getByTestId("stub-game-over")); await act(async () => { await Promise.resolve(); - vi.advanceTimersByTime(450); + vi.advanceTimersByTime(TERMINAL_HOLD_MS.snake); }); expect(screen.getByText("New best!")).toBeTruthy(); }); diff --git a/frontend/src/ui/arcade/ArcadeOverlay.tsx b/frontend/src/ui/arcade/ArcadeOverlay.tsx index a16c258..2587bb2 100644 --- a/frontend/src/ui/arcade/ArcadeOverlay.tsx +++ b/frontend/src/ui/arcade/ArcadeOverlay.tsx @@ -9,14 +9,14 @@ import { import { Maximize, Minimize, Vibrate, VibrateOff, Volume2, VolumeX, X } from "lucide-react"; import type { ArcadeGame, ArcadeLeaderboard } from "@metaverse/shared"; import { toSeed } from "../../game/arcade/prng"; -import type { SnakeLevelId, SnakeSpeedId } from "../../game/arcade/snake"; import { bus } from "../../game/eventBus"; import { fetchLeaderboard, submitScore } from "../../net/arcade"; import { getSettings, setSettings, subscribeSettings } from "../settings"; +import { useReducedMotion } from "../reducedMotionBridge"; import SnakeGame from "./SnakeGame"; import FlappyGame from "./FlappyGame"; import MergeDropGame from "./MergeDropGame"; -import SnakeOptions from "./SnakeOptions"; +import { terminalHoldMs } from "./terminalHold"; import { exitFullscreen, fullscreenAvailable, @@ -54,8 +54,6 @@ type Phase = | { k: "closing" }; const MENU_LENGTH = 3; -/** Let terminal particles/shake read before the game-over card covers them. */ -const DEATH_FREEZE_MS = 450; export interface ArcadeOverlayProps { game: ArcadeGame; @@ -129,8 +127,7 @@ export default function ArcadeOverlay({ game, label, onClose }: ArcadeOverlayPro const [arcadeVolume, setArcadeVolume] = useState(() => getSettings().arcadeVolume); const [muteArcade, setMuteArcade] = useState(() => getSettings().muteArcade); const [arcadeShake, setArcadeShake] = useState(() => getSettings().arcadeShake); - const [snakeSpeed, setSnakeSpeed] = useState(() => getSettings().snakeSpeed); - const [snakeLevel, setSnakeLevel] = useState(() => getSettings().snakeLevel); + const reducedMotion = useReducedMotion(); const activeRunIdRef = useRef(run.id); const aliveRef = useRef(true); const reqSeqRef = useRef(0); @@ -152,8 +149,6 @@ export default function ArcadeOverlay({ game, label, onClose }: ArcadeOverlayPro setArcadeVolume(s.arcadeVolume); setMuteArcade(s.muteArcade); setArcadeShake(s.arcadeShake); - setSnakeSpeed(s.snakeSpeed); - setSnakeLevel(s.snakeLevel); }), [] ); @@ -365,7 +360,7 @@ export default function ArcadeOverlay({ game, label, onClose }: ArcadeOverlayPro setLastFinal(finalScore); setPhase({ k: "finishing", score: finalScore }); - // Persist immediately. The 450ms terminal hold is presentation only and + // Persist immediately. The terminal hold is presentation only and // cannot lose a score if the player restarts or quits during it. const reqId = ++reqSeqRef.current; submitScore(game, finalScore) @@ -386,9 +381,9 @@ export default function ArcadeOverlay({ game, label, onClose }: ArcadeOverlayPro setPhase({ k: "over", score: finalScore }); } finishTimerRef.current = null; - }, DEATH_FREEZE_MS); + }, terminalHoldMs(game, reducedMotion)); }, - [game, absorbBoard, refresh] + [game, absorbBoard, refresh, reducedMotion] ); const menuOpen = phase.k === "paused" && phase.reason !== "auto"; @@ -589,20 +584,6 @@ export default function ArcadeOverlay({ game, label, onClose }: ArcadeOverlayPro
- {game === "snake" && ( - { - setSettings({ snakeSpeed: id }); - restart(); - }} - onPickLevel={(id) => { - setSettings({ snakeLevel: id }); - restart(); - }} - /> - )}
Score diff --git a/frontend/src/ui/arcade/SnakeGame.tsx b/frontend/src/ui/arcade/SnakeGame.tsx index 3070c48..17c9adc 100644 --- a/frontend/src/ui/arcade/SnakeGame.tsx +++ b/frontend/src/ui/arcade/SnakeGame.tsx @@ -1,40 +1,73 @@ import { useCallback, useEffect, useRef, useState } from "react"; -import { bus } from "../../game/eventBus"; +import { bus, type ArcadeBusEvent } from "../../game/eventBus"; import { + DEFAULT_SNAKE_HEIGHT, + DEFAULT_SNAKE_WIDTH, initSnake, snakeInput, - snakeLevelById, - snakeSpeedById, - snakeTick, + snakeFrame, type Dir, + type Difficulty, + type SnakeEvent, type SnakeState, } from "../../game/arcade/snake"; -import { snakeNearMiss } from "../../game/arcade/feedback"; +import { createFoodAnim } from "./snake/food"; +import { createFx, startGameOverFx, stepGameOverFx } from "./snake/fx"; import { - FOOD_TILE, - SNAKE_SHEET_URL, - SNAKE_TILE_PX, - WALL_TILE, - snakeTileIndex, -} from "../../game/arcade/snakeSprites"; -import { - burst, - fadeAlpha, - initJuice, - popup, - shake, - shakeOffset, - stepJuice, - type JuiceState, -} from "../../game/arcade/juice"; -import { getSettings } from "../settings"; + GLIDE_MS, + interpolateSnakeBody, + isSingleTickBodyTransition, + renderHeading, +} from "./snake/interp"; +import { renderSnake } from "./snake/render"; import { useReducedMotion } from "../reducedMotionBridge"; import type { ArcadeGameProps } from "./gameTypes"; -/** Render cell size: exactly 2x the 16px sprite tile, so scaling stays crisp. */ -const CELL = SNAKE_TILE_PX * 2; -/** Frame cadence for juice + drawing; the reducer ticks on the level's own cadence. */ -const FRAME_MS = 16; +const MAX_DPR = 2; +/** + * FIXED cell size in CSS px (the original game's grid). Cells are NEVER scaled + * to fit the stage β€” that just reads as "zoomed in". The board fills the stage + * by GRID SIZE instead: more cells, same 20px each. + */ +const CELL_CSS = 20; +/** Smallest playable board if the stage is tiny. */ +const MIN_COLS = 20; +const MIN_ROWS = 12; +/** + * Minimum letterbox per side. The stage surface clips with a 10px border + * radius; anything closer than ~3px to its edge gets a bite taken out of the + * corner cells, which players read as "the end cell is cut off". + */ +const EDGE_INSET = 4; +const DIFFICULTY_KEY = "arcadeSnakeDifficulty"; +const DIFFICULTIES: readonly Difficulty[] = ["easy", "normal", "hard"]; + +/** Board size in whole cells for the current stage box. */ +interface Board { + readonly cols: number; + readonly rows: number; +} + +interface SnakeMotion { + readonly previous: SnakeState["body"]; + readonly current: SnakeState["body"]; + readonly dir: Dir; + readonly snap: boolean; +} + +const INITIAL_BOARD: Board = { cols: MIN_COLS, rows: MIN_ROWS }; +/** Fixed dimensions keep globally ranked Snake runs comparable. */ +const SCORE_BOARD: Board = { cols: DEFAULT_SNAKE_WIDTH, rows: DEFAULT_SNAKE_HEIGHT }; + +/** Reducer domain event β†’ bus event name (glue only β€” no game rules). */ +const BUS_FOR_EVENT: Readonly>> = { + eat: "arcade-eat", + bonus: "arcade-bonus", + milestone: "arcade-milestone", + die: "arcade-snake-over", + win: "arcade-over", + // bonus-expired is silent +}; const KEY_DIR: Record = { ArrowUp: "up", @@ -51,284 +84,504 @@ const KEY_DIR: Record = { D: "right", }; -/** - * The sprite sheet is shared across mounts (a run restart remounts this - * component) so a restart never re-requests it. Absent in non-DOM test - * environments β€” the renderer falls back to plain shapes until it decodes. - */ -let sheetImage: HTMLImageElement | null = null; -function snakeSheet(): HTMLImageElement | null { - if (typeof Image === "undefined") return null; - if (!sheetImage) { - sheetImage = new Image(); - sheetImage.src = SNAKE_SHEET_URL; +function readStoredDifficulty(): Difficulty { + try { + const raw = localStorage.getItem(DIFFICULTY_KEY); + if (raw === "easy" || raw === "normal" || raw === "hard") return raw; + } catch { + /* private mode / denied β€” fall through */ } - return sheetImage; + return "normal"; } -function roundRect( - ctx: CanvasRenderingContext2D, - x: number, - y: number, - w: number, - h: number, - r: number -) { - ctx.beginPath(); - if (typeof ctx.roundRect === "function") ctx.roundRect(x, y, w, h, r); - else ctx.rect(x, y, w, h); - ctx.fill(); +function writeStoredDifficulty(d: Difficulty): void { + try { + localStorage.setItem(DIFFICULTY_KEY, d); + } catch { + /* ignore */ + } } /** * Thin canvas renderer for the pure Snake module. A new run remounts this - * component (ArcadeOverlay keys it by a monotonic run id), so the refs init - * fresh from `seed` AND from the persisted level/speed settings β€” which is why - * changing an option in the overlay starts a new run. Screen shake is a live - * prop (accessibility toggle mid-run must apply immediately). - * - * All game rules stay in game/arcade/snake; the level layout and tick cadence - * are data from that module; the particles/pop-ups/shake are the pure - * game/arcade/juice reducer. This file only draws and wires input, and it stays - * audio-agnostic: it emits domain events on the bus and the sound mixer decides - * the clip. + * component (ArcadeOverlay keys it by seed), so the refs init fresh from `seed`. + * All game rules stay in game/arcade/snake β€” this only draws the returned state, + * forwards input, and relays reducer-emitted domain events to the bus. */ -// The `shake` prop is destructured as `shakeSetting` β€” the bare name would -// shadow the imported juice `shake` reducer. export default function SnakeGame({ seed, paused, - shake: shakeSetting, + shake, onScore, onGameOver, + bestScore = null, }: ArcadeGameProps) { + const reducedMotion = useReducedMotion(); + const shakeEnabled = shake && !reducedMotion; + const rootRef = useRef(null); const canvasRef = useRef(null); - // Snapshot the board-shaping options once per run so a mid-run settings - // change cannot reshape the board under the player. The overlay remounts this - // component for every run (it keys the game by run id), so a fresh run always - // re-reads them. Screen shake is deliberately NOT snapshotted: it arrives as - // a live prop so the accessibility toggle applies to the active run. - const [options] = useState(() => { - const s = getSettings(); - return { - level: snakeLevelById(s.snakeLevel), - tickMs: snakeSpeedById(s.snakeSpeed).tickMs, - }; - }); - const reduced = useReducedMotion(); - const shakeEnabled = shakeSetting && !reduced; - - const stateRef = useRef(initSnake(seed, options.level)); - // Juice gets its own PRNG stream seeded from the run seed, so the feedback is - // as reproducible as the game itself and never touches the game's own seed. - const juiceRef = useRef(initJuice(((seed ^ 0x9e3779b9) >>> 0) || 1)); + const stateRef = useRef(null); + const motionRef = useRef(null); + const accMsRef = useRef(0); + const foodAnimRef = useRef(createFoodAnim()); + const fxRef = useRef(createFx()); const overRef = useRef(false); - const nearRef = useRef(false); - const accRef = useRef(0); + /** Cell size in DEVICE pixels β€” what every drawing module scales off. */ + const cellRef = useRef(CELL_CSS); + const highscoreBeatenRef = useRef(false); + const bestScoreRef = useRef(bestScore); + const pausedRef = useRef(paused); + /** Latest measurement of the stage; only adopted between runs. */ + const measuredRef = useRef(INITIAL_BOARD); + /** Board the canvas backing store is currently sized for (mirror of state). */ + const sizedRef = useRef(INITIAL_BOARD); + const phaseRef = useRef<"menu" | "play">("menu"); + /** Board the canvas is currently sized for (frozen while a run is live). */ + const [board, setBoard] = useState(INITIAL_BOARD); + const [phase, setPhase] = useState<"menu" | "play">("menu"); + const [difficulty, setDifficulty] = useState(readStoredDifficulty); - const centre = useCallback( - (c: { x: number; y: number }) => ({ x: c.x * CELL + CELL / 2, y: c.y * CELL + CELL / 2 }), - [] - ); + // Keep the latest props available to the raf loop / key handlers without + // writing refs during render (react-hooks/refs). + useEffect(() => { + bestScoreRef.current = bestScore; + }, [bestScore]); + useEffect(() => { + pausedRef.current = paused; + }, [paused]); + useEffect(() => { + phaseRef.current = phase; + }, [phase]); const draw = useCallback(() => { const canvas = canvasRef.current; const ctx = canvas?.getContext("2d"); if (!canvas || !ctx) return; - const s = stateRef.current; - const j = juiceRef.current; - const { width, height } = canvas; - const sheet = snakeSheet(); - const ready = !!sheet && sheet.complete && sheet.naturalWidth > 0; - - ctx.setTransform(1, 0, 0, 1, 0, 0); - ctx.imageSmoothingEnabled = false; - - // Playfield backdrop + subtle checker so motion reads clearly. - ctx.fillStyle = "#0b0f18"; - ctx.fillRect(0, 0, width, height); - - const off = shakeEnabled ? shakeOffset(j) : { x: 0, y: 0 }; - ctx.save(); - ctx.translate(off.x, off.y); - - ctx.fillStyle = "rgba(127, 209, 185, 0.04)"; - for (let y = 0; y < s.height; y++) { - for (let x = 0; x < s.width; x++) { - if ((x + y) % 2 === 0) ctx.fillRect(x * CELL, y * CELL, CELL, CELL); - } - } - const tile = (index: number, cx: number, cy: number, size = CELL) => { - if (!ready || !sheet) return false; - const inset = (CELL - size) / 2; - ctx.drawImage( - sheet, - index * SNAKE_TILE_PX, - 0, - SNAKE_TILE_PX, - SNAKE_TILE_PX, - cx * CELL + inset, - cy * CELL + inset, - size, - size - ); - return true; - }; + // Menu: show an idle board at the current size so the surface isn't blank. + const s = + stateRef.current ?? + initSnake(seed, { + difficulty, + width: board.cols, + height: board.rows, + }); - // Level walls. - for (const w of s.walls) { - if (!tile(WALL_TILE, w.x, w.y)) { - ctx.fillStyle = "#3a4767"; - ctx.fillRect(w.x * CELL, w.y * CELL, CELL, CELL); - } - } + const motion = motionRef.current; + const interpolate = + phaseRef.current === "play" && + !pausedRef.current && + !reducedMotion && + s.alive && + !s.won && + !fxRef.current.animating; + const tickMs = 1000 / Math.max(0.5, s.speed); + const body = motion + ? interpolateSnakeBody( + motion.previous, + motion.current, + accMsRef.current / Math.min(tickMs, GLIDE_MS), + motion.snap || !interpolate + ) + : s.body; - // Food, with a gentle breathing pulse (skipped under reduced motion). - const pulse = reduced ? 1 : 1 + 0.07 * Math.sin(j.elapsedMs / 180); - ctx.save(); - ctx.shadowColor = "#e0567a"; - ctx.shadowBlur = 10; - if (!tile(FOOD_TILE, s.food.x, s.food.y, CELL * pulse)) { - ctx.fillStyle = "#e0567a"; - roundRect(ctx, s.food.x * CELL + 6, s.food.y * CELL + 6, CELL - 12, CELL - 12, 6); - } - ctx.restore(); - - // Snake: one sprite tile per segment, chosen from its neighbours. - s.body.forEach((c, i) => { - if (tile(snakeTileIndex(s.body, i, s.dir), c.x, c.y)) return; - // Fallback while the sheet decodes: the pre-#163 rounded segments. - const n = s.body.length; - const t = n > 1 ? i / (n - 1) : 0; - ctx.fillStyle = - i === 0 ? "#8ff0d6" : `rgb(${74 - t * 20}, ${157 - t * 40}, ${142 - t * 30})`; - roundRect(ctx, c.x * CELL + 2, c.y * CELL + 2, CELL - 4, CELL - 4, 8); - }); - - // Particles. - for (const p of j.particles) { - ctx.globalAlpha = fadeAlpha(p.life, p.maxLife); - ctx.fillStyle = p.color; - ctx.fillRect(p.x - p.size, p.y - p.size, p.size * 2, p.size * 2); - } - ctx.globalAlpha = 1; - - // Score pop-ups. - ctx.font = "bold 20px system-ui, sans-serif"; - ctx.textAlign = "center"; - ctx.textBaseline = "middle"; - for (const u of j.popups) { - ctx.globalAlpha = fadeAlpha(u.life, u.maxLife); - ctx.lineWidth = 4; - ctx.strokeStyle = "rgba(11, 15, 24, 0.9)"; - ctx.strokeText(u.text, u.x, u.y); - ctx.fillStyle = u.color; - ctx.fillText(u.text, u.x, u.y); - } - ctx.globalAlpha = 1; - ctx.restore(); + renderSnake( + ctx, + s, + fxRef.current, + foodAnimRef.current, + performance.now(), + cellRef.current, + body, + renderHeading(motion?.dir ?? s.dir, s.dirQueue), + shakeEnabled, + !reducedMotion, + ); + }, [seed, difficulty, board, reducedMotion, shakeEnabled]); + + /** + * Place the canvas inside the stage box. Two jobs: + * - Land it on WHOLE page pixels: flex centring an odd letterbox remainder + * puts the canvas on a half-pixel boundary, which smears every cell border + * (worst at the board edge, where the outermost row/column reads as "half + * a cell") on dpr-1 screens. + * - If the box shrank below the live board mid-run (e.g. leaving fullscreen), + * downscale uniformly instead of cropping β€” hidden edge cells made the + * snake steer blind near walls. Cells are still never scaled UP. + */ + const layout = useCallback(() => { + const root = rootRef.current; + const canvas = canvasRef.current; + if (!root || !canvas) return; + const idealW = sizedRef.current.cols * CELL_CSS; + const idealH = sizedRef.current.rows * CELL_CSS; + const availW = root.clientWidth - 2 * EDGE_INSET; + const availH = root.clientHeight - 2 * EDGE_INSET; + if (availW < 1 || availH < 1) return; + const scale = Math.min(1, availW / idealW, availH / idealH); + const w = Math.round(idealW * scale); + const h = Math.round(idealH * scale); + // Round against the page, not the box: the box's own origin can sit on a + // fractional pixel (fr-grid layout), which local rounding would keep. + const rect = root.getBoundingClientRect(); + const left = Math.round(rect.left + (root.clientWidth - w) / 2) - rect.left; + const top = Math.round(rect.top + (root.clientHeight - h) / 2) - rect.top; + canvas.style.width = `${w}px`; + canvas.style.height = `${h}px`; + canvas.style.left = `${left}px`; + canvas.style.top = `${top}px`; + }, []); + + /** + * How many whole FIXED-size cells the stage can show. A mid-run resize only + * re-lays-out what is already on screen β€” the live board keeps its + * dimensions; the next run picks the new ones up. + */ + const measure = useCallback(() => { + const root = rootRef.current; + if (!root) return; + const boxW = root.clientWidth - 2 * EDGE_INSET; + const boxH = root.clientHeight - 2 * EDGE_INSET; + if (boxW < 1 || boxH < 1) return; + const next: Board = { + cols: Math.max(MIN_COLS, Math.floor(boxW / CELL_CSS)), + rows: Math.max(MIN_ROWS, Math.floor(boxH / CELL_CSS)), + }; + measuredRef.current = next; + layout(); + if (phaseRef.current !== "menu") return; + setBoard((prev) => + prev.cols === next.cols && prev.rows === next.rows ? prev : next + ); + }, [layout]); - // Frame. - ctx.strokeStyle = "#1d2740"; - ctx.lineWidth = 2; - ctx.strokeRect(1, 1, width - 2, height - 2); - }, [reduced, shakeEnabled]); + // Re-measure on container resize (overlay layout, window) and on fullscreen + // enter/leave, which can change the box without a ResizeObserver-visible + // layout pass in some browsers. + useEffect(() => { + measure(); + const root = rootRef.current; + const observer = + typeof ResizeObserver === "undefined" ? null : new ResizeObserver(measure); + if (observer && root) observer.observe(root); + window.addEventListener("resize", measure); + document.addEventListener("fullscreenchange", measure); + return () => { + observer?.disconnect(); + window.removeEventListener("resize", measure); + document.removeEventListener("fullscreenchange", measure); + }; + }, [measure]); - // Paint the initial frame once mounted, and again when the sheet decodes. + // Size the canvas to the board: fixed 20px CSS cells, dpr-aware backing store + // so the art stays crisp without a single cell ever being scaled. useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + const dpr = Math.min(Math.max(window.devicePixelRatio || 1, 1), MAX_DPR); + cellRef.current = CELL_CSS * dpr; + canvas.width = board.cols * CELL_CSS * dpr; + canvas.height = board.rows * CELL_CSS * dpr; + sizedRef.current = board; + layout(); draw(); - const sheet = snakeSheet(); - if (!sheet || sheet.complete) return; - sheet.addEventListener("load", draw); - return () => sheet.removeEventListener("load", draw); - }, [draw]); + }, [board, layout, draw]); + + const startRun = useCallback( + (d: Difficulty) => { + if (pausedRef.current) return; + writeStoredDifficulty(d); + setDifficulty(d); + // Scored runs always use one board size; CSS layout handles letterboxing + // on narrow/fullscreen surfaces without changing leaderboard potential. + setBoard(SCORE_BOARD); + stateRef.current = initSnake(seed, { + difficulty: d, + width: SCORE_BOARD.cols, + height: SCORE_BOARD.rows, + }); + const initial = stateRef.current; + motionRef.current = { + previous: initial.body, + current: initial.body, + dir: initial.dir, + snap: true, + }; + accMsRef.current = 0; + foodAnimRef.current = createFoodAnim(); + fxRef.current = createFx(); + overRef.current = false; + highscoreBeatenRef.current = false; + onScore(0); + setPhase("play"); + }, + [seed, onScore] + ); + + /** Forward reducer events to the bus + score/highscore stings (glue only). */ + const relayEvents = useCallback( + (prevScore: number, next: SnakeState, events: readonly SnakeEvent[]) => { + if (next.score !== prevScore) onScore(next.score); + + for (const ev of events) { + const busName: ArcadeBusEvent | undefined = BUS_FOR_EVENT[ev]; + if (busName) bus.emit(busName); + } - // Game loop: fixed-cadence reducer ticks, per-frame juice + repaint. + // Highscore sting: only when the overlay has a concrete personal best and + // this run first exceeds it (no parallel leaderboard fetch in the renderer). + const best = bestScoreRef.current; + if ( + typeof best === "number" && + !highscoreBeatenRef.current && + next.score > best + ) { + highscoreBeatenRef.current = true; + const highscoreEvent: ArcadeBusEvent = "arcade-highscore"; + bus.emit(highscoreEvent); + } + }, + [onScore] + ); + + // Game loop: raf drives pure snakeFrame (interleaved time + ticks). useEffect(() => { - if (paused) return; - const id = window.setInterval(() => { - accRef.current += FRAME_MS; - while (accRef.current >= options.tickMs) { - accRef.current -= options.tickMs; - const prev = stateRef.current; - const next = snakeTick(prev); - stateRef.current = next; - let j = juiceRef.current; - - if (next.score !== prev.score) { - // Eating is Snake's score event; it gets its own cue (issue #163). - onScore(next.score); - bus.emit("arcade-eat"); - const at = centre(prev.food); - j = burst(j, { - x: at.x, - y: at.y, - count: 14, - color: "#e0567a", - speed: 3.4, - life: 420, - size: 2.5, - }); - j = popup(j, { x: at.x, y: at.y - 6, text: "+1" }); - if (shakeEnabled) j = shake(j, 0.16); + if (paused || phase !== "play") return; + let raf = 0; + let last = 0; + + const frame = (now: number) => { + raf = requestAnimationFrame(frame); + if (!last) last = now; + const dt = Math.min(100, now - last); + last = now; + + const cur = stateRef.current; + if (!cur) return; + + // Death animation (dt-based) plays after the terminal state is reported; + // the overlay owns the death-card delay and score submission. + if (fxRef.current.animating) { + stepGameOverFx(fxRef.current, dt); + draw(); + return; + } + + // Terminal states already settled into death FX / reported win. + if (!cur.alive || cur.won) { + if (!cur.alive && !fxRef.current.settled && !fxRef.current.animating) { + startGameOverFx(fxRef.current); } + if (cur.won && !overRef.current) { + overRef.current = true; + onGameOver(cur.score); + } + draw(); + return; + } - // Near miss: edge-triggered so threading a long corridor blips once. - const near = snakeNearMiss(next); - if (near && !nearRef.current) bus.emit("arcade-near"); - nearRef.current = near; + const prevScore = cur.score; + const result = snakeFrame(cur, accMsRef.current, dt); + accMsRef.current = result.accMs; + stateRef.current = result.state; + if (!result.state.alive || result.state.won) { + motionRef.current = { + previous: result.state.body, + current: result.state.body, + dir: result.state.dir, + snap: true, + }; + } else if (result.state.body !== cur.body) { + const singleTick = isSingleTickBodyTransition(cur.body, result.state.body); + motionRef.current = { + previous: singleTick ? cur.body : result.state.body, + current: result.state.body, + dir: result.state.dir, + snap: !singleTick, + }; + } + relayEvents(prevScore, result.state, result.events); - // Terminal states: death or a full-board win both end the run. - if ((!next.alive || next.won) && !overRef.current) { + // Same-frame terminal handling so death FX / win report aren't delayed. + if (result.events.includes("die")) { + startGameOverFx(fxRef.current); + if (!overRef.current) { overRef.current = true; - const head = next.body[0]; - if (head) { - const at = centre(head); - j = burst(j, { - x: at.x, - y: at.y, - count: 26, - color: next.won ? "#f2c14e" : "#8ff0d6", - speed: 5, - life: 620, - size: 3, - }); - } - if (shakeEnabled) j = shake(j, next.won ? 0.4 : 0.9); - bus.emit("arcade-over"); - // Report immediately β€” a deferred report is lost if we unmount first. - // The overlay owns the death-freeze presentation; this loop keeps - // running (the reducer is idempotent on terminal states) so the death - // burst + shake still animate until the overlay pauses us. - onGameOver(next.score); + onGameOver(result.state.score); } - juiceRef.current = j; } - juiceRef.current = stepJuice(juiceRef.current, FRAME_MS); + if (result.events.includes("win") && !overRef.current) { + overRef.current = true; + onGameOver(result.state.score); + } draw(); - }, FRAME_MS); - return () => window.clearInterval(id); - }, [paused, draw, onScore, onGameOver, options, centre, shakeEnabled]); + }; + + raf = requestAnimationFrame(frame); + return () => cancelAnimationFrame(raf); + }, [paused, phase, draw, onGameOver, relayEvents]); - // Keyboard: window-level so a stale focused input can't swallow it. + // A paused surface is a truthful snapshot, never a body left between cells. + useEffect(() => { + if (!paused) return; + const current = stateRef.current; + if (current) { + motionRef.current = { + previous: current.body, + current: current.body, + dir: current.dir, + snap: true, + }; + } + draw(); + }, [paused, draw]); + + // Keyboard: steer while playing; Enter starts from the menu. All input is + // gated on not-paused so a blur/refocus cannot buffer turns mid-pause. useEffect(() => { const onKey = (e: KeyboardEvent) => { + if (pausedRef.current) return; + if (phase === "menu") { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + startRun(difficulty); + return; + } + const step = + e.key === "ArrowRight" || e.key === "ArrowDown" + ? 1 + : e.key === "ArrowLeft" || e.key === "ArrowUp" + ? -1 + : 0; + if (step === 0) return; + e.preventDefault(); + const at = DIFFICULTIES.indexOf(difficulty); + const next = + DIFFICULTIES[ + (at + step + DIFFICULTIES.length) % DIFFICULTIES.length + ]; + if (!next) return; + setDifficulty(next); + writeStoredDifficulty(next); + return; + } const dir = KEY_DIR[e.key]; - if (!dir) return; + if (!dir || !stateRef.current) return; e.preventDefault(); - stateRef.current = snakeInput(stateRef.current, dir); + const st = stateRef.current; + stateRef.current = snakeInput(st, dir); }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); - }, []); + }, [phase, difficulty, startRun]); + + // Paint once on mount / when the menu difficulty changes the preview board. + useEffect(() => { + draw(); + }, [draw, phase]); return ( - +
+ + {phase === "menu" && ( +
+
+

+ Snake +

+ {DIFFICULTIES.map((d) => { + const selected = d === difficulty; + return ( + + ); + })} +
+
+ )} +
); } diff --git a/frontend/src/ui/arcade/SnakeOptions.tsx b/frontend/src/ui/arcade/SnakeOptions.tsx deleted file mode 100644 index 1677b36..0000000 --- a/frontend/src/ui/arcade/SnakeOptions.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import { - SNAKE_LEVELS, - SNAKE_SPEEDS, - snakeLevelById, - type SnakeLevelId, - type SnakeSpeedId, -} from "../../game/arcade/snake"; - -export interface SnakeOptionsProps { - speed: SnakeSpeedId; - level: SnakeLevelId; - /** Picking either option starts a fresh run (the board shape changes). */ - onPickSpeed: (id: SnakeSpeedId) => void; - onPickLevel: (id: SnakeLevelId) => void; -} - -/** - * Snake's cabinet options: speed tier + level. Both lists are DATA from the pure - * `game/arcade/snake` module β€” this component never knows what a level contains, - * only how to list it. The chosen values are persisted by the overlay through - * the shared settings store. - */ -export default function SnakeOptions({ - speed, - level, - onPickSpeed, - onPickLevel, -}: SnakeOptionsProps) { - return ( -
-
- Speed - {SNAKE_SPEEDS.map((s) => ( - - ))} -
- -
- Level - {SNAKE_LEVELS.map((l) => ( - - ))} -
- -

{snakeLevelById(level).hint}

-
- ); -} diff --git a/frontend/src/ui/arcade/arcade.css b/frontend/src/ui/arcade/arcade.css index 71d243e..09c41d8 100644 --- a/frontend/src/ui/arcade/arcade.css +++ b/frontend/src/ui/arcade/arcade.css @@ -846,57 +846,7 @@ } } -/* ── Per-game options + run feedback (issue #163) ──────────────────────────── */ - -.arcade-options { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 8px 14px; - flex: 0 0 auto; -} -.arcade-optgroup { - display: flex; - align-items: center; - gap: 4px; - background: #10141f; - border: 1px solid #26304a; - border-radius: 8px; - padding: 3px 6px; -} -.arcade-optlabel { - font-size: 0.72rem; - text-transform: uppercase; - letter-spacing: 0.08em; - color: #7c88a6; - margin-right: 2px; -} -.arcade-opt { - background: transparent; - color: #aeb8d0; - border: 1px solid transparent; - border-radius: 6px; - padding: 3px 9px; - font-size: 0.82rem; - cursor: pointer; -} -.arcade-opt:hover { - background: #24304a; - color: #e8ecf5; -} -.arcade-opt.is-on { - background: #7fd1b9; - border-color: #7fd1b9; - color: #10141f; - font-weight: 600; -} -.arcade-levelhint { - margin: 0; - font-size: 0.8rem; - color: #7c88a6; - flex: 1 1 auto; - min-width: 0; -} +/* ── Per-game run feedback ─────────────────────────────────────────────────── */ .arcade-newbest { color: #7fd1b9; @@ -908,9 +858,3 @@ color: #7c88a6; font-size: 0.82rem !important; } - -@media (max-width: 720px) { - .arcade-levelhint { - display: none; - } -} diff --git a/frontend/src/ui/arcade/snake/food.ts b/frontend/src/ui/arcade/snake/food.ts new file mode 100644 index 0000000..8be89a5 --- /dev/null +++ b/frontend/src/ui/arcade/snake/food.ts @@ -0,0 +1,119 @@ +/** + * Normal food (pulsing red) + bonus food (orange sparkle + top timer bar). + * Sizes are stored as fractions of a cell, so the art scales with whatever + * cell size the host computes for the current stage. + */ +import { + BONUS_LIFETIME_MS, + type BonusFood, + type Cell, +} from "../../../game/arcade/snake"; +import { DESIGN_CELL } from "./util"; + +export interface FoodAnim { + /** Current drawn radius of the normal food pellet, as a fraction of a cell. */ + radius: number; + /** Pulse step per frame (fraction of a cell); sign flips at the bounds. */ + radiusDir: number; + minRadius: number; + maxRadius: number; +} + +export function createFoodAnim(): FoodAnim { + // 8 / 6 / 10 px against the original 20px grid. + return { radius: 0.4, radiusDir: 0.01, minRadius: 0.3, maxRadius: 0.5 }; +} + +/** Step the food pulse (visual only β€” no game state). */ +export function stepFoodAnim(anim: FoodAnim): void { + anim.radius += anim.radiusDir; + if (anim.radius >= anim.maxRadius || anim.radius <= anim.minRadius) { + anim.radiusDir *= -1; + } +} + +export function drawFood( + ctx: CanvasRenderingContext2D, + food: Cell, + anim: FoodAnim, + cell: number +): void { + const cx = food.x * cell + cell / 2; + const cy = food.y * cell + cell / 2; + const r = Math.max(1, anim.radius * cell); + + const gradient = ctx.createRadialGradient(cx, cy, r * 0.05, cx, cy, r); + gradient.addColorStop(0, "#ff0000"); + gradient.addColorStop(1, "#990000"); + + ctx.beginPath(); + ctx.arc(cx, cy, r, 0, Math.PI * 2); + ctx.fillStyle = gradient; + ctx.fill(); + + // White highlight. + ctx.beginPath(); + ctx.arc(cx - r * 0.35, cy - r * 0.35, r / 4, 0, Math.PI * 2); + ctx.fillStyle = "rgba(255, 255, 255, 0.6)"; + ctx.fill(); +} + +export function drawBonusFood( + ctx: CanvasRenderingContext2D, + bonus: BonusFood, + nowMs: number, + cell: number, + motionEnabled = true, +): void { + const cx = bonus.cell.x * cell + cell / 2; + const cy = bonus.cell.y * cell + cell / 2; + // Size is authored in original "pixel" units at gridSize=20; scale relative + // to the live cell, but keep the orb inside the ONE cell its eat-hitbox + // occupies β€” a spill-over orb reads as edible from the side when it isn't. + const size = Math.min( + cell * 0.45, + Math.max(cell * 0.2, (bonus.size / DESIGN_CELL) * cell * 0.45) + ); + + const gradient = ctx.createRadialGradient(cx, cy, size * 0.05, cx, cy, size); + gradient.addColorStop(0, "#ffcc00"); + gradient.addColorStop(0.7, "#ff6600"); + gradient.addColorStop(1, "#ff3300"); + + ctx.beginPath(); + ctx.arc(cx, cy, size, 0, Math.PI * 2); + ctx.fillStyle = gradient; + ctx.fill(); + ctx.strokeStyle = "rgba(255,255,255,0.5)"; + ctx.lineWidth = Math.max(1, cell * 0.1); + ctx.stroke(); + + const sparkleAngle = motionEnabled ? (nowMs / 200) % (Math.PI * 2) : 0; + const sparkleX = cx + Math.cos(sparkleAngle) * size * 0.7; + const sparkleY = cy + Math.sin(sparkleAngle) * size * 0.7; + ctx.beginPath(); + ctx.arc(sparkleX, sparkleY, size / 4, 0, Math.PI * 2); + ctx.fillStyle = "rgba(255, 255, 255, 0.8)"; + ctx.fill(); +} + +/** Top timer bar: width = remaining lifetime fraction. */ +export function drawBonusTimer( + ctx: CanvasRenderingContext2D, + bonus: BonusFood, + boardWidthPx: number, + cell: number +): void { + const timeRatio = Math.max(0, Math.min(1, bonus.remainingMs / BONUS_LIFETIME_MS)); + const barWidth = boardWidthPx * timeRatio; + const barHeight = Math.max(2, cell * 0.25); + + ctx.fillStyle = "rgba(200, 200, 200, 0.5)"; + ctx.fillRect(0, 0, boardWidthPx, barHeight); + + const timerGradient = ctx.createLinearGradient(0, 0, Math.max(1, barWidth), 0); + timerGradient.addColorStop(0, "rgba(0, 150, 255, 0.9)"); + timerGradient.addColorStop(1, "rgba(0, 80, 200, 0.9)"); + ctx.fillStyle = timerGradient; + ctx.fillRect(0, 0, barWidth, barHeight); +} diff --git a/frontend/src/ui/arcade/snake/fx.test.ts b/frontend/src/ui/arcade/snake/fx.test.ts new file mode 100644 index 0000000..3538a75 --- /dev/null +++ b/frontend/src/ui/arcade/snake/fx.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; +import { + createFx, + flashOpacity, + GAME_OVER_DURATION_MS, + shakeOffset, + startGameOverFx, + stepGameOverFx, +} from "./fx"; + +describe("Snake death shake", () => { + it("returns no offset when shake is disabled", () => { + const fx = createFx(); + startGameOverFx(fx); + expect(shakeOffset(fx, 20, false)).toEqual({ x: 0, y: 0 }); + }); + + it("does not settle before the full terminal effect duration", () => { + const fx = createFx(); + startGameOverFx(fx); + + expect(stepGameOverFx(fx, GAME_OVER_DURATION_MS - 1)).toBe(false); + expect(fx.animating).toBe(true); + expect(stepGameOverFx(fx, 1)).toBe(true); + expect(fx.settled).toBe(true); + }); + + it("suppresses the death flash when motion is disabled", () => { + const fx = createFx(); + startGameOverFx(fx); + + expect(flashOpacity(fx)).toBeGreaterThan(0); + expect(flashOpacity(fx, false)).toBe(0); + }); +}); diff --git a/frontend/src/ui/arcade/snake/fx.ts b/frontend/src/ui/arcade/snake/fx.ts new file mode 100644 index 0000000..81b7686 --- /dev/null +++ b/frontend/src/ui/arcade/snake/fx.ts @@ -0,0 +1,103 @@ +/** + * Game-over shake + red flash (visual only). Driven by elapsed wall time so + * duration is stable across 60/144 Hz and under throttling. The overlay owns + * the final "Game over / Play again" panel β€” this only keeps the visual death + * feedback playing after the renderer reports the terminal state. + */ +import { hash } from "./util"; + +/** ~50 frames at 60 Hz β€” the original's MAX_GAME_OVER_FRAMES pacing. */ +export const GAME_OVER_DURATION_MS = 830; + +/** Shake fades out over this fraction of the total duration. */ +const SHAKE_END = 30 / 50; +/** Intense flash window as a fraction of total duration. */ +const FLASH_PULSE_END = 35 / 50; + +export interface SnakeFx { + /** Elapsed ms into the death animation. */ + elapsedMs: number; + /** True while the death animation is running. */ + animating: boolean; + /** Set once the animation finishes so the visual effect settles once. */ + settled: boolean; +} + +export function createFx(): SnakeFx { + return { elapsedMs: 0, animating: false, settled: false }; +} + +/** Start the death animation (idempotent). */ +export function startGameOverFx(fx: SnakeFx): void { + if (fx.animating || fx.settled) return; + fx.animating = true; + fx.elapsedMs = 0; + fx.settled = false; +} + +/** + * Advance the death animation by `dtMs`. Returns true the first time it + * settles. + */ +export function stepGameOverFx(fx: SnakeFx, dtMs: number): boolean { + if (!fx.animating || fx.settled) return false; + fx.elapsedMs += Math.max(0, dtMs); + if (fx.elapsedMs >= GAME_OVER_DURATION_MS) { + fx.elapsedMs = GAME_OVER_DURATION_MS; + fx.animating = false; + fx.settled = true; + return true; + } + return false; +} + +function progress(fx: SnakeFx): number { + return Math.min(1, fx.elapsedMs / GAME_OVER_DURATION_MS); +} + +/** + * Shake offset (px) for the current death progress. Scaled by the live cell + * size (15px against the original 20px grid) so the sting reads the same at + * any board scale. + */ +export function shakeOffset( + fx: SnakeFx, + cell: number, + enabled = true, +): { x: number; y: number } { + if (!enabled || !fx.animating) return { x: 0, y: 0 }; + const p = progress(fx); + if (p >= SHAKE_END) return { x: 0, y: 0 }; + const maxShake = cell * 0.75; + const amount = maxShake * (1 - p / SHAKE_END); + // Deterministic "random" keyed by elapsed ms so a given time is stable. + const t = fx.elapsedMs; + const dx = (hash(t * 17.3) * 2 - 1) * amount; + const dy = (hash(t * 31.7 + 3) * 2 - 1) * amount; + return { x: dx, y: dy }; +} + +/** Red flash opacity for the current death progress (0 = none). */ +export function flashOpacity(fx: SnakeFx, enabled = true): number { + if (!enabled) return 0; + if (!fx.animating && !fx.settled) return 0; + if (!fx.animating) return 0; + const p = progress(fx); + // Approximate the original frame-based pulse with time-based phases. + if (p < FLASH_PULSE_END) { + // Alternate ~every 4 frames at 60Hz β‰ˆ 66ms. + const phase = Math.floor(fx.elapsedMs / 66) % 2; + if (phase === 0) { + return Math.min(0.8, 0.4 + 0.4 * Math.sin(fx.elapsedMs / 32)); + } + return 0; + } + const fadeT = (p - FLASH_PULSE_END) / (1 - FLASH_PULSE_END); + return Math.max(0, 0.3 * (1 - fadeT)); +} + +/** Snake draw alpha during death (matches original 0.8 β†’ 0.4 cutover). */ +export function deathSnakeAlpha(fx: SnakeFx): number { + if (!fx.animating) return 1; + return progress(fx) < SHAKE_END ? 0.8 : 0.4; +} diff --git a/frontend/src/ui/arcade/snake/grid.ts b/frontend/src/ui/arcade/snake/grid.ts new file mode 100644 index 0000000..486cf54 --- /dev/null +++ b/frontend/src/ui/arcade/snake/grid.ts @@ -0,0 +1,36 @@ +/** + * White playfield + dotted grid background (original Snake look). + * `cell` is the host-computed cell size in device pixels β€” every dimension + * here is a ratio of it, so the board scales to any stage size. + */ +import { SNAKE_COLORS } from "./util"; + +export function drawBoard( + ctx: CanvasRenderingContext2D, + cols: number, + rows: number, + cell: number +): void { + const w = cols * cell; + const h = rows * cell; + ctx.fillStyle = SNAKE_COLORS.board; + ctx.fillRect(0, 0, w, h); + + const dotSize = Math.max(0.75, cell * 0.05); + ctx.fillStyle = SNAKE_COLORS.gridDot; + for (let x = 0; x <= cols; x++) { + for (let y = 0; y <= rows; y++) { + ctx.beginPath(); + ctx.arc(x * cell, y * cell, dotSize, 0, Math.PI * 2); + ctx.fill(); + } + } + + // The board edge kills β€” draw it. Without a stroke the outermost row/column + // reads as letterbox (the edge grid dots are half-clipped by the canvas + // bounds), so players misjudged where the wall is. + const lw = Math.max(1, Math.round(cell * 0.05)); + ctx.strokeStyle = SNAKE_COLORS.boardEdge; + ctx.lineWidth = lw; + ctx.strokeRect(lw / 2, lw / 2, w - lw, h - lw); +} diff --git a/frontend/src/ui/arcade/snake/interp.test.ts b/frontend/src/ui/arcade/snake/interp.test.ts new file mode 100644 index 0000000..6339586 --- /dev/null +++ b/frontend/src/ui/arcade/snake/interp.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from "vitest"; +import { + clampUnit, + interpolateSnakeBody, + isSingleTickBodyTransition, + renderHeading, +} from "./interp"; + +describe("Snake renderer interpolation", () => { + it("interpolates head-first segment pairs at mid-tick", () => { + const previous = [ + { x: 2, y: 1 }, + { x: 1, y: 1 }, + ]; + const current = [ + { x: 3, y: 1 }, + { x: 2, y: 1 }, + ]; + + expect(interpolateSnakeBody(previous, current, 0.5, false)).toEqual([ + { x: 2.5, y: 1 }, + { x: 1.5, y: 1 }, + ]); + }); + + it("keeps a newly grown tail at its stable current cell", () => { + const previous = [ + { x: 2, y: 1 }, + { x: 1, y: 1 }, + ]; + const current = [ + { x: 3, y: 1 }, + { x: 2, y: 1 }, + { x: 1, y: 1 }, + ]; + + expect(interpolateSnakeBody(previous, current, 0.25, false)).toEqual([ + { x: 2.25, y: 1 }, + { x: 1.25, y: 1 }, + { x: 1, y: 1 }, + ]); + expect(isSingleTickBodyTransition(previous, current)).toBe(true); + }); + + it("snaps a multi-tick catch-up frame instead of crossing several cells", () => { + const previous = [ + { x: 2, y: 1 }, + { x: 1, y: 1 }, + ]; + const current = [ + { x: 4, y: 1 }, + { x: 3, y: 1 }, + ]; + + expect(isSingleTickBodyTransition(previous, current)).toBe(false); + expect(interpolateSnakeBody(previous, current, 0.25, true)).toBe(current); + }); + + it("turns the rendered heading to the reducer's next legal queued direction", () => { + expect(renderHeading("right", [])).toBe("right"); + expect(renderHeading("right", ["up"])).toBe("up"); + expect(renderHeading("right", ["up", "left"])).toBe("up"); + // A queued 180 is ignored β€” the reducer will discard it too. + expect(renderHeading("right", ["left"])).toBe("right"); + expect(renderHeading("up", ["left", "down"])).toBe("left"); + }); + + it("clamps interpolation alpha to the renderable range", () => { + expect(clampUnit(-0.2)).toBe(0); + expect(clampUnit(1.4)).toBe(1); + expect( + interpolateSnakeBody([{ x: 1, y: 1 }], [{ x: 2, y: 1 }], 3, false) + ).toEqual([{ x: 2, y: 1 }]); + }); +}); diff --git a/frontend/src/ui/arcade/snake/interp.ts b/frontend/src/ui/arcade/snake/interp.ts new file mode 100644 index 0000000..b5b6a85 --- /dev/null +++ b/frontend/src/ui/arcade/snake/interp.ts @@ -0,0 +1,68 @@ +import { previewSnakeDir, type Cell, type Dir } from "../../../game/arcade/snake"; + +export function clampUnit(value: number): number { + return Math.max(0, Math.min(1, value)); +} + +/** + * Each cell glide must finish well before the next tick: stretching it across + * the whole interval leaves the on-screen snake a full tick behind the + * committed state, which reads as input latency (deadly near walls). + */ +export const GLIDE_MS = 60; + +/** + * Heading for the head/eyes: use the pure reducer's queued-turn preview so the + * face answers a keypress on the very next frame without duplicating rule logic. + */ +export function renderHeading(dir: Dir, dirQueue: readonly Dir[]): Dir { + return previewSnakeDir(dir, dirQueue); +} + +/** + * A single reducer tick shifts every prior segment one index toward the tail. + * Checking the whole overlap distinguishes it from a multi-tick catch-up frame + * without adding scheduling data to the locked reducer contract. + */ +export function isSingleTickBodyTransition( + previous: readonly Cell[], + current: readonly Cell[] +): boolean { + if ( + previous.length === 0 || + current.length < 2 || + (current.length !== previous.length && current.length !== previous.length + 1) + ) { + return false; + } + + for (let index = 0; index < current.length - 1; index += 1) { + const before = previous[index]; + const after = current[index + 1]; + if (!before || !after || before.x !== after.x || before.y !== after.y) return false; + } + return true; +} + +/** + * Pair segments by head-first index and interpolate in grid coordinates. A new + * growth tail has no prior index partner, so it draws at its final cell instead + * of stretching in from an unrelated segment. + */ +export function interpolateSnakeBody( + previous: readonly Cell[], + current: readonly Cell[], + alpha: number, + snap: boolean +): readonly Cell[] { + if (snap || previous.length === 0) return current; + const t = clampUnit(alpha); + return current.map((cell, index) => { + const before = previous[index]; + if (!before) return cell; + return { + x: before.x + (cell.x - before.x) * t, + y: before.y + (cell.y - before.y) * t, + }; + }); +} diff --git a/frontend/src/ui/arcade/snake/render.ts b/frontend/src/ui/arcade/snake/render.ts new file mode 100644 index 0000000..7c1155f --- /dev/null +++ b/frontend/src/ui/arcade/snake/render.ts @@ -0,0 +1,73 @@ +/** + * Composes one Snake frame: white board + dot grid β†’ bonus timer β†’ food β†’ + * bonus β†’ snake β†’ death flash. Draw-only; it never mutates game state. + * + * `cell` is the cell size in DEVICE pixels (a FIXED 20 CSS px Γ— dpr β€” cells are + * never scaled to fit, the board just gets more of them). The canvas transform + * stays identity β€” every module scales off `cell` instead, so lines and dots + * land on device pixels. + */ +import type { Cell, Dir, SnakeState } from "../../../game/arcade/snake"; +import { drawBoard } from "./grid"; +import { drawSnake } from "./snakeBody"; +import { + drawBonusFood, + drawBonusTimer, + drawFood, + stepFoodAnim, + type FoodAnim, +} from "./food"; +import { deathSnakeAlpha, flashOpacity, shakeOffset, type SnakeFx } from "./fx"; + +export function renderSnake( + ctx: CanvasRenderingContext2D, + state: SnakeState, + fx: SnakeFx, + foodAnim: FoodAnim, + nowMs: number, + cell: number, + body: readonly Cell[] = state.body, + dir: Dir = state.dir, + shakeEnabled = true, + motionEnabled = true, +): void { + const boardW = state.width * cell; + const boardH = state.height * cell; + + ctx.setTransform(1, 0, 0, 1, 0, 0); + + // Base wash so a shake offset never exposes the previous frame at the edges. + ctx.fillStyle = "#ffffff"; + ctx.fillRect(0, 0, boardW, boardH); + + const shake = shakeOffset(fx, cell, shakeEnabled); + ctx.save(); + ctx.translate(shake.x, shake.y); + + drawBoard(ctx, state.width, state.height, cell); + + // Pulse food only while the run is live (not during death settle). + if (motionEnabled && state.alive && !state.won && !fx.animating) { + stepFoodAnim(foodAnim); + } + + if (state.bonus) { + drawBonusTimer(ctx, state.bonus, boardW, cell); + } + + drawFood(ctx, state.food, foodAnim, cell); + + if (state.bonus) { + drawBonusFood(ctx, state.bonus, nowMs, cell, motionEnabled); + } + + drawSnake(ctx, body, dir, cell, deathSnakeAlpha(fx)); + + const flash = flashOpacity(fx, motionEnabled); + if (flash > 0.001) { + ctx.fillStyle = `rgba(255, 0, 0, ${flash})`; + ctx.fillRect(0, 0, boardW, boardH); + } + + ctx.restore(); +} diff --git a/frontend/src/ui/arcade/snake/snakeBody.ts b/frontend/src/ui/arcade/snake/snakeBody.ts new file mode 100644 index 0000000..832e227 --- /dev/null +++ b/frontend/src/ui/arcade/snake/snakeBody.ts @@ -0,0 +1,68 @@ +/** + * Rounded snake segments with direction-facing eyes on the head. + * All dimensions derive from the host-computed `cell` (device px). + */ +import type { Cell, Dir } from "../../../game/arcade/snake"; +import { SNAKE_COLORS, eyeOffsets, roundedRect } from "./util"; + +export function drawSnake( + ctx: CanvasRenderingContext2D, + body: readonly Cell[], + dir: Dir, + cell: number, + alpha = 1 +): void { + if (body.length === 0) return; + ctx.save(); + ctx.globalAlpha = alpha; + for (let i = 0; i < body.length; i++) { + const segment = body[i]; + if (!segment) continue; + const isHead = i === 0; + const fill = isHead + ? SNAKE_COLORS.head + : i % 2 === 0 + ? SNAKE_COLORS.body + : SNAKE_COLORS.bodyAlt; + drawSegment(ctx, segment, isHead, dir, fill, cell); + } + ctx.restore(); +} + +function drawSegment( + ctx: CanvasRenderingContext2D, + segment: Cell, + isHead: boolean, + dir: Dir, + fillColor: string, + cell: number +): void { + const gap = Math.max(1, cell * 0.05); + const x = segment.x * cell; + const y = segment.y * cell; + const size = cell - gap; + const radius = cell / 3; + + ctx.fillStyle = fillColor; + roundedRect(ctx, x, y, size, size, radius); + ctx.fill(); + ctx.strokeStyle = SNAKE_COLORS.outline; + ctx.lineWidth = Math.max(1, cell * 0.05); + ctx.stroke(); + + if (!isHead) return; + + const eyeSize = cell / 5; + for (const [ex, ey] of eyeOffsets(dir)) { + const cx = x + ex * cell; + const cy = y + ey * cell; + ctx.fillStyle = SNAKE_COLORS.eye; + ctx.beginPath(); + ctx.arc(cx, cy, eyeSize, 0, Math.PI * 2); + ctx.fill(); + ctx.fillStyle = SNAKE_COLORS.pupil; + ctx.beginPath(); + ctx.arc(cx, cy, eyeSize / 2, 0, Math.PI * 2); + ctx.fill(); + } +} diff --git a/frontend/src/ui/arcade/snake/util.ts b/frontend/src/ui/arcade/snake/util.ts new file mode 100644 index 0000000..dca38dd --- /dev/null +++ b/frontend/src/ui/arcade/snake/util.ts @@ -0,0 +1,79 @@ +/** + * Shared canvas helpers for the Snake art modules. Drawing only β€” no game + * rules live here (those stay in game/arcade/snake.ts). + */ +import type { Dir } from "../../../game/arcade/snake"; + +/** + * Reference cell size the original art was authored against. Nothing draws at + * this size β€” it exists only so authored pixel values can be expressed as a + * ratio of the *actual* cell size the host computes for the current viewport. + */ +export const DESIGN_CELL = 20; + +export const SNAKE_COLORS = { + head: "#006400", + body: "#32CD32", + bodyAlt: "#228B22", + outline: "#004d00", + eye: "white", + pupil: "black", + board: "#ffffff", + gridDot: "rgba(0, 0, 0, 0.2)", + boardEdge: "rgba(0, 0, 0, 0.35)", +} as const; + +/** Cheap deterministic 0..1 noise (stable shake without Math.random). */ +export function hash(n: number): number { + const s = Math.sin(n * 127.1 + 311.7) * 43758.5453; + return s - Math.floor(s); +} + +/** Path a rounded rectangle (caller fills/strokes it). */ +export function roundedRect( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + w: number, + h: number, + radius: number +): void { + const r = Math.min(radius, w * 0.5, h * 0.5); + ctx.beginPath(); + ctx.moveTo(x + r, y); + ctx.lineTo(x + w - r, y); + ctx.quadraticCurveTo(x + w, y, x + w, y + r); + ctx.lineTo(x + w, y + h - r); + ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h); + ctx.lineTo(x + r, y + h); + ctx.quadraticCurveTo(x, y + h, x, y + h - r); + ctx.lineTo(x, y + r); + ctx.quadraticCurveTo(x, y, x + r, y); + ctx.closePath(); +} + +/** Eye centre offsets (cell-local 0..1) by travel direction. */ +export function eyeOffsets(dir: Dir): readonly [number, number][] { + switch (dir) { + case "up": + return [ + [0.3, 0.32], + [0.7, 0.32], + ]; + case "down": + return [ + [0.3, 0.68], + [0.7, 0.68], + ]; + case "left": + return [ + [0.32, 0.3], + [0.32, 0.7], + ]; + case "right": + return [ + [0.68, 0.3], + [0.68, 0.7], + ]; + } +} diff --git a/frontend/src/ui/arcade/terminalHold.ts b/frontend/src/ui/arcade/terminalHold.ts new file mode 100644 index 0000000..5cac518 --- /dev/null +++ b/frontend/src/ui/arcade/terminalHold.ts @@ -0,0 +1,13 @@ +import type { ArcadeGame } from "@metaverse/shared"; + +/** Terminal presentation hold per cabinet; Snake's death FX needs the longer hold. */ +export const TERMINAL_HOLD_MS: Readonly> = { + snake: 830, + flappy: 450, + "merge-drop": 450, +}; + +/** Reduced motion removes decorative terminal effects and their delay. */ +export function terminalHoldMs(game: ArcadeGame, reducedMotion: boolean): number { + return reducedMotion ? 0 : TERMINAL_HOLD_MS[game]; +} diff --git a/frontend/src/ui/settings.ts b/frontend/src/ui/settings.ts index 8cea7a7..4328896 100644 --- a/frontend/src/ui/settings.ts +++ b/frontend/src/ui/settings.ts @@ -4,8 +4,6 @@ * read one source of truth. No backend. */ import type { ReducedMotionSetting } from "../game/reducedMotion"; -// Type-only: erased at build, so the arcade module never enters the entry chunk. -import type { SnakeLevelId, SnakeSpeedId } from "../game/arcade/snake"; export interface Settings { masterVolume: number; // 0..1, master gain over every channel @@ -23,10 +21,8 @@ export interface Settings { reducedMotion: ReducedMotionSetting; // Issue #163 (arcade juice). The shake toggle is an explicit off switch for // the mini-games' screen kick, independent of `reducedMotion` (which also - // suppresses it); the two Snake fields persist the cabinet's option pickers. + // suppresses it). arcadeShake: boolean; - snakeSpeed: SnakeSpeedId; - snakeLevel: SnakeLevelId; } const KEY = "mv:settings"; @@ -47,8 +43,6 @@ const DEFAULTS: Settings = { tabFlash: true, reducedMotion: "system", arcadeShake: true, - snakeSpeed: "normal", - snakeLevel: "open", }; function load(): Settings { diff --git a/package-lock.json b/package-lock.json index e40eef8..3472d78 100644 --- a/package-lock.json +++ b/package-lock.json @@ -144,10 +144,6 @@ "@redis/client": "^5.12.1" } }, - "backend/node_modules/@socket.io/component-emitter": { - "version": "3.1.2", - "license": "MIT" - }, "backend/node_modules/@types/body-parser": { "version": "1.19.6", "dev": true, @@ -838,13 +834,6 @@ "version": "2.0.4", "license": "ISC" }, - "backend/node_modules/ip-address": { - "version": "10.2.0", - "license": "MIT", - "engines": { - "node": ">= 12" - } - }, "backend/node_modules/ipaddr.js": { "version": "1.9.1", "license": "MIT", @@ -1477,17 +1466,6 @@ "node": ">=10.0.0" } }, - "backend/node_modules/socket.io-parser": { - "version": "4.2.6", - "license": "MIT", - "dependencies": { - "@socket.io/component-emitter": "~3.1.0", - "debug": "~4.4.1" - }, - "engines": { - "node": ">=10.0.0" - } - }, "backend/node_modules/socket.io/node_modules/accepts": { "version": "1.3.8", "license": "MIT", @@ -2058,10 +2036,6 @@ "node": ">=18" } }, - "frontend/node_modules/@socket.io/component-emitter": { - "version": "3.1.2", - "license": "MIT" - }, "frontend/node_modules/@testing-library/dom": { "version": "10.4.1", "dev": true, @@ -2668,17 +2642,6 @@ "node": ">=10.0.0" } }, - "frontend/node_modules/socket.io-parser": { - "version": "4.2.6", - "license": "MIT", - "dependencies": { - "@socket.io/component-emitter": "~3.1.0", - "debug": "~4.4.1" - }, - "engines": { - "node": ">=10.0.0" - } - }, "frontend/node_modules/typed-emitter": { "version": "2.1.0", "license": "MIT", @@ -3526,6 +3489,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@socket.io/component-emitter": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", + "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==", + "license": "MIT" + }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", @@ -4041,9 +4010,9 @@ } }, "node_modules/brace-expansion": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz", - "integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==", + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", "dev": true, "license": "MIT", "dependencies": { @@ -4556,6 +4525,15 @@ "node": ">=0.8.19" } }, + "node_modules/ip-address": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.4.0.tgz", + "integrity": "sha512-oSK96Grm3aP6OrS263xVxbNDGVL7rzBtYdpGqlDG8iQdoenDoTs/nkki+DflYbAEE8Xl6o5YxhxlrKvI3nqKXQ==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -5010,9 +4988,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.16", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", - "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", "dev": true, "funding": [ { @@ -5307,6 +5285,19 @@ "dev": true, "license": "ISC" }, + "node_modules/socket.io-parser": { + "version": "4.2.7", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.7.tgz", + "integrity": "sha512-IH/iSeO9T6gz1KkFleGDWkG9N3dl4jXVYUtMhIqH10Md0ttMer8nUNWiP1DKuNrybD2xBrixLJdCC9J6ECoYkg==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -5499,9 +5490,9 @@ } }, "node_modules/undici": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", - "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz", + "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==", "dev": true, "license": "MIT", "engines": { diff --git a/shared/src/constants.test.ts b/shared/src/constants.test.ts index ef274dd..aab5195 100644 --- a/shared/src/constants.test.ts +++ b/shared/src/constants.test.ts @@ -7,6 +7,7 @@ import { roomDisplayName, BOARD_TABLES, gameForTable, + scoreGameForArcadeGame, } from "./constants.js"; /** Every private room the world gates behind a door (seeded ids 1-6). */ @@ -64,3 +65,11 @@ describe("AREA_NAMES registry", () => { } }); }); + +describe("arcade score namespaces", () => { + it("keeps the cabinet id stable while versioning Snake scores", () => { + expect(scoreGameForArcadeGame("snake")).toBe("snake-v2"); + expect(scoreGameForArcadeGame("flappy")).toBe("flappy"); + expect(scoreGameForArcadeGame("merge-drop")).toBe("merge-drop"); + }); +}); diff --git a/shared/src/constants.ts b/shared/src/constants.ts index 47d4cf1..da745dd 100644 --- a/shared/src/constants.ts +++ b/shared/src/constants.ts @@ -28,6 +28,25 @@ export const GEOMETRY_MANIFEST_VERSION = 1; export const ARCADE_GAMES = ["snake", "flappy", "merge-drop"] as const; export type ArcadeGame = (typeof ARCADE_GAMES)[number]; +/** + * Score namespaces accepted by REST. `snake-v2` is separate from legacy + * `snake` rows so independent frontend/backend deployments cannot mix the + * restored cabinet's 10-point scale with historical 1-point scores. + */ +export const ARCADE_SCORE_GAMES = ["snake", "snake-v2", "flappy", "merge-drop"] as const; +export type ArcadeScoreGame = (typeof ARCADE_SCORE_GAMES)[number]; + +export const SCORE_GAME_BY_ARCADE_GAME: Readonly> = { + snake: "snake-v2", + flappy: "flappy", + "merge-drop": "merge-drop", +}; + +/** Resolve the current score namespace for a cabinet without changing its map id. */ +export function scoreGameForArcadeGame(game: ArcadeGame): ArcadeScoreGame { + return SCORE_GAME_BY_ARCADE_GAME[game]; +} + /** * Board-game tables (PRD 11 phase 2). Each table is a two-seat, server- * authoritative match of one board game. This registry is the single source of diff --git a/shared/src/rest.test.ts b/shared/src/rest.test.ts index 3809bc5..55fae03 100644 --- a/shared/src/rest.test.ts +++ b/shared/src/rest.test.ts @@ -291,6 +291,7 @@ describe("operational report", () => { describe("arcade score submission", () => { it("accepts a valid score for a known game", () => { expect(arcadeScoreSchema.safeParse({ game: "snake", score: 12 }).success).toBe(true); + expect(arcadeScoreSchema.safeParse({ game: "snake-v2", score: 12 }).success).toBe(true); expect(arcadeScoreSchema.safeParse({ game: "flappy", score: 0 }).success).toBe(true); expect(arcadeScoreSchema.safeParse({ game: "merge-drop", score: 4096 }).success).toBe(true); }); @@ -360,6 +361,7 @@ describe("arcade leaderboard response", () => { it("requires an authoritative improvement verdict on score submissions", () => { const result = { game: "snake", top: [], best: 9, newBest: true }; expect(arcadeScoreResultSchema.safeParse(result).success).toBe(true); + expect(arcadeScoreResultSchema.safeParse({ ...result, game: "snake-v2" }).success).toBe(true); expect(arcadeScoreResultSchema.safeParse({ ...result, newBest: "yes" }).success).toBe(false); const { newBest: _newBest, ...missingVerdict } = result; expect(arcadeScoreResultSchema.safeParse(missingVerdict).success).toBe(false); diff --git a/shared/src/rest.ts b/shared/src/rest.ts index 784deef..11fcc48 100644 --- a/shared/src/rest.ts +++ b/shared/src/rest.ts @@ -6,7 +6,7 @@ import { z } from "zod"; import { dirSchema } from "./socket.js"; import { ACTIVE_SPACE_KINDS, - ARCADE_GAMES, + ARCADE_SCORE_GAMES, AUTH_TRANSPORT_REASONS, BLOCK_ACK_STATUSES, LIMITS, @@ -75,9 +75,9 @@ export const operationalReportSchema = z.discriminatedUnion("category", [ ]); export type OperationalReport = z.infer; -/** `POST /api/v1/arcade/scores` body: a client-reported score for one cabinet. */ +/** `POST /api/v1/arcade/scores` body: a client-reported score namespace. */ export const arcadeScoreSchema = z.object({ - game: z.enum(ARCADE_GAMES), + game: z.enum(ARCADE_SCORE_GAMES), score: z.number().int().min(0).max(LIMITS.arcadeScoreMax), }); export type ArcadeScoreSubmission = z.infer; @@ -244,11 +244,11 @@ export const arcadeScoreEntrySchema = z.object({ export type ArcadeScoreEntry = z.infer; /** - * `GET /api/v1/arcade/scores/:game`: the top-N leaderboard for a cabinet plus - * the requesting player's personal best (null if they have never scored). + * `GET /api/v1/arcade/scores/:game`: the top-N leaderboard for a score namespace + * plus the requesting player's personal best (null if they have never scored). */ export const arcadeLeaderboardSchema = z.object({ - game: z.enum(ARCADE_GAMES), + game: z.enum(ARCADE_SCORE_GAMES), top: z.array(arcadeScoreEntrySchema), best: z.number().int().nullable(), });