explodorama / commit
Initial commit: turn-based bomb-laying roguelike prototype
.gitignore | 10 + .gitlab-ci.yml | 25 + PLAN.md | 579 ++++++++++++++ README.md | 89 +++ decisions.md | 56 ++ docs/screenshots/bomb-picked.png | Bin 0 -> 35985 bytes docs/screenshots/countdown.png | Bin 0 -> 38812 bytes docs/screenshots/death.png | Bin 0 -> 38055 bytes docs/screenshots/desktop.png | Bin 0 -> 58710 bytes docs/screenshots/scrolled.png | Bin 0 -> 35250 bytes docs/screenshots/start.png | Bin 0 -> 35171 bytes index.html | 27 + package-lock.json | 1542 ++++++++++++++++++++++++++++++++++++++ package.json | 32 + src/main.ts | 158 ++++ src/render/board.ts | 113 +++ src/render/camera.ts | 35 + src/sim/actions.ts | 66 ++ src/sim/danger.ts | 24 + src/sim/explosions.ts | 58 ++ src/sim/levelgen.ts | 44 ++ src/sim/record.ts | 27 + src/sim/rng.ts | 41 + src/sim/state.ts | 49 ++ src/sim/turn.ts | 50 ++ src/sim/types.ts | 77 ++ src/style.css | 118 +++ src/ui/controls.ts | 72 ++ test/danger.test.ts | 55 ++ test/explosions.test.ts | 52 ++ test/helpers.ts | 31 + test/levelgen.test.ts | 36 + test/turn.test.ts | 110 +++ tools/screenshots.mjs | 53 ++ tsconfig.json | 17 + vite.config.ts | 20 + 36 files changed, 3666 insertions(+) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3ad39ec --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +node_modules/ +dist/ +.DS_Store +*.local + +# Playwright verification screenshots +.playwright-mcp/ + +# Left by the test runner. +.vitest/ diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000..c99b0b7 --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,25 @@ +image: node:22 + +stages: [test, deploy] + +test: + stage: test + script: + - npm ci + - npm test + - npm run build + +pages: + stage: deploy + script: + - npm ci + - npm run build + # GitLab Pages requires the artifact to be a directory named `public`. There is no + # `public/` in the repository (vite.config.ts), so this is a rename, not a nest. + - mv dist public + # Fail loudly rather than publishing a 404. + - test -f public/index.html || (echo "no index.html at artifact root" && exit 1) + artifacts: + paths: [public] + rules: + - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000..afe9c7a --- /dev/null +++ b/PLAN.md @@ -0,0 +1,579 @@ +# Explodorama — Plan + +A turn-based, mobile-first, roguelike take on the 1985 NES *Bomberman*: 50 levels and an ending. +Built with **LittleJS + TypeScript + Vite**, tested with **Vitest**, deployed to **GitLab Pages**. + +Status: **M0–M2 done, M3 mostly done** (see §18). This document is the design and the hand-off +for whoever continues the implementation. Read it fully before writing code, especially §1 (the +turn), §15 (notes for the implementer) and §18 (milestones). Reasons for decisions are in +`decisions.md`. + +--- + +## 0. Decisions already made + +| # | Question | Decision | +|---|---|---| +| 1 | Turn order | **Enemies move *before* bombs explode.** Enemies announce their move one turn ahead (intent arrows), so the player can plan traps. | +| 2 | Run: charge or always available? | **Run is an ability the player gains** (the NES roller skates, "Speed"). Once gained, always available — no charges. | +| 3 | Lives | **3 lives**, plus a modern roguelike layer: **coins** buy upgrades. | +| 4 | Undo | **None.** | +| 5 | Which actions | **All actions named in this plan** (§3), plus any agreed later. | +| 6 | Name | **Explodorama.** ("Bomberman" is a Konami trademark — never use it in the UI, title, manifest or repo name.) | +| 7 | Shop purchases | **Last for the current run only.** Permanent progress comes from the Workshop (§7), which is a later milestone. | +| 8 | Permanent upgrades | **Workshop between runs** (Everspace-style): coins are deposited into upgrade tracks whose tiers cost 5, 10, 15, … coins. See §7. | +| 9 | Death | **The player chooses** after each death: **Keep** (keep Bomb+, Fire+, Skates) or **Fresh** (lose them, get coins). Balanced so most players would pick each about half the time. See §9. | +| 10 | Curses | **Exist, but only appear after the first shop** (level 2 onward). See §6. | +| 11 | Reapers / turn limit | **Later** (post-MVP). The par-turn coin bonus stays. | +| 12 | Run length | **50 levels with an ending**, like the NES. A win produces a **victory certificate with a QR code** for that seed. See §11. | +| 13 | Art | **No final art yet.** Use one consistent icon set: **Icongeek26, "Outline" style, from Flaticon** (black line on transparent PNG), with attribution — the same artist the user's other projects use. See §13. | + +Still open: see §14. + +--- + +## 1. The turn (the core rule — everything else follows from it) + +One turn, in this exact order: + +1. **Player acts.** The player picks one action and a target; it resolves immediately + (move, run, place bomb, kick, …). Picking up whatever is on the destination tile happens here. +2. **Enemies execute their announced intents.** Each enemy moves as its intent arrow showed. + An enemy whose intended tile became blocked this turn (e.g. the player put a bomb there) + stays put — it does **not** re-plan mid-turn. +3. **Bombs tick.** Every bomb's timer decreases by 1. Bombs reaching **0** explode. + Chain reactions resolve **within the same phase** (a bomb caught in a blast explodes now, + regardless of its timer). +4. **Damage.** Flame kills the player (unless protected) and enemies, destroys soft blocks + (revealing anything under them), and destroys pickups lying in the open (see §5, NES rule). + An enemy sharing the player's tile kills the player. +5. **Enemies plan.** Every surviving enemy computes and displays its next intent. + Curse and Star/Shield turn counters advance. Input unlocks. + +Rules that fall out of this: + +- **Bomb number = how many more player actions until it explodes.** A newly placed bomb shows + its fuse value (default **3**) and is **not** ticked on the turn it is placed. A bomb showing + **1** explodes at the end of the player's next action. +- **Flame is instantaneous.** It exists only during phases 3–4 (and visually during the + animation). It does not persist on the board into the next turn. +- **Contact is checked twice:** after phase 1 (player walked into an enemy — death) and after + phase 2 (enemy walked into player — death). Player and enemy swapping tiles counts as contact. +- **Bombs block movement** for the player and for (most) enemies. Flame stops at the first hard + wall or soft block (destroying the soft block); a bomb in the flame's path chain-explodes. + +--- + +## 2. Player stats and abilities + +| Stat | Start | Notes | +|---|---|---| +| Lives | 3 | Shop and Workshop can add more. | +| Bomb capacity | 1 | Max own bombs on the board at once. | +| Fire (blast radius) | 1 | Tiles in each of 4 directions. | +| Fuse | 3 | Starting timer of the player's bombs. | +| Abilities | none | From pickups or the shop (§3.2, §5). | +| Action charges | none | Consumable actions dropped by enemies (§3.3). | +| Coins | 0 | Spent in the shop (§8); leftovers go to the Workshop (§7). | +| Curse | none | At most one active at a time (§6). | + +Starting values can be raised by Workshop tiers (§7). + +--- + +## 3. Actions + +### 3.1 Always available + +| Action | Rule | Highlight when selected | +|---|---|---| +| **Move** | 1 tile up, down, left or right (WASD). Target must be free (no wall, soft block, bomb). | Up to 4 reachable tiles. | +| **Place bomb** | On any **adjacent** free tile (orthogonal). Limited by bomb capacity. | Valid tiles. Hovering/pressing one shows the faded **blast cross** and the starting timer. | +| **Wait** | Do nothing; the world advances one turn. | Nothing (or a subtle pulse on the player). | + +### 3.2 Abilities (permanent once gained — no charges) + +| Action | Source | Rule | +|---|---|---| +| **Run** | Skates pickup (NES "Speed"), or shop | 2 tiles in a straight line. **Both** tiles must be free — no jumping over anything. Highlight: path tile + landing tile. An enemy on the path tile = death (you ran into it). | +| **Detonate** | Detonator pickup (NES "Detonator"), or shop | Explode one chosen own bomb now. | + +### 3.3 Action charges (consumable, dropped by slain enemies) + +The button shows a count, e.g. "Kick ×2". Using one spends one charge and the turn. + +| Action | Rule | +|---|---| +| **Kick** | Push an adjacent bomb; it slides until the next tile is not free. It keeps its timer. The escape hatch for boxing yourself in. | +| **Throw** | Place a bomb **exactly 2 tiles away** in a straight line, over a wall/soft block/bomb. Target must be free. | +| **Fuse +1 / Fuse −1** | Change any visible bomb's timer by 1 (minimum 1). One action; the direction is chosen at target time. | +| **Shield** | Absorb the next hit (flame or enemy). Shown as a ring around the player. Arming it costs the turn. | +| **Diagonal step** | Move 1 tile diagonally. Both orthogonal neighbours may be blocked — it's a squeeze. | +| **Teleport** (rare) | Jump to any free tile within Manhattan distance 3. | +| **Pierce bomb** | Place a bomb whose flame passes **through** soft blocks (destroying all in range). From later Bomberman games. | +| **Power bomb** | Place a bomb with maximum fire (whole row/column up to hard walls). | +| **Line bomb** | Place all remaining bomb capacity in a straight line from the player, in one action. From *Super Bomberman*. | +| **Freeze** | All enemies skip their next move (intents greyed out). | +| **Decoy** | Place a decoy on an adjacent tile; chasing enemies target it instead of the player for 3 turns. Flame destroys it. | + +All charge actions can also appear in the shop (§8). + +--- + +## 4. Readability (turn-based only feels fair if everything is visible) + +- **Danger overlay (always on, unless cursed with Fog):** tiles that will burn **this turn** = + strong red; **next turn** = light red. Includes chain reactions. Computed by a pure function + in `sim/danger.ts` from the current state. +- **Enemy intent arrows** on every enemy (phase 5). A 2-tile mover shows a 2-step arrow. +- **Bomb timers:** large number on each bomb; pulses at 1. +- **Commit preview:** after picking an action and pressing a target, show the outcome + (player ghost at destination, faded blast cross, slide path for Kick) before release/confirm. +- **Pickups, curses and drops** have distinct icons (§13), readable at phone size. + +--- + +## 5. Pickups (NES-style, hidden under soft blocks) + +The NES original had exactly 8 power-ups, one hidden per level under a soft block +(listed from memory — inspiration only, no need to verify): + +| NES item | Explodorama equivalent | Kept by "Keep" on death (§9)? | +|---|---|---| +| Bombs | **Bomb +1** (capacity) | yes | +| Flames | **Fire +1** | yes | +| Speed (roller skates) | **Skates → unlocks Run** | yes | +| Wallpass | **Wall Pass** — walk through soft blocks | no — always lost on death | +| Detonator | **Detonator → unlocks Detonate** | no | +| Bombpass | **Bomb Pass** — walk over bombs | no | +| Flamepass | **Flame Pass** — immune to flame (tune later if too strong) | no | +| Mystery | **Star** — invincible for 8 turns | no (it's temporary anyway) | + +Additions: + +- **Fuse +1** — the player's bombs start at 4. Kept by "Keep". +- **Coin bag** — +N coins. +- **Heart** — +1 life (rare). + +NES rules kept (they create the classic tension): + +- **One pickup per level** under a soft block (more via shop/Workshop upgrades), plus the + **exit door** under another soft block. +- **Flame destroys an exposed pickup** — and spawns extra enemies, as in the NES. +- **Flaming the exit door** spawns a group of enemies from it. +- **The exit opens only when all enemies are dead.** +- **Skates are guaranteed on level 1 or 2** so every player learns Run early. + +--- + +## 6. Curses (from the *Super Bomberman* skull) + +- **Never before the first shop** — i.e. not on level 1. From level 2 onward a **skull** can + appear: under soft blocks (as an extra item, never replacing the level's pickup) and as a rare + enemy drop. Frequency rises gently with level. +- The skull is **visible** once revealed, so the player can choose to avoid it. + **Flaming a skull destroys it safely** (no enemy spawn — unlike pickups), which gives bombs a + second use. +- Walking onto it applies a **random curse for 10 turns** (HUD shows icon + countdown). + One curse at a time; a new skull replaces the current curse. +- Curse list: + +| Curse | Effect | +|---|---| +| **Short Fuse** | Your bombs start at 2. | +| **Leaky** | Each turn, if capacity allows, a bomb drops on a random free adjacent tile. | +| **Butterfingers** | Your bombs land on a random free adjacent tile, not the one you chose. | +| **Fog** | Danger overlay and enemy intent arrows are hidden. | +| **Heavy Feet** | Run is disabled. | +| **Damp** | Fire is 1. | + +- **Cures:** wait it out, a Star, or the shop's **Cleanse** charge. Workshop track + **Curse Ward** makes the first skull per level harmless. +- (Open, §14: should skulls also give coins — a risk-for-reward choice?) + +--- + +## 7. The Workshop (permanent upgrades between runs) — later milestone + +Like *Everspace*, where you spend between runs on more weapon slots or stronger shields. + +- **Coins left over at the end of a run** (win or lose) are banked into the Workshop. + (Open, §14: leftover only, or all coins earned?) +- The Workshop has **upgrade tracks**. Each track has tiers; **tier n costs 5 × n coins** + (5, 10, 15, 20, …). Coins can be **deposited partially** — a track shows a progress bar toward + its next tier, so even a short run moves something forward. +- Tracks (each capped; values to tune): + +| Track | Per tier | Cap | +|---|---|---| +| Extra life | +1 starting life | +2 | +| Bomb satchel | +1 starting bomb capacity | +2 | +| Gunpowder | +1 starting fire | +2 | +| Shop shelf | +1 offer per shop | +2 | +| Haggler | 1 free reroll per shop | 1 | +| Charge pouch | Start each run with 1 random action charge | +3 | +| Lucky | +5% drop chance | +3 | +| Purse | Start each run with +5 coins | +3 | +| Prospector | +1 hidden pickup per level | +1 | +| Curse Ward | First skull per level is harmless | 1 | +| Skates from the start | Start with Run | 1 | + +- Workshop state lives in `localStorage` (try/catch — it can be unavailable). Offer + **export/import** of the save as text, because phones clear site data. +- The Workshop loadout at run start is part of the run record (§11) — it changes the + starting state, so replays and certificates must include it. + +--- + +## 8. Coins and the in-run shop + +- **Coin sources:** some enemy types always drop coins, others sometimes; occasional coins under + soft blocks; Coin bag pickups; a **par bonus** for finishing a level within its turn budget. +- **Shop after every level** (so the first shop follows level 1). A random selection, e.g. + 4 offers, one paid reroll: + - Stats: Bomb +1, Fire +1, Fuse +1, Heart + - Abilities: Skates (Run), Detonator, Bomb Pass, Wall Pass + - Action charges: any from §3.3, plus **Cleanse** (removes the current curse) + - Run modifiers: **Magnet** (collect drops within 1 tile), **Lucky** (higher drop chance), + **Map** (reveals the exit and hidden pickup each level), **Coin multiplier** +- Shop offers come from the seeded RNG (§12), so a seed always offers the same items. +- Purchases last for this run only (decision 7). + +--- + +## 9. Death: Keep or Fresh + +When the player dies with lives left, the level will restart from its seed (same layout, +enemies reset). Before that, a choice screen with two buttons, **both showing exact numbers**: + +| | **Keep** | **Fresh** | +|---|---|---| +| Bomb +, Fire +, Fuse +, Skates (Run) | kept | **reset to starting values** | +| Wall Pass, Bomb Pass, Flame Pass, Detonator, Star | lost (NES rule) | lost | +| Coins | kept | kept, **plus a coin payout** | +| Action charges | kept | kept | +| Curse | removed | removed | + +Example buttons: "**Keep** — 💣2 🔥3 Run" / "**Fresh** — +14 coins". + +**Fairness (target: most players pick each about half the time).** The trade-off is +*power now* vs *coins*, and coins are worth more than their shop price because leftovers become +**permanent Workshop progress** (§7). Starting formula, to tune by playtesting: + + payout = round(0.75 × shop value of everything Fresh resets) + 3 + +- The flat +3 keeps Fresh meaningful early, when there's little to give up. +- The 0.75 factor means Fresh never fully refunds the run's power, so it's not a free win late. +- **Measuring balance:** the app has no backend or telemetry (on purpose). Record each choice in + local stats (shown on the run summary and in a dev overlay `?debug=1`), and use playtests. +- Coins collected during the failed attempt are kept either way. +- (Open, §14: should Fresh also re-roll the level layout?) + +--- + +## 10. Enemies + +All move on their own schedule ("speed"), are blocked by bombs unless stated, and kill on +contact. Intent shown every turn. Adapted from the NES eight: + +| Name (ours) | NES inspiration | Speed | Behaviour | Drops | +|---|---|---|---|---| +| Blob | Balloom | 1 tile/turn | Random wander, prefers to keep its direction | rarely coins | +| Seeker | Oneal | 1 tile/turn | 50% step toward player (BFS), else wander | coins sometimes | +| Drifter | Doll | 1 tile/turn | Bounces straight until blocked, then turns | action charge sometimes | +| Sprinter | Minvo | 2 tiles every other turn | Toward player 50% | action charge | +| Ghost | Kondoria | moves every other turn | Passes through soft blocks; chases | coins | +| Wisp | Ovapi | 1 tile/turn | Passes through soft blocks; wanders | action charge | +| Hunter | Pass | 1 tile/turn | Always BFS shortest path to player | coins + charge | +| Reaper | Pontan | 2 tiles/turn | Passes soft blocks, always chases. **Later milestone:** spawns only as punishment (turn limit, destroyed exit/pickup) | big coins | + +- Until Reapers exist, "destroyed pickup/exit" spawns a group of the level's tougher types. +- Base drop table: ~30–40% action charge, separate coin roll, rare skull from level 2. Tune. +- Drops land on the enemy's tile and stay until collected or flamed. +- **Introduction schedule over 50 levels** (like the NES): Blob only at first; one new type + every ~6 levels; later levels mix types. Exact table to be drafted in `design.md`. + +--- + +## 11. Levels, scrolling, progression, the ending + +- **Grid 31 × 13** like the NES: hard walls around the border, indestructible pillars at every + even (x, y), soft blocks placed by seeded RNG at ~30–40% density, start corner (3 tiles in an L) + always clear, no enemy within ~4 tiles of the start. (Open, §14: narrower early levels?) +- **Generation must guarantee:** the exit and the pickup are under soft blocks; every free tile is + reachable once soft blocks are gone (assert across many seeds in tests). +- **Camera:** all 13 rows always fit vertically (portrait phone is the primary target), about + 7 columns visible on a 390 × 844 phone (measured), since 13 rows must fill the height. Follows the player **horizontally**, eased between turns, clamped to the + level edges. Landscape/desktop show more columns. +- **Par:** each level has a turn budget; finishing under it pays a coin bonus. + (Turn **limit** with Reapers: later milestone.) +- **50 levels**, difficulty rising through enemy count, types, speed and soft-block density. +- **Game over** at 0 lives → run summary (level reached, coins banked, kills, Keep/Fresh choices, + seed) → Workshop → new run. +- **Ending** after level 50: an ending screen, then the **victory certificate**. +- **Mid-run save is required**, not optional: a 50-level run won't happen in one sitting on a + phone. The save is just the run record (seed + Workshop loadout + action log, §12), replayed on + load — or a JSON snapshot of the state plus the log so far. + +### Victory certificate + +Shows: **seed, date, turns, deaths, Keep/Fresh choices, Workshop loadout, sim version**, and a +**QR code** linking to `https://pavelsafronov.gitlab.io/explodorama/#cert=…`. + +Being honest about what this can prove: the game is fully client-side and open source, so any +code-only "signature" can be forged by someone determined. What *can* be verified is the run +itself, because the sim is deterministic: + +- The certificate carries a **hash of the full run record** (seed + loadout + every action). +- **"Save replay"** downloads the run record as a small file. A **verify page** replays it in the + real sim and confirms it reaches level 50's ending and matches the certificate's hash. +- The full record (~7,500 turns × ~5 bits ≈ 5 KB) is too big for a single QR code, so the QR holds + the summary + hash, and the replay file is the proof. +- Bonus: the same replay file lets anyone **watch** the winning run. +- Replays only work with the same rules, so the record stores `SIM_VERSION`; the verify page + must say clearly when a replay comes from a different version. + +(Open, §14: is this level of proof wanted, or is a simple checksum enough?) + +--- + +## 12. Seeds and randomness + +- One **run seed** (shown on the run summary and certificate; can be entered to replay a seed). +- **Separate RNG streams** derived from the run seed by purpose and level, e.g. + `hash(seed, "levelgen", level)`, `hash(seed, "ai", level)`, `hash(seed, "drops", level)`, + `hash(seed, "shop", level)`, `hash(seed, "curses", level)`. This way an extra bomb doesn't + change what the next shop offers, and the same seed always gives the same 50 level layouts. +- Never `Math.random()` in `sim/`. + +--- + +## 13. Look and icons + +**LittleJS ships no icon set** (its npm package contains only a small built-in font image), +so the game uses Flaticon icons: + +- **Artist and style:** Icongeek26, "Outline" style: + <https://www.flaticon.com/search?author_id=296&style_id=1001&type=standard>. + Black line art on a transparent background, downloaded as PNG (512 px). +- **Licence:** Flaticon free licence, which **requires attribution**. Follow `nordic-skating`: + credit in the README (Attribution section) and in an in-game About/Credits dialog — + "Icons by [Icongeek26](https://www.flaticon.com/authors/icongeek26), from Flaticon." + The icons are not covered by the project's own licence; say so in the README. +- **Who downloads them:** the user, by hand (Flaticon needs a browser session). Until a file is + present, the game draws a labelled placeholder shape, and a check (`npm run icons:check` or a + test) lists missing icons by name. +- **Source files:** `assets/icons/<name>.png`, names from the table below. +- **Atlas:** a `tools/atlas.mjs` script packs them into one `static/atlas.png` (e.g. 128 px + cells) plus a generated `src/render/atlas.ts` (name → tile index), so LittleJS loads a single + image. Use Playwright for image work, as `nordic-skating/tools/icons.mjs` does — no extra + image library. +- **Colouring:** the icons are black. LittleJS's `drawTile(pos, size, tileInfo, color, angle, + mirror, additiveColor)` (confirmed in the type definitions of the pinned 1.25.0) can turn them any colour: + black ink + `additiveColor` = that colour, and transparency is kept. The DOM action bar uses the + same PNGs as a CSS `mask` over a coloured background (the `nordic-skating` technique). +- **Board look:** walls, pillars, soft blocks and floor are plain drawn shapes, not icons. + Icons are for things: player, enemies, bombs, pickups, drops, curses, door, and actions. + Colour shows meaning (player blue, enemies red, pickups green, coins gold, curses purple, + danger red overlay), but **shape alone must already tell them apart** — never colour only. +- **PWA icons and favicons:** generated from one chosen icon (bomb) by `npm run icons`, copied + from `nordic-skating/tools/icons.mjs`. + +Icon list (search terms are suggestions; any Icongeek26 Outline icon that reads clearly works): + +| Name (`assets/icons/<name>.png`) | Used for | Search for | +|---|---|---| +| player | the player | robot / astronaut / person | +| bomb | bombs, Place bomb, app icon | bomb | +| explosion | flame (animation) | explosion / fire | +| door | exit (open) | door | +| lock | exit (closed) | padlock | +| coin | coins | coin | +| coin-bag | Coin bag pickup | money bag | +| heart | Heart pickup, lives in HUD | heart | +| bomb-plus | Bomb +1 | bombs / plus | +| fire | Fire +1 | fire / flame | +| fuse | Fuse +1, Fuse ± action | timer / stopwatch | +| skates | Skates, Run | roller skate | +| wall-pass | Wall Pass | ghost / wall | +| bomb-pass | Bomb Pass | jump / step over | +| flame-pass | Flame Pass | fireproof / fire shield | +| detonator | Detonator, Detonate | remote control | +| star | Star | star | +| skull | curses | skull | +| move | Move | footprints | +| wait | Wait | hourglass | +| kick | Kick | boot / kick | +| throw | Throw | throw / hand | +| shield | Shield | shield | +| diagonal | Diagonal step | diagonal arrow | +| teleport | Teleport | portal / teleport | +| pierce | Pierce bomb | drill | +| power | Power bomb | lightning | +| line | Line bomb | line / row | +| freeze | Freeze | snowflake | +| decoy | Decoy | scarecrow / dummy | +| cleanse | Cleanse | potion / sparkle | +| magnet | Magnet | magnet | +| lucky | Lucky | clover | +| map | Map | treasure map | +| shop | shop | shopping cart / store | +| workshop | Workshop | wrench / toolbox | +| trophy | ending, certificate | trophy / medal | +| enemy-blob | Blob | balloon / slime | +| enemy-seeker | Seeker | eye | +| enemy-drifter | Drifter | spinning top | +| enemy-sprinter | Sprinter | lightning bug / rocket | +| enemy-ghost | Ghost | ghost | +| enemy-wisp | Wisp | flame spirit / jellyfish | +| enemy-hunter | Hunter | spider | +| enemy-reaper | Reaper (later) | grim reaper / scythe | +| settings, sound-on, sound-off, info | UI | gear, speaker, speaker off, info | + +--- + +## 14. Open questions + +1. **Workshop banking:** only coins **left over** at the end of a run, or **all** coins earned? + *Recommendation:* left over — it creates a real "spend now or bank it" choice in every shop. +2. **Workshop deposits:** is "tier n costs 5 × n, deposit partially with a progress bar" the + right reading of "put 5/10/15 coins into a permanently-increasing slot"? +3. **Fresh start and the level:** should Fresh also **re-roll the level layout** (a new board as + well as new pockets)? *Recommendation:* yes, it gives Fresh a feel of its own. It stays + deterministic (the attempt number goes into the levelgen stream). +4. **Skulls with coins:** should a skull also give coins, making it a risk-for-reward choice + rather than only something to avoid? *Recommendation:* yes, a small amount. +5. **Level width ramp:** start narrower (e.g. 15 columns) and grow to the full 31 by ~level 10, + so early levels are short phone sessions? *Recommendation:* yes. +6. **Shared / daily seeds:** seeds already make runs reproducible. Add a "daily seed" everyone + plays the same day? *Recommendation:* later milestone, cheap to add. +7. **Certificate strength:** replay-verifiable (§11, recommended; nearly free since the sim is + deterministic) or a simple checksum? +8. **Board theme:** light "paper" board with the icons black as drawn (matches the icon style), + or dark board with icons recoloured light? *Recommendation:* light by default, dark as a + setting/OS preference — `additiveColor` makes both cheap. + +--- + +## 15. Notes for the implementer + +- **Read the user's `CLAUDE.md` rules.** In particular: **never commit or push** until the user + explicitly confirms they reviewed screenshots and ran the app. Docs must be updated as part of + each feature (the README lists every action, pickup, curse and enemy). Run the tests often. +- **Verify the LittleJS API against the installed `dist/littlejs.d.ts`**, not memory. Already + confirmed in the pinned 1.25.0: `engineInit(gameInit, gameUpdate, gameUpdatePost, gameRender, + gameRenderPost, imageSources, rootElement): Promise<void>`, `cameraPos`, `cameraScale`, + `drawTile(… color, angle, mirror, additiveColor …)`, `drawTextScreen`, + `setTouchInputEnable`, `drawRect`, `drawCircle`, `drawText`, `screenToWorld`, + `setCanvasPixelRatio`, `setGLEnable`. Still check: `tile()`, `TileLayer`, `zzfx`. +- **The game uses LittleJS's Canvas 2D renderer** (`setGLEnable(false)`), and handles taps with + pointer events on the canvas — see `decisions.md` for why. +- **LittleJS world space is y-up**; the sim grid is row 0 = top. Convert in exactly one place + (`render/board.ts`), e.g. `worldY = (rows - 1) - row`. +- **Fit the board:** `cameraScale` = canvas height available between HUD and action bar ÷ 13 rows; + recompute on resize/orientation change. +- **Keep LittleJS out of `sim/`.** If a test needs to import LittleJS, the boundary is wrong. +- **Record the run from day one.** Every input that changes state — actions, shop purchases, + rerolls, Keep/Fresh, Workshop loadout at start — goes through `sim/` and is appended to the run + record. That one log gives mid-run save, replays and certificates. +- **Test priorities** (Vitest): turn phase order; bomb timer semantics (not ticked on placement + turn); chain reactions; flame stops at walls and destroys the first soft block; Pierce/Power; + Run needs both tiles free; Kick slide stop; enemy blocked by a bomb placed this turn stays put; + contact checks including swaps; levelgen invariants across many seeds; danger overlay matches + what `step()` actually does (predict → step → compare); **replay determinism** (record a random + run, replay it, identical final state and hash); Keep/Fresh payout formula; curses never before + the first shop. +- **Screenshots:** Playwright at a phone viewport (e.g. 390×844) and desktop, with URL params + like `?seed=123&level=1` for reproducible scenes. The user reviews them before any commit. +- **Start simple:** labelled shapes until icons arrive; no animation polish until M7. + +--- + +## 16. Architecture + +Golden rule: **all game rules are pure TypeScript with zero LittleJS imports**, deterministic +given a seed, and fully unit-testable. + +``` +src/ + sim/ pure, deterministic, no DOM, no LittleJS + types.ts Vec, Dir, Tile, GameState, Event, ActionId … + rng.ts seeded PRNG + per-purpose streams (§12) + state.ts create/clone state, grid helpers + levelgen.ts seeded level generation (§11) + actions.ts registry: { id, isAvailable(state), validTargets(state), apply(state, target) } + turn.ts step(state, action, target) → { state, events[] } — the 5 phases of §1 + explosions.ts blast shapes, chain reactions + enemies.ts intent planning per enemy type + danger.ts predicted flame tiles this turn / next turn + curses.ts + shop.ts offers, prices, purchase + death.ts Keep / Fresh options and payout + workshop.ts tracks, tiers, deposits + record.ts run record, replay, hash, SIM_VERSION + render/ LittleJS + board.ts shapes, icons, highlights, danger overlay, intents + atlas.ts generated by tools/atlas.mjs + animate.ts plays events[] as tweens; owns the input lock + camera.ts + ui/ DOM: action bar, HUD, shop, death choice, Workshop, certificate, About + audio.ts ZzFX sound effects (built into LittleJS — no audio files) + persist.ts localStorage save/load, export/import + main.ts +assets/icons/ Icongeek26 PNGs (user-downloaded) +static/ Vite publicDir (NOT public/ — see §17) +test/ Vitest +tools/ atlas.mjs, icons.mjs, screenshots.mjs (Playwright) +``` + +- `step()` **returns events** (`moved`, `bombPlaced`, `bombTicked`, `exploded`, `blockDestroyed`, + `enemyDied`, `dropSpawned`, `pickedUp`, `cursed`, `playerDied`, …). The renderer only plays + events back. +- `step()` does not mutate its input (clone, then mutate the clone) — makes previews and the + danger overlay safe. +- Adding an action = one registry entry + tests + an icon. + +--- + +## 17. Tooling and deploy (copy from `~/code/nordic-skating`) + +- `package.json` scripts: `dev` = `vite --host`, `build` = `tsc --noEmit && vite build`, + `test` = `vitest run`, `screenshots`, `icons`, `atlas`. +- Dev deps as in nordic-skating: `typescript`, `vite`, `vitest`, `playwright`, + `vite-plugin-qrcode` (scan to open on a phone), `@types/node`. `vite-plugin-pwa` arrives with + the icons in M7. +- Dependency: **`littlejsengine`**, **pinned to exactly 1.25.0** (the newest release at least two + weeks old on 2026-10-19). Upgrade deliberately. +- A QR library for the certificate (e.g. `qrcode`, which `~/code/pretty` already uses). +- `tsconfig.json`: copy nordic-skating's (strict, `noUncheckedIndexedAccess`, …). +- `vite.config.ts`: `base: process.env.BASE || './'`, `publicDir: 'static'`, QR plugin, PWA + (display fullscreen, icons, offline cache incl. `atlas.png`). Read nordic-skating's file — its + comments explain each choice. +- `.gitlab-ci.yml`: copy nordic-skating's (test stage, then `pages` with `mv dist public` and the + "fail loudly" checks; add a check that `atlas.png` exists). Project at + `gitlab.com/PavelSafronov/explodorama`, served at `https://pavelsafronov.gitlab.io/explodorama/`. +- Docs to create and keep current: `README.md` (how to play; every action, pickup, curse, enemy, + Workshop track; Attribution), this `PLAN.md`, `decisions.md` (each decision + why — seed it + from §0), `design.md` (balance tables: enemy schedule, prices, drop rates, payout formula). + +--- + +## 18. Milestones + +1. **M0 – Scaffold:** ✅ npm, Vite, TS, LittleJS, Vitest, `git init`, CI file, README with + Attribution section. ⏳ GitLab remote, first commit and first Pages deploy — waiting on the user. +2. **M1 – Sim core:** ✅ types, RNG streams, grid, Move, Wait, `step()` with all 5 phases + (enemy phases are marked placeholders), run record + replay, tests. +3. **M2 – Bombs:** ✅ adjacent placement, timers, blast, chains, soft blocks, death, lives. +4. **M3 – Mobile UI:** ✅ action bar, two-tap targeting, highlights, danger overlay, commit preview + (press-and-hold / hover), keyboard controls, placeholder shapes. ⏳ No animation of moves yet + (instant), only a flame flash. +5. **M4 – World:** ✅ levelgen (31 × 13), side-scrolling camera. ⏳ Exit door, par. +6. **M5 – Enemies:** Blob, Seeker, Drifter first; intents, drops, charge inventory, charge actions. +7. **M6 – Run loop:** NES pickups, coins, shop after each level, Keep/Fresh death choice, curses + from level 2, 50-level progression and ending, mid-run save. +8. **M7 – Look & feel:** icon atlas + attribution, animations, ZzFX sounds, PWA icons, remaining + enemies and actions, screenshots tool. +9. **M8 – Permanence:** Workshop, save export/import, victory certificate + QR, replay file and + verify page. +10. **Later:** Reapers + turn limit, daily seed, final art. diff --git a/README.md b/README.md new file mode 100644 index 0000000..4fcfa59 --- /dev/null +++ b/README.md @@ -0,0 +1,89 @@ +# Explodorama + +A turn-based, mobile-first, roguelike take on the 1985 NES bomb-laying classic. Every bomb +counts down with *your* moves, so nothing happens until you act — and you can see exactly +what will burn before you commit. + +Status: **early development.** The board, moving, bombs, chain reactions and lives work. +Enemies, pickups, the shop and the 50-level run are planned — see [PLAN.md](PLAN.md). + + + +## How to play + +You are the blue circle. Each turn you pick **one** action; then the world takes its turn. + +| Action | Phone | Keyboard | What it does | +|---|---|---|---| +| **Move** | Tap a tile next to you | W A S D or arrow keys | Step one tile up, down, left or right onto an empty tile. | +| **Bomb** | Tap **Bomb**, then a highlighted tile | **B**, then a direction | Place a bomb on an empty tile next to you. You can have 1 bomb on the board at a time. | +| **Wait** | Tap **Wait** | Space | Do nothing; the world takes its turn. | + +- Picking an action highlights the tiles you can choose, in blue. Tapping the action again, + pressing **Esc**, or tapping anywhere else cancels it. +- **Preview before you commit:** on a phone, press and hold a highlighted tile to see the + result (for a bomb, its blast in orange); slide to another tile to change your mind; let go + to commit. With a mouse, hovering shows the same preview. + +### Bombs + +- The number on a bomb is **how many more of your actions until it explodes**. A new bomb + shows **3**. A bomb showing **1** (red, pulsing) explodes at the end of your next action. +- The blast is a cross reaching **1 tile** in each direction. It stops at walls; it destroys + the first soft block (brown) in each direction and stops there. +- A bomb caught in a blast **explodes at once**, whatever its number (a chain reaction). +- Bombs block movement. + +### Danger + +- **Strong red** tiles will burn at the end of this turn if you wait. +- **Light red** tiles will burn at the end of the next turn. + +### Lives + +You have **3 lives**. Caught in a blast, you lose one and the level starts again with the same +layout. With none left, the run is over and the seed is shown, so the same board can be replayed. + +### The board + +31 × 13 tiles, like the original: dark walls and pillars (indestructible), brown soft blocks +(destructible), and the camera follows you sideways. + +## Running it + +```sh +npm install +npm run dev # http://localhost:5173 — also prints a QR code to open it on a phone on the same Wi-Fi +npm test # Vitest +npm run build # type-check and build into dist/ +npm run screenshots # build, then photograph the game into docs/screenshots/ (Playwright) +``` + +URL parameters: `?seed=123` plays a fixed board; `?level=4` starts at a later level. + +Deployed to GitLab Pages by `.gitlab-ci.yml` on every push to the default branch. + +## How it's built + +- **`src/sim/`** — all the game's rules, in plain TypeScript with no rendering. Deterministic: + every random choice comes from the seed. `step(state, action)` plays one turn and returns the + new state plus a list of events. The danger overlay runs the same code as a real turn, so it + cannot disagree with what happens. +- **`src/render/`** — draws the board with [LittleJS](https://github.com/KilledByAPixel/LittleJS) + (pinned to an exact version, using its Canvas 2D renderer). +- **`src/ui/`** — the HUD, action bar and banners, as plain HTML. +- Every run is recorded as its seed plus the list of actions, which replays to the identical + state. This will become mid-run saves, replays and victory certificates. + +Design and decisions: [PLAN.md](PLAN.md), [decisions.md](decisions.md). + +## Attribution + +No third-party artwork is used yet. The planned icons are by +[Icongeek26](https://www.flaticon.com/authors/icongeek26), from Flaticon, under the Flaticon +free licence, which requires attribution; they will be credited here and in the game, and will +not be covered by this project's licence. + +## Licence + +MIT. diff --git a/decisions.md b/decisions.md new file mode 100644 index 0000000..dce4a51 --- /dev/null +++ b/decisions.md @@ -0,0 +1,56 @@ +# Decisions + +Each design or technical decision, why it was made, and when. Newest last. The full design is +in [PLAN.md](PLAN.md); this file records the reasoning so it isn't lost. + +## Design (agreed in planning, 2026-10-19) + +1. **Enemies move before bombs explode.** Enemies announce their next move (intent arrows), so + the player can plan traps. Luck matters less than planning. +2. **Run is an ability, not a charge** — gained like the NES roller skates. +3. **3 lives**, plus a roguelike layer of coins and upgrades. +4. **No undo.** +5. **All actions in PLAN.md §3** are in scope. +6. **Name: Explodorama.** "Bomberman" is a Konami trademark and must never appear in the game. +7. **Shop purchases last for the current run only.** +8. **Permanent upgrades come from a Workshop between runs** (Everspace-style tracks, tier n costs + 5 × n coins). Later milestone. +9. **On death the player chooses Keep or Fresh**, balanced so each is picked about half the + time. See PLAN.md §9. +10. **Curses only appear after the first shop.** +11. **Reapers and the turn limit come later.** +12. **50 levels and an ending**, with a victory certificate and QR code. +13. **Icons: Icongeek26 "Outline" from Flaticon**, with attribution, until there is final art. + LittleJS ships no icons. + +## Technical + +### 2026-10-19 — LittleJS pinned to 1.25.0 + +Exact version, the newest release that was at least two weeks old at install time (1.27.2 was +4 days old). Upgrade deliberately, not by range. + +### 2026-10-19 — Canvas 2D renderer, not WebGL + +`setGLEnable(false)`. Headless Chromium's full-viewport screenshots of LittleJS's WebGL canvas +showed a black strip below the HUD that is not in the drawing (a clipped screenshot of the same +page didn't show it, and Canvas 2D doesn't show it at all). Screenshots are how changes are +reviewed before committing, so they must be trustworthy. The board is a few hundred rectangles; +Canvas 2D is plenty. Revisit only if drawing becomes slow. + +### 2026-10-19 — Taps handled with pointer events on the canvas, not LittleJS's mouse input + +Press shows the commit preview on the tile under the finger, sliding changes it, release +commits. That needs press, move and release positions, which pointer events give directly. +LittleJS still handles nothing on the HTML buttons: it skips `preventDefault` for buttons. + +### 2026-10-19 — Bombs block flame by exploding; flame stops on a bomb + +A blast reaching another bomb sets it off and stops there; the second bomb's own blast covers +the tiles beyond. Soft blocks hit during a chain are only removed once the whole chain is done, +so every flame that reaches a block that turn stops at it. + +### 2026-10-19 — PWA deferred to M7 + +`vite-plugin-pwa` needs icons, which come with the icon set in M7. The CI checks for a manifest +and service worker will be added then. diff --git a/docs/screenshots/bomb-picked.png b/docs/screenshots/bomb-picked.png new file mode 100644 index 0000000..82b288a Binary files /dev/null and b/docs/screenshots/bomb-picked.png differ diff --git a/docs/screenshots/countdown.png b/docs/screenshots/countdown.png new file mode 100644 index 0000000..9014a94 Binary files /dev/null and b/docs/screenshots/countdown.png differ diff --git a/docs/screenshots/death.png b/docs/screenshots/death.png new file mode 100644 index 0000000..e6e12b9 Binary files /dev/null and b/docs/screenshots/death.png differ diff --git a/docs/screenshots/desktop.png b/docs/screenshots/desktop.png new file mode 100644 index 0000000..2ba5115 Binary files /dev/null and b/docs/screenshots/desktop.png differ diff --git a/docs/screenshots/scrolled.png b/docs/screenshots/scrolled.png new file mode 100644 index 0000000..7b22db6 Binary files /dev/null and b/docs/screenshots/scrolled.png differ diff --git a/docs/screenshots/start.png b/docs/screenshots/start.png new file mode 100644 index 0000000..3145463 Binary files /dev/null and b/docs/screenshots/start.png differ diff --git a/index.html b/index.html new file mode 100644 index 0000000..8002e41 --- /dev/null +++ b/index.html @@ -0,0 +1,27 @@ +<!doctype html> +<html lang="en"> +<head> +<meta charset="utf-8" /> +<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, user-scalable=no" /> +<title>Explodorama</title> +<meta name="description" content="A turn-based, mobile-first, roguelike bomb-laying game. Every bomb counts down with your moves." /> +<meta name="theme-color" content="#f4efe4" /> +<meta name="mobile-web-app-capable" content="yes" /> +<meta name="apple-mobile-web-app-capable" content="yes" /> +<link rel="stylesheet" href="/src/style.css" /> +</head> +<body> +<header id="hud" aria-live="polite"> + <span id="hud-lives"></span> + <span id="hud-level"></span> + <span id="hud-turn"></span> + <span id="hud-stats"></span> +</header> +<nav id="actions" aria-label="Actions"></nav> +<div id="banner" hidden> + <p id="banner-text"></p> + <button id="banner-button" type="button"></button> +</div> +<script type="module" src="/src/main.ts"></script> +</body> +</html> diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..c9f1a48 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1542 @@ +{ + "name": "explodorama", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "explodorama", + "version": "0.1.0", + "license": "MIT", + "dependencies": { + "littlejsengine": "1.25.0" + }, + "devDependencies": { + "@types/node": "^26.6.5", + "playwright": "^1.63.0", + "typescript": "^7.0.2", + "vite": "^8.3.4", + "vite-plugin-qrcode": "^0.4.1", + "vitest": "^5.0.5" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.154.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.154.0.tgz", + "integrity": "sha512-KYOUGQFlLWR+s4QMV2Q9ko38TZm7EdO87Qm7rvZLqk/webse/cDjtyxKhg/47l0byCbs/k46LT8qc4PKjAikzA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/oxc-project" + } + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.14.tgz", + "integrity": "sha512-aWR+XKH9+rW7m5fWq/5360J3mHycrx/o6xZM2DQFdJnTMdyP7/WzxPjhtLbVWdKo2y612/4U1bWqb3HQhiF7vg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.14.tgz", + "integrity": "sha512-QiVxHofl6A8lQU7WAtOUY4QMVZXohxwz7WJJoeh8NBEUyKfIkMRKhYyq8az/1RypNiIXzNTtiS6KwHz0erxYRQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.14.tgz", + "integrity": "sha512-lTQOzHuBIooh3jIr5XINQkjPGEuGy+Dsy0meXDr6Sjty0b99kYgGh4uBC/8mJX1T0kC8d6S0aPP1Y+bHkh6aBw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.14.tgz", + "integrity": "sha512-hyMKEGatjmBYCs/Q4sbIcL/GqpFEqsX3wc+HYVQ/85A4rxc1o7rPm4DUI4GV4k/h0ITddwFSGpqQunI14GXjTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.14.tgz", + "integrity": "sha512-u371GbXoVXrEgP20PwzoGNOdAOqAMhzo19JzzY+V4YmmnC+DTOoqdbMu+O+54emM59Mik/UdrtWgcuEDKWRPKA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.14.tgz", + "integrity": "sha512-BqwKsdZQf6EqVWr8fv2MrDYzAvOZ7+NYFSJESRGs87fkseEhs0TU9TXgRJTkZoRRkxTizyEg1LKUXfU3/78uqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.14.tgz", + "integrity": "sha512-SD9kY8M009x/7s57Z1dgUVlP5+uYBgKQK0Tkr1WIWngrtUJmRCW1RJFMY7QDla2khXhmg2i5Io7MUjcmZNZ/KQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.14.tgz", + "integrity": "sha512-BJ2CL19M6uuWesMgnqJzgxoI+N+eV7b0A9O9vLV42y+gitC41fNa8KElau1tHVzuLWX5GzEuxMPhAO0tQXQMkg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.14.tgz", + "integrity": "sha512-4+Kl8daPV8NDHaD7mfCcc1pRM+Qg7ab8DyMm5FG5WoZgv4h2i3KFN1CybYBNbQqmuYm7ftJLvmMI1eHYJ22S/Q==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.14.tgz", + "integrity": "sha512-V/h8ePq1fKDrmtFMz6xcCN3DKvuvMRrwwD1q0PkBeeo4DpK64biaKvqWGVxqCeYKqeRk7gE7/iKPxLCn08O97A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.14.tgz", + "integrity": "sha512-WxalXzgrLVpKC93xfpaJ3gCXUWzeCr+FRCbDVD2mUAy4L6Vo3lNVz0epUEUpxPWJkLNYUwM4OSIikv36OJ9mLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.14.tgz", + "integrity": "sha512-3G3tnAmTpdk9jO3GQtyHXqeFD2SlecfTws/Y3M2mmj9nMBwwuLT4kur8sZKeu7ENUHSpVO3cIsm5A52LEWWwlg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.14.tgz", + "integrity": "sha512-qyr5XiNGxm2ytx17vvtcJ9nXn6v1gENETi503bJx3OGwZ1tSonj7BosxbMa/xeAfSl5iS9X6KjEWYXLu6v1L7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.14.tgz", + "integrity": "sha512-oA4o5o0S2yB199YyYXDzKUv1Z56Rc+mgOjwm7Z/vJ9Z8igiDPYvoOuDyo6g7AK/ZaF09nPhDKVnGBcYHO11GXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.14.tgz", + "integrity": "sha512-9mE94bf2GZUaR/fUSXCMpBoWkctu421Mi0ORpm4zqfbkjKep7RxzPFa5etcGs5h+2KApoWBOA/hAJa/7saGgEQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "26.6.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.5.tgz", + "integrity": "sha512-uMzsv9stDdofIZBx4UGh4Z9ZgXnGC9seRDscilBcTHAeOFk67MZg/k9bWVfO+bk4qZmG+l52hRnLwUiH2LnJTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~8.9.0" + } + }, + "node_modules/@typescript/typescript-aix-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", + "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", + "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", + "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", + "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", + "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", + "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", + "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-loong64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", + "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-mips64el": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", + "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", + "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-riscv64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", + "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-s390x": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", + "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", + "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", + "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", + "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", + "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", + "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-sunos-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", + "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", + "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", + "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@vitest/mocker": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.5.tgz", + "integrity": "sha512-xLL3lkDegJZw+MGoGaCCsFayDZthGgpAtr3Vziyfc4p7xH89K7KRPcRBBh/kEYm0Aqkt5DtTmkYpBtRV/FrHZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.31", + "@vitest/spy": "5.0.5", + "estree-walker": "^3.0.3", + "magic-string": "^1.4.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9 || ^3.0.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/spy": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.5.tgz", + "integrity": "sha512-lwJRExuH417257Q5wv9QYugN9IMJkoxcrKrYxGPQkBzl7wlNpTUxD/5yNX4ESKVUIShKPgkGchYPFMWOOk0wzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/chai": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz", + "integrity": "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "dev": true, + "license": "MIT" + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/littlejsengine": { + "version": "1.25.0", + "resolved": "https://registry.npmjs.org/littlejsengine/-/littlejsengine-1.25.0.tgz", + "integrity": "sha512-qk/Vhua9IemOD+mSOGB1AbGCSoJV4AY7aC62d+7G9FDlxXVfokk39oXuVixfwxoV6n6Y4b24scMcgtTtmhQI1w==", + "license": "MIT", + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/magic-string": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.3.tgz", + "integrity": "sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.6.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/obug": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/obug/-/obug-3.0.0.tgz", + "integrity": "sha512-5vvB5+W7ePv+p3uqxi+RcW1XAzLW0/hxt3/4X4Lc4qHudzOhmBiBwOY6DRob4WnanAEGvNcLjF+KNOufrUoEQw==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/playwright": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz", + "integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.63.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright-core": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz", + "integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/postcss": { + "version": "8.5.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.31.tgz", + "integrity": "sha512-YUbWYCgErwnMAVkjUnYa+KlMBzGpHwiIt/DE7f0G33AuX2LH9UcTk6KgWxd3zRU+pN3u/C+Xl3EnMLM4Hea7Sg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.20", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rolldown": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.14.tgz", + "integrity": "sha512-eTBTTqMR6uHKLgcUqpJP8ntzDI6V4LYA8negAMBEI7gkgVvOdrx6VsvS2Ym+83KhOPdLCHWJu0w7UQFJZhHKNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.154.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.14", + "@rolldown/binding-android-arm64": "1.2.14", + "@rolldown/binding-darwin-arm64": "1.2.14", + "@rolldown/binding-darwin-x64": "1.2.14", + "@rolldown/binding-freebsd-x64": "1.2.14", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.14", + "@rolldown/binding-linux-arm64-gnu": "1.2.14", + "@rolldown/binding-linux-arm64-musl": "1.2.14", + "@rolldown/binding-linux-ppc64-gnu": "1.2.14", + "@rolldown/binding-linux-s390x-gnu": "1.2.14", + "@rolldown/binding-linux-x64-gnu": "1.2.14", + "@rolldown/binding-linux-x64-musl": "1.2.14", + "@rolldown/binding-openharmony-arm64": "1.2.14", + "@rolldown/binding-win32-arm64-msvc": "1.2.14", + "@rolldown/binding-win32-x64-msvc": "1.2.14" + } + }, + "node_modules/source-map-js": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/std-env": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.3.0.tgz", + "integrity": "sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.2.2.tgz", + "integrity": "sha512-nIZ2N2dFpjBsg2hgitKu7mA+FvsIJuGJOwysRngmVRRmWgR/UyPjwJj8c/fT/dhm8EkHnF5EOogRf+0Z2qrimA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/tinyexec": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz", + "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", + "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc" + }, + "engines": { + "node": ">=16.20.0" + }, + "optionalDependencies": { + "@typescript/typescript-aix-ppc64": "7.0.2", + "@typescript/typescript-darwin-arm64": "7.0.2", + "@typescript/typescript-darwin-x64": "7.0.2", + "@typescript/typescript-freebsd-arm64": "7.0.2", + "@typescript/typescript-freebsd-x64": "7.0.2", + "@typescript/typescript-linux-arm": "7.0.2", + "@typescript/typescript-linux-arm64": "7.0.2", + "@typescript/typescript-linux-loong64": "7.0.2", + "@typescript/typescript-linux-mips64el": "7.0.2", + "@typescript/typescript-linux-ppc64": "7.0.2", + "@typescript/typescript-linux-riscv64": "7.0.2", + "@typescript/typescript-linux-s390x": "7.0.2", + "@typescript/typescript-linux-x64": "7.0.2", + "@typescript/typescript-netbsd-arm64": "7.0.2", + "@typescript/typescript-netbsd-x64": "7.0.2", + "@typescript/typescript-openbsd-arm64": "7.0.2", + "@typescript/typescript-openbsd-x64": "7.0.2", + "@typescript/typescript-sunos-x64": "7.0.2", + "@typescript/typescript-win32-arm64": "7.0.2", + "@typescript/typescript-win32-x64": "7.0.2" + } + }, + "node_modules/undici-types": { + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz", + "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==", + "dev": true, + "license": "MIT" + }, + "node_modules/uqr": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/uqr/-/uqr-0.1.3.tgz", + "integrity": "sha512-0rjE8iEJe4YmT9TOhwsZtqCMRLc5DXZUI2UEYUUg63ikBkqqE5EYWaI0etFe/5KUcmcYwLih2RND1kq+hrUJXA==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "8.3.4", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.4.tgz", + "integrity": "sha512-IftLE8phJhdNRIGNEV4JCCvfNScoZQVfcXaN8TQropWGJtOHcu+iAB9pHcuGvmEb/kddw0LBe/jS8ztdC6xdiQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.7", + "postcss": "^8.5.29", + "rolldown": "~1.2.12", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.7.1", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-plugin-qrcode": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/vite-plugin-qrcode/-/vite-plugin-qrcode-0.4.1.tgz", + "integrity": "sha512-t3gdCbn/a2HHLK3M8bUrQ+4c+I0YQGJ/STxAL9eI6TBnfdeM/CJr3HctvOb7UYhEtWxHs5xZWpz7kYnRd5w+rA==", + "dev": true, + "license": "MIT", + "dependencies": { + "uqr": "^0.1.2" + }, + "peerDependencies": { + "vite": ">=3" + } + }, + "node_modules/vitest": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.5.tgz", + "integrity": "sha512-cPODOMF8YnkiiYtDdHo6n4wWmBrOBBbOIYH2d33M4l3CdNn72h+qMmO6uXffUN6A5AZFXNND/d+krS9mgkXRfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/mocker": "5.0.5", + "chai": "^6.3.0", + "es-module-lexer": "^2.3.2", + "expect-type": "^1.4.0", + "magic-string": "^1.4.3", + "obug": "^3.0.0", + "picomatch": "^4.0.7", + "std-env": "^4.3.0", + "tinybench": "^6.2.2", + "tinyexec": "^1.3.1", + "tinyglobby": "^0.2.17", + "why-is-node-running": "3.2.1" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^22.12.0 || ^24.0.0 || >=26.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "5.0.5", + "@vitest/browser-preview": "5.0.5", + "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", + "@vitest/coverage-istanbul": "5.0.5", + "@vitest/coverage-v8": "5.0.5", + "@vitest/ui": "5.0.5", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.4.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/why-is-node-running": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-3.2.1.tgz", + "integrity": "sha512-Tb2FUhB4vUsGQlfSquQLYkApkuPAFQXGFzxWKHHumVz2dK+X1RUm/HnID4+TfIGYJ1kTcwOaCk/buYCEJr6YjQ==", + "dev": true, + "license": "MIT", + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=20.11" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..a5c51ae --- /dev/null +++ b/package.json @@ -0,0 +1,32 @@ +{ + "name": "explodorama", + "private": true, + "version": "0.1.0", + "type": "module", + "description": "A turn-based, mobile-first, roguelike take on the 1985 NES bomb-laying classic.", + "scripts": { + "dev": "vite --host", + "build": "tsc --noEmit && vite build", + "preview": "vite preview", + "test": "vitest run", + "test:watch": "vitest", + "screenshots": "npm run build && node tools/screenshots.mjs" + }, + "repository": { + "type": "git", + "url": "https://gitlab.com/PavelSafronov/explodorama.git" + }, + "homepage": "https://pavelsafronov.gitlab.io/explodorama/", + "license": "MIT", + "dependencies": { + "littlejsengine": "1.25.0" + }, + "devDependencies": { + "@types/node": "^26.6.5", + "playwright": "^1.63.0", + "typescript": "^7.0.2", + "vite": "^8.3.4", + "vite-plugin-qrcode": "^0.4.1", + "vitest": "^5.0.5" + } +} diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..ec5f51d --- /dev/null +++ b/src/main.ts @@ -0,0 +1,158 @@ +import { + engineInit, + mainCanvas, + screenToWorld, + setCanvasClearColor, + setCanvasPixelRatio, + setGLEnable, + setShowSplashScreen, + time, + vec2, +} from 'littlejsengine' +import { BACKGROUND, drawBoard, FLAME_SECONDS, toTile, type Flame } from './render/board' +import { updateCamera } from './render/camera' +import { isAvailable, isValidTarget, ACTIONS } from './sim/actions' +import { predictDanger, type Danger } from './sim/danger' +import { newRecord, type RunRecord } from './sim/record' +import { add, inBounds, newRun } from './sim/state' +import { step } from './sim/turn' +import { DIRS, type Action, type ActionId, type Dir, type GameState, type Vec } from './sim/types' +import { createControls } from './ui/controls' + +// `?seed=123&level=4` gives a reproducible board (screenshots, bug reports). Otherwise a random +// seed — chosen here, never inside the sim. +const params = new URLSearchParams(location.search) +const startLevel = Number(params.get('level')) || 1 + +let record: RunRecord +let state: GameState +let danger: Danger +let selected: ActionId | null = null +let hover: Vec | null = null +let flames: Flame[] = [] +let snapCamera = true + +function startRun(seed: number): void { + record = newRecord(seed, startLevel) + state = newRun(seed, startLevel) + afterChange() +} + +function randomSeed(): number { + return Math.floor(Math.random() * 2 ** 31) +} + +function act(action: Action): void { + const result = step(state, action) + state = result.state + record.actions.push(action) + for (const e of result.events) { + if (e.type === 'exploded') flames.push({ tiles: e.tiles, born: time }) + if (e.type === 'levelRestarted') snapCamera = true + } + selected = null + afterChange() +} + +function afterChange(): void { + danger = predictDanger(state) + controls.update(state, selected) +} + +/** Two taps: pick an action, then a highlighted tile. With nothing picked, a tap next to the player moves. */ +function tapTile(p: Vec): void { + if (state.status !== 'playing') return + if (selected) { + if (isValidTarget(state, selected, p)) act({ id: selected, target: p }) + else { + selected = null + afterChange() + } + } else if (isValidTarget(state, 'move', p)) act({ id: 'move', target: p }) +} + +function pick(id: ActionId): void { + if (!isAvailable(state, id)) return + if (!ACTIONS[id].needsTarget) act({ id }) + else { + selected = selected === id ? null : id + afterChange() + } +} + +/** A direction key: places a bomb that way if Bomb is picked, otherwise moves. */ +function direction(d: Dir): void { + if (state.status !== 'playing') return + const p = add(state.player, DIRS[d]) + const id: ActionId = selected === 'bomb' ? 'bomb' : 'move' + if (isValidTarget(state, id, p)) act({ id, target: p }) +} + +const controls = createControls({ + pick, + banner() { + if (state.status === 'dead') act({ id: 'continue' }) + else if (state.status === 'gameOver') startRun(randomSeed()) + }, +}) + +const KEY_DIRS: Record<string, Dir> = { + KeyW: 'up', ArrowUp: 'up', + KeyS: 'down', ArrowDown: 'down', + KeyA: 'left', ArrowLeft: 'left', + KeyD: 'right', ArrowRight: 'right', +} + +window.addEventListener('keydown', (e) => { + if (e.repeat) return + const d = KEY_DIRS[e.code] + if (d) direction(d) + else if (e.code === 'KeyB') pick('bomb') + else if (e.code === 'Space') pick('wait') + else if (e.code === 'Escape') { + selected = null + afterChange() + } else if (e.code === 'Enter') controls.pressBanner() +}) + +function tileUnder(e: PointerEvent): Vec | null { + const r = mainCanvas.getBoundingClientRect() + const p = toTile(state, screenToWorld(vec2(e.clientX - r.left, e.clientY - r.top))) + return inBounds(state, p) ? p : null +} + +startRun(Number(params.get('seed')) || randomSeed()) + +// Sharp on high-density screens, capped so phones don't pay for 3×. +setCanvasPixelRatio(Math.min(devicePixelRatio, 2)) +// Canvas 2D, not WebGL: a board of a few hundred rectangles doesn't need it, and headless +// Chromium's screenshots of the WebGL canvas showed a black strip that isn't in the drawing, +// which made the screenshots untrustworthy for review (decisions.md). +setGLEnable(false) + +await engineInit( + () => { + setShowSplashScreen(false) + setCanvasClearColor(BACKGROUND) + // Pressing shows the preview on the tile under the finger; releasing commits to it. + // Sliding to another tile before letting go changes the choice. + mainCanvas.addEventListener('pointerdown', (e) => (hover = tileUnder(e))) + mainCanvas.addEventListener('pointermove', (e) => { + if (e.pointerType === 'mouse' || e.buttons) hover = tileUnder(e) + }) + mainCanvas.addEventListener('pointerup', (e) => { + const p = tileUnder(e) + if (e.pointerType !== 'mouse') hover = null + if (p) tapTile(p) + }) + mainCanvas.addEventListener('pointerleave', () => (hover = null)) + }, + () => { + updateCamera(state, controls.insets(), snapCamera) + snapCamera = false + flames = flames.filter((f) => time - f.born < FLAME_SECONDS) + }, + () => {}, + () => drawBoard({ state, danger, selected, hover, flames }), + () => {}, +) diff --git a/src/render/board.ts b/src/render/board.ts new file mode 100644 index 0000000..95d0db3 --- /dev/null +++ b/src/render/board.ts @@ -0,0 +1,113 @@ +import { drawCircle, drawRect, drawText, rgb, time, vec2, type Color, type Vector2 } from 'littlejsengine' +import { ACTIONS } from '../sim/actions' +import type { Danger } from '../sim/danger' +import { blastTiles } from '../sim/explosions' +import { eq, idx } from '../sim/state' +import type { ActionId, GameState, Vec } from '../sim/types' + +/** + * The one place where sim tiles meet LittleJS world space. The sim's y grows downward + * (row 0 at the top); LittleJS's y grows upward. A tile's centre is at (x + 0.5, y + 0.5). + */ +export function toWorld(s: GameState, p: Vec): Vector2 { + return vec2(p.x + 0.5, s.height - 1 - p.y + 0.5) +} + +export function toTile(s: GameState, w: Vector2): Vec { + return { x: Math.floor(w.x), y: s.height - 1 - Math.floor(w.y) } +} + +/** Shown for a moment after a bomb goes off. Flame has no lasting effect on the board. */ +export interface Flame { + tiles: Vec[] + born: number +} + +export interface View { + state: GameState + danger: Danger + selected: ActionId | null + /** The tile under the pointer or finger, for the commit preview. */ + hover: Vec | null + flames: Flame[] +} + +export const FLAME_SECONDS = 0.45 + +const C = { + grid: rgb(0.85, 0.82, 0.75), + floor: rgb(0.96, 0.94, 0.89), + wall: rgb(0.23, 0.21, 0.19), + block: rgb(0.64, 0.48, 0.3), + blockFace: rgb(0.72, 0.57, 0.37), + dangerNow: rgb(0.9, 0.15, 0.1, 0.45), + dangerNext: rgb(1, 0.35, 0.2, 0.18), + target: rgb(0.18, 0.44, 0.84, 0.4), + targetHover: rgb(0.18, 0.44, 0.84, 0.7), + preview: rgb(1, 0.55, 0.1, 0.4), + flame: rgb(1, 0.6, 0.1), + bomb: rgb(0.11, 0.1, 0.09), + bombHot: rgb(0.8, 0.1, 0.08), + white: rgb(1, 1, 1), + player: rgb(0.18, 0.44, 0.84), + playerDead: rgb(0.55, 0.55, 0.55), +} + +export const BACKGROUND: Color = C.grid + +export function drawBoard(view: View): void { + const { state: s, danger, selected, hover } = view + // A hair over a tile, so neighbouring walls overlap and no anti-aliased seam shows between them. + const wallSize = vec2(1.02) + + for (let y = 0; y < s.height; y++) { + for (let x = 0; x < s.width; x++) { + const p = { x, y } + const pos = toWorld(s, p) + const cell = s.cells[idx(s, p)] + if (cell === 'wall') drawRect(pos, wallSize, C.wall) + else if (cell === 'block') { + drawRect(pos, vec2(0.94), C.block) + drawRect(pos, vec2(0.62), C.blockFace) + } else drawRect(pos, vec2(0.94), C.floor) + } + } + + for (const i of danger.next) if (!danger.now.has(i)) drawRect(toWorld(s, tileOf(s, i)), vec2(0.94), C.dangerNext) + for (const i of danger.now) drawRect(toWorld(s, tileOf(s, i)), vec2(0.94), C.dangerNow) + + const hovered = selected && hover && ACTIONS[selected].validTargets(s).some((t) => eq(t, hover)) ? hover : null + if (selected) { + for (const t of ACTIONS[selected].validTargets(s)) { + drawRect(toWorld(s, t), vec2(0.94), hovered && eq(t, hovered) ? C.targetHover : C.target) + } + } + if (hovered && selected === 'bomb') { + for (const t of blastTiles(s, { pos: hovered, fire: s.stats.fire })) drawRect(toWorld(s, t), vec2(0.7), C.preview) + } + + for (const f of view.flames) { + const fade = 1 - (time - f.born) / FLAME_SECONDS + if (fade <= 0) continue + for (const t of f.tiles) drawRect(toWorld(s, t), vec2(0.9), C.flame.scale(1, fade)) + } + + for (const b of s.bombs) { + const pos = toWorld(s, b.pos) + const hot = b.timer <= 1 + const size = hot ? 0.74 + 0.06 * Math.sin(time * 10) : 0.74 + drawCircle(pos, size, hot ? C.bombHot : C.bomb) + drawText(String(b.timer), pos, 0.5, C.white) + } + if (hovered && selected === 'bomb') { + drawCircle(toWorld(s, hovered), 0.74, C.bomb.scale(1, 0.45)) + drawText(String(s.stats.fuse), toWorld(s, hovered), 0.5, C.white) + } + + if (hovered && selected === 'move') drawCircle(toWorld(s, hovered), 0.72, C.player.scale(1, 0.4)) + drawCircle(toWorld(s, s.player), 0.72, s.status === 'playing' ? C.player : C.playerDead, 0.06, C.white) +} + +function tileOf(s: GameState, i: number): Vec { + return { x: i % s.width, y: Math.floor(i / s.width) } +} diff --git a/src/render/camera.ts b/src/render/camera.ts new file mode 100644 index 0000000..ef9803c --- /dev/null +++ b/src/render/camera.ts @@ -0,0 +1,35 @@ +import { mainCanvasSize, setCameraPos, setCameraScale, vec2 } from 'littlejsengine' +import type { GameState } from '../sim/types' + +/** CSS pixels covered at the top (HUD) and bottom (action bar) of the canvas. */ +export interface Insets { + top: number + bottom: number +} + +let x: number | undefined + +/** + * All rows always fit between the HUD and the action bar. Horizontally the camera follows the + * player, eased, and stops at the level's edges, as in the original (PLAN.md §11). + * `snap` jumps straight there, for the first frame of a level. + */ +export function updateCamera(s: GameState, insets: Insets, snap = false): void { + const width = mainCanvasSize.x + const height = mainCanvasSize.y + const available = Math.max(1, height - insets.top - insets.bottom) + const scale = available / s.height + setCameraScale(scale) + + const halfView = width / 2 / scale + const target = s.width <= 2 * halfView ? s.width / 2 : clamp(s.player.x + 0.5, halfView, s.width - halfView) + x = x === undefined || snap ? target : x + (target - x) * 0.2 + + // The camera centres the canvas; move it so the board centres in the gap between the bars. + const offset = insets.top + available / 2 - height / 2 + setCameraPos(vec2(x, s.height / 2 + offset / scale)) +} + +function clamp(v: number, lo: number, hi: number): number { + return Math.min(hi, Math.max(lo, v)) +} diff --git a/src/sim/actions.ts b/src/sim/actions.ts new file mode 100644 index 0000000..32d96c4 --- /dev/null +++ b/src/sim/actions.ts @@ -0,0 +1,66 @@ +import { add, eq, isFree } from './state' +import { DIRS, DIR_LIST, type ActionId, type GameEvent, type GameState, type Vec } from './types' + +/** + * The board actions (PLAN.md §3). Adding one = an entry here, its tests, and a button. + * `apply` mutates the state it is given; `step()` hands it a clone. + */ +export interface ActionDef { + id: ActionId + label: string + /** False for actions like Wait that happen as soon as they are picked. */ + needsTarget: boolean + validTargets(s: GameState): Vec[] + apply(s: GameState, target: Vec | undefined, events: GameEvent[]): void +} + +function neighbours(p: Vec): Vec[] { + return DIR_LIST.map((d) => add(p, DIRS[d])) +} + +export const ACTIONS: Record<ActionId, ActionDef> = { + move: { + id: 'move', + label: 'Move', + needsTarget: true, + validTargets: (s) => neighbours(s.player).filter((p) => isFree(s, p)), + apply(s, target, events) { + const from = s.player + s.player = { ...target! } + events.push({ type: 'moved', from, to: s.player }) + }, + }, + bomb: { + id: 'bomb', + label: 'Bomb', + needsTarget: true, + validTargets: (s) => (s.bombs.length >= s.stats.capacity ? [] : neighbours(s.player).filter((p) => isFree(s, p))), + apply(s, target, events) { + const pos = { ...target! } + s.bombs.push({ pos, timer: s.stats.fuse, fire: s.stats.fire, placedTurn: s.turn }) + events.push({ type: 'bombPlaced', pos, timer: s.stats.fuse }) + }, + }, + wait: { + id: 'wait', + label: 'Wait', + needsTarget: false, + validTargets: () => [], + apply(_s, _target, events) { + events.push({ type: 'waited' }) + }, + }, +} + +export const ACTION_ORDER: readonly ActionId[] = ['move', 'bomb', 'wait'] + +export function isValidTarget(s: GameState, id: ActionId, target: Vec): boolean { + return ACTIONS[id].validTargets(s).some((p) => eq(p, target)) +} + +/** Whether the action can be picked right now. */ +export function isAvailable(s: GameState, id: ActionId): boolean { + if (s.status !== 'playing') return false + const def = ACTIONS[id] + return !def.needsTarget || def.validTargets(s).length > 0 +} diff --git a/src/sim/danger.ts b/src/sim/danger.ts new file mode 100644 index 0000000..6f03643 --- /dev/null +++ b/src/sim/danger.ts @@ -0,0 +1,24 @@ +import { detonate, tickBombs } from './explosions' +import { cloneState } from './state' +import type { GameEvent, GameState } from './types' + +/** Tile indices that will burn at the end of this turn, and at the end of the next one. */ +export interface Danger { + now: Set<number> + next: Set<number> +} + +/** + * The danger overlay (PLAN.md §4), assuming the player waits. It runs the same countdown and + * chain code as `step()`, so it can't disagree with what actually happens. + */ +export function predictDanger(state: GameState): Danger { + const s = cloneState(state) + const ignored: GameEvent[] = [] + tickBombs(s, ignored) + const now = detonate(s, ignored) + s.turn++ + tickBombs(s, ignored) + const next = detonate(s, ignored) + return { now, next } +} diff --git a/src/sim/explosions.ts b/src/sim/explosions.ts new file mode 100644 index 0000000..641c5b7 --- /dev/null +++ b/src/sim/explosions.ts @@ -0,0 +1,58 @@ +import { add, bombAt, cellAt, idx } from './state' +import { DIRS, DIR_LIST, type Bomb, type GameEvent, type GameState, type Vec } from './types' + +/** + * The tiles a bomb's flame reaches: its own tile, then up to `fire` tiles in each direction. + * Flame stops before a hard wall, and stops *on* a soft block or another bomb (burning it). + */ +export function blastTiles(s: GameState, bomb: Pick<Bomb, 'pos' | 'fire'>): Vec[] { + const tiles = [bomb.pos] + for (const d of DIR_LIST) { + let p = bomb.pos + for (let i = 0; i < bomb.fire; i++) { + p = add(p, DIRS[d]) + const cell = cellAt(s, p) + if (cell === 'wall') break + tiles.push(p) + if (cell === 'block' || bombAt(s, p)) break + } + } + return tiles +} + +/** + * Explode every bomb whose timer is at 0 or below, and every bomb their flame reaches, in a + * chain, all in this one call. Soft blocks are destroyed only once the whole chain is done, so + * a block stops every flame that reaches it this turn. Returns the burned tiles' indices. + */ +export function detonate(s: GameState, events: GameEvent[]): Set<number> { + const burned = new Set<number>() + const hitBlocks = new Set<number>() + let bomb: Bomb | undefined + while ((bomb = s.bombs.find((b) => b.timer <= 0))) { + s.bombs.splice(s.bombs.indexOf(bomb), 1) + const tiles = blastTiles(s, bomb) + for (const t of tiles) { + const i = idx(s, t) + burned.add(i) + if (s.cells[i] === 'block') hitBlocks.add(i) + const other = bombAt(s, t) + if (other) other.timer = 0 + } + events.push({ type: 'exploded', pos: bomb.pos, tiles }) + } + for (const i of hitBlocks) { + s.cells[i] = 'floor' + events.push({ type: 'blockDestroyed', pos: { x: i % s.width, y: Math.floor(i / s.width) } }) + } + return burned +} + +/** Phase 3's countdown: every bomb not placed this turn loses 1 from its timer. */ +export function tickBombs(s: GameState, events: GameEvent[]): void { + for (const b of s.bombs) { + if (b.placedTurn === s.turn) continue + b.timer-- + events.push({ type: 'bombTicked', pos: b.pos, timer: b.timer }) + } +} diff --git a/src/sim/levelgen.ts b/src/sim/levelgen.ts new file mode 100644 index 0000000..324ab47 --- /dev/null +++ b/src/sim/levelgen.ts @@ -0,0 +1,44 @@ +import { stream } from './rng' +import type { Cell, Vec } from './types' + +/** The NES layout: 31 × 13 with a wall all round and a pillar at every even (x, y). */ +export const LEVEL_WIDTH = 31 +export const LEVEL_HEIGHT = 13 +export const START: Vec = { x: 1, y: 1 } +/** Share of the open tiles that get a soft block. */ +export const BLOCK_DENSITY = 0.35 + +export interface Layout { + width: number + height: number + cells: Cell[] + start: Vec +} + +export function generateLevel(seed: number, level: number): Layout { + const width = LEVEL_WIDTH + const height = LEVEL_HEIGHT + const rng = stream(seed, 'levelgen', level) + const cells: Cell[] = [] + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const border = x === 0 || y === 0 || x === width - 1 || y === height - 1 + const pillar = x % 2 === 0 && y % 2 === 0 + cells.push(border || pillar ? 'wall' : 'floor') + } + } + // The start and the two tiles beside it stay clear, so the first bomb has somewhere to go + // and the player somewhere to hide from it. + const clear = new Set([key(START), key({ x: START.x + 1, y: START.y }), key({ x: START.x, y: START.y + 1 })]) + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const i = y * width + x + if (cells[i] === 'floor' && !clear.has(key({ x, y })) && rng.next() < BLOCK_DENSITY) cells[i] = 'block' + } + } + return { width, height, cells, start: { ...START } } +} + +function key(p: Vec): string { + return `${p.x},${p.y}` +} diff --git a/src/sim/record.ts b/src/sim/record.ts new file mode 100644 index 0000000..3824fa5 --- /dev/null +++ b/src/sim/record.ts @@ -0,0 +1,27 @@ +import { newRun } from './state' +import { step } from './turn' +import type { Action, GameState } from './types' + +/** + * Bumped whenever a rule change would make an old record replay differently. Records from + * another version can't be trusted to replay (PLAN.md §11). + */ +export const SIM_VERSION = 1 + +/** Everything needed to reproduce a run exactly: mid-run saves, replays, certificates. */ +export interface RunRecord { + simVersion: number + seed: number + startLevel: number + actions: Action[] +} + +export function newRecord(seed: number, startLevel = 1): RunRecord { + return { simVersion: SIM_VERSION, seed, startLevel, actions: [] } +} + +export function replay(record: RunRecord): GameState { + let s = newRun(record.seed, record.startLevel) + for (const a of record.actions) s = step(s, a).state + return s +} diff --git a/src/sim/rng.ts b/src/sim/rng.ts new file mode 100644 index 0000000..c0e4878 --- /dev/null +++ b/src/sim/rng.ts @@ -0,0 +1,41 @@ +/** + * Seeded randomness. Every random choice in the game comes from a stream derived from the run + * seed by purpose and level (PLAN.md §12), so an extra bomb in one place never changes what + * happens somewhere else, and the same seed always gives the same levels. + */ + +/** FNV-1a over the parts joined by `:`. A 32-bit unsigned integer. */ +export function hashSeed(...parts: (string | number)[]): number { + let h = 0x811c9dc5 + for (const ch of parts.join(':')) { + h ^= ch.codePointAt(0)! + h = Math.imul(h, 0x01000193) + } + return h >>> 0 +} + +/** + * mulberry32. Its whole state is one 32-bit number, kept in `state`, so a stream can be stored + * in the game state and resumed exactly. + */ +export class Rng { + constructor(public state: number) {} + + /** A float in [0, 1). */ + next(): number { + this.state = (this.state + 0x6d2b79f5) >>> 0 + let t = this.state + t = Math.imul(t ^ (t >>> 15), t | 1) + t ^= t + Math.imul(t ^ (t >>> 7), t | 61) + return ((t ^ (t >>> 14)) >>> 0) / 4294967296 + } + + /** An integer in [0, n). */ + int(n: number): number { + return Math.floor(this.next() * n) + } +} + +export function stream(seed: number, purpose: string, level: number): Rng { + return new Rng(hashSeed(seed, purpose, level)) +} diff --git a/src/sim/state.ts b/src/sim/state.ts new file mode 100644 index 0000000..325b6e1 --- /dev/null +++ b/src/sim/state.ts @@ -0,0 +1,49 @@ +import { generateLevel } from './levelgen' +import type { Bomb, Cell, GameState, Stats, Vec } from './types' + +export const START_LIVES = 3 +export const START_STATS: Stats = { capacity: 1, fire: 1, fuse: 3 } + +export function newRun(seed: number, level = 1): GameState { + return createLevel(seed, level, START_LIVES, { ...START_STATS }) +} + +/** A fresh attempt at a level: the layout comes from the seed, the player keeps lives and stats. */ +export function createLevel(seed: number, level: number, lives: number, stats: Stats): GameState { + const { width, height, cells, start } = generateLevel(seed, level) + return { seed, level, turn: 0, width, height, cells, player: start, lives, stats, bombs: [], status: 'playing' } +} + +export function cloneState(s: GameState): GameState { + return structuredClone(s) +} + +export function add(a: Vec, b: Vec): Vec { + return { x: a.x + b.x, y: a.y + b.y } +} + +export function eq(a: Vec, b: Vec): boolean { + return a.x === b.x && a.y === b.y +} + +export function idx(s: GameState, p: Vec): number { + return p.y * s.width + p.x +} + +export function inBounds(s: GameState, p: Vec): boolean { + return p.x >= 0 && p.y >= 0 && p.x < s.width && p.y < s.height +} + +/** Outside the grid counts as wall. */ +export function cellAt(s: GameState, p: Vec): Cell { + return inBounds(s, p) ? s.cells[idx(s, p)]! : 'wall' +} + +export function bombAt(s: GameState, p: Vec): Bomb | undefined { + return s.bombs.find((b) => eq(b.pos, p)) +} + +/** Nothing in the way: floor, and no bomb on it. */ +export function isFree(s: GameState, p: Vec): boolean { + return cellAt(s, p) === 'floor' && !bombAt(s, p) +} diff --git a/src/sim/turn.ts b/src/sim/turn.ts new file mode 100644 index 0000000..5ae92a5 --- /dev/null +++ b/src/sim/turn.ts @@ -0,0 +1,50 @@ +import { ACTIONS, isValidTarget } from './actions' +import { detonate, tickBombs } from './explosions' +import { cloneState, createLevel, idx } from './state' +import type { Action, GameEvent, GameState } from './types' + +export interface StepResult { + state: GameState + events: GameEvent[] +} + +/** + * One turn (PLAN.md §1). Never mutates `prev`. Throws on an action that isn't allowed right + * now; the UI only offers allowed ones, so a throw is a bug. + */ +export function step(prev: GameState, action: Action): StepResult { + const s = cloneState(prev) + const events: GameEvent[] = [] + + if (action.id === 'continue') { + if (s.status !== 'dead') throw new Error(`continue while ${s.status}`) + events.push({ type: 'levelRestarted' }) + return { state: createLevel(s.seed, s.level, s.lives, s.stats), events } + } + if (s.status !== 'playing') throw new Error(`${action.id} while ${s.status}`) + + // Phase 1: the player acts. + const def = ACTIONS[action.id] + if (def.needsTarget && (!action.target || !isValidTarget(s, action.id, action.target))) { + throw new Error(`invalid target for ${action.id}: ${JSON.stringify(action.target)}`) + } + def.apply(s, action.target, events) + + // Phase 2: enemies carry out their announced intents (M5). + + // Phase 3: bombs tick; those at 0 explode, in chains. + tickBombs(s, events) + const burned = detonate(s, events) + + // Phase 4: damage. + if (burned.has(idx(s, s.player))) { + s.lives-- + s.status = s.lives > 0 ? 'dead' : 'gameOver' + events.push({ type: 'playerDied', pos: s.player, livesLeft: s.lives }) + } + + // Phase 5: enemies announce their next intents (M5). + + s.turn++ + return { state: s, events } +} diff --git a/src/sim/types.ts b/src/sim/types.ts new file mode 100644 index 0000000..b6d0c66 --- /dev/null +++ b/src/sim/types.ts @@ -0,0 +1,77 @@ +/** + * The shapes of the game's state, actions and events. Everything in `sim/` is plain data and + * pure functions: no DOM, no LittleJS, no `Math.random()`. + */ + +/** A tile position. x grows to the right, y grows downward; (0, 0) is the top-left corner. */ +export interface Vec { + x: number + y: number +} + +export type Dir = 'up' | 'down' | 'left' | 'right' + +export const DIRS: Record<Dir, Vec> = { + up: { x: 0, y: -1 }, + down: { x: 0, y: 1 }, + left: { x: -1, y: 0 }, + right: { x: 1, y: 0 }, +} + +export const DIR_LIST: readonly Dir[] = ['up', 'down', 'left', 'right'] + +/** `wall` is a hard wall or pillar (indestructible); `block` is a soft block (flame destroys it). */ +export type Cell = 'floor' | 'wall' | 'block' + +export interface Bomb { + pos: Vec + /** How many more player actions until it explodes (PLAN.md §1). */ + timer: number + /** Blast radius in tiles, in each of the 4 directions. */ + fire: number + /** The turn it was placed on. A bomb is not ticked on the turn it is placed. */ + placedTurn: number +} + +export interface Stats { + /** Most of the player's own bombs on the board at once. */ + capacity: number + fire: number + /** The timer a newly placed bomb starts at. */ + fuse: number +} + +/** `dead`: lost a life and waiting for `continue`. `gameOver`: no lives left. */ +export type Status = 'playing' | 'dead' | 'gameOver' + +export interface GameState { + seed: number + level: number + /** Turns taken on this attempt at this level, starting at 0. */ + turn: number + width: number + height: number + /** Row-major: the cell at (x, y) is `cells[y * width + x]`. */ + cells: Cell[] + player: Vec + lives: number + stats: Stats + bombs: Bomb[] + status: Status +} + +/** The actions the player can pick on the board. */ +export type ActionId = 'move' | 'bomb' | 'wait' + +export type Action = { id: ActionId; target?: Vec } | { id: 'continue' } + +/** What `step()` reports happened, in order, for the renderer to play back. */ +export type GameEvent = + | { type: 'moved'; from: Vec; to: Vec } + | { type: 'waited' } + | { type: 'bombPlaced'; pos: Vec; timer: number } + | { type: 'bombTicked'; pos: Vec; timer: number } + | { type: 'exploded'; pos: Vec; tiles: Vec[] } + | { type: 'blockDestroyed'; pos: Vec } + | { type: 'playerDied'; pos: Vec; livesLeft: number } + | { type: 'levelRestarted' } diff --git a/src/style.css b/src/style.css new file mode 100644 index 0000000..f04fa06 --- /dev/null +++ b/src/style.css @@ -0,0 +1,118 @@ +:root { + --paper: #f4efe4; + --ink: #1d1b18; + --muted: #6b665c; + --accent: #2f6fd6; + --bar-bg: #fffaf0; + --line: #d9d1c0; +} + +html, +body { + margin: 0; + height: 100%; + overflow: hidden; + background: var(--paper); + color: var(--ink); + font: 16px/1.3 system-ui, -apple-system, sans-serif; + /* A game surface: no pull-to-refresh, no double-tap zoom, no text selection. */ + touch-action: none; + user-select: none; + -webkit-user-select: none; + -webkit-tap-highlight-color: transparent; + overscroll-behavior: none; +} + +#hud { + position: fixed; + top: 0; + left: 0; + right: 0; + z-index: 2; + display: flex; + justify-content: space-between; + gap: 8px; + padding: calc(env(safe-area-inset-top) + 8px) 16px 8px; + background: var(--bar-bg); + border-bottom: 1px solid var(--line); + font-variant-numeric: tabular-nums; +} + +#actions { + position: fixed; + bottom: 0; + left: 0; + right: 0; + z-index: 2; + display: flex; + gap: 8px; + overflow-x: auto; + padding: 8px 16px calc(env(safe-area-inset-bottom) + 8px); + background: var(--bar-bg); + border-top: 1px solid var(--line); +} + +#actions button { + flex: 1 0 auto; + min-width: 72px; + min-height: 56px; + border: 2px solid var(--ink); + border-radius: 12px; + background: #fff; + color: var(--ink); + font: inherit; + font-weight: 600; +} + +#actions button[aria-pressed='true'] { + background: var(--accent); + border-color: var(--accent); + color: #fff; +} + +#actions button:disabled { + opacity: 0.35; +} + +#actions kbd { + display: block; + font: 11px ui-monospace, monospace; + color: var(--muted); +} + +#actions button[aria-pressed='true'] kbd { + color: #dce8ff; +} + +#banner { + position: fixed; + inset: 0; + z-index: 3; + display: grid; + place-content: center; + gap: 16px; + padding: 16px; + text-align: center; + background: rgb(244 239 228 / 0.85); +} + +#banner[hidden] { + display: none; +} + +#banner p { + margin: 0; + font-size: 24px; + font-weight: 700; +} + +#banner button { + min-height: 56px; + padding: 0 32px; + border: 0; + border-radius: 12px; + background: var(--accent); + color: #fff; + font: inherit; + font-weight: 700; +} diff --git a/src/ui/controls.ts b/src/ui/controls.ts new file mode 100644 index 0000000..2cbd69e --- /dev/null +++ b/src/ui/controls.ts @@ -0,0 +1,72 @@ +import { ACTIONS, ACTION_ORDER, isAvailable } from '../sim/actions' +import type { ActionId, GameState } from '../sim/types' + +/** The HTML around the board: HUD, action bar, and the banner shown on death. */ +export interface Handlers { + /** An action button was pressed. */ + pick(id: ActionId): void + /** The banner's button: continue after losing a life, or a new run after game over. */ + banner(): void +} + +const KEYS: Record<ActionId, string> = { move: 'WASD', bomb: 'B', wait: 'Space' } + +export function createControls(handlers: Handlers) { + const bar = document.getElementById('actions')! + const buttons = new Map<ActionId, HTMLButtonElement>() + for (const id of ACTION_ORDER) { + const b = document.createElement('button') + b.type = 'button' + b.dataset.action = id + b.innerHTML = `${ACTIONS[id].label}<kbd>${KEYS[id]}</kbd>` + b.addEventListener('click', () => handlers.pick(id)) + bar.append(b) + buttons.set(id, b) + } + + const banner = document.getElementById('banner')! + const bannerText = document.getElementById('banner-text')! + const bannerButton = document.getElementById('banner-button') as HTMLButtonElement + bannerButton.addEventListener('click', () => handlers.banner()) + + const hud = { + lives: document.getElementById('hud-lives')!, + level: document.getElementById('hud-level')!, + turn: document.getElementById('hud-turn')!, + stats: document.getElementById('hud-stats')!, + } + + return { + update(s: GameState, selected: ActionId | null): void { + for (const [id, b] of buttons) { + b.disabled = !isAvailable(s, id) + b.setAttribute('aria-pressed', String(selected === id)) + } + hud.lives.textContent = `♥ ${s.lives}` + hud.level.textContent = `Level ${s.level}` + hud.turn.textContent = `Turn ${s.turn}` + hud.stats.textContent = `💣${s.stats.capacity} 🔥${s.stats.fire}` + + banner.hidden = s.status === 'playing' + if (s.status === 'dead') { + bannerText.textContent = `Boom. ${s.lives} ${s.lives === 1 ? 'life' : 'lives'} left.` + bannerButton.textContent = 'Try again' + } else if (s.status === 'gameOver') { + bannerText.textContent = `Game over on level ${s.level}. Seed ${s.seed}.` + bannerButton.textContent = 'New run' + } + }, + /** True if the banner is showing; Enter presses its button. */ + pressBanner(): boolean { + if (banner.hidden) return false + bannerButton.click() + return true + }, + insets() { + return { + top: document.getElementById('hud')!.getBoundingClientRect().height, + bottom: bar.getBoundingClientRect().height, + } + }, + } +} diff --git a/test/danger.test.ts b/test/danger.test.ts new file mode 100644 index 0000000..1d0c4e3 --- /dev/null +++ b/test/danger.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from 'vitest' +import { ACTIONS, ACTION_ORDER, isAvailable } from '../src/sim/actions' +import { predictDanger } from '../src/sim/danger' +import { newRecord, replay } from '../src/sim/record' +import { idx, newRun } from '../src/sim/state' +import { step } from '../src/sim/turn' +import type { Action, GameState } from '../src/sim/types' +import { Rng } from '../src/sim/rng' + +/** A random but legal action; `continue` when dead. */ +function randomAction(s: GameState, rng: Rng): Action { + if (s.status === 'dead') return { id: 'continue' } + const ids = ACTION_ORDER.filter((id) => isAvailable(s, id)) + const id = ids[rng.int(ids.length)]! + const targets = ACTIONS[id].validTargets(s) + return ACTIONS[id].needsTarget ? { id, target: targets[rng.int(targets.length)]! } : { id } +} + +function burnedBy(s: GameState): Set<number> { + const tiles = new Set<number>() + for (const e of step(s, { id: 'wait' }).events) if (e.type === 'exploded') e.tiles.forEach((t) => tiles.add(idx(s, t))) + return tiles +} + +describe('danger overlay', () => { + it('"now" is exactly what burns if the player waits, and "next" what burns the turn after', () => { + const rng = new Rng(7) + for (let seed = 1; seed <= 20; seed++) { + let s = newRun(seed) + for (let t = 0; t < 150 && s.status !== 'gameOver'; t++) { + if (s.status === 'playing') { + const danger = predictDanger(s) + expect(danger.now).toEqual(burnedBy(s)) + const waited = step(s, { id: 'wait' }).state + if (waited.status === 'playing') expect(danger.next).toEqual(burnedBy(waited)) + } + s = step(s, randomAction(s, rng)).state + } + } + }) +}) + +describe('run record', () => { + it('replays to the identical state', () => { + const rng = new Rng(99) + const record = newRecord(1234) + let s = newRun(record.seed) + for (let t = 0; t < 300 && s.status !== 'gameOver'; t++) { + const a = randomAction(s, rng) + record.actions.push(a) + s = step(s, a).state + } + expect(replay(structuredClone(record))).toEqual(s) + }) +}) diff --git a/test/explosions.test.ts b/test/explosions.test.ts new file mode 100644 index 0000000..df4f663 --- /dev/null +++ b/test/explosions.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from 'vitest' +import { blastTiles, detonate } from '../src/sim/explosions' +import { step } from '../src/sim/turn' +import type { GameEvent } from '../src/sim/types' +import { board, v } from './helpers' + +describe('blast', () => { + it('is a cross of radius fire, stopped before walls', () => { + const s = board(` + ####### + #P....# + #.#.#.# + #.....# + #######`) + expect(blastTiles(s, { pos: v(3, 1), fire: 2 })).toEqual([v(3, 1), v(3, 2), v(3, 3), v(2, 1), v(1, 1), v(4, 1), v(5, 1)]) + }) + + it('stops on the first soft block and destroys only that one', () => { + let s = board(` + ####### + #P1++.# + #######`, { fire: 3 }) + s = step(s, { id: 'wait' }).state + expect(s.cells.slice(7, 14)).toEqual(['wall', 'floor', 'floor', 'floor', 'block', 'floor', 'wall']) + }) + + it('chains: a bomb in the flame explodes now, whatever its timer', () => { + const s = board(` + ######## + #P1.3.+# + ########`, { fire: 2 }) + const events: GameEvent[] = [] + s.bombs[0]!.timer = 0 + const burned = detonate(s, events) + expect(s.bombs).toEqual([]) + expect(events.filter((e) => e.type === 'exploded').map((e) => e.type === 'exploded' && e.pos)).toEqual([v(2, 1), v(4, 1)]) + expect(burned.has(1 * 8 + 6)).toBe(true) // the second bomb's flame reached the block + expect(s.cells[1 * 8 + 6]).toBe('floor') + }) + + it('a block hit by two flames in one chain still stops both', () => { + const s = board(` + ####### + #P1+1.# + #######`, { fire: 3 }) + s.bombs.forEach((b) => (b.timer = 0)) + const burned = detonate(s, []) + expect(burned.has(1 * 7 + 5)).toBe(true) // right bomb burns rightwards + expect(burned.has(1 * 7 + 1)).toBe(true) // left bomb burns the player's tile + expect(s.cells[1 * 7 + 3]).toBe('floor') + }) +}) diff --git a/test/helpers.ts b/test/helpers.ts new file mode 100644 index 0000000..ece641f --- /dev/null +++ b/test/helpers.ts @@ -0,0 +1,31 @@ +import { START_LIVES, START_STATS } from '../src/sim/state' +import type { Cell, GameState, Stats, Vec } from '../src/sim/types' + +/** + * A state from a picture: `#` wall, `+` soft block, `.` floor, `P` player (on floor), + * `1`–`9` a bomb with that timer (on floor, placed on an earlier turn). + */ +export function board(picture: string, stats: Partial<Stats> = {}): GameState { + const rows = picture.trim().split('\n').map((r) => r.trim()) + const height = rows.length + const width = rows[0]!.length + const cells: Cell[] = [] + let player: Vec | undefined + const s: GameState = { + seed: 1, level: 1, turn: 5, width, height, cells, player: { x: 0, y: 0 }, + lives: START_LIVES, stats: { ...START_STATS, ...stats }, bombs: [], status: 'playing', + } + rows.forEach((row, y) => { + if (row.length !== width) throw new Error(`row ${y} is ${row.length} wide, not ${width}`) + ;[...row].forEach((ch, x) => { + cells.push(ch === '#' ? 'wall' : ch === '+' ? 'block' : 'floor') + if (ch === 'P') player = { x, y } + if (/[1-9]/.test(ch)) s.bombs.push({ pos: { x, y }, timer: Number(ch), fire: s.stats.fire, placedTurn: 0 }) + }) + }) + if (!player) throw new Error('no P in the picture') + s.player = player + return s +} + +export const v = (x: number, y: number): Vec => ({ x, y }) diff --git a/test/levelgen.test.ts b/test/levelgen.test.ts new file mode 100644 index 0000000..9265806 --- /dev/null +++ b/test/levelgen.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest' +import { generateLevel, LEVEL_HEIGHT, LEVEL_WIDTH, START } from '../src/sim/levelgen' +import type { Vec } from '../src/sim/types' + +describe('levelgen', () => { + it('is the same for the same seed and level, and differs between levels', () => { + expect(generateLevel(42, 3)).toEqual(generateLevel(42, 3)) + expect(generateLevel(42, 3).cells).not.toEqual(generateLevel(42, 4).cells) + }) + + for (let seed = 1; seed <= 200; seed++) { + it(`seed ${seed}: walls, pillars, a clear start, everything reachable`, () => { + const { width, height, cells, start } = generateLevel(seed, 1 + (seed % 50)) + expect([width, height]).toEqual([LEVEL_WIDTH, LEVEL_HEIGHT]) + const at = (p: Vec) => cells[p.y * width + p.x] + for (let x = 0; x < width; x++) expect([at({ x, y: 0 }), at({ x, y: height - 1 })]).toEqual(['wall', 'wall']) + for (let y = 0; y < height; y++) expect([at({ x: 0, y }), at({ x: width - 1, y })]).toEqual(['wall', 'wall']) + expect(start).toEqual(START) + for (const p of [start, { x: 2, y: 1 }, { x: 1, y: 2 }]) expect(at(p)).toBe('floor') + + // With soft blocks gone, every non-wall tile is reachable from the start. + const seen = new Set([`${start.x},${start.y}`]) + const queue = [start] + while (queue.length) { + const p = queue.pop()! + for (const q of [{ x: p.x + 1, y: p.y }, { x: p.x - 1, y: p.y }, { x: p.x, y: p.y + 1 }, { x: p.x, y: p.y - 1 }]) { + if (at(q) !== 'wall' && !seen.has(`${q.x},${q.y}`)) { + seen.add(`${q.x},${q.y}`) + queue.push(q) + } + } + } + expect(seen.size).toBe(cells.filter((c) => c !== 'wall').length) + }) + } +}) diff --git a/test/turn.test.ts b/test/turn.test.ts new file mode 100644 index 0000000..bc2aad5 --- /dev/null +++ b/test/turn.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, it } from 'vitest' +import { ACTIONS, isAvailable } from '../src/sim/actions' +import { step } from '../src/sim/turn' +import { board, v } from './helpers' + +describe('move', () => { + it('goes to a free neighbour only', () => { + const s = board(` + ##### + #P.+# + #.#.# + #####`) + expect(ACTIONS.move.validTargets(s)).toEqual([v(1, 2), v(2, 1)]) + expect(() => step(s, { id: 'move', target: v(1, 0) })).toThrow() + expect(step(s, { id: 'move', target: v(2, 1) }).state.player).toEqual(v(2, 1)) + }) + + it('is blocked by a bomb', () => { + const s = board(` + ##### + #P3.# + #####`) + expect(ACTIONS.move.validTargets(s)).toEqual([]) + expect(isAvailable(s, 'move')).toBe(false) + }) + + it('does not change the state it was given', () => { + const s = board(` + #### + #P.# + ####`) + const before = structuredClone(s) + step(s, { id: 'move', target: v(2, 1) }) + expect(s).toEqual(before) + }) +}) + +describe('bomb timers', () => { + it('a placed bomb starts at the fuse and is not ticked on the turn it is placed', () => { + const s = board(` + ##### + #P..# + #####`) + const { state, events } = step(s, { id: 'bomb', target: v(2, 1) }) + expect(state.bombs).toEqual([{ pos: v(2, 1), timer: 3, fire: 1, placedTurn: 5 }]) + expect(events.map((e) => e.type)).toEqual(['bombPlaced']) + }) + + it('the number is how many more actions until it explodes', () => { + let s = board(` + ####### + #P....# + #######`, { fire: 1 }) + s = step(s, { id: 'bomb', target: v(2, 1) }).state // shows 3 + s = step(s, { id: 'wait' }).state + expect(s.bombs[0]!.timer).toBe(2) + s = step(s, { id: 'wait' }).state + expect(s.bombs[0]!.timer).toBe(1) + const last = step(s, { id: 'wait' }) + expect(last.state.bombs).toEqual([]) + expect(last.events.some((e) => e.type === 'exploded')).toBe(true) + }) + + it('respects bomb capacity', () => { + const s = board(` + ##### + #P.3# + #####`) + expect(ACTIONS.bomb.validTargets(s)).toEqual([]) + expect(ACTIONS.bomb.validTargets({ ...s, stats: { ...s.stats, capacity: 2 } })).toEqual([v(2, 1)]) + }) +}) + +describe('death', () => { + it('flame on the player costs a life and waits for continue', () => { + const s = board(` + ##### + #P1.# + #####`) + const { state, events } = step(s, { id: 'wait' }) + expect(state.status).toBe('dead') + expect(state.lives).toBe(2) + expect(events.at(-1)).toEqual({ type: 'playerDied', pos: v(1, 1), livesLeft: 2 }) + expect(() => step(state, { id: 'wait' })).toThrow() + }) + + it('continue restarts the level from its seed, keeping lives and stats', () => { + const s = board(` + ##### + #P1.# + #####`, { fire: 2 }) + const dead = step(s, { id: 'wait' }).state + const again = step(dead, { id: 'continue' }).state + expect(again.status).toBe('playing') + expect(again.lives).toBe(2) + expect(again.stats.fire).toBe(2) + expect(again.turn).toBe(0) + expect(again.width).toBe(31) + }) + + it('the last life ends the run', () => { + const s = { ...board(` + ##### + #P1.# + #####`), lives: 1 } + const { state } = step(s, { id: 'wait' }) + expect(state.status).toBe('gameOver') + expect(() => step(state, { id: 'continue' })).toThrow() + }) +}) diff --git a/tools/screenshots.mjs b/tools/screenshots.mjs new file mode 100644 index 0000000..89635cb --- /dev/null +++ b/tools/screenshots.mjs @@ -0,0 +1,53 @@ +/** + * Photograph the built game into docs/screenshots/, for the README and for looking a change + * over before it is committed. Always seed 1, so the pictures are of the same board each run. + * + * npm run screenshots + */ +import { mkdirSync } from 'node:fs' +import { chromium } from 'playwright' +import { preview } from 'vite' + +const OUT = 'docs/screenshots' +const PHONE = { width: 390, height: 844 } +const DESKTOP = { width: 1280, height: 720 } + +const press = (...keys) => async (page) => { + for (const k of keys) { + await page.keyboard.press(k) + await page.waitForTimeout(60) + } +} + +const SHOTS = [ + { name: 'start', act: async () => {} }, + // Bomb picked: the tiles it can go on are highlighted. + { name: 'bomb-picked', act: async (page) => { await page.click('[data-action=bomb]') } }, + // A bomb placed to the right, then a step down out of its way: its countdown and the danger overlay. + { name: 'countdown', act: press('KeyB', 'KeyD', 'KeyS') }, + // Walked to the right a little way: the camera follows. + { name: 'scrolled', act: press('KeyD', 'KeyD', 'KeyD', 'KeyD', 'KeyD', 'KeyD', 'KeyD', 'KeyD') }, + // Standing next to our own bomb as it goes off. + { name: 'death', act: press('KeyB', 'KeyD', 'Space', 'Space', 'Space') }, + { name: 'desktop', viewport: DESKTOP, act: press('KeyB', 'KeyD', 'KeyS') }, +] + +mkdirSync(OUT, { recursive: true }) +const server = await preview({ preview: { port: 4179, strictPort: true } }) +const browser = await chromium.launch() +try { + for (const { name, viewport = PHONE, act } of SHOTS) { + const page = await browser.newPage({ viewport, deviceScaleFactor: 2, hasTouch: viewport === PHONE }) + await page.goto('http://localhost:4179/?seed=1') + await page.waitForSelector('canvas') + await page.waitForTimeout(300) + await act(page) + await page.waitForTimeout(250) + await page.screenshot({ path: `${OUT}/${name}.png` }) + await page.close() + console.log(` ${OUT}/${name}.png`) + } +} finally { + await browser.close() + server.httpServer.close() +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..d49d58e --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "strict": true, + "noUncheckedIndexedAccess": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noEmit": true, + "isolatedModules": true, + "skipLibCheck": true, + "types": ["vite/client", "node"] + }, + "include": ["src", "test", "vite.config.ts"] +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..74e70f8 --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,20 @@ +import { defineConfig } from 'vite' +import { qrcode } from 'vite-plugin-qrcode' + +/** + * Relative asset paths by default, so the same build works wherever Pages puts it: + * at https://<user>.gitlab.io/<project>/ or at the root of a generated domain. Set BASE to + * override. `??` would not catch BASE="", so `||`. + */ +const base = process.env.BASE || './' + +export default defineConfig({ + base, + // `npm run dev` binds to every interface (--host) so a phone on the same Wi-Fi can open the + // game; this prints a scannable QR of that network URL alongside it. + plugins: [qrcode()], + // Not `public/`: GitLab Pages requires its artifact to be a directory named `public`, and + // `mv dist public` moves dist *inside* an existing `public/` instead of renaming it. + publicDir: 'static', + build: { target: 'es2022' }, +})