From 44d955af98c268d8426fe0bbc37c3e44b75b92c3 Mon Sep 17 00:00:00 2001 From: Kyle Deliyannides <112675076+kkd16@users.noreply.github.com> Date: Thu, 24 Sep 2026 19:10:57 -0400 Subject: [PATCH] Add Voidwake space strategy roguelite --- projects/voidwake-1aa9/.gitignore | 30 + projects/voidwake-1aa9/JOURNAL.md | 42 + projects/voidwake-1aa9/eslint.config.js | 22 + projects/voidwake-1aa9/index.html | 15 + projects/voidwake-1aa9/package.json | 38 + projects/voidwake-1aa9/pnpm-lock.yaml | 1770 ++++++++++++++++++ projects/voidwake-1aa9/project.json | 12 + projects/voidwake-1aa9/public/favicon.svg | 1 + projects/voidwake-1aa9/public/thumbnail.svg | 1 + projects/voidwake-1aa9/src/App.css | 217 +++ projects/voidwake-1aa9/src/App.tsx | 257 +++ projects/voidwake-1aa9/src/Art.tsx | 74 + projects/voidwake-1aa9/src/audio.ts | 16 + projects/voidwake-1aa9/src/content.ts | 99 + projects/voidwake-1aa9/src/game.ts | 240 +++ projects/voidwake-1aa9/src/index.css | 13 + projects/voidwake-1aa9/src/main.tsx | 10 + projects/voidwake-1aa9/tests/browser.mjs | 92 + projects/voidwake-1aa9/tests/engine.test.mjs | 114 ++ projects/voidwake-1aa9/tests/pilot.mjs | 49 + projects/voidwake-1aa9/tsconfig.app.json | 26 + projects/voidwake-1aa9/tsconfig.json | 7 + projects/voidwake-1aa9/tsconfig.node.json | 25 + projects/voidwake-1aa9/vite.config.ts | 8 + 24 files changed, 3178 insertions(+) create mode 100644 projects/voidwake-1aa9/.gitignore create mode 100644 projects/voidwake-1aa9/JOURNAL.md create mode 100644 projects/voidwake-1aa9/eslint.config.js create mode 100644 projects/voidwake-1aa9/index.html create mode 100644 projects/voidwake-1aa9/package.json create mode 100644 projects/voidwake-1aa9/pnpm-lock.yaml create mode 100644 projects/voidwake-1aa9/project.json create mode 100644 projects/voidwake-1aa9/public/favicon.svg create mode 100644 projects/voidwake-1aa9/public/thumbnail.svg create mode 100644 projects/voidwake-1aa9/src/App.css create mode 100644 projects/voidwake-1aa9/src/App.tsx create mode 100644 projects/voidwake-1aa9/src/Art.tsx create mode 100644 projects/voidwake-1aa9/src/audio.ts create mode 100644 projects/voidwake-1aa9/src/content.ts create mode 100644 projects/voidwake-1aa9/src/game.ts create mode 100644 projects/voidwake-1aa9/src/index.css create mode 100644 projects/voidwake-1aa9/src/main.tsx create mode 100644 projects/voidwake-1aa9/tests/browser.mjs create mode 100644 projects/voidwake-1aa9/tests/engine.test.mjs create mode 100644 projects/voidwake-1aa9/tests/pilot.mjs create mode 100644 projects/voidwake-1aa9/tsconfig.app.json create mode 100644 projects/voidwake-1aa9/tsconfig.json create mode 100644 projects/voidwake-1aa9/tsconfig.node.json create mode 100644 projects/voidwake-1aa9/vite.config.ts diff --git a/projects/voidwake-1aa9/.gitignore b/projects/voidwake-1aa9/.gitignore new file mode 100644 index 00000000..610c1768 --- /dev/null +++ b/projects/voidwake-1aa9/.gitignore @@ -0,0 +1,30 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + +# Local verification artifacts +tests/artifacts/ +test-results/ +playwright-report/ +.pr-body.md diff --git a/projects/voidwake-1aa9/JOURNAL.md b/projects/voidwake-1aa9/JOURNAL.md new file mode 100644 index 00000000..6e314a14 --- /dev/null +++ b/projects/voidwake-1aa9/JOURNAL.md @@ -0,0 +1,42 @@ +# Voidwake — The Last Signal + +A single-player space roguelite: command a small crew through three procedurally connected sectors, build a tactical card deck, and discover the source of a mysterious transmission. This is an entirely local game with no accounts or external services. + +## Design and architecture + +- Observatory instruments and expedition cartography: midnight navy, weathered steel, amber signals, mist teal, and muted lavender. A branching star chart with a planet on its horizon anchors the bridge. +- Barlow Condensed display, DM Sans body, IBM Plex Mono instruments; fonts are bundled locally. Ships, portraits, map symbols, and the catalog thumbnail are original SVG assets. +- `src/game.ts` is a deterministic, immutable state machine. `src/content.ts` holds cards, ships, sectors, crew, artifacts, and narrative encounters. `src/App.tsx` provides navigation and all game phases. `src/Art.tsx` contains vector artwork. `src/audio.ts` synthesizes optional sound effects. +- Seeds normalize to uppercase. Campaigns save after each action in `voidwake-1aa9-save-v1`; malformed saves fall back safely. Best completed-run score is retained separately. If storage fails, play continues with a visible warning. +- Each sector has an arrival point, five columns of three destinations, and a guardian. Every route reaches its guardian. Gate transit adds 6 fuel and repairs 25 hull. Emergency fuel prevents a stranded campaign. +- Combat draws five cards, refills three energy, and resets passive shields per turn. Crew share one ability use per battle. Burn resolves before enemy intent; vulnerable increases damage by 50%; exhaust lasts one battle. Explorer reduces enemy stats and adds post-battle repairs. Captain removes that assistance. + +## Shipped + +- [x] Three complete sectors, seeded maps, branching routes, and guardian battles. +- [x] Three playable ships with distinct starting decks and passive statistics. +- [x] Eighteen combat systems, enhanced variants, energy, shields, burn, vulnerability, evade, draw/discard/exhaust piles, and enemy intent patterns. +- [x] Three crew abilities, illustrated portraits, trust, and a trust-dependent ending. +- [x] Six narrative encounters with eighteen resource-sensitive decisions. +- [x] Six persistent artifacts, elite rewards, salvage, and a ship upgrade economy. +- [x] Stations with repairs, refueling, permanent upgrades, card purchases, enhancement, and removal. +- [x] Victory, defeat, six milestones, campaign scores, best score, and a captain's log. +- [x] Automatic saves, validation, save recovery, sound toggle, keyboard shortcuts, native accessible dialogs, mobile layouts, and reduced-motion support. +- [x] Custom static SVG catalog thumbnail and favicon. +- [x] Meaningful engine regression tests and automated complete-campaign simulations. +- [x] Production-subpath browser campaign, desktop/mobile screenshots, keyboard controls, save recovery, storage failure handling, and zero console/network failures. +- [ ] Explore additional enemy silhouettes and more sector-specific story encounters in a future iteration. +- [ ] Consider a challenge mode with daily shared seeds and limited resupply. + +## Verification + +- `pnpm test` uses Node 24's TypeScript support and built-in test runner. Tests cover map reachability, all card variants, legal actions, deck conservation, combat status effects, shops, story affordability, terminal states, and save round trips. A deterministic pilot completes 36 campaigns across all ships and both difficulties without modifying their game state. +- `pnpm build` and `pnpm lint` are unchanged template scripts. +- `pnpm test:browser` uses Playwright Chromium and serves the production build under `/agent-experiments/projects/voidwake-1aa9/`. Install Chromium with `pnpm exec playwright install chromium`, or set `PLAYWRIGHT_CHROMIUM_EXECUTABLE` to an existing compatible Chromium binary. Screenshots stay in ignored `tests/artifacts/`. +- The publication gate is `node scripts/verify-project.mjs voidwake-1aa9` from the repository root. + +## Session log + +- 2026-09-24 (codex / gpt-6): Created Voidwake from the required template; implemented the complete three-sector campaign, custom visual identity, responsive command bridge, persistence, and regression coverage. All work is contained in this project folder. + +- 2026-09-24 validation: 16 engine tests passed; 36 complete campaigns exercised 2,480 actions; a 59-action browser campaign reached victory; the exact publication gate passed scope, conformance, frozen install, lint, build, and static-thumbnail/subpath validation. diff --git a/projects/voidwake-1aa9/eslint.config.js b/projects/voidwake-1aa9/eslint.config.js new file mode 100644 index 00000000..ef614d25 --- /dev/null +++ b/projects/voidwake-1aa9/eslint.config.js @@ -0,0 +1,22 @@ +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import tseslint from 'typescript-eslint' +import { defineConfig, globalIgnores } from 'eslint/config' + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + js.configs.recommended, + tseslint.configs.recommended, + reactHooks.configs.flat.recommended, + reactRefresh.configs.vite, + ], + languageOptions: { + globals: globals.browser, + }, + }, +]) diff --git a/projects/voidwake-1aa9/index.html b/projects/voidwake-1aa9/index.html new file mode 100644 index 00000000..06eb43f8 --- /dev/null +++ b/projects/voidwake-1aa9/index.html @@ -0,0 +1,15 @@ + + + + + + + + + Voidwake — The Last Signal + + +
+ + + diff --git a/projects/voidwake-1aa9/package.json b/projects/voidwake-1aa9/package.json new file mode 100644 index 00000000..02f969b0 --- /dev/null +++ b/projects/voidwake-1aa9/package.json @@ -0,0 +1,38 @@ +{ + "name": "voidwake", + "private": true, + "version": "0.0.0", + "type": "module", + "packageManager": "pnpm@11.5.2", + "scripts": { + "preinstall": "npx only-allow pnpm", + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "eslint .", + "preview": "vite preview", + "test": "node --test tests/engine.test.mjs", + "test:browser": "node tests/browser.mjs" + }, + "dependencies": { + "@fontsource/barlow-condensed": "^5.3.0", + "@fontsource/dm-sans": "^5.3.0", + "@fontsource/ibm-plex-mono": "^5.3.0", + "react": "^19.2.6", + "react-dom": "^19.2.6" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "@playwright/test": "^1.63.0", + "@types/node": "^24.12.3", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.1", + "eslint": "^10.3.0", + "eslint-plugin-react-hooks": "^7.1.1", + "eslint-plugin-react-refresh": "^0.5.2", + "globals": "^17.6.0", + "typescript": "~6.0.2", + "typescript-eslint": "^8.59.2", + "vite": "^8.0.12" + } +} diff --git a/projects/voidwake-1aa9/pnpm-lock.yaml b/projects/voidwake-1aa9/pnpm-lock.yaml new file mode 100644 index 00000000..a64d8520 --- /dev/null +++ b/projects/voidwake-1aa9/pnpm-lock.yaml @@ -0,0 +1,1770 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@fontsource/barlow-condensed': + specifier: ^5.3.0 + version: 5.3.0 + '@fontsource/dm-sans': + specifier: ^5.3.0 + version: 5.3.0 + '@fontsource/ibm-plex-mono': + specifier: ^5.3.0 + version: 5.3.0 + react: + specifier: ^19.2.6 + version: 19.2.7 + react-dom: + specifier: ^19.2.6 + version: 19.2.7(react@19.2.7) + devDependencies: + '@eslint/js': + specifier: ^10.0.1 + version: 10.0.1(eslint@10.4.1) + '@playwright/test': + specifier: ^1.63.0 + version: 1.63.0 + '@types/node': + specifier: ^24.12.3 + version: 24.13.2 + '@types/react': + specifier: ^19.2.14 + version: 19.2.17 + '@types/react-dom': + specifier: ^19.2.3 + version: 19.2.3(@types/react@19.2.17) + '@vitejs/plugin-react': + specifier: ^6.0.1 + version: 6.0.2(vite@8.0.16(@types/node@24.13.2)) + eslint: + specifier: ^10.3.0 + version: 10.4.1 + eslint-plugin-react-hooks: + specifier: ^7.1.1 + version: 7.1.1(eslint@10.4.1) + eslint-plugin-react-refresh: + specifier: ^0.5.2 + version: 0.5.2(eslint@10.4.1) + globals: + specifier: ^17.6.0 + version: 17.6.0 + typescript: + specifier: ~6.0.2 + version: 6.0.3 + typescript-eslint: + specifier: ^8.59.2 + version: 8.61.0(eslint@10.4.1)(typescript@6.0.3) + vite: + specifier: ^8.0.12 + version: 8.0.16(@types/node@24.13.2) + +packages: + + '@babel/code-frame@7.29.7': + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} + engines: {node: '>=6.9.0'} + + '@babel/compat-data@7.29.7': + resolution: {integrity: sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==} + engines: {node: '>=6.9.0'} + + '@babel/core@7.29.7': + resolution: {integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==} + engines: {node: '>=6.9.0'} + + '@babel/generator@7.29.7': + resolution: {integrity: sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==} + engines: {node: '>=6.9.0'} + + '@babel/helper-compilation-targets@7.29.7': + resolution: {integrity: sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-globals@7.29.7': + resolution: {integrity: sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-imports@7.29.7': + resolution: {integrity: sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==} + engines: {node: '>=6.9.0'} + + '@babel/helper-module-transforms@7.29.7': + resolution: {integrity: sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-option@7.29.7': + resolution: {integrity: sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==} + engines: {node: '>=6.9.0'} + + '@babel/helpers@7.29.7': + resolution: {integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.7': + resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/template@7.29.7': + resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} + engines: {node: '>=6.9.0'} + + '@babel/traverse@7.29.7': + resolution: {integrity: sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==} + engines: {node: '>=6.9.0'} + + '@babel/types@7.29.7': + resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} + engines: {node: '>=6.9.0'} + + '@emnapi/core@1.10.0': + resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} + + '@emnapi/runtime@1.10.0': + resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} + + '@emnapi/wasi-threads@1.2.1': + resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} + + '@eslint-community/eslint-utils@4.9.1': + resolution: {integrity: sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + '@eslint-community/regexpp@4.12.2': + resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + + '@eslint/config-array@0.23.5': + resolution: {integrity: sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/config-helpers@0.6.0': + resolution: {integrity: sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/core@1.2.1': + resolution: {integrity: sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/js@10.0.1': + resolution: {integrity: sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + peerDependencies: + eslint: ^10.0.0 + peerDependenciesMeta: + eslint: + optional: true + + '@eslint/object-schema@3.0.5': + resolution: {integrity: sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@eslint/plugin-kit@0.7.2': + resolution: {integrity: sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + '@fontsource/barlow-condensed@5.3.0': + resolution: {integrity: sha512-RA7MmIx0v3eQ6Gcf74qLVLOwLB0GpPOzx+eU9iG/AmCnSJGcIWYeU8y6CEMGZ210MGwGg5CZhzLkkvyZdTTk1g==} + + '@fontsource/dm-sans@5.3.0': + resolution: {integrity: sha512-lYJtMXXO28q1z+yz+z8XKd0s4hXaa9QdkETzkyD760sidCv5heI86weYA0sx0Nc4pAMAQTUuyf4gO44cYKKS9g==} + + '@fontsource/ibm-plex-mono@5.3.0': + resolution: {integrity: sha512-eTgnZjZEGk1QtD3ZstF+Vclo2HLAni8YMy34/DxllwZvyz1lR/1RF/xTiAquOBO7MvqBx8D2Ig2WCPMVfdZu7Q==} + + '@humanfs/core@0.19.2': + resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} + engines: {node: '>=18.18.0'} + + '@humanfs/node@0.16.8': + resolution: {integrity: sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==} + engines: {node: '>=18.18.0'} + + '@humanfs/types@0.15.0': + resolution: {integrity: sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==} + engines: {node: '>=18.18.0'} + + '@humanwhocodes/module-importer@1.0.1': + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + + '@humanwhocodes/retry@0.4.3': + resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} + engines: {node: '>=18.18'} + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@napi-rs/wasm-runtime@1.1.5': + resolution: {integrity: sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==} + peerDependencies: + '@emnapi/core': ^1.7.1 + '@emnapi/runtime': ^1.7.1 + + '@oxc-project/types@0.133.0': + resolution: {integrity: sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==} + + '@playwright/test@1.63.0': + resolution: {integrity: sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==} + engines: {node: '>=20'} + hasBin: true + + '@rolldown/binding-android-arm64@1.0.3': + resolution: {integrity: sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.0.3': + resolution: {integrity: sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.0.3': + resolution: {integrity: sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.0.3': + resolution: {integrity: sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.0.3': + resolution: {integrity: sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.0.3': + resolution: {integrity: sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-arm64-musl@1.0.3': + resolution: {integrity: sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-ppc64-gnu@1.0.3': + resolution: {integrity: sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-s390x-gnu@1.0.3': + resolution: {integrity: sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-gnu@1.0.3': + resolution: {integrity: sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-musl@1.0.3': + resolution: {integrity: sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rolldown/binding-openharmony-arm64@1.0.3': + resolution: {integrity: sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-wasm32-wasi@1.0.3': + resolution: {integrity: sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [wasm32] + + '@rolldown/binding-win32-arm64-msvc@1.0.3': + resolution: {integrity: sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.0.3': + resolution: {integrity: sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + + '@tybys/wasm-util@0.10.2': + resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==} + + '@types/esrecurse@4.3.1': + resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/json-schema@7.0.15': + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + + '@types/node@24.13.2': + resolution: {integrity: sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==} + + '@types/react-dom@19.2.3': + resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} + peerDependencies: + '@types/react': ^19.2.0 + + '@types/react@19.2.17': + resolution: {integrity: sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==} + + '@typescript-eslint/eslint-plugin@8.61.0': + resolution: {integrity: sha512-bFNvl9ZczlVb+wR2Akszf3gHfKVj/8WanXaGJ3UstTA7brNKg0cNdk6X1Psu5V7MZ2oQtzZKOEzIUehaoxbDGw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + '@typescript-eslint/parser': ^8.61.0 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/parser@8.61.0': + resolution: {integrity: sha512-5B7PfA2e1NQGCnDHd/0lW7W3gvp3d59Ryw54FYO8Uswxo9f6ikw3AZV+Xj/TvpImmpsiYyUqAfhC6kJID1jF6w==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/project-service@8.61.0': + resolution: {integrity: sha512-DV42F7MLJO6Rax7SK1yg43tcnEfGUrurSpSxKuVX+a3RCTzBlH3fuxprrOJXKCJGAaw82xXocikJ0uQaqwXgGA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/scope-manager@8.61.0': + resolution: {integrity: sha512-IWdXFHFSb6mlC3HPc7QsLDm5zYEbUla6trDEHf32D3/dnuUyXd87plScSNXSbm0/RxMvObpI17sv/EDTGrGZkA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/tsconfig-utils@8.61.0': + resolution: {integrity: sha512-O5Amvdv9ztMpxpf+vmFULGG78IE6Qwdr3bCGvqwG4nwc9H2qXkOYJJnRbRHyMkQTjv1d03olqwwwzHLMqpFePQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/type-utils@8.61.0': + resolution: {integrity: sha512-TuBiQYIkd97yBfInHCTKVYMbX4kvEmpOEuixIuzCU9p8BGT1SfyyO0d0IfDMbPIHcjn/hWnusUX5e8v5Xg+X8A==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/types@8.61.0': + resolution: {integrity: sha512-9QTQpZ5Iin4CdIodfbDQFSeiSJKidgYJYug1P9CC2xWgUTvlmixViqDZNciMjwLBZyJnG4tGmPl97rVAFb1AJg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@typescript-eslint/typescript-estree@8.61.0': + resolution: {integrity: sha512-42zatd5qSvvcV1JdDBCLxYRznvP4eIHpPoZXdkPFnAmanA4FuZ5dibSnCBggY8hQnqajPpoGjXFdZ7fIJKQnlA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/utils@8.61.0': + resolution: {integrity: sha512-3bzFt7ImFMW/jVYwJamDoe/dMOdFLSC6pom6rRjdh4SZJEYupyMzem8e7vKZLclLfpHjlwSAXOUxtKxGXUiLqA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + '@typescript-eslint/visitor-keys@8.61.0': + resolution: {integrity: sha512-QVLZu3ZPQEE+HICQyAMZ2yLQhxf0meY/wx6Hx14YcTNj13JB3qHlX3lJ02L3fLGHgERRH71kvYDwiXIguT3AjQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + + '@vitejs/plugin-react@6.0.2': + resolution: {integrity: sha512-DlSMqo4WhThw4vB8Mpn0Woe9J+Jfq1geJ61AKW0QEgLzGMNwtIMdxbDUzLxcun8W7NbJO0e2Jg/Nxm3cCSVzzg==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + '@rolldown/plugin-babel': ^0.1.7 || ^0.2.0 + babel-plugin-react-compiler: ^1.0.0 + vite: ^8.0.0 + peerDependenciesMeta: + '@rolldown/plugin-babel': + optional: true + babel-plugin-react-compiler: + optional: true + + acorn-jsx@5.3.2: + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + acorn@8.17.0: + resolution: {integrity: sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==} + engines: {node: '>=0.4.0'} + hasBin: true + + ajv@6.15.0: + resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} + + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + + baseline-browser-mapping@2.10.36: + resolution: {integrity: sha512-lVq/Df7LXlO79MVaaUHztSwWiG9oXoWHlgvNS51v8Dpd4+G4/VIy6qYePTw31nAVls33nUtnfezYeLkYAak9dg==} + engines: {node: '>=6.0.0'} + hasBin: true + + brace-expansion@5.0.6: + resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==} + engines: {node: 18 || 20 || >=22} + + browserslist@4.28.2: + resolution: {integrity: sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + + caniuse-lite@1.0.30001799: + resolution: {integrity: sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + electron-to-chromium@1.5.372: + resolution: {integrity: sha512-M3yhbAlilnwqC8D21t28UCDGHyitShTmmLRU/H+b74P6Ski16Nb9HONYEaVpMj/pwC7BEo5B95FpjODLCWbtfA==} + + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + + escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + eslint-plugin-react-hooks@7.1.1: + resolution: {integrity: sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==} + engines: {node: '>=18'} + peerDependencies: + eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0 + + eslint-plugin-react-refresh@0.5.2: + resolution: {integrity: sha512-hmgTH57GfzoTFjVN0yBwTggnsVUF2tcqi7RJZHqi9lIezSs4eFyAMktA68YD4r5kNw1mxyY4dmkyoFDb3FIqrA==} + peerDependencies: + eslint: ^9 || ^10 + + eslint-scope@9.1.2: + resolution: {integrity: sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + + eslint-visitor-keys@5.0.1: + resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + eslint@10.4.1: + resolution: {integrity: sha512-AyIKhnOBuOAdueD7RB3xB+YeAWScb9jHsJBgH2Hcde8InP5JYhqrRR6iTMHyTEwgENK54Cp44e4v8BwNhsuHuw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + hasBin: true + peerDependencies: + jiti: '*' + peerDependenciesMeta: + jiti: + optional: true + + espree@11.2.0: + resolution: {integrity: sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24} + + esquery@1.7.0: + resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} + engines: {node: '>=0.10'} + + esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + + estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + + esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + + fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + file-entry-cache@8.0.0: + resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} + engines: {node: '>=16.0.0'} + + find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + + flat-cache@4.0.1: + resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} + engines: {node: '>=16'} + + flatted@3.4.2: + resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + + glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + + globals@17.6.0: + resolution: {integrity: sha512-sepffkT8stwnIYbsMBpoCHJuJM5l98FUF2AnE07hfvE0m/qp3R586hw4jF4uadbhvg1ooIdzuu7CsfD2jzCaNA==} + engines: {node: '>=18'} + + hermes-estree@0.25.1: + resolution: {integrity: sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==} + + hermes-parser@0.25.1: + resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==} + + ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + + ignore@7.0.5: + resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} + engines: {node: '>= 4'} + + imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + jsesc@3.1.0: + resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} + engines: {node: '>=6'} + hasBin: true + + json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + + json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + + json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + + json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + + keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + + levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + + lightningcss-android-arm64@1.32.0: + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.32.0: + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.32.0: + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.32.0: + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.32.0: + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.32.0: + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.32.0: + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.32.0: + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.32.0: + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.32.0: + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.32.0: + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.32.0: + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} + engines: {node: '>= 12.0.0'} + + locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + + lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + + minimatch@10.2.5: + resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} + engines: {node: 18 || 20 || >=22} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@3.3.12: + resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + + node-releases@2.0.47: + resolution: {integrity: sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og==} + engines: {node: '>=18'} + + optionator@0.9.4: + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} + engines: {node: '>= 0.8.0'} + + p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + + p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + + playwright-core@1.63.0: + resolution: {integrity: sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==} + engines: {node: '>=20'} + hasBin: true + + playwright@1.63.0: + resolution: {integrity: sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==} + engines: {node: '>=20'} + hasBin: true + + postcss@8.5.15: + resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} + engines: {node: ^10 || ^12 || >=14} + + prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + react-dom@19.2.7: + resolution: {integrity: sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==} + peerDependencies: + react: ^19.2.7 + + react@19.2.7: + resolution: {integrity: sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==} + engines: {node: '>=0.10.0'} + + rolldown@1.0.3: + resolution: {integrity: sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + + semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + + semver@7.8.4: + resolution: {integrity: sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==} + engines: {node: '>=10'} + hasBin: true + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + ts-api-utils@2.5.0: + resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} + engines: {node: '>=18.12'} + peerDependencies: + typescript: '>=4.8.4' + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + + typescript-eslint@8.61.0: + resolution: {integrity: sha512-8y31Rd0eGTrDKqhy6vT0HtzhN+YLjQizwX3aA3hPXP/ynSfnrBXcQY5IzsP9/DM7+klX4IUncZZjkchP0z+rUw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + + typescript@6.0.3: + resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@7.18.2: + resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} + + update-browserslist-db@1.2.3: + resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + + uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + + vite@8.0.16: + resolution: {integrity: sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.1.18 + 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 + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} + + yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + + yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + + zod-validation-error@4.0.2: + resolution: {integrity: sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==} + engines: {node: '>=18.0.0'} + peerDependencies: + zod: ^3.25.0 || ^4.0.0 + + zod@4.4.3: + resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + +snapshots: + + '@babel/code-frame@7.29.7': + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/compat-data@7.29.7': {} + + '@babel/core@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.7 + '@babel/helper-compilation-targets': 7.29.7 + '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) + '@babel/helpers': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/template': 7.29.7 + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 + '@jridgewell/remapping': 2.3.5 + convert-source-map: 2.0.0 + debug: 4.4.3 + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + + '@babel/generator@7.29.7': + dependencies: + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + jsesc: 3.1.0 + + '@babel/helper-compilation-targets@7.29.7': + dependencies: + '@babel/compat-data': 7.29.7 + '@babel/helper-validator-option': 7.29.7 + browserslist: 4.28.2 + lru-cache: 5.1.1 + semver: 6.3.1 + + '@babel/helper-globals@7.29.7': {} + + '@babel/helper-module-imports@7.29.7': + dependencies: + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)': + dependencies: + '@babel/core': 7.29.7 + '@babel/helper-module-imports': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + '@babel/traverse': 7.29.7 + transitivePeerDependencies: + - supports-color + + '@babel/helper-string-parser@7.29.7': {} + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/helper-validator-option@7.29.7': {} + + '@babel/helpers@7.29.7': + dependencies: + '@babel/template': 7.29.7 + '@babel/types': 7.29.7 + + '@babel/parser@7.29.7': + dependencies: + '@babel/types': 7.29.7 + + '@babel/template@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 + + '@babel/traverse@7.29.7': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/generator': 7.29.7 + '@babel/helper-globals': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/template': 7.29.7 + '@babel/types': 7.29.7 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + + '@babel/types@7.29.7': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + + '@emnapi/core@1.10.0': + dependencies: + '@emnapi/wasi-threads': 1.2.1 + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.10.0': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.2.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@eslint-community/eslint-utils@4.9.1(eslint@10.4.1)': + dependencies: + eslint: 10.4.1 + eslint-visitor-keys: 3.4.3 + + '@eslint-community/regexpp@4.12.2': {} + + '@eslint/config-array@0.23.5': + dependencies: + '@eslint/object-schema': 3.0.5 + debug: 4.4.3 + minimatch: 10.2.5 + transitivePeerDependencies: + - supports-color + + '@eslint/config-helpers@0.6.0': + dependencies: + '@eslint/core': 1.2.1 + + '@eslint/core@1.2.1': + dependencies: + '@types/json-schema': 7.0.15 + + '@eslint/js@10.0.1(eslint@10.4.1)': + optionalDependencies: + eslint: 10.4.1 + + '@eslint/object-schema@3.0.5': {} + + '@eslint/plugin-kit@0.7.2': + dependencies: + '@eslint/core': 1.2.1 + levn: 0.4.1 + + '@fontsource/barlow-condensed@5.3.0': {} + + '@fontsource/dm-sans@5.3.0': {} + + '@fontsource/ibm-plex-mono@5.3.0': {} + + '@humanfs/core@0.19.2': + dependencies: + '@humanfs/types': 0.15.0 + + '@humanfs/node@0.16.8': + dependencies: + '@humanfs/core': 0.19.2 + '@humanfs/types': 0.15.0 + '@humanwhocodes/retry': 0.4.3 + + '@humanfs/types@0.15.0': {} + + '@humanwhocodes/module-importer@1.0.1': {} + + '@humanwhocodes/retry@0.4.3': {} + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/remapping@2.3.5': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@napi-rs/wasm-runtime@1.1.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': + dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@tybys/wasm-util': 0.10.2 + optional: true + + '@oxc-project/types@0.133.0': {} + + '@playwright/test@1.63.0': + dependencies: + playwright: 1.63.0 + + '@rolldown/binding-android-arm64@1.0.3': + optional: true + + '@rolldown/binding-darwin-arm64@1.0.3': + optional: true + + '@rolldown/binding-darwin-x64@1.0.3': + optional: true + + '@rolldown/binding-freebsd-x64@1.0.3': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.0.3': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.0.3': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.0.3': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.0.3': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.0.3': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.0.3': + optional: true + + '@rolldown/binding-linux-x64-musl@1.0.3': + optional: true + + '@rolldown/binding-openharmony-arm64@1.0.3': + optional: true + + '@rolldown/binding-wasm32-wasi@1.0.3': + dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@napi-rs/wasm-runtime': 1.1.5(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.0.3': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.0.3': + optional: true + + '@rolldown/pluginutils@1.0.1': {} + + '@tybys/wasm-util@0.10.2': + dependencies: + tslib: 2.8.1 + optional: true + + '@types/esrecurse@4.3.1': {} + + '@types/estree@1.0.9': {} + + '@types/json-schema@7.0.15': {} + + '@types/node@24.13.2': + dependencies: + undici-types: 7.18.2 + + '@types/react-dom@19.2.3(@types/react@19.2.17)': + dependencies: + '@types/react': 19.2.17 + + '@types/react@19.2.17': + dependencies: + csstype: 3.2.3 + + '@typescript-eslint/eslint-plugin@8.61.0(@typescript-eslint/parser@8.61.0(eslint@10.4.1)(typescript@6.0.3))(eslint@10.4.1)(typescript@6.0.3)': + dependencies: + '@eslint-community/regexpp': 4.12.2 + '@typescript-eslint/parser': 8.61.0(eslint@10.4.1)(typescript@6.0.3) + '@typescript-eslint/scope-manager': 8.61.0 + '@typescript-eslint/type-utils': 8.61.0(eslint@10.4.1)(typescript@6.0.3) + '@typescript-eslint/utils': 8.61.0(eslint@10.4.1)(typescript@6.0.3) + '@typescript-eslint/visitor-keys': 8.61.0 + eslint: 10.4.1 + ignore: 7.0.5 + natural-compare: 1.4.0 + ts-api-utils: 2.5.0(typescript@6.0.3) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/parser@8.61.0(eslint@10.4.1)(typescript@6.0.3)': + dependencies: + '@typescript-eslint/scope-manager': 8.61.0 + '@typescript-eslint/types': 8.61.0 + '@typescript-eslint/typescript-estree': 8.61.0(typescript@6.0.3) + '@typescript-eslint/visitor-keys': 8.61.0 + debug: 4.4.3 + eslint: 10.4.1 + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/project-service@8.61.0(typescript@6.0.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.61.0(typescript@6.0.3) + '@typescript-eslint/types': 8.61.0 + debug: 4.4.3 + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/scope-manager@8.61.0': + dependencies: + '@typescript-eslint/types': 8.61.0 + '@typescript-eslint/visitor-keys': 8.61.0 + + '@typescript-eslint/tsconfig-utils@8.61.0(typescript@6.0.3)': + dependencies: + typescript: 6.0.3 + + '@typescript-eslint/type-utils@8.61.0(eslint@10.4.1)(typescript@6.0.3)': + dependencies: + '@typescript-eslint/types': 8.61.0 + '@typescript-eslint/typescript-estree': 8.61.0(typescript@6.0.3) + '@typescript-eslint/utils': 8.61.0(eslint@10.4.1)(typescript@6.0.3) + debug: 4.4.3 + eslint: 10.4.1 + ts-api-utils: 2.5.0(typescript@6.0.3) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/types@8.61.0': {} + + '@typescript-eslint/typescript-estree@8.61.0(typescript@6.0.3)': + dependencies: + '@typescript-eslint/project-service': 8.61.0(typescript@6.0.3) + '@typescript-eslint/tsconfig-utils': 8.61.0(typescript@6.0.3) + '@typescript-eslint/types': 8.61.0 + '@typescript-eslint/visitor-keys': 8.61.0 + debug: 4.4.3 + minimatch: 10.2.5 + semver: 7.8.4 + tinyglobby: 0.2.17 + ts-api-utils: 2.5.0(typescript@6.0.3) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/utils@8.61.0(eslint@10.4.1)(typescript@6.0.3)': + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@10.4.1) + '@typescript-eslint/scope-manager': 8.61.0 + '@typescript-eslint/types': 8.61.0 + '@typescript-eslint/typescript-estree': 8.61.0(typescript@6.0.3) + eslint: 10.4.1 + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/visitor-keys@8.61.0': + dependencies: + '@typescript-eslint/types': 8.61.0 + eslint-visitor-keys: 5.0.1 + + '@vitejs/plugin-react@6.0.2(vite@8.0.16(@types/node@24.13.2))': + dependencies: + '@rolldown/pluginutils': 1.0.1 + vite: 8.0.16(@types/node@24.13.2) + + acorn-jsx@5.3.2(acorn@8.17.0): + dependencies: + acorn: 8.17.0 + + acorn@8.17.0: {} + + ajv@6.15.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + + balanced-match@4.0.4: {} + + baseline-browser-mapping@2.10.36: {} + + brace-expansion@5.0.6: + dependencies: + balanced-match: 4.0.4 + + browserslist@4.28.2: + dependencies: + baseline-browser-mapping: 2.10.36 + caniuse-lite: 1.0.30001799 + electron-to-chromium: 1.5.372 + node-releases: 2.0.47 + update-browserslist-db: 1.2.3(browserslist@4.28.2) + + caniuse-lite@1.0.30001799: {} + + convert-source-map@2.0.0: {} + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + csstype@3.2.3: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + deep-is@0.1.4: {} + + detect-libc@2.1.2: {} + + electron-to-chromium@1.5.372: {} + + escalade@3.2.0: {} + + escape-string-regexp@4.0.0: {} + + eslint-plugin-react-hooks@7.1.1(eslint@10.4.1): + dependencies: + '@babel/core': 7.29.7 + '@babel/parser': 7.29.7 + eslint: 10.4.1 + hermes-parser: 0.25.1 + zod: 4.4.3 + zod-validation-error: 4.0.2(zod@4.4.3) + transitivePeerDependencies: + - supports-color + + eslint-plugin-react-refresh@0.5.2(eslint@10.4.1): + dependencies: + eslint: 10.4.1 + + eslint-scope@9.1.2: + dependencies: + '@types/esrecurse': 4.3.1 + '@types/estree': 1.0.9 + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-visitor-keys@3.4.3: {} + + eslint-visitor-keys@5.0.1: {} + + eslint@10.4.1: + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@10.4.1) + '@eslint-community/regexpp': 4.12.2 + '@eslint/config-array': 0.23.5 + '@eslint/config-helpers': 0.6.0 + '@eslint/core': 1.2.1 + '@eslint/plugin-kit': 0.7.2 + '@humanfs/node': 0.16.8 + '@humanwhocodes/module-importer': 1.0.1 + '@humanwhocodes/retry': 0.4.3 + '@types/estree': 1.0.9 + ajv: 6.15.0 + cross-spawn: 7.0.6 + debug: 4.4.3 + escape-string-regexp: 4.0.0 + eslint-scope: 9.1.2 + eslint-visitor-keys: 5.0.1 + espree: 11.2.0 + esquery: 1.7.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 8.0.0 + find-up: 5.0.0 + glob-parent: 6.0.2 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + json-stable-stringify-without-jsonify: 1.0.1 + minimatch: 10.2.5 + natural-compare: 1.4.0 + optionator: 0.9.4 + transitivePeerDependencies: + - supports-color + + espree@11.2.0: + dependencies: + acorn: 8.17.0 + acorn-jsx: 5.3.2(acorn@8.17.0) + eslint-visitor-keys: 5.0.1 + + esquery@1.7.0: + dependencies: + estraverse: 5.3.0 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@5.3.0: {} + + esutils@2.0.3: {} + + fast-deep-equal@3.1.3: {} + + fast-json-stable-stringify@2.1.0: {} + + fast-levenshtein@2.0.6: {} + + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + + file-entry-cache@8.0.0: + dependencies: + flat-cache: 4.0.1 + + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + flat-cache@4.0.1: + dependencies: + flatted: 3.4.2 + keyv: 4.5.4 + + flatted@3.4.2: {} + + fsevents@2.3.3: + optional: true + + gensync@1.0.0-beta.2: {} + + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + globals@17.6.0: {} + + hermes-estree@0.25.1: {} + + hermes-parser@0.25.1: + dependencies: + hermes-estree: 0.25.1 + + ignore@5.3.2: {} + + ignore@7.0.5: {} + + imurmurhash@0.1.4: {} + + is-extglob@2.1.1: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + isexe@2.0.0: {} + + js-tokens@4.0.0: {} + + jsesc@3.1.0: {} + + json-buffer@3.0.1: {} + + json-schema-traverse@0.4.1: {} + + json-stable-stringify-without-jsonify@1.0.1: {} + + json5@2.2.3: {} + + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + + lightningcss-android-arm64@1.32.0: + optional: true + + lightningcss-darwin-arm64@1.32.0: + optional: true + + lightningcss-darwin-x64@1.32.0: + optional: true + + lightningcss-freebsd-x64@1.32.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.32.0: + optional: true + + lightningcss-linux-arm64-gnu@1.32.0: + optional: true + + lightningcss-linux-arm64-musl@1.32.0: + optional: true + + lightningcss-linux-x64-gnu@1.32.0: + optional: true + + lightningcss-linux-x64-musl@1.32.0: + optional: true + + lightningcss-win32-arm64-msvc@1.32.0: + optional: true + + lightningcss-win32-x64-msvc@1.32.0: + optional: true + + lightningcss@1.32.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.32.0 + lightningcss-darwin-arm64: 1.32.0 + lightningcss-darwin-x64: 1.32.0 + lightningcss-freebsd-x64: 1.32.0 + lightningcss-linux-arm-gnueabihf: 1.32.0 + lightningcss-linux-arm64-gnu: 1.32.0 + lightningcss-linux-arm64-musl: 1.32.0 + lightningcss-linux-x64-gnu: 1.32.0 + lightningcss-linux-x64-musl: 1.32.0 + lightningcss-win32-arm64-msvc: 1.32.0 + lightningcss-win32-x64-msvc: 1.32.0 + + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + + lru-cache@5.1.1: + dependencies: + yallist: 3.1.1 + + minimatch@10.2.5: + dependencies: + brace-expansion: 5.0.6 + + ms@2.1.3: {} + + nanoid@3.3.12: {} + + natural-compare@1.4.0: {} + + node-releases@2.0.47: {} + + optionator@0.9.4: + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.5 + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + + path-exists@4.0.0: {} + + path-key@3.1.1: {} + + picocolors@1.1.1: {} + + picomatch@4.0.4: {} + + playwright-core@1.63.0: {} + + playwright@1.63.0: + dependencies: + playwright-core: 1.63.0 + + postcss@8.5.15: + dependencies: + nanoid: 3.3.12 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + prelude-ls@1.2.1: {} + + punycode@2.3.1: {} + + react-dom@19.2.7(react@19.2.7): + dependencies: + react: 19.2.7 + scheduler: 0.27.0 + + react@19.2.7: {} + + rolldown@1.0.3: + dependencies: + '@oxc-project/types': 0.133.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.0.3 + '@rolldown/binding-darwin-arm64': 1.0.3 + '@rolldown/binding-darwin-x64': 1.0.3 + '@rolldown/binding-freebsd-x64': 1.0.3 + '@rolldown/binding-linux-arm-gnueabihf': 1.0.3 + '@rolldown/binding-linux-arm64-gnu': 1.0.3 + '@rolldown/binding-linux-arm64-musl': 1.0.3 + '@rolldown/binding-linux-ppc64-gnu': 1.0.3 + '@rolldown/binding-linux-s390x-gnu': 1.0.3 + '@rolldown/binding-linux-x64-gnu': 1.0.3 + '@rolldown/binding-linux-x64-musl': 1.0.3 + '@rolldown/binding-openharmony-arm64': 1.0.3 + '@rolldown/binding-wasm32-wasi': 1.0.3 + '@rolldown/binding-win32-arm64-msvc': 1.0.3 + '@rolldown/binding-win32-x64-msvc': 1.0.3 + + scheduler@0.27.0: {} + + semver@6.3.1: {} + + semver@7.8.4: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + source-map-js@1.2.1: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + + ts-api-utils@2.5.0(typescript@6.0.3): + dependencies: + typescript: 6.0.3 + + tslib@2.8.1: + optional: true + + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + + typescript-eslint@8.61.0(eslint@10.4.1)(typescript@6.0.3): + dependencies: + '@typescript-eslint/eslint-plugin': 8.61.0(@typescript-eslint/parser@8.61.0(eslint@10.4.1)(typescript@6.0.3))(eslint@10.4.1)(typescript@6.0.3) + '@typescript-eslint/parser': 8.61.0(eslint@10.4.1)(typescript@6.0.3) + '@typescript-eslint/typescript-estree': 8.61.0(typescript@6.0.3) + '@typescript-eslint/utils': 8.61.0(eslint@10.4.1)(typescript@6.0.3) + eslint: 10.4.1 + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + + typescript@6.0.3: {} + + undici-types@7.18.2: {} + + update-browserslist-db@1.2.3(browserslist@4.28.2): + dependencies: + browserslist: 4.28.2 + escalade: 3.2.0 + picocolors: 1.1.1 + + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + + vite@8.0.16(@types/node@24.13.2): + dependencies: + lightningcss: 1.32.0 + picomatch: 4.0.4 + postcss: 8.5.15 + rolldown: 1.0.3 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 24.13.2 + fsevents: 2.3.3 + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + word-wrap@1.2.5: {} + + yallist@3.1.1: {} + + yocto-queue@0.1.0: {} + + zod-validation-error@4.0.2(zod@4.4.3): + dependencies: + zod: 4.4.3 + + zod@4.4.3: {} diff --git a/projects/voidwake-1aa9/project.json b/projects/voidwake-1aa9/project.json new file mode 100644 index 00000000..312a67fb --- /dev/null +++ b/projects/voidwake-1aa9/project.json @@ -0,0 +1,12 @@ +{ + "title": "Voidwake \u2014 The Last Signal", + "description": "Command a starship across three sectors in a space roguelite with tactical card combat, branching encounters, crew abilities, ship upgrades, and ancient artifacts.", + "agent": "codex", + "model": "gpt-6", + "tags": [ + "game", + "strategy", + "space" + ], + "createdAt": "2026-09-24" +} diff --git a/projects/voidwake-1aa9/public/favicon.svg b/projects/voidwake-1aa9/public/favicon.svg new file mode 100644 index 00000000..7aa8f5ed --- /dev/null +++ b/projects/voidwake-1aa9/public/favicon.svg @@ -0,0 +1 @@ + diff --git a/projects/voidwake-1aa9/public/thumbnail.svg b/projects/voidwake-1aa9/public/thumbnail.svg new file mode 100644 index 00000000..6dbbd21f --- /dev/null +++ b/projects/voidwake-1aa9/public/thumbnail.svg @@ -0,0 +1 @@ +VOIDWAKE.THE LAST SIGNALA deep-space strategy rogueliteEXPEDITION / SECTOR 01The Orpheus ReachARRIVALCINDERPALE DRIFTECHO VIIRUST BELTHOLLOWAYSABLELOST SIGNALKEPLERWATCH IXRELAYCOPPERWIDOWHALOARGENTMEMORYGUARDIANYOUYOUR VESSELThe WayfarerHull integrity90 / 90Crew online03 / 03ENGAGE JUMP DRIVE →03 SECTORS18 COMBAT SYSTEMSONE WAY HOMEPlot your course. Build your deck. Bring your crew home.IN THE DARK, WE GO TOGETHER. \ No newline at end of file diff --git a/projects/voidwake-1aa9/src/App.css b/projects/voidwake-1aa9/src/App.css new file mode 100644 index 00000000..443732f1 --- /dev/null +++ b/projects/voidwake-1aa9/src/App.css @@ -0,0 +1,217 @@ +:root { --ink:#080f19; --panel:#101b28; --panel-light:#152231; --line:#263341; --soft-line:#1d2a38; --text:#e9e6df; --muted:#8496a7; --faint:#617488; --teal:#8bc6c5; --orange:#f0ac73; --purple:#b8a6d9; --red:#dc9183; --display:'Barlow Condensed',sans-serif; --mono:'IBM Plex Mono',monospace; } +button { border:0; background:none; } svg { flex-shrink:0; } h1,h2,h3,p { margin:0; } +.app-shell { display:grid; grid-template-columns:224px minmax(0,1fr); grid-template-rows:84px 1fr; min-height:100vh; max-width:2100px; margin:auto; } +.topbar { grid-column:1/-1; display:flex; align-items:center; gap:35px; padding:0 30px; border-bottom:1px solid var(--line); background:#0b1420; } +.brand { display:flex; align-items:center; gap:12px; text-decoration:none; font:600 33px/1 var(--display); letter-spacing:3.5px; } +.brand-mark { color:var(--orange); width:39px; height:42px; display:grid; place-items:center; transform:rotate(-12deg); } +.brand-dot { color:var(--orange); } +.brand small { display:block; font:7px var(--mono); letter-spacing:3.5px; color:var(--muted); margin-top:6px; } +.voyage-id { display:flex; align-items:center; gap:11px; font:9px var(--mono); letter-spacing:1.1px; color:#abb7c2; margin-left:27px; } +.live-dot { width:5px; height:5px; border-radius:50%; display:inline-block; background:var(--teal); box-shadow:0 0 9px #8bc6c533; flex:0 0 auto; } +.hostile { background:var(--red)!important; } +.top-resources { margin-left:auto; display:flex; gap:26px; align-items:center; } +.resource { display:flex; gap:12px; align-items:center; } +.resource>svg { color:var(--orange); } .resource:nth-child(2)>svg { color:var(--teal); } +.resource small { display:block; font:8px var(--mono); color:var(--muted); letter-spacing:1.2px; margin-bottom:5px; } +.resource strong { font-size:17px; font-weight:500; } .resource strong span { font:9px var(--mono); color:var(--faint); } +.resource.warning strong { color:var(--orange); } +.icon-button { width:32px; height:34px; display:inline-grid; place-items:center; color:var(--muted); border-radius:4px; padding:0; transition:background .15s,color .15s; } +.icon-button:hover { background:var(--panel-light); color:var(--text); } +.sound-button { margin-left:3px; margin-right:-15px; } +.sidebar { grid-column:1; grid-row:2; border-right:1px solid var(--line); background:#0d1622; display:flex; flex-direction:column; padding:29px 19px 20px; } +.ship-identity { text-align:center; } +.eyebrow,.micro { font-family:var(--mono); text-transform:uppercase; } +.eyebrow { font-size:9px; letter-spacing:1.65px; color:var(--orange); display:flex; align-items:center; gap:7px; } +.micro { font-size:8px; letter-spacing:1px; color:var(--muted); } +.ship-identity .eyebrow { justify-content:center; color:var(--faint); font-size:8px; } +.ship-identity h2 { font:500 27px var(--display); margin-top:7px; letter-spacing:.6px; } +.ship-mini { height:95px; position:relative; display:grid; place-items:center; } +.ship-mini::before { content:''; position:absolute; width:83px; height:83px; border:1px solid #a0b1c00d; border-radius:50%; } +.ship-mini .ship-art { width:200px; position:absolute; transform:rotate(-19deg); } +.ship-identity>.micro { color:var(--faint); font-size:7px; letter-spacing:1.2px; } +.ship-vitals { padding:24px 2px 21px; border-bottom:1px solid var(--line); } +.vital-label,.vital-small { display:flex; justify-content:space-between; align-items:center; font-size:10px; gap:6px; } +.vital-label>span,.vital-small>span { display:flex; align-items:center; gap:6px; color:#acb8c2; } +.vital-label strong { font:10px var(--mono); } .vital-label small,.vital-small small { color:var(--faint); font-size:8px; } +.meter { background:#293746; height:4px; border-radius:1px; overflow:hidden; } +.meter span { display:block; height:100%; background:var(--teal); transition:width .35s ease; } .meter.red span { background:var(--red); } +.ship-vitals .meter { margin:10px 0 17px; } .vital-small { margin-top:12px; } +.vital-small strong { font:10px var(--mono); color:var(--teal); } .vital-small:last-child strong { color:var(--orange); } +.nav-label { margin:24px 12px 10px; color:var(--faint); } +.main-nav { display:flex; flex-direction:column; gap:4px; } +.main-nav button { display:flex; align-items:center; gap:11px; text-align:left; padding:12px; font-size:11px; border-radius:4px; color:var(--muted); position:relative; } +.main-nav button>svg:last-child,.nav-indicator { margin-left:auto; } +.main-nav button:hover { color:var(--text); background:#ffffff04; } +.main-nav button.active { color:var(--orange); background:#f0ac7310; } +.main-nav button.active::before { content:''; height:16px; width:2px; background:var(--orange); position:absolute; left:-19px; } +.nav-indicator { width:5px; height:5px; background:var(--orange); border-radius:50%; } +.deck-nav { display:flex; align-items:center; gap:11px; padding:14px 12px 20px; color:var(--muted); font-size:11px; } +.deck-nav:hover { color:var(--text); } +.count { margin-left:auto; font:9px var(--mono); background:var(--panel-light); border:1px solid var(--line); padding:3px 5px; border-radius:3px; } +.mission-card { padding:16px 13px; border:1px solid #b9946633; background:linear-gradient(140deg,#9f724311,transparent); border-radius:5px; margin-top:10px; } +.mission-card .eyebrow { font-size:7px; letter-spacing:.8px; } +.mission-card h3 { font:500 23px var(--display); margin:11px 0 7px; } +.mission-card p { font-size:10px; line-height:1.8; color:var(--muted); } +.mission-progress { display:flex; gap:4px; margin:19px 0 8px; } .mission-progress span { width:100%; height:3px; background:#29313b; } .mission-progress .complete { background:var(--orange); } +.mission-card .micro { font-size:7px; letter-spacing:.5px; } .mission-card .micro>span { float:right; color:var(--orange); } +.sidebar-bottom { margin-top:auto; padding:31px 10px 0; } +.save-label { display:flex; align-items:center; gap:6px; font-size:8px; margin-top:19px; color:var(--faint); line-height:1.5; } .save-label .live-dot { width:4px; height:4px; } +.main-content { min-width:0; padding:31px 30px 0; background:radial-gradient(ellipse at 70% 0%,#17203155,transparent 60%); } +.page-heading { display:flex; align-items:center; justify-content:space-between; gap:22px; margin-bottom:25px; } +.page-heading .eyebrow { font-size:8px; letter-spacing:1.7px; } +.page-heading h1 { font:500 clamp(35px,3.3vw,51px)/1.1 var(--display); letter-spacing:.4px; margin:7px 0 9px; } +.page-heading p { font-size:11px; color:var(--muted); } +.heading-status { align-self:flex-end; display:flex; flex-wrap:wrap; justify-content:flex-end; align-items:center; gap:7px; font:7px var(--mono); letter-spacing:.8px; color:var(--teal); padding-bottom:2px; } +.coordinates { width:100%; text-align:right; color:var(--faint); margin-top:4px; } +.map-layout { display:grid; grid-template-columns:minmax(0,1fr) 250px; gap:17px; align-items:stretch; } +.chart-panel,.destination-panel,.crew-panel,.recent-log { border:1px solid var(--line); border-radius:6px; background:#0d1723; overflow:hidden; } +.chart-panel { display:flex; flex-direction:column; } +.chart-toolbar { display:flex; justify-content:space-between; align-items:center; padding:15px 19px; border-bottom:1px solid var(--soft-line); font:8px var(--mono); letter-spacing:1px; } +.chart-toolbar>span { display:flex; align-items:center; gap:8px; } .chart-toolbar>span:first-child>svg { color:var(--teal); } +.chart-scale { color:var(--faint); font-size:6px; letter-spacing:.7px; } .chart-scale svg { margin-left:7px; } +.starmap { flex:1; display:flex; align-items:center; position:relative; min-height:358px; background:#0b1420; } +.starmap::after { content:''; pointer-events:none; position:absolute; inset:0; box-shadow:inset 0 0 45px #080f1966; } +.starmap>svg { width:100%; display:block; } +.map-caption { fill:#667c91; font:7px var(--mono); letter-spacing:1.4px; } +.planet-label { fill:#b6a69077; font:12px var(--display); letter-spacing:4px; } +.node-name { font-family:'DM Sans',sans-serif; font-size:9px; letter-spacing:.2px; } +.map-node { cursor:pointer; transition:opacity .2s; } .map-node>path { transition:fill .2s; } .map-node:hover>path { fill:#374451; } .map-node.passed { opacity:.34; } +.map-node:focus-visible { outline:none; } .map-node:focus-visible>path { stroke:#fff; stroke-width:3px; } +.selection-ring { transform-origin:0 0; animation:orbit-ring 40s linear infinite; } @keyframes orbit-ring { to { transform:rotate(360deg); } } +.map-legend { display:flex; align-items:center; justify-content:center; gap:19px; padding:15px 10px; border-top:1px solid var(--soft-line); } +.map-legend span { display:flex; align-items:center; gap:6px; font-size:8px; color:var(--muted); } +.map-instruction { display:flex; align-items:flex-start; gap:8px; font-size:9px; color:var(--muted); padding:14px 17px; line-height:1.6; border-top:1px solid var(--soft-line); background:#131e2b; } +.map-instruction>.live-dot { margin-top:5px; } .map-instruction>span:nth-child(2) { max-width:450px; } .map-instruction .text-button { margin-left:auto; white-space:nowrap; font-size:8px; } +.panel-title { display:flex; justify-content:space-between; gap:10px; align-items:center; font:8px var(--mono); letter-spacing:1px; color:#aab7c3; padding:16px; border-bottom:1px solid var(--soft-line); } +.panel-title>span:first-child { display:flex; align-items:center; gap:8px; } .panel-title svg { color:var(--faint); } +.destination-panel { display:flex; flex-direction:column; } +.destination-scene { position:relative; display:grid; place-items:center; height:135px; overflow:hidden; background:radial-gradient(ellipse at center,#212c3b,transparent 65%); border-bottom:1px solid #26334188; } +.destination-scene::before { content:''; position:absolute; inset:0; background-image:radial-gradient(#8ba0b93b .6px,transparent .6px); background-size:27px 23px; opacity:.6; } +.destination-orbit { width:74px; height:74px; border:1px solid color-mix(in srgb,var(--node-color) 35%,transparent); border-radius:50%; display:grid; place-items:center; color:var(--node-color); transform:rotate(-15deg); box-shadow:0 0 30px color-mix(in srgb,var(--node-color) 7%,transparent); position:relative; } +.destination-orbit::before { content:''; position:absolute; inset:-15px 10px; border:1px solid color-mix(in srgb,var(--node-color) 35%,transparent); border-radius:50%; transform:rotate(65deg); } +.destination-orbit::after { content:''; position:absolute; inset:-10px; border:1px dashed color-mix(in srgb,var(--node-color) 15%,transparent); border-radius:50%; } +.destination-orbit svg { transform:rotate(15deg); } +.scene-coordinate { position:absolute; bottom:11px; right:14px; font:6px var(--mono); letter-spacing:1px; color:var(--faint); } +.destination-copy { padding:19px 19px 13px; flex:1; display:flex; flex-direction:column; align-items:flex-start; } +.badge { display:inline-flex; align-items:center; gap:6px; font:7px var(--mono); letter-spacing:1px; text-transform:uppercase; padding:5px 7px; border:1px solid var(--line); border-radius:3px; color:var(--muted); } +.badge.teal { background:#8bc6c50b; color:var(--teal); border-color:#8bc6c525; } .badge.orange { background:#f0ac730b; color:var(--orange); border-color:#f0ac7325; } .badge.purple { background:#b8a6d90b; color:var(--purple); border-color:#b8a6d925; } +.destination-copy h2 { font:500 31px var(--display); margin:9px 0; } .destination-copy>p { color:var(--muted); font-size:10px; line-height:1.8; } +.destination-facts { border-top:1px solid var(--soft-line); margin:19px 0; padding-top:15px; width:100%; display:flex; flex-direction:column; gap:12px; } +.destination-facts>div { display:flex; justify-content:space-between; align-items:center; font-size:9px; color:var(--muted); } .destination-facts strong { font:7px var(--mono); display:flex; align-items:center; gap:5px; color:#c1c9ce; } +.primary-button,.secondary-button { display:inline-flex; align-items:center; justify-content:center; gap:9px; min-height:40px; padding:11px 16px; border:1px solid transparent; border-radius:4px; font-size:10px; font-weight:600; transition:background .15s,border-color .15s,transform .15s; } +.primary-button { background:var(--orange); color:#231c19; } .primary-button:hover:not(:disabled) { background:#f9bc8b; transform:translateY(-1px); } .primary-button:disabled { background:#263341; color:#708195; } +.secondary-button { border-color:#36495d; color:#c4ced6; background:#172433; } .secondary-button:hover:not(:disabled) { background:#243748; border-color:#6b8799; } .secondary-button:disabled { opacity:.35; } +.jump-button { width:100%; margin-top:auto; padding:12px 9px; gap:7px; } .jump-button svg:last-child { margin-left:auto; } +.destination-copy .jump-note { margin:10px auto 0; font-size:7px; color:var(--faint); text-align:center; } +.sos-button { color:var(--orange); font-size:9px; text-decoration:underline; margin:12px auto 0; } +.text-button { display:inline-flex; align-items:center; gap:7px; color:var(--muted); font-size:10px; padding:0; line-height:1.6; } .text-button:hover:not(:disabled) { color:var(--text); } .text-button:disabled { opacity:.35; } +.bridge-bottom { display:grid; grid-template-columns:1.08fr 1fr; gap:17px; margin-top:19px; } +.crew-panel .panel-title,.recent-log .panel-title { padding:13px 15px; font-size:7px; } .crew-panel .panel-title>.micro { font-size:7px; letter-spacing:.5px; white-space:nowrap; } +.crew-list { display:grid; grid-template-columns:repeat(3,1fr); padding:20px 15px; gap:14px; } +.crew-person { display:flex; align-items:center; gap:9px; } .portrait { border:1px solid #94a9bb2b; border-radius:6px; } +.crew-person strong { font-size:10px; font-weight:500; display:block; margin-bottom:7px; white-space:nowrap; } .crew-person .micro { font-size:6px; letter-spacing:.8px; display:flex; align-items:center; gap:7px; } .crew-dot { width:3px; height:3px; border-radius:50%; } +.recent-log-list { padding:13px 15px; } .recent-log-list>div { display:flex; align-items:baseline; gap:12px; padding:3px 0; } .recent-log-list .micro { font-size:7px; color:var(--faint); } +.recent-log-list p { font-size:9px; line-height:1.7; color:var(--muted); display:-webkit-box; -webkit-line-clamp:2; -webkit-box-orient:vertical; overflow:hidden; } .recent-log .text-button { font-size:8px; } +.page-footer { display:flex; justify-content:space-between; gap:10px; color:var(--faint); font:6px var(--mono); letter-spacing:1px; padding:25px 0 20px; } .page-footer>span { display:flex; align-items:center; gap:6px; } .page-footer>span:nth-child(2) { letter-spacing:.2px; } +kbd { font:8px var(--mono); border:1px solid #53667b4d; border-radius:3px; padding:2px 4px; color:var(--muted); } +.muted { color:var(--faint); } .orange-text { color:var(--orange)!important; } .teal-text { color:var(--teal)!important; } .danger-text { color:var(--red)!important; } +.sr-only { position:absolute; height:1px; width:1px; clip:rect(0,0,0,0); overflow:hidden; white-space:nowrap; } +/* Tactical bridge */ +.combat-arena { border:1px solid var(--line); border-radius:6px; background:radial-gradient(ellipse at 50% 60%,#21364a44,transparent 65%),#0c1622; overflow:hidden; } +.combat-ships { display:grid; grid-template-columns:1fr 50px 1fr; padding:22px 38px 6px; background-image:radial-gradient(#a5b9d132 .7px,transparent .7px); background-size:71px 43px; } +.combat-name { max-width:320px; margin:0 auto; } .combat-name>.micro { font-size:7px; color:var(--teal); } .combat-name h2 { font:500 30px var(--display); margin:5px 0 9px; } +.combat-health { display:flex; align-items:center; gap:6px; font:9px var(--mono); margin-bottom:9px; } .combat-health>svg { color:var(--teal); } .combat-health>span { margin-left:auto; color:var(--muted); display:flex; align-items:center; gap:5px; } +.combat-vessel.enemy .combat-name>.micro,.combat-vessel.enemy .combat-health>svg { color:var(--red); } +.combat-vessel .ship-art { width:100%; max-height:200px; display:block; margin:0 auto; } .combat-vessel.friendly .ship-art { transform:rotate(-7deg); } .combat-vessel.enemy .ship-art { transform:rotate(7deg); } +.versus { display:flex; flex-direction:column; align-items:center; justify-content:center; gap:13px; padding-top:60px; color:#7c95a447; } .versus span { height:37px; width:1px; background:linear-gradient(transparent,#8cabc04d,transparent); } +.vessel-effects { min-height:27px; display:flex; align-items:center; justify-content:center; gap:8px; } .vessel-effects .micro { font-size:7px; } +.combat-readout { display:flex; align-items:center; justify-content:space-between; gap:18px; background:#182431b3; padding:14px 23px; border-top:1px solid var(--line); } +.intent { display:flex; gap:10px; align-items:center; color:var(--orange); } .intent .micro { display:block; color:var(--faint); font-size:7px; margin-bottom:4px; } .intent strong { font-size:10px; font-weight:500; white-space:nowrap; } .intent strong>span { margin-left:4px; font-family:var(--mono); font-size:12px; } +.battle-message { font-size:10px; color:#bcc7ce; animation:log-in .35s ease-out; } .intent-tip { text-align:right; font-size:7px; text-transform:none; letter-spacing:.3px; } +@keyframes log-in { from { opacity:.15; transform:translateY(3px); } to { opacity:1; transform:translateY(0); } } +.hand-panel { margin-top:17px; } .hand-toolbar { display:flex; align-items:center; gap:20px; justify-content:space-between; margin:0 2px 15px; } +.energy-display { display:flex; align-items:center; gap:10px; } .energy-display>span { display:flex; align-items:center; gap:5px; font:500 31px var(--display); color:var(--orange); } .energy-display strong { font-size:10px; font-weight:500; display:block; } .energy-display small { font-size:8px; color:var(--faint); display:block; margin-top:4px; } +.hand-toolbar>.text-button { font-size:9px; } .end-turn { padding:9px 15px; min-height:37px; } .end-turn kbd { color:#6b513b; border-color:#785c3d55; margin-left:7px; } +.hand-cards { display:flex; justify-content:center; gap:11px; flex-wrap:wrap; } +.game-card { --card-color:var(--orange); position:relative; text-align:left; display:flex; flex-direction:column; border:1px solid #a883674f; border-radius:7px; background:linear-gradient(160deg,#292d32,#121e2c 70%); padding:0; overflow:hidden; min-height:215px; min-width:0; width:165px; color:var(--text); } +.game-card.defense { --card-color:var(--teal); background:linear-gradient(160deg,#20363b,#121e2c 70%); border-color:#6b9b9c4f; } .game-card.system { --card-color:var(--purple); background:linear-gradient(160deg,#302d42,#121e2c 70%); border-color:#9882b04f; } +button.game-card { transition:transform .2s,box-shadow .2s,opacity .2s; } button.game-card:hover:not(:disabled) { transform:translateY(-7px); box-shadow:0 6px 28px color-mix(in srgb,var(--card-color) 10%,transparent); border-color:var(--card-color); } button.game-card:disabled { opacity:.44; filter:saturate(.5); } +.card-top { display:flex; align-items:center; gap:8px; padding:9px 10px 0; } .energy-cost { font:600 19px var(--display); border:1px solid color-mix(in srgb,var(--card-color) 60%,transparent); border-radius:50%; width:27px; height:27px; display:grid; place-items:center; background:#0b1521; color:var(--card-color); } +.card-top .micro { font-size:6px; color:var(--card-color); letter-spacing:1px; } .card-top kbd { margin-left:auto; border:0; color:#a6b2ba88; } +.card-illustration { color:var(--card-color); height:66px; display:grid; place-items:center; position:relative; } .card-orbit { position:absolute; width:63px; height:48px; border:1px solid color-mix(in srgb,var(--card-color) 15%,transparent); border-radius:50%; transform:rotate(-30deg); } .card-orbit::after { position:absolute; content:''; border:1px dashed color-mix(in srgb,var(--card-color) 20%,transparent); inset:-6px; border-radius:50%; transform:rotate(60deg); } +.card-content { padding:8px 13px 13px; } .card-content h3 { font:500 20px var(--display); margin-bottom:7px; letter-spacing:.2px; } .card-content h3 span { color:var(--teal); } .card-content p { font-size:9px; line-height:1.65; color:#aebbc5; } +.card-footer { display:flex; justify-content:space-between; margin-top:auto; padding:8px 11px; border-top:1px solid #a8bbcd12; color:#8898aa; font:5px var(--mono); letter-spacing:.8px; text-transform:uppercase; } +.hand-hint { text-align:center; color:var(--faint); font-size:8px; margin:13px 0 19px; } +.empty-hand { display:grid; place-items:center; text-align:center; padding:40px; min-height:215px; color:var(--muted); gap:8px; } .empty-hand h3 { font:24px var(--display); } .empty-hand p { font-size:11px; } +.crew-ability { text-align:left; display:block; margin-top:9px; padding:5px 8px; color:var(--teal); border:1px solid #8bc6c52b; border-radius:4px; font-size:9px; } .crew-ability small { display:block; font-size:8px; margin-top:4px; color:var(--muted); } .crew-ability:hover:not(:disabled) { background:#8bc6c512; } .crew-ability:disabled { opacity:.4; } +.combat-view .crew-list { padding:14px 20px; } .combat-view .crew-person { justify-content:center; gap:13px; } +.battle-log { border:1px solid var(--line); margin-top:14px; border-radius:4px; font-size:10px; color:var(--muted); } .battle-log summary { padding:12px 15px; cursor:pointer; } .battle-log summary>span { float:right; color:var(--faint); } .battle-log p { font:9px/1.8 var(--mono); padding:3px 17px; } .battle-log p:last-child { padding-bottom:14px; } +/* Encounters and resupply */ +.story-panel { display:grid; grid-template-columns:.75fr 1.3fr; min-height:510px; border:1px solid var(--line); border-radius:6px; overflow:hidden; background:#0d1825; } +.story-art { background:radial-gradient(ellipse at 50% 40%,#5f537233,transparent 67%),radial-gradient(#a7a9cc44 .7px,transparent .7px); background-size:auto,43px 51px; display:flex; flex-direction:column; align-items:center; justify-content:center; border-right:1px solid var(--line); gap:32px; } +.signal-rings { position:relative; width:200px; height:200px; display:grid; place-items:center; color:var(--purple); } .signal-rings span { position:absolute; inset:0; border:1px solid #b8a6d917; border-radius:50%; } .signal-rings span:nth-child(2) { inset:25px; border-style:dashed; border-color:#b8a6d942; } .signal-rings span:nth-child(3) { inset:50px; border-color:#b8a6d95a; } +.story-art>.micro { color:var(--purple); font-size:7px; } .waveform { height:48px; display:flex; align-items:center; justify-content:center; gap:3px; } .waveform i { width:2px; background:var(--purple); opacity:.4; border-radius:2px; } +.story-content { padding:43px 37px; } .story-content .eyebrow { color:var(--purple); font-size:7px; } .story-content h2 { font:500 42px/1.05 var(--display); margin:16px 0 19px; } .story-prose { font-size:12px; line-height:1.95; color:#a6b5c3; } +.event-choices { display:flex; flex-direction:column; gap:9px; margin:27px 0 22px; } .event-choices button { border:1px solid #344257; border-radius:4px; display:flex; align-items:center; padding:14px 12px; gap:12px; text-align:left; background:#172232; } .event-choices button:hover:not(:disabled) { border-color:var(--purple); background:#282a40; } .event-choices button:disabled { opacity:.4; } +.choice-number { color:var(--purple); font:10px var(--mono); } .event-choices button>span:nth-child(2) { flex:1; } .event-choices strong { display:block; font-size:11px; font-weight:500; margin-bottom:5px; } .event-choices small { display:block; color:var(--muted); line-height:1.5; font-size:9px; } .event-choices button>svg { color:var(--faint); } .story-content>.micro { font-size:6px; } +.reward-panel { border:1px solid var(--line); border-radius:6px; text-align:center; padding:30px; background:radial-gradient(ellipse at 50% 0%,#59868719,transparent 65%),#0d1723; } +.reward-emblem { width:57px; height:57px; display:grid; place-items:center; border:1px solid #8bc6c54d; border-radius:50%; color:var(--teal); margin:0 auto 18px; } .reward-panel>.eyebrow { justify-content:center; color:var(--teal); font-size:8px; } +.reward-panel h2 { font:500 45px var(--display); margin:11px 0 8px; } .reward-panel>p { font-size:11px; color:var(--muted); line-height:1.7; } .reward-loot { display:flex; justify-content:center; gap:9px; margin:18px 0 6px; } .reward-panel .relic-explanation { color:var(--purple); font-size:10px; } +.reward-cards { display:flex; justify-content:center; gap:21px; margin:24px 0 20px; } .reward-cards .game-card { width:185px; min-height:235px; } .reward-cards .secondary-button,.shop-cards .secondary-button { width:100%; margin-top:10px; } .skip-reward { margin:5px auto 15px; } +.station-welcome { display:flex; align-items:center; gap:18px; padding:24px; border:1px solid #87b1cc3b; background:linear-gradient(100deg,#1b3045,#101b28); border-radius:6px; } .station-symbol { color:#8ab6cf; border:1px solid #8ab6cf33; border-radius:8px; width:61px; height:61px; display:grid; place-items:center; } +.station-welcome .eyebrow { font-size:7px; color:#8ab6cf; } .station-welcome h2 { font:500 32px var(--display); margin:6px 0; } .station-welcome p { color:var(--muted); font-size:11px; } .station-welcome>button { margin-left:auto; } +.station-services { display:grid; grid-template-columns:repeat(5,1fr); gap:11px; margin:19px 0 24px; } .service { display:flex; flex-direction:column; background:#121e2b; border:1px solid var(--line); border-radius:5px; padding:19px 13px 13px; } .service>svg { color:var(--teal); margin-bottom:13px; } .service h3 { font:500 23px var(--display); margin-bottom:7px; } .service p { font-size:9px; color:var(--muted); line-height:1.7; margin-bottom:20px; } .service>button { margin-top:auto; font-size:9px; padding:9px; } +.station-view>.panel-title { padding-left:0; } .shop-cards { display:grid; grid-template-columns:repeat(4,1fr); gap:18px; max-width:850px; margin:20px auto 26px; } .shop-cards .game-card { width:100%; } +.refit-deck { border:1px solid var(--line); border-radius:5px; background:#101b28; } .refit-deck summary { padding:18px; font-size:11px; display:flex; align-items:center; justify-content:space-between; gap:15px; cursor:pointer; } .refit-deck summary>span:first-child { display:flex; gap:9px; align-items:center; } .refit-deck summary>span:last-child { color:var(--muted); font-size:9px; } .refit-deck>p { font-size:11px; color:var(--muted); padding:0 18px 17px; line-height:1.7; } +.refit-list { padding:0 18px 18px; } .refit-list>div { display:flex; align-items:center; gap:14px; padding:12px 0; border-top:1px solid var(--line); } .refit-list>div>svg { color:var(--teal); } .refit-list>div>span { flex:1; } .refit-list strong { display:block; font-size:11px; font-weight:500; } .refit-list small { display:block; font-size:9px; color:var(--muted); margin-top:6px; } .refit-list button { font-size:9px; min-height:33px; } +.station-notice { font-size:11px; color:var(--teal); padding:19px 0 0; } +/* Hangar, artifacts, and expedition records */ +.ship-view { display:grid; grid-template-columns:1.2fr 1fr; gap:20px; } .hangar { background:radial-gradient(ellipse at 50% 40%,#7296a21a,transparent 65%),#0e1a27; border:1px solid var(--line); border-radius:6px; padding:26px; } .hangar-heading { display:flex; justify-content:space-between; align-items:center; gap:12px; } .hangar-heading .eyebrow { font-size:7px; color:var(--teal); } .hangar-heading h2 { font:500 34px var(--display); margin-top:7px; } +.hangar .ship-art { display:block; width:100%; max-height:230px; transform:rotate(-14deg); } .hangar>p { font-size:11px; color:var(--muted); line-height:1.8; } .hangar-stats { display:grid; grid-template-columns:repeat(4,1fr); gap:10px; border-top:1px solid var(--line); padding-top:20px; margin:22px 0; } .hangar-stats small { font:6px var(--mono); color:var(--muted); letter-spacing:.6px; } .hangar-stats strong { font:500 27px var(--display); display:block; margin-top:6px; } .hangar-stats strong>span { font-size:17px; color:var(--faint); } .hangar>button { width:100%; } +.crew-roster { border:1px solid var(--line); background:#0d1825; border-radius:6px; overflow:hidden; } .crew-roster>article { display:flex; align-items:flex-start; gap:17px; padding:23px 23px 17px; } .crew-roster>article:not(:nth-child(2)) { border-top:1px solid var(--soft-line); } .crew-roster h3 { font:500 25px var(--display); margin:5px 0; } .crew-roster p { font-size:10px; color:var(--muted); margin-bottom:12px; } .crew-roster strong { display:block; font-size:10px; font-weight:500; } .crew-roster small { display:block; font-size:9px; color:var(--muted); margin-top:5px; } .crew-roster .roster-note { font-size:9px; line-height:1.7; padding:0 23px 12px; } +.artifact-vault { grid-column:1/-1; border:1px solid var(--line); background:#0e1926; border-radius:6px; } .artifact-grid { display:grid; grid-template-columns:repeat(3,1fr); gap:14px; padding:20px; } .artifact-grid article { display:flex; align-items:center; gap:13px; padding:14px; border:1px dashed #28384a; border-radius:5px; color:#5e7081; opacity:.65; } .artifact-grid article>div { flex:1; } .artifact-grid article.discovered { border-style:solid; border-color:#b8a6d933; opacity:1; color:var(--purple); background:#b8a6d908; } .artifact-grid h3 { font-size:11px; font-weight:500; margin-bottom:6px; } .artifact-grid p { font-size:9px; line-height:1.7; color:var(--muted); } .artifact-vault>p { padding:0 20px 20px; font-size:10px; color:var(--muted); } .back-bridge { grid-column:1/-1; justify-self:end; } +.archive-stats { display:grid; grid-template-columns:repeat(5,1fr); border:1px solid var(--line); background:#0f1b29; border-radius:5px; padding:24px 0; margin-bottom:22px; } .archive-stats>div { text-align:center; border-right:1px solid var(--line); } .archive-stats>div:last-child { border:0; } .archive-stats .micro { font-size:7px; } .archive-stats strong { display:block; font:500 36px var(--display); margin-top:7px; } .archive-stats>div:first-child strong { color:var(--orange); } +.achievements { border:1px solid var(--line); background:#0e1926; border-radius:6px; margin-bottom:21px; } .achievements>div:last-child { padding:20px; display:grid; grid-template-columns:repeat(3,1fr); gap:12px; } .achievements article { display:flex; align-items:center; gap:13px; border:1px solid var(--line); border-radius:4px; padding:16px 12px; color:#6d8193; opacity:.65; } .achievements article>span { flex:1; } .achievements article.unlocked { color:var(--teal); border-color:#8bc6c52d; opacity:1; background:#8bc6c506; } .achievements strong { display:block; font-size:11px; font-weight:500; } .achievements small { display:block; color:var(--muted); font-size:9px; margin-top:6px; } +.full-log { border:1px solid var(--line); border-radius:6px; background:#0d1825; } .full-log article { display:flex; gap:20px; align-items:center; padding:21px; border-bottom:1px solid var(--soft-line); } .full-log article:last-child { border-bottom:0; } .log-date { color:var(--faint); font:7px var(--mono); white-space:nowrap; } .log-date strong { font-size:10px; color:var(--muted); margin-left:5px; } .log-dot { width:5px; height:5px; border-radius:50%; background:var(--muted); flex:0 0 auto; } .log-dot.success { background:var(--teal); } .log-dot.danger { background:var(--red); } .full-log p { font-size:11px; line-height:1.8; color:#a5b5c3; } +.ending { text-align:center; min-height:630px; padding:40px; border:1px solid var(--line); border-radius:6px; background:radial-gradient(ellipse at 50% 10%,#947d5828,transparent 65%),radial-gradient(#b2c4cf44 .7px,transparent .7px); background-size:auto,67px 71px; } .ending-stars { height:140px; display:grid; place-items:center; color:var(--orange); } .ending>.eyebrow { justify-content:center; font-size:8px; } .ending h2 { font:500 65px/1.1 var(--display); margin:19px 0; } .ending>p { max-width:560px; margin:auto; font-size:12px; line-height:2; color:#a8b7c3; } .ending-score { margin-top:28px; } .ending-score strong { display:block; font:500 56px var(--display); color:var(--orange); margin:6px 0; } .ending-stats { display:flex; justify-content:center; gap:25px; font-size:10px; color:var(--muted); margin:25px 0; } .ending-actions { display:flex; justify-content:center; gap:10px; } .ending.lost .ending-stars,.ending.lost .eyebrow { color:var(--purple); } +/* Native dialogs contain keyboard focus. */ +.dialog { padding:0; width:min(650px,calc(100vw - 30px)); max-height:calc(100dvh - 50px); overflow:auto; color:var(--text); background:#101b29; border:1px solid #495464; border-radius:8px; box-shadow:0 20px 90px #0009; } .dialog.wide { width:min(990px,calc(100vw - 30px)); } .dialog::backdrop { background:#050a12bd; backdrop-filter:blur(7px); } +.dialog-heading { display:flex; align-items:center; justify-content:space-between; padding:25px 29px; border-bottom:1px solid var(--line); position:sticky; top:0; background:#111d2a; z-index:5; } .dialog-heading .eyebrow { font-size:7px; } .dialog-heading h2 { font:500 34px var(--display); margin-top:7px; } .dialog-heading .icon-button { margin-left:15px; } .dialog-body { padding:27px 29px; } .dialog-intro { color:var(--muted); font-size:11px; line-height:1.8; margin-bottom:23px; } +.deck-grid { display:grid; grid-template-columns:repeat(5,1fr); gap:13px; } .deck-grid .game-card { width:100%; } +.help-content>p { font-size:12px; line-height:1.9; color:#aebcc8; } .help-content>p strong { color:var(--text); font-weight:500; } .help-step { display:flex; gap:18px; margin-top:24px; } .help-step>svg { margin-top:3px; color:var(--orange); } .help-step h3 { font:500 24px var(--display); margin-bottom:7px; } .help-step p,.glossary p { font-size:11px; line-height:1.9; color:var(--muted); } .glossary { border-top:1px solid var(--line); margin-top:25px; padding-top:20px; } .glossary h3 { font:500 23px var(--display); margin-bottom:10px; } .glossary strong { color:var(--purple); font-weight:500; } .help-keys { display:flex; flex-wrap:wrap; gap:16px; margin:23px 0; color:var(--muted); font-size:9px; } .help-keys kbd { margin-right:4px; } .help-content .help-save { font-size:10px; color:var(--teal); background:#8bc6c507; border:1px solid #8bc6c525; border-radius:4px; padding:15px; } +.ship-choices { display:grid; grid-template-columns:repeat(3,1fr); gap:15px; } .ship-choice { text-align:left; position:relative; border:1px solid var(--line); background:#111e2c; padding:0 20px 20px; border-radius:6px; } .ship-choice:hover { border-color:#637482; } .ship-choice.chosen { border-color:var(--teal); background:#8bc6c508; } .ship-choice .ship-art { width:100%; height:150px; transform:rotate(-17deg); } .ship-choice .micro { font-size:6px; color:var(--teal); } .ship-choice h3 { font:500 29px var(--display); margin:8px 0 10px; } .ship-choice p { font-size:10px; color:var(--muted); line-height:1.8; min-height:74px; } .ship-choice>span { display:block; font:8px var(--mono); color:#aebfcd; margin-top:20px; } .ship-choice>span>span { margin:0 10px; color:var(--faint); } .ship-chosen-mark { position:absolute; top:13px; right:13px; color:var(--teal); } +.new-voyage-settings { display:grid; grid-template-columns:1.4fr 1fr; gap:30px; margin:26px 0; } .new-voyage-settings fieldset { border:0; padding:0; margin:0; } .new-voyage-settings legend,.seed-field { font-size:11px; font-weight:500; margin-bottom:15px; } .new-voyage-settings fieldset label { display:flex; gap:9px; margin-bottom:15px; } .new-voyage-settings input[type=radio] { accent-color:var(--teal); margin-top:2px; } .new-voyage-settings label strong { font-size:11px; font-weight:500; } .new-voyage-settings small { display:block; font-size:9px; font-weight:400; color:var(--muted); margin-top:5px; line-height:1.6; } .seed-field input { width:100%; background:#0c1622; border:1px solid #3c4b5d; color:var(--text); border-radius:4px; padding:12px; margin-top:13px; font:11px var(--mono); } .launch-button { margin-left:auto; display:flex; } +@media(min-width:1650px) { + .app-shell { grid-template-columns:244px minmax(0,1fr); } .main-content { padding:38px 42px 0; } .map-layout { grid-template-columns:minmax(0,1fr) 280px; gap:22px; } .destination-scene { height:170px; } .destination-copy { padding:23px; } .destination-copy p { font-size:11px; } .starmap { min-height:425px; } .crew-list { padding:25px; } .crew-person strong { font-size:12px; } .resource small { font-size:9px; } .page-heading { margin-bottom:30px; } .chart-toolbar,.map-legend span { font-size:9px; } +} +@media(max-width:1200px) { + .app-shell { grid-template-columns:190px minmax(0,1fr); } .sidebar { padding-left:14px; padding-right:14px; } .main-content { padding:27px 20px 0; } .topbar { padding:0 20px; gap:15px; } .voyage-id { margin-left:15px; font-size:8px; } .map-layout { grid-template-columns:minmax(0,1fr) 222px; gap:12px; } .destination-copy { padding:16px; } .destination-copy h2 { font-size:28px; } .destination-facts { margin:16px 0; } .starmap { min-height:355px; } .map-legend { gap:10px; } .map-legend span { font-size:7px; gap:4px; } .chart-scale { display:none!important; } .map-instruction .text-button { display:none; } .bridge-bottom { grid-template-columns:1fr; gap:13px; } .crew-list { padding:15px 22px; } .recent-log-list { padding:10px 15px; } .recent-log-list>div:nth-child(2) { display:none; } .heading-status { display:none; } .main-nav button.active::before { left:-14px; } .mission-card { padding:13px 10px; } .mission-card .eyebrow { font-size:6px; } .ship-identity>.micro { font-size:6px; } .game-card { width:145px; } .combat-ships { padding:20px 24px 6px; } .combat-readout { padding:12px 18px; } .intent-tip { display:none; } .story-content { padding:34px 25px; } .story-panel { grid-template-columns:.65fr 1.3fr; } .station-services { gap:8px; } .service { padding:15px 10px 10px; } .service h3 { font-size:21px; } .artifact-grid { grid-template-columns:repeat(2,1fr); } .achievements>div:last-child { grid-template-columns:repeat(2,1fr); } .combat-view .crew-list { padding:14px 12px; gap:8px; } .crew-ability { font-size:8px; padding:5px; } .combat-view .crew-person { gap:8px; } .crew-ability small { font-size:7px; } +} +@media(max-width:1000px) { + .app-shell { grid-template-columns:170px minmax(0,1fr); } .top-resources { gap:20px; } .voyage-id { display:none; } .sidebar { padding-left:11px; padding-right:11px; } .ship-mini .ship-art { width:176px; } .main-nav button { padding:11px 8px; gap:8px; font-size:10px; } .deck-nav { padding-left:8px; gap:8px; font-size:10px; } .main-nav button.active::before { left:-11px; } .map-layout { grid-template-columns:1fr; } .starmap { min-height:300px; } .destination-panel { display:grid; grid-template-columns:145px 1fr; } .destination-panel>.panel-title { display:none; } .destination-scene { height:auto; min-height:210px; border:0; border-right:1px solid var(--line); } .destination-copy { display:grid; grid-template-columns:1fr 180px; column-gap:19px; align-items:start; } .destination-copy>.badge { justify-self:start; } .destination-copy>h2 { grid-column:1; margin:8px 0; font-size:28px; } .destination-copy>p { grid-column:1; } .destination-facts { grid-column:2; grid-row:1/4; padding:3px 0 0; margin:0; border:0; gap:14px; } .jump-button { grid-column:2; grid-row:4; font-size:9px; padding:10px 8px; margin-top:14px; } .destination-copy>.jump-note { display:none; } .destination-copy>.sos-button { grid-column:2; } .page-footer>span:last-child { display:none; } .hand-cards { gap:8px; } .game-card { width:130px; } .hand-cards .game-card { flex-basis:calc(20% - 8px); } .card-content { padding:8px 10px 11px; } .card-content h3 { font-size:18px; } .card-content p { font-size:8px; } .card-top { gap:5px; padding:8px 7px 0; } .card-top .micro { font-size:5px; } .card-top kbd { font-size:7px; padding:0; } .combat-name h2 { font-size:26px; } .combat-ships { padding:20px 15px 6px; grid-template-columns:1fr 30px 1fr; } .combat-readout { gap:12px; } .battle-message { font-size:9px; } .crew-ability small { max-width:110px; line-height:1.7; } .hand-toolbar { gap:12px; } .hand-toolbar>.text-button { font-size:8px; } .hand-toolbar>.text-button svg { display:none; } .energy-display strong { font-size:9px; } .energy-display small { font-size:7px; } .story-panel { grid-template-columns:1fr; } .story-art { min-height:155px; flex-direction:row; gap:25px; border-right:0; border-bottom:1px solid var(--line); } .signal-rings { width:120px; height:120px; } .signal-rings span:nth-child(2) { inset:15px; } .signal-rings span:nth-child(3) { inset:30px; } .signal-rings>svg { width:35px; } .story-art .waveform { display:none; } .story-content { padding:30px; } .station-services { grid-template-columns:repeat(3,1fr); } .shop-cards { gap:10px; } .ship-view { grid-template-columns:1fr; } .crew-roster>article { padding:17px 23px; } .ship-choices { gap:10px; } .ship-choice { padding:0 14px 14px; } .ship-choice .ship-art { height:125px; } .ship-choice p { min-height:110px; } +} +@media(max-width:760px) { + .app-shell { grid-template-columns:1fr; grid-template-rows:70px auto 1fr; } .topbar { padding:0 18px; gap:10px; } .brand { font-size:28px; letter-spacing:2.5px; gap:8px; } .brand-mark { width:26px; } .brand-mark>svg { width:26px; } .brand small { font-size:5px; letter-spacing:2.8px; } .top-resources { gap:14px; } .resource { gap:6px; } .resource>svg { width:15px; } .resource strong { font-size:13px; } .resource strong span { display:none; } .resource small { font-size:6px; letter-spacing:.5px; } .sound-button { display:none; } .top-resources>.icon-button { width:24px; } .sidebar { grid-column:1; grid-row:2; padding:0 14px; border-right:0; border-bottom:1px solid var(--line); flex-direction:row; align-items:center; gap:10px; } .ship-identity,.ship-vitals,.nav-label,.mission-card,.sidebar-bottom { display:none; } .main-nav { flex-direction:row; flex:1; gap:8px; } .main-nav button { font-size:10px; padding:14px 10px; border-radius:0; gap:8px; } .main-nav button>svg:last-child,.nav-indicator { display:none; } .main-nav button.active { background:none; } .main-nav button.active::before { bottom:-1px; height:2px; width:calc(100% - 20px); left:10px; } .deck-nav { padding:10px 0; font-size:9px; gap:5px; } .deck-nav>span:not(.count) { display:none; } .count { font-size:8px; padding:2px 4px; } .main-content { grid-column:1; grid-row:3; padding:24px 18px 0; } .page-heading { margin-bottom:21px; } .page-heading h1 { font-size:40px; } .page-heading .eyebrow { font-size:7px; } .page-heading p { font-size:10px; } .starmap { min-height:270px; } .map-legend { gap:17px; } .destination-scene { min-height:200px; } .page-footer { font-size:5px; letter-spacing:.7px; padding-top:23px; } .page-footer kbd { font-size:6px; } .combat-name h2 { font-size:24px; } .combat-health { font-size:8px; } .combat-name>.micro { font-size:6px; letter-spacing:.6px; } .combat-ships { padding-top:17px; } .hand-toolbar>.text-button { max-width:130px; flex-wrap:wrap; justify-content:center; gap:5px; } .hand-cards .game-card { flex-basis:calc(20% - 7px); min-height:205px; } .energy-display { gap:6px; } .energy-display>span { font-size:26px; } .end-turn { padding:9px 12px; gap:5px; } .end-turn kbd { display:none; } .combat-view .crew-list { padding:14px 10px; } .combat-view .crew-person { align-items:flex-start; } .combat-view .crew-person>.portrait { width:28px; height:28px; } .crew-person strong { font-size:9px; } .crew-ability { margin-left:-35px; margin-top:15px; } .station-welcome { padding:18px; gap:13px; } .station-welcome h2 { font-size:28px; } .station-welcome p { font-size:10px; } .station-welcome .primary-button { padding:10px; } .station-symbol { width:49px; height:49px; } .ending { padding:25px; } .ending h2 { font-size:52px; } .dialog-body { padding:23px; } .dialog-heading { padding:20px 23px; } .dialog-heading h2 { font-size:30px; } .deck-grid { grid-template-columns:repeat(3,1fr); } .ship-choices { grid-template-columns:1fr; } .ship-choice { display:grid; grid-template-columns:120px 1fr; column-gap:15px; padding:17px; } .ship-choice .ship-art { grid-column:1; grid-row:1/5; height:105px; align-self:center; } .ship-choice .micro,.ship-choice h3,.ship-choice p,.ship-choice>span { grid-column:2; } .ship-choice h3 { font-size:27px; margin:6px 0; } .ship-choice p { min-height:auto; font-size:9px; } .ship-choice>span { margin-top:10px; font-size:7px; } .new-voyage-settings { gap:18px; } +} +@media(max-width:520px) { + .topbar { padding:0 14px; } .brand { font-size:26px; gap:5px; } .brand-mark { width:25px; } .brand small { letter-spacing:2px; } .top-resources { gap:10px; } .resource>svg { display:none; } .resource small { font-size:5px; } .resource strong { font-size:12px; } .top-resources>.icon-button { margin-left:2px; width:21px; } .sidebar { padding:0 8px; gap:5px; } .main-nav { gap:1px; } .main-nav button { font-size:9px; gap:6px; padding:14px 8px; } .main-nav button>svg { width:15px; } .deck-nav { padding-right:4px; } .main-content { padding:22px 13px 0; } .page-heading h1 { font-size:36px; } .page-heading p { font-size:9px; } .chart-toolbar { padding:13px; font-size:7px; } .starmap { min-height:245px; overflow-x:auto; justify-content:flex-start; } .starmap>svg { min-width:575px; } .map-legend { gap:11px; padding:12px 6px; flex-wrap:wrap; } .map-legend span { font-size:6px; gap:4px; } .map-legend svg { width:11px; } .map-instruction { font-size:8px; padding:12px; } .destination-panel { grid-template-columns:87px minmax(0,1fr); } .destination-scene { min-height:244px; } .destination-orbit { width:50px; height:50px; } .destination-orbit>svg { width:25px; } .destination-orbit::before { inset:-10px 5px; } .scene-coordinate { right:9px; font-size:5px; } .destination-copy { display:flex; padding:14px; } .destination-copy h2 { font-size:29px; margin:7px 0; } .destination-copy>p { font-size:9px; } .destination-copy .badge { font-size:6px; } .destination-facts { gap:9px; margin:14px 0 12px; } .destination-facts>div { font-size:8px; } .destination-facts strong { font-size:6px; } .jump-button { margin-top:0; min-height:38px; } .crew-list { padding:16px 10px; gap:8px; } .crew-person { gap:6px; } .crew-person .portrait { width:30px; height:30px; } .crew-person strong { font-size:8px; margin-bottom:6px; } .crew-person .micro { font-size:5px; } .crew-panel .panel-title { font-size:6px; padding:12px; letter-spacing:.6px; } .crew-panel .panel-title .micro { font-size:5px; } .page-footer>span:nth-child(2) { display:none; } + .combat-ships { grid-template-columns:1fr 18px 1fr; padding:15px 11px 5px; } .combat-name h2 { font-size:22px; min-height:48px; display:flex; align-items:center; margin:4px 0 6px; } .combat-health { font-size:6px; gap:4px; flex-wrap:wrap; } .combat-health>svg { width:10px; } .combat-health>span { font-size:6px; gap:3px; } .combat-health>span svg { width:9px; } .combat-name>.micro { font-size:5px; } .combat-vessel .ship-art { width:112%; max-width:185px; margin-left:-6%; } .versus>svg { width:18px; } .vessel-effects { flex-wrap:wrap; gap:4px; } .vessel-effects .micro { font-size:5px; } .vessel-effects .badge { font-size:5px; padding:3px; } .combat-readout { padding:13px; align-items:flex-start; gap:10px; } .intent { gap:7px; } .intent>svg { width:17px; } .intent strong { font-size:8px; } .intent .micro { font-size:6px; } .battle-message { font-size:8px; line-height:1.6; max-width:135px; } .hand-toolbar { flex-wrap:wrap; row-gap:10px; } .hand-toolbar>.text-button { order:3; max-width:none; width:100%; justify-content:flex-start; font-size:8px; } .energy-display strong { font-size:10px; } .energy-display small { font-size:8px; } .hand-cards { gap:9px; justify-content:flex-start; overflow-x:auto; flex-wrap:nowrap; padding:6px 2px 9px; margin:0 -2px; scroll-snap-type:x proximity; } .hand-cards .game-card { flex:0 0 143px; min-height:213px; scroll-snap-align:start; } .card-content h3 { font-size:21px; } .card-content p { font-size:9px; } .card-top .micro { font-size:6px; } .card-top { padding:9px 10px 0; } .hand-hint { font-size:7px; line-height:1.8; text-align:left; } .crew-ability { font-size:7px; margin-left:-34px; padding:5px; min-height:47px; } .crew-ability small { font-size:6px; max-width:80px; } .combat-view .crew-list { padding:14px 8px; gap:5px; } .combat-view .crew-person { gap:6px; } + .reward-panel { padding:25px 15px; } .reward-panel h2 { font-size:37px; } .reward-panel>p { font-size:10px; } .reward-panel>.eyebrow { font-size:6px; } .reward-cards { gap:9px; } .reward-cards>div { min-width:0; flex:1; } .reward-cards .game-card { width:100%; min-height:236px; } .reward-cards .card-top { padding:8px 6px 0; gap:5px; } .reward-cards .energy-cost { font-size:16px; width:23px; height:23px; } .reward-cards .card-top .micro { font-size:4px; } .reward-cards .card-content { padding:6px 9px 10px; } .reward-cards .card-content h3 { font-size:18px; } .reward-cards .card-content p { font-size:8px; } .reward-cards .card-footer { font-size:4px; padding:7px; letter-spacing:.5px; } .reward-cards .secondary-button { font-size:8px; gap:5px; padding:9px 5px; } .reward-cards .secondary-button svg { display:none; } .reward-loot { flex-wrap:wrap; } + .story-art { min-height:140px; gap:15px; } .story-art>.micro { font-size:6px; } .story-content { padding:25px 20px; } .story-content h2 { font-size:36px; } .story-content .eyebrow { font-size:6px; } .story-prose { font-size:11px; } .event-choices strong { font-size:10px; } .event-choices small { font-size:8px; } + .station-welcome { padding:17px; flex-wrap:wrap; gap:12px; } .station-welcome h2 { font-size:29px; } .station-symbol { width:43px; height:47px; } .station-welcome .eyebrow { font-size:6px; } .station-welcome>div:nth-child(2) { max-width:calc(100% - 60px); } .station-welcome p { font-size:9px; line-height:1.7; } .station-welcome>button { width:100%; margin-top:5px; } .station-services { grid-template-columns:repeat(2,1fr); gap:11px; } .service:last-child { grid-column:1/-1; } .service p { margin-bottom:14px; } .shop-cards { grid-template-columns:repeat(2,1fr); gap:20px 13px; } .shop-cards .game-card { min-height:220px; } .refit-deck summary { padding:14px; flex-wrap:wrap; font-size:10px; } .refit-deck summary>span:last-child { font-size:8px; } .refit-list>div { gap:8px; flex-wrap:wrap; } .refit-list>div>span { flex-basis:calc(100% - 27px); } .refit-list>div>button { flex:1; justify-content:center; } .refit-list small { font-size:8px; line-height:1.7; } + .hangar { padding:20px; } .hangar-heading .badge { font-size:5px; padding:5px; } .hangar-heading .eyebrow { font-size:6px; } .hangar-stats { gap:5px; } .hangar-stats small { font-size:5px; letter-spacing:.2px; } .hangar-stats strong { font-size:25px; } .artifact-grid { grid-template-columns:1fr; padding:15px; } .artifact-grid article { padding:13px; } .archive-stats { grid-template-columns:repeat(3,1fr); gap:20px 0; padding:20px 0; } .archive-stats .micro { font-size:6px; } .archive-stats strong { font-size:30px; } .archive-stats>div:nth-child(3) { border:0; } .achievements>div:last-child { grid-template-columns:1fr; padding:15px; } .full-log article { padding:16px 13px; gap:12px; align-items:flex-start; } .full-log p { font-size:10px; } .log-dot { margin-top:5px; } .log-date { font-size:6px; padding-top:2px; } .log-date strong { font-size:8px; } .full-log .panel-title { font-size:6px; } .full-log .panel-title>.micro { font-size:5px; } + .ending { padding:24px 18px; } .ending h2 { font-size:46px; } .ending .eyebrow { font-size:6px; } .ending>p { font-size:11px; } .ending-stats { gap:15px; font-size:8px; } .ending-actions { flex-direction:column; } + .dialog,.dialog.wide { max-height:calc(100dvh - 24px); width:calc(100vw - 20px); } .dialog-heading { padding:19px; } .dialog-heading h2 { font-size:28px; } .dialog-heading .eyebrow { font-size:6px; } .dialog-body { padding:20px; } .deck-grid { grid-template-columns:repeat(2,1fr); gap:11px; } .dialog-intro { font-size:10px; } .ship-choice { grid-template-columns:80px 1fr; padding:14px; gap:0 10px; } .ship-choice .ship-art { width:120px; max-width:none; margin-left:-21px; } .ship-choice .micro { font-size:5px; } .ship-choice h3 { font-size:24px; } .ship-choice p { font-size:8px; } .ship-choice>span { font-size:6px; } .ship-choice>span>span { margin:0 5px; } .ship-chosen-mark { top:9px; right:9px; width:13px; } .new-voyage-settings { grid-template-columns:1fr; gap:13px; } .launch-button { width:100%; } .help-content>p { font-size:11px; } .help-step { gap:13px; } .help-step p { font-size:10px; } +} +@media(prefers-reduced-motion:reduce) { *,*::before,*::after { animation:none!important; transition:none!important; scroll-behavior:auto!important; } } +.manual-options { display:flex; justify-content:space-between; gap:12px; margin-top:20px; } +.combat-ships { position:relative; } +.combat-fx { position:absolute; inset:0; pointer-events:none; z-index:2; overflow:hidden; } +.combat-fx.outgoing span,.combat-fx.incoming span { position:absolute; top:62%; left:37%; width:30%; height:2px; background:var(--orange); box-shadow:0 0 12px 3px #f0ac7355; opacity:0; animation:laser .4s ease-out; } +.combat-fx.incoming span { background:var(--red); box-shadow:0 0 12px 3px #dc918366; animation-direction:reverse; } +.combat-fx.system span { position:absolute; width:30%; height:62%; top:25%; left:10%; border:1px solid var(--teal); border-radius:50%; opacity:0; animation:shield-pulse .6s ease-out; } +@keyframes laser { 0% { transform:scaleX(0); transform-origin:left; opacity:1; } 30% { transform:scaleX(1); opacity:1; } 100% { transform:scaleX(.3); transform-origin:right; opacity:0; } } +@keyframes shield-pulse { 0% { transform:scale(.5); opacity:0; } 25% { opacity:.8; } 100% { transform:scale(1.2); opacity:0; } } +@media(max-width:520px) { .combat-health { font-size:9px; } .combat-health>span { font-size:8px; } .combat-health>svg { width:12px; } .combat-health>span svg { width:11px; } .resource small { font-size:6px; } } + +.storage-warning { display:flex; align-items:center; gap:8px; padding:12px 15px; margin-bottom:20px; border:1px solid #f0ac7344; background:#f0ac730b; border-radius:4px; color:var(--orange); font-size:11px; } diff --git a/projects/voidwake-1aa9/src/App.tsx b/projects/voidwake-1aa9/src/App.tsx new file mode 100644 index 00000000..6690d5b2 --- /dev/null +++ b/projects/voidwake-1aa9/src/App.tsx @@ -0,0 +1,257 @@ +import { useEffect, useRef, useState } from 'react' +import type { CSSProperties, ReactNode } from 'react' +import { CARDS, CREW, EVENTS, NODE_INFO, RELICS, SECTORS, SHIPS } from './content' +import type { ShipKey } from './content' +import { achievements, choiceAvailable, currentIntent, jumpCost, newGame, parseSave, passiveShield, reachable, reducer, score, upgradeCost, weaponBonus } from './game' +import type { Action, Card, Game, MapNode, Upgrade } from './game' +import { Icon, Portrait, ShipArt } from './Art' +import { playSound } from './audio' +import '@fontsource/barlow-condensed/latin-500.css' +import '@fontsource/barlow-condensed/latin-600.css' +import '@fontsource/dm-sans/latin-400.css' +import '@fontsource/dm-sans/latin-500.css' +import '@fontsource/dm-sans/latin-600.css' +import '@fontsource/ibm-plex-mono/latin-400.css' +import './App.css' + +const SAVE_KEY = 'voidwake-1aa9-save-v1' +type View = 'bridge' | 'ship' | 'archive' +type Modal = 'help' | 'new' | 'deck' | null +function loadGame() { try { return parseSave(localStorage.getItem(SAVE_KEY)) ?? newGame() } catch { return newGame() } } +function loadSound() { try { return localStorage.getItem('voidwake-sound') === 'on' } catch { return false } } +const pad = (n: number) => String(n).padStart(2, '0') +const css = (values: Record) => values as CSSProperties + +function Meter({ value, max, tone = 'teal', label }: { value: number; max: number; tone?: string; label: string }) { + return
+} +function Badge({ children, tone = '' }: { children: ReactNode; tone?: string }) { return {children} } +function PanelTitle({ icon, children, trailing }: { icon: string; children: ReactNode; trailing?: ReactNode }) { return
{children}{trailing}
} + +export default function App() { + const [game, setGame] = useState(loadGame) + const [view, setView] = useState('bridge') + const [selected, setSelected] = useState('1-0') + const [modal, setModal] = useState(null) + const [sound, setSound] = useState(loadSound) + const [saveStatus, setSaveStatus] = useState('Saved locally') + const [best, setBest] = useState(() => { try { const n = Number(localStorage.getItem('voidwake-best')); return Number.isFinite(n) ? n : 0 } catch { return 0 } }) + const [flash, setFlash] = useState({ id: 0, kind: 'system' }) + const act = (action: Action) => { + const next = reducer(game, action) + if (next !== game) { + playSound(action.type === 'jump' ? 'jump' : action.type === 'play' || action.type === 'crew' ? 'attack' : action.type === 'endTurn' ? 'end' : action.type === 'claim' ? 'reward' : 'click', sound) + setGame(next) + if (action.type === 'play' || action.type === 'crew' || action.type === 'endTurn') setFlash(f => ({ id: f.id + 1, kind: action.type === 'endTurn' ? 'incoming' : next.battle && game.battle && next.battle.enemy.hull < game.battle.enemy.hull ? 'outgoing' : 'system' })) + if (next.sector !== game.sector) setSelected('1-0') + else if (next.phase === 'map' && action.type !== 'sos') setSelected(reachable(next)[0] ?? next.current) + } + } + useEffect(() => { + const timer = window.setTimeout(() => { + try { + localStorage.setItem(SAVE_KEY, JSON.stringify(game)); setSaveStatus('Saved locally') + if (game.phase === 'won' || game.phase === 'lost') { const nextBest = Math.max(best, score(game)); localStorage.setItem('voidwake-best', String(nextBest)); setBest(nextBest) } + } catch { setSaveStatus('Storage unavailable · keep this tab open') } + }, 100) + return () => window.clearTimeout(timer) + }, [game, best]) + useEffect(() => { + const keydown = (event: KeyboardEvent) => { + if ((event.target as HTMLElement).matches('input, textarea, select') || event.ctrlKey || event.metaKey || event.altKey || event.repeat) return + if (event.key === 'Escape') { setModal(null); return } + if (modal) return + if (event.key === '?') { setModal('help'); return } + if (event.key.toLowerCase() === 'm') { setView('bridge'); return } + if (event.key.toLowerCase() === 'd') { setModal('deck'); return } + if (game.phase === 'combat' && view === 'bridge') { + if (/^[1-9]$/.test(event.key)) { event.preventDefault(); act({ type: 'play', index: Number(event.key) - 1 }) } + if (event.key.toLowerCase() === 'e') { event.preventDefault(); act({ type: 'endTurn' }) } + } + } + document.addEventListener('keydown', keydown) + return () => document.removeEventListener('keydown', keydown) + }) + const toggleSound = () => { const enabled = !sound; setSound(enabled); playSound('click', enabled); try { localStorage.setItem('voidwake-sound', enabled ? 'on' : 'off') } catch { /* Optional preference. */ } } + const sector = SECTORS[game.sector] + return
+
+ { e.preventDefault(); setView('bridge') }}>VOIDWAKE.THE LAST SIGNAL +
VOYAGE {game.seed} / DAY {pad(game.stats.jumps + 1)}
+
+
CREDITS{game.credits.toLocaleString()}
+
FUEL CELLS{pad(game.fuel)} jumps
+ + +
+
+ + + +
+ {saveStatus !== 'Saved locally' &&
{saveStatus}
} +
{view === 'bridge' ? `EXPEDITION / SECTOR ${pad(game.sector + 1)}` : view === 'ship' ? 'VESSEL / PERSONNEL & SYSTEMS' : 'ARCHIVE / EXPEDITION RECORD'}

{view === 'bridge' ? game.phase === 'combat' ? 'Battle stations' : game.phase === 'station' ? 'A moment of sanctuary' : sector.name : view === 'ship' ? 'A ship. A crew. A chance.' : 'Every light leaves a trace.'}

{view === 'bridge' ? game.phase === 'combat' ? 'Read their intentions. Make every command count.' : game.phase === 'station' ? 'Repair, refit, and prepare for the dark ahead.' : sector.subtitle : view === 'ship' ? 'The only things between you and the void.' : 'Your journey, one impossible decision at a time.'}

{game.phase === 'combat' ? 'HOSTILES ENGAGED' : game.phase === 'station' ? 'DOCKING COMPLETE' : 'LONG-RANGE COMMS ONLINE'}{sector.code} · {pad(game.stats.jumps)}:47:09
+ + {view === 'bridge' && <> + {game.phase === 'map' && setModal('help')}/>} + {game.phase === 'combat' && setModal('deck')}/>} + {game.phase === 'event' && } + {game.phase === 'station' && } + {game.phase === 'reward' && } + {(game.phase === 'won' || game.phase === 'lost') && setModal('new')} onLog={() => setView('archive')}/>} + {!['combat', 'won', 'lost'].includes(game.phase) &&
setView('archive')}/>
} + } + {view === 'ship' && setModal('deck')} onBridge={() => setView('bridge')}/>} + {view === 'archive' && } +
IN THE DARK, WE GO TOGETHER.M bridge D deck ? helpFLIGHT COMPUTER v.01
+
+
{game.notice}
+ {modal && setModal(null)} wide={modal !== 'help'}> + {modal === 'help' && <>
} + {modal === 'deck' && <>

{game.deck.length} systems installed. Draw 5 cards each turn. Spent and unplayed cards return when your draw pile empties. Exhausted cards return next battle.

{game.deck.map(c => )}
} + {modal === 'new' && { setGame(g); setSelected('1-0'); setView('bridge'); setModal(null); playSound('jump', sound) }}/>} +
} +
+} + +function MapView({ game, selected, onSelect, act, onHelp }: { game: Game; selected: string; onSelect: (id: string) => void; act: (a: Action) => void; onHelp: () => void }) { + const node = game.nodes.find(n => n.id === selected) ?? game.nodes[1] + const available = reachable(game), canJump = available.includes(node.id), info = NODE_INFO[node.kind] + return
+
+
NAVIGATION CHART / {pad(game.sector + 1)}1 GRID = 4.2 LIGHT YEARS
+ +
{(['battle', 'elite', 'salvage', 'event', 'station', 'relay'] as const).map(kind => {kind === 'event' ? 'Signal' : kind === 'battle' ? 'Hostile' : kind[0].toUpperCase() + kind.slice(1)})}
+
{game.stats.jumps === 0 ? 'Your voyage starts here. Select a connected destination, then engage the jump drive.' : 'Highlighted routes are reachable. Every jump takes you closer to the signal.'}
+
+ +
+} + +function StarMap({ game, selected, onSelect }: { game: Game; selected: string; onSelect: (id: string) => void }) { + const available = reachable(game), current = game.nodes.find(n => n.id === game.current)! + const colors = ['#e4a16f', '#a39ccc', '#86baba'] + return
+ + + {Array.from({ length: 155 }, (_, i) => )} + + + LOCAL CLUSTER / {SECTORS[game.sector].code}DEEP SPACE TELEMETRY + {['ORPHEUS B', 'VESPER PRIME', 'THE MERIDIAN'][game.sector]}{['GAS GIANT · UNINHABITABLE', 'SHATTERED WORLD · CLASS IV', 'ORIGIN SIGNAL · CONFIRMED'][game.sector]} + {game.nodes.flatMap(n => n.links.map(id => { const target = game.nodes.find(other => other.id === id)!; const active = n.id === current.id, past = n.visited && target.visited; return }))} + {game.nodes.map(node => )} + YOU + ↗ GALACTIC NORTH +
+} +function MapPoint({ node, selected, current, available, passed, onSelect }: { node: MapNode; selected: boolean; current: boolean; available: boolean; passed: boolean; onSelect: (id: string) => void }) { + const info = NODE_INFO[node.kind], size = node.kind === 'boss' ? 23 : 17 + return onSelect(node.id)} onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onSelect(node.id) } }}> + {node.name} · {info.label}{available ? ' · Reachable' : ''} + {selected && <>} + + + {node.name.length > 19 ? node.name.slice(0, 18) + '…' : node.name} + {node.kind === 'boss' && JUMP GATE} + +} + +function CrewStrip({ game, act }: { game: Game; act?: (a: Action) => void }) { + const inCombat = game.phase === 'combat', used = game.battle?.crewUsed + return
{inCombat ? used ? 'ABILITY USED' : '1 ABILITY / BATTLE' : `TRUST ${game.reputation >= 0 ? '+' : ''}${game.reputation}`}}>THE PEOPLE YOU BRING HOME
{CREW.map((c, i) =>
{c.name}{c.role}{inCombat && }
)}
+} +function RecentLog({ game, onFull }: { game: Game; onFull: () => void }) { + return
Full log }>FLIGHT RECORDER
{game.journal.slice(0, 2).map((entry, i) =>
{pad(entry.day)}:04

{entry.text}

)}
+} + +function GameCard({ card, onPlay, disabled, index, compact = false }: { card: Card; onPlay?: () => void; disabled?: boolean; index?: number; compact?: boolean }) { + const def = CARDS[card.key] + const body = <>
{def.cost}{def.type}{index !== undefined && {index + 1}}

{def.name}{card.rank ? + : ''}

{card.rank ? def.enhanced : def.text}

{def.rarity}{card.rank ? 'ENHANCED' : 'MK. I'}
+ return onPlay ? :
{body}
+} + +function Combat({ game, act, flash, onDeck }: { game: Game; act: (a: Action) => void; flash: { id: number; kind: string }; onDeck: () => void }) { + const b = game.battle!, e = b.enemy, intent = currentIntent(game)! + const intentText = { attack: 'Incoming attack', guard: 'Reinforcing shields', charge: 'Charging weapons', drain: 'Shield siphon', repair: 'Repairing hull' }[intent.kind] + return
+
+
TACTICAL DISPLAYROUND {pad(b.turn)} / {SECTORS[game.sector].code}
+
{flash.id > 0 && }
YOUR VESSEL

{SHIPS[game.ship].name}

{game.hull} / {game.maxHull}{b.shield} shield
{b.evade > 0 && {b.evade} evade}WEAPON BONUS +{weaponBonus(game)}
+
+
{e.faction}

{e.name}

{e.hull} / {e.maxHull}{e.shield} shield
{e.burn > 0 && {e.burn} burn}{e.vulnerable > 0 && Vulnerable {e.vulnerable} turns}{e.power > 0 && +{e.power} power}
+
+
ENEMY INTENT{intentText} {intent.value}
{b.log[0]}
{intent.kind === 'attack' || intent.kind === 'drain' ? `${b.evade ? 0 : Math.max(0, intent.value - b.shield)} projected hull damage${b.evade ? ' · evade ready' : ''}` : 'A window to strike.'}
+
+
{b.energy}
Reactor energyRefills to {b.maxEnergy} each turn
+
{b.hand.map((c, i) => act({ type: 'play', index: i })} disabled={CARDS[c.key].cost > b.energy}/>)}{b.hand.length === 0 &&

All commands executed.

End your turn to refill energy and draw a new hand.

}
Click a card or press its number to play. Shields reset to {passiveShield(game)} at the start of your next turn.
+
+ +
Combat transcript {b.log.length} entries{b.log.map((line, i) =>

{line}

)}
+
+} + +function EventView({ game, act }: { game: Game; act: (a: Action) => void }) { + const event = EVENTS[game.event] + return
TRANSMISSION RECEIVED
{Array.from({ length: 36 }, (_, i) => )}
{event.eyebrow}

{event.title}

{event.text}

{event.choices.map((choice, i) => )}

EVERY DECISION BECOMES PART OF YOUR STORY.

+} + +function RewardView({ game, act }: { game: Game; act: (a: Action) => void }) { + const r = game.reward!, relic = RELICS.find(a => a.id === r.relic) + return
CONTACT NEUTRALIZED · SALVAGE SECURED

{r.boss ? 'The way forward is open.' : 'Another light stays on.'}

Your crew catches their breath. Choose one recovered system to install.

+{r.credits} credits{relic && {relic.name}}
{relic &&

{relic.text}

}
{r.cards.map((key, i) =>
)}
{r.boss && game.sector < 2 &&
GATE TRANSIT RESTORES 25 HULL AND 6 FUEL
}
+} + +function Station({ game, act }: { game: Game; act: (a: Action) => void }) { + const node = game.nodes.find(n => n.id === game.current)! + return
INDEPENDENT TRADING POST

{node.name}

“You made it this far. Let’s get you a little further.”

+
act({ type: 'buy', item: 'repair' })}/> act({ type: 'buy', item: 'fuel' })}/>{(['weapons', 'shields', 'reactor'] as Upgrade[]).map(u => = (u === 'reactor' ? 2 : 3) ? 'maxed' : `MK. ${game.upgrades[u] + 2}`}`} text={u === 'weapons' ? '+2 damage per weapon hit.' : u === 'shields' ? '+3 passive shield every turn.' : '+1 energy every turn.'} cost={upgradeCost(game, u)} disabled={game.credits < upgradeCost(game, u) || game.upgrades[u] >= (u === 'reactor' ? 2 : 3)} onBuy={() => act({ type: 'buy', item: u })}/>)}
+ ONE OF EACH IN STOCK}>SYSTEMS EXCHANGE
{game.shop.map((key, i) =>
)}
+
Deck workshopEnhance a card · 25 cr / Remove · 20 cr

Enhance a system once for a stronger effect, or remove a card to draw your best systems more often. Keep at least 5 cards.

{game.deck.map(c =>
{CARDS[c.key].name}{c.rank ? ' +' : ''}{c.rank ? CARDS[c.key].enhanced : CARDS[c.key].text}
)}
{game.notice}
+
+} +function Service({ icon, title, text, cost, disabled, onBuy }: { icon: string; title: string; text: string; cost: number; disabled: boolean; onBuy: () => void }) { return

{title}

{text}

} + +function ShipView({ game, onDeck, onBridge }: { game: Game; onDeck: () => void; onBridge: () => void }) { + return
{SHIPS[game.ship].class}

{SHIPS[game.ship].name}

FLIGHTWORTHY

{SHIPS[game.ship].description}

HULL{game.hull}/{game.maxHull}
SHIELD / TURN{passiveShield(game)}
ENERGY / TURN{3 + game.upgrades.reactor}
WEAPON BONUS+{weaponBonus(game)}
+
CREW MANIFEST{CREW.map((c, i) =>
{c.role}

{c.name}

{c.quote}

{c.ability}{c.text}
)}

Choose one crew ability per battle. Their expertise may turn an impossible fight into a narrow escape.

+
{game.relics.length} / {RELICS.length} RECOVERED}>ARTIFACT VAULT
{RELICS.map(r =>

{r.name}

{r.text}

{!game.relics.includes(r.id) && }
)}

Artifacts are recovered from ancient relays, elite enemies, guardians, and certain story choices.

+} +function Archive({ game, best }: { game: Game; best: number }) { + return
{[{ label: 'VOYAGE SCORE', value: score(game) }, { label: 'JUMPS MADE', value: game.stats.jumps }, { label: 'BATTLES WON', value: game.stats.battles }, { label: 'DAMAGE DEALT', value: game.stats.damage }, { label: 'PERSONAL BEST', value: best }].map(item =>
{item.label}{item.value.toLocaleString()}
)}
EXPEDITION MILESTONES
{achievements(game).map(a =>
{a.name}{a.text}
)}
VOYAGE {game.seed}}>CAPTAIN’S LOG{game.journal.map((entry, i) =>
DAY {pad(entry.day)}

{entry.text}

)}
+} +function Ending({ game, best, onNew, onLog }: { game: Game; best: number; onNew: () => void; onLog: () => void }) { + const won = game.phase === 'won' + return
{won ? 'TRANSMISSION COMPLETE · THE MERIDIAN' : 'TRANSMISSION LOST · FLIGHT RECORDER RECOVERED'}

{won ? 'You were never alone.' : 'Even stars go quiet.'}

{won ? game.reputation >= 5 ? 'Beyond the sentinel, the sleepers wake. The people you saved carry your signal across the Reach. This time, no one has to make the journey alone.' : 'The signal opens a door in the dark. On the other side, a thousand sleeping ships begin to wake. You have found them. Now the journey home can begin.' : 'The void keeps your vessel, but not your story. Somewhere in the Reach, another crew picks up the signal. There is still a way through.'}

FINAL VOYAGE SCORE{score(game).toLocaleString()}PERSONAL BEST {Math.max(best, score(game)).toLocaleString()}
{game.stats.jumps} jumps{game.stats.battles} victories{game.relics.length} artifacts{game.reputation} trust
+} + +function Dialog({ title, onClose, children, wide }: { title: string; onClose: () => void; children: ReactNode; wide?: boolean }) { + const ref = useRef(null) + useEffect(() => { const dialog = ref.current; const previous = document.activeElement as HTMLElement; dialog?.showModal(); return () => { dialog?.close(); previous?.focus() } }, []) + return { e.preventDefault(); onClose() }} onClick={e => { if (e.target === ref.current) { const r = ref.current.getBoundingClientRect(); if (e.clientX < r.left || e.clientX > r.right || e.clientY < r.top || e.clientY > r.bottom) onClose() } }} aria-labelledby="dialog-title">
VOIDWAKE / FLIGHT COMPUTER

{title}

{children}
+} +function Help() { + return

Follow a strange transmission through three sectors. Defeat each sector’s guardian to open the next jump gate. Your hull, fuel, deck, and choices carry forward.

Plot a route

Select any destination connected to your current location, then engage the jump drive. Each jump costs 1 fuel. Routes only go forward. Salvage repairs 4 hull and grants credits and 2 fuel. Relays repair 5 hull and grant an artifact.

Command the fight

Play cards using reactor energy. Read the enemy’s next intention, attack, and build shields before ending your turn. Each turn refills energy and draws a new hand. Unused cards are discarded. Shields reset to your passive value at the start of your turn.

Trust your crew

Use one crew ability per battle: Mara evades an attack, Ada repairs 10 hull, or Ren deals 15 piercing damage. Choose when their help matters most.

Make the ship your own

Stations sell repairs, fuel, cards, and permanent upgrades. The workshop enhances or removes cards. Combat victories offer one optional card. Artifacts grant passive bonuses for the whole voyage.

Combat terms

Burn damages enemy hull before their action, then decreases by 2. Vulnerable increases damage taken by 50%. Exhaust removes a card for the rest of the current battle. Evade prevents one attack entirely.

1–9 Play cardE End turnM BridgeD DeckEsc Close

Your voyage saves automatically in this browser. If you run out of fuel, use Emergency refuel on the sector map. Guardians restore 25 hull and 6 fuel before the next sector.

+} +function NewVoyage({ game, onLaunch }: { game: Game; onLaunch: (g: Game) => void }) { + const [ship, setShip] = useState(game.ship), [difficulty, setDifficulty] = useState(game.difficulty), [seed, setSeed] = useState('') + return
{ e.preventDefault(); onLaunch(newGame(seed.trim() || `V-${Date.now().toString(36).slice(-6)}`, ship, difficulty)) }}>

Three ships. Three sectors. One last signal. Launching replaces your current voyage; your personal best remains.

{(Object.keys(SHIPS) as ShipKey[]).map(key => )}
Difficulty
+} diff --git a/projects/voidwake-1aa9/src/Art.tsx b/projects/voidwake-1aa9/src/Art.tsx new file mode 100644 index 00000000..2a969634 --- /dev/null +++ b/projects/voidwake-1aa9/src/Art.tsx @@ -0,0 +1,74 @@ +import { useId } from 'react' +import type { CSSProperties } from 'react' + +const paths: Record = { + ship: <>, + map: <>, + crosshair: <>, + shield: <>, + bolt: , + fuel: <>, + coin: <>, + heart: , + signal: <>, + layers: <>, + hex: <>, + orbit: <>, + crown: <>, + target: <>, + wrench: <>, + eye: <>, + sun: <>, + rocket: <>, + radar: <>, + book: <>, + arrow: , + check: , + close: , + help: <>, + sound: <>, + mute: <>, + plus: , + refresh: <>, + chevron: , + lock: <>, +} +export function Icon({ name, size = 20, className = '', style }: { name: string; size?: number; className?: string; style?: CSSProperties }) { + return +} + +export function ShipArt({ enemy = false, variant = 'wayfarer', className = '' }: { enemy?: boolean; variant?: string; className?: string }) { + const id = useId().replace(/:/g, '') + const accent = enemy ? '#e68d75' : variant === 'kestrel' ? '#efb47f' : variant === 'bulwark' ? '#b0a1d4' : '#8bc6c5' + return + + + + + + + + + + + + + + + + + + + + + + {variant === 'bulwark' && <>} + {enemy && <>} + + +} + +export function Portrait({ index, size = 40 }: { index: number; size?: number }) { + const colors = ['#8bc6c5', '#b8a6d9', '#f0ac73'] + return +} diff --git a/projects/voidwake-1aa9/src/audio.ts b/projects/voidwake-1aa9/src/audio.ts new file mode 100644 index 00000000..a8700910 --- /dev/null +++ b/projects/voidwake-1aa9/src/audio.ts @@ -0,0 +1,16 @@ +let context: AudioContext | null = null +export function playSound(kind: 'click' | 'jump' | 'attack' | 'end' | 'reward', enabled: boolean) { + if (!enabled) return + try { + context ??= new AudioContext() + if (context.state === 'suspended') void context.resume() + const now = context.currentTime + const notes = kind === 'jump' ? [110, 165, 220, 330] : kind === 'reward' ? [330, 440, 550] : kind === 'attack' ? [95, 50] : kind === 'end' ? [180, 120] : [550] + notes.forEach((frequency, i) => { + const oscillator = context!.createOscillator(), gain = context!.createGain() + oscillator.type = kind === 'attack' ? 'triangle' : 'sine'; oscillator.frequency.setValueAtTime(frequency, now + i * .07) + gain.gain.setValueAtTime(0, now + i * .07); gain.gain.linearRampToValueAtTime(.045, now + i * .07 + .015); gain.gain.exponentialRampToValueAtTime(.001, now + i * .07 + .2) + oscillator.connect(gain); gain.connect(context!.destination); oscillator.start(now + i * .07); oscillator.stop(now + i * .07 + .21) + }) + } catch { /* Audio is optional when unavailable in the browser. */ } +} diff --git a/projects/voidwake-1aa9/src/content.ts b/projects/voidwake-1aa9/src/content.ts new file mode 100644 index 00000000..480448a4 --- /dev/null +++ b/projects/voidwake-1aa9/src/content.ts @@ -0,0 +1,99 @@ +export type CardKey = 'pulse' | 'barrier' | 'torpedo' | 'scan' | 'repair' | 'railgun' | 'overclock' | 'broadside' | 'drone' | 'cloak' | 'siphon' | 'flare' | 'barrage' | 'fortify' | 'salvo' | 'vent' | 'lance' | 'echo' +export type CardType = 'weapon' | 'defense' | 'system' +export interface CardDefinition { name: string; type: CardType; cost: number; text: string; enhanced: string; rarity: 'standard' | 'uncommon' | 'rare'; icon: string } +export const CARDS: Record = { + pulse: { name: 'Pulse cannon', type: 'weapon', cost: 1, text: 'Deal 8 damage.', enhanced: 'Deal 12 damage.', rarity: 'standard', icon: 'crosshair' }, + barrier: { name: 'Deflector', type: 'defense', cost: 1, text: 'Gain 10 shield.', enhanced: 'Gain 15 shield.', rarity: 'standard', icon: 'shield' }, + torpedo: { name: 'Void torpedo', type: 'weapon', cost: 2, text: 'Deal 19 damage. Ignores shields.', enhanced: 'Deal 27 damage. Ignores shields.', rarity: 'uncommon', icon: 'rocket' }, + scan: { name: 'Deep scan', type: 'system', cost: 0, text: 'Draw 2 cards. Exhaust.', enhanced: 'Draw 3 cards. Exhaust.', rarity: 'standard', icon: 'radar' }, + repair: { name: 'Field repair', type: 'system', cost: 1, text: 'Restore 5 hull. Exhaust.', enhanced: 'Restore 9 hull. Exhaust.', rarity: 'standard', icon: 'wrench' }, + railgun: { name: 'Rail driver', type: 'weapon', cost: 1, text: 'Deal 11 damage. Apply 2 vulnerable.', enhanced: 'Deal 16 damage. Apply 3 vulnerable.', rarity: 'uncommon', icon: 'crosshair' }, + overclock: { name: 'Overclock', type: 'system', cost: 0, text: 'Gain 2 energy. Lose 3 hull. Exhaust.', enhanced: 'Gain 2 energy. Exhaust.', rarity: 'uncommon', icon: 'bolt' }, + broadside: { name: 'Broadside', type: 'weapon', cost: 2, text: 'Deal 9 damage twice.', enhanced: 'Deal 13 damage twice.', rarity: 'uncommon', icon: 'target' }, + drone: { name: 'Defense drone', type: 'defense', cost: 1, text: 'Gain 7 shield. Deal 5 damage.', enhanced: 'Gain 11 shield. Deal 8 damage.', rarity: 'standard', icon: 'hex' }, + cloak: { name: 'Phase cloak', type: 'defense', cost: 2, text: 'Evade the next attack. Draw 1 card.', enhanced: 'Evade the next 2 attacks. Draw 1 card.', rarity: 'rare', icon: 'eye' }, + siphon: { name: 'Flux siphon', type: 'weapon', cost: 1, text: 'Deal 7 damage. Restore 3 hull.', enhanced: 'Deal 11 damage. Restore 5 hull.', rarity: 'uncommon', icon: 'orbit' }, + flare: { name: 'Solar flare', type: 'weapon', cost: 1, text: 'Apply 6 burn. Burn deals hull damage each turn.', enhanced: 'Apply 10 burn. Burn deals hull damage each turn.', rarity: 'uncommon', icon: 'sun' }, + barrage: { name: 'Meteor swarm', type: 'weapon', cost: 3, text: 'Deal 8 damage four times.', enhanced: 'Deal 11 damage four times.', rarity: 'rare', icon: 'target' }, + fortify: { name: 'Bastion field', type: 'defense', cost: 2, text: 'Gain 24 shield.', enhanced: 'Gain 34 shield.', rarity: 'uncommon', icon: 'shield' }, + salvo: { name: 'Quickshot', type: 'weapon', cost: 0, text: 'Deal 5 damage. Exhaust.', enhanced: 'Deal 9 damage. Exhaust.', rarity: 'standard', icon: 'bolt' }, + vent: { name: 'Capacitor', type: 'system', cost: 1, text: 'Gain 6 shield. Draw 2 cards.', enhanced: 'Gain 10 shield. Draw 3 cards.', rarity: 'uncommon', icon: 'layers' }, + lance: { name: 'Singularity lance', type: 'weapon', cost: 2, text: 'Deal 16 damage. +4 for each other weapon in hand.', enhanced: 'Deal 22 damage. +6 for each other weapon in hand.', rarity: 'rare', icon: 'rocket' }, + echo: { name: 'Echo reactor', type: 'system', cost: 1, text: 'Gain 1 energy and draw 3 cards. Exhaust.', enhanced: 'Gain 2 energy and draw 4 cards. Exhaust.', rarity: 'rare', icon: 'orbit' }, +} + +export type ShipKey = 'wayfarer' | 'kestrel' | 'bulwark' +export const SHIPS: Record = { + wayfarer: { name: 'The Wayfarer', class: 'EXPLORATION FRIGATE', hull: 90, shield: 4, weapon: 0, description: 'A dependable long-range vessel. Balanced defenses, a repair bay, and room for one more impossible journey.', color: '#8bc6c5' }, + kestrel: { name: 'The Kestrel', class: 'STRIKE CORVETTE', hull: 70, shield: 2, weapon: 2, description: 'Light armor. Heavy guns. Every weapon deals 2 extra damage, and a rail driver replaces your repair kit.', color: '#f0ac73' }, + bulwark: { name: 'The Bulwark', class: 'ESCORT CRUISER', hull: 115, shield: 7, weapon: 0, description: 'Built to outlast the dark. Reinforced hull, 7 passive shield, and a bastion field in the starting deck.', color: '#b8a6d9' }, +} +export type NodeKind = 'start' | 'battle' | 'elite' | 'salvage' | 'event' | 'station' | 'relay' | 'boss' +export const NODE_INFO: Record = { + start: { label: 'Arrival point', icon: 'ship', color: '#8bc6c5', description: 'The last familiar light is already behind you. Set a course, Captain.' }, + battle: { label: 'Hostile contact', icon: 'crosshair', color: '#da8d7f', description: 'An armed vessel is tracking your signature. Prepare your combat systems.' }, + elite: { label: 'Elite patrol', icon: 'target', color: '#e09a60', description: 'A dangerous adversary guards valuable technology. High risk, exceptional salvage.' }, + salvage: { label: 'Salvage field', icon: 'layers', color: '#94b4a4', description: 'A silent graveyard of old ships. Recover credits, fuel, and a little history.' }, + event: { label: 'Unknown signal', icon: 'signal', color: '#b0a0d5', description: 'Something is transmitting from the dark. Someone might still be listening.' }, + station: { label: 'Trading station', icon: 'hex', color: '#8ab6cf', description: 'Dock for hull repairs, fuel, new systems, and permanent ship upgrades.' }, + relay: { label: 'Ancient relay', icon: 'orbit', color: '#d5bd82', description: 'A dormant signal relay holds a fragment of the way home, and an ancient artifact.' }, + boss: { label: 'Sector guardian', icon: 'crown', color: '#f0ac73', description: 'The only passage onward is held by a powerful guardian. All routes converge here.' }, +} +export const SECTORS = [ + { name: 'The Orpheus Reach', subtitle: 'Somewhere beyond the last charted star.', code: 'ORPHEUS', boss: 'The Tollkeeper', faction: 'REVENANT BLOCKADE', flavor: 'A freighter-sized hull turns across the jump gate. Its running lights spell out a single word: PAY.' }, + { name: 'The Glass Expanse', subtitle: 'Where dying stars remember their light.', code: 'GLASS', boss: 'Choir of Glass', faction: 'ECHO COLLECTIVE', flavor: 'A thousand mirrored drones speak with one voice. They have been waiting for your signal.' }, + { name: 'The Silent Meridian', subtitle: 'At the edge of everything, a voice.', code: 'MERIDIAN', boss: 'The Last Witness', faction: 'MERIDIAN SENTINEL', flavor: 'The sentinel unfolds around the origin of the signal. Behind it, a whole civilization sleeps.' }, +] +export const NODE_NAMES = [ + ['Pale Anchorage', 'Cinder Drift', 'Echo Seven', 'The Rust Belt', 'Holloway Port', 'Sable Crossing', 'Lost Frequency', 'Kepler Wrecks', 'Watchpost IX', 'The Quiet Array', 'Copper Harbor', 'Widow’s Light', 'Broken Halo', 'Argent Station', 'Memory Well'], + ['Mirror Shoals', 'Velvet Ruin', 'Lacuna Point', 'The Shatter', 'Opal Exchange', 'White Noise', 'Ghost Orchard', 'Crystal Wake', 'Grave of Suns', 'Penumbral Array', 'Prism Harbor', 'Singing Dust', 'Ivory Rift', 'Astral Bazaar', 'Vesper’s Echo'], + ['Far Lantern', 'The Still Sea', 'Afterimage', 'Last Resupply', 'Dawnless Reach', 'Ashen Choir', 'Missing Years', 'The Long Sleep', 'Ember Watch', 'First Memory', 'Solace Port', 'The Unwritten', 'Eventide', 'Homeward Star', 'Origin Point'], +] +export const RELICS = [ + { id: 'capacitor', name: 'Precursor capacitor', icon: 'bolt', text: 'Start each combat with 1 additional energy.' }, + { id: 'nanites', name: 'Mending nanites', icon: 'wrench', text: 'Restore 6 hull after every victory.' }, + { id: 'lens', name: 'Prismatic lens', icon: 'eye', text: 'All weapons deal 2 extra damage.' }, + { id: 'plating', name: 'Memory alloy', icon: 'shield', text: 'Gain 4 additional shield every turn.' }, + { id: 'antenna', name: 'Ghost antenna', icon: 'signal', text: 'Draw 1 additional card at the start of every turn.' }, + { id: 'compass', name: 'Foldspace compass', icon: 'orbit', text: 'Every third jump costs no fuel.' }, +] +export const CREW = [ + { id: 'mara', name: 'Mara Voss', role: 'PILOT', initials: 'MV', color: '#8bc6c5', ability: 'Ghost maneuver', text: 'Evade the next enemy attack.', quote: '“There is always a way through.”' }, + { id: 'ada', name: 'Ada-9', role: 'ENGINEER', initials: 'A9', color: '#b8a6d9', ability: 'Emergency weld', text: 'Restore 10 hull.', quote: '“Unlikely is not impossible.”' }, + { id: 'ren', name: 'Ren Okoro', role: 'GUNNER', initials: 'RO', color: '#f0ac73', ability: 'Precision shot', text: 'Deal 15 damage, ignoring shields.', quote: '“Give me one clean angle.”' }, +] + +export interface EventChoice { title: string; text: string; credits?: number; fuel?: number; hull?: number; maxHull?: number; relic?: boolean; card?: CardKey; reputation?: number } +export interface StoryEvent { title: string; eyebrow: string; text: string; choices: EventChoice[] } +export const EVENTS: StoryEvent[] = [ + { title: 'The little things', eyebrow: 'DISTRESS CALL · CIVILIAN TRANSPORT', text: 'A family of seven has been drifting for nine days. Their youngest asks over the comms whether your ship has windows. It does. They have nothing to trade but a hand-drawn star map.', choices: [ + { title: 'Share your fuel', text: 'Spend 2 fuel. Gain a Ghost Antenna and 2 trust.', fuel: -2, relic: true, reputation: 2 }, + { title: 'Patch their reactor', text: 'Spend 25 credits. Restore 8 hull and gain 2 trust.', credits: -25, hull: 8, reputation: 2 }, + { title: 'Send their coordinates', text: 'A rescue beacon is the least you can offer. Gain 1 trust.', reputation: 1 }, + ] }, + { title: 'An echo of tomorrow', eyebrow: 'TEMPORAL ANOMALY · UNREGISTERED', text: 'You intercept a message in your own voice. It lists the names of your crew, then a sequence of numbers. Ada thinks it is a blueprint. Mara thinks you should keep moving.', choices: [ + { title: 'Build the impossible', text: 'Spend 20 credits. Add Echo Reactor to your deck.', credits: -20, card: 'echo' }, + { title: 'Follow the coordinates', text: 'Lose 10 hull in the rift. Recover an artifact.', hull: -10, relic: true }, + { title: 'Erase the message', text: 'Some things can stay unwritten. Gain 20 credits.', credits: 20 }, + ] }, + { title: 'The garden in the dark', eyebrow: 'DERELICT · BIOLOGICAL SIGNATURE', text: 'Inside a fractured research station, a garden has outgrown its gravity. Silver leaves reach toward a miniature sun. A maintenance drone offers a cutting, and patiently holds out an invoice.', choices: [ + { title: 'Buy a living fragment', text: 'Spend 30 credits. Increase maximum hull by 12 and repair 12.', credits: -30, maxHull: 12, hull: 12 }, + { title: 'Harvest the power cells', text: 'Gain 3 fuel. Lose 1 trust.', fuel: 3, reputation: -1 }, + { title: 'Leave it growing', text: 'The drone waves goodbye. Restore 5 hull and gain 1 trust.', hull: 5, reputation: 1 }, + ] }, + { title: 'A fair exchange', eyebrow: 'ENCRYPTED HAIL · INDEPENDENT TRADER', text: '“No questions. No receipts.” The trader’s ship looks less like a vessel and more like a very determined collection of spare parts. Their weapons inventory, however, is impeccable.', choices: [ + { title: 'Buy the singularity lance', text: 'Spend 40 credits. Add a rare weapon to your deck.', credits: -40, card: 'lance' }, + { title: 'Trade a fuel cell', text: 'Spend 1 fuel. Gain 30 credits.', fuel: -1, credits: 30 }, + { title: 'Ask about the signal', text: 'They know a shortcut. Gain 1 fuel.', fuel: 1 }, + ] }, + { title: 'Nobody left behind', eyebrow: 'LIFE POD · FAILING ORBIT', text: 'The pod carries a Revenant insignia. The person inside is barely breathing. Ren goes quiet when he recognizes the uniform. Then he opens the airlock anyway.', choices: [ + { title: 'Bring them aboard', text: 'Spend 15 credits on medicine. Gain 3 trust and Field Repair.', credits: -15, reputation: 3, card: 'repair' }, + { title: 'Extract their flight recorder', text: 'Gain 35 credits. Lose 2 trust.', credits: 35, reputation: -2 }, + { title: 'Stabilize the orbit', text: 'Spend 1 fuel. Gain 1 trust and 15 credits.', fuel: -1, reputation: 1, credits: 15 }, + ] }, + { title: 'The station that remembers', eyebrow: 'ARCHIVE · PRECURSOR ORIGIN', text: 'The terminal recognizes you, though you have never been here. On screen, a fleet of tiny lights goes dark one by one. One light remains. It has the same transponder code as your ship.', choices: [ + { title: 'Download the old defenses', text: 'Add Phase Cloak to your deck.', card: 'cloak' }, + { title: 'Recover the reserve cells', text: 'Gain 2 fuel and 20 credits.', fuel: 2, credits: 20 }, + { title: 'Listen to the last recording', text: 'Gain an artifact. Lose 8 hull as the station collapses.', relic: true, hull: -8 }, + ] }, +] diff --git a/projects/voidwake-1aa9/src/game.ts b/projects/voidwake-1aa9/src/game.ts new file mode 100644 index 00000000..d8a33391 --- /dev/null +++ b/projects/voidwake-1aa9/src/game.ts @@ -0,0 +1,240 @@ +import { CARDS, SHIPS, NODE_NAMES, EVENTS, RELICS, SECTORS } from './content.ts' +import type { CardKey, ShipKey, NodeKind } from './content.ts' + +export interface Card { uid: number; key: CardKey; rank: number } +export interface MapNode { id: string; col: number; row: number; x: number; y: number; kind: NodeKind; name: string; links: string[]; visited: boolean; cleared: boolean } +export interface Enemy { name: string; faction: string; hull: number; maxHull: number; shield: number; burn: number; vulnerable: number; power: number; pattern: Intent[] } +export interface Intent { kind: 'attack' | 'guard' | 'charge' | 'drain' | 'repair'; value: number } +export interface Battle { enemy: Enemy; turn: number; energy: number; maxEnergy: number; shield: number; evade: number; draw: Card[]; hand: Card[]; discard: Card[]; exhaust: Card[]; crewUsed: boolean; log: string[] } +export interface Reward { credits: number; cards: CardKey[]; relic?: string; boss: boolean } +export interface Stats { jumps: number; battles: number; damage: number; cardsPlayed: number; rescued: number; creditsEarned: number } +export type Upgrade = 'weapons' | 'shields' | 'reactor' +export interface Game { + version: 1; seed: string; rng: number; serial: number; ship: ShipKey; difficulty: 'explorer' | 'captain'; + phase: 'map' | 'combat' | 'event' | 'station' | 'reward' | 'won' | 'lost'; sector: number; + hull: number; maxHull: number; credits: number; fuel: number; reputation: number; + deck: Card[]; relics: string[]; upgrades: Record; nodes: MapNode[]; current: string; + battle: Battle | null; reward: Reward | null; event: number; shop: CardKey[]; bought: string[]; + stats: Stats; journal: { text: string; day: number; tone: string }[]; notice: string; +} +export type Action = { type: 'jump'; id: string } | { type: 'play'; index: number } | { type: 'endTurn' } | { type: 'crew'; id: string } | { type: 'choice'; index: number } | { type: 'claim'; key: CardKey | null } | { type: 'buy'; item: string } | { type: 'leave' } | { type: 'sos' } | { type: 'upgradeCard'; uid: number } | { type: 'removeCard'; uid: number } +const hash = (text: string) => [...text].reduce((n, c) => Math.imul(n ^ c.charCodeAt(0), 16777619) >>> 0, 2166136261) +function random(g: Game) { g.rng = (Math.imul(g.rng, 1664525) + 1013904223) >>> 0; return g.rng / 4294967296 } +function pick(g: Game, items: readonly T[]): T { return items[Math.floor(random(g) * items.length)] } +function shuffle(g: Game, items: T[]): T[] { const out = [...items]; for (let i = out.length - 1; i > 0; i--) { const j = Math.floor(random(g) * (i + 1)); [out[i], out[j]] = [out[j], out[i]] } return out } +function log(g: Game, text: string, tone = 'info') { g.journal.unshift({ text, day: g.stats.jumps + 1, tone }); g.journal = g.journal.slice(0, 80); g.notice = text } +function card(g: Game, key: CardKey): Card { return { uid: ++g.serial, key, rank: 0 } } +function heal(g: Game, amount: number) { g.hull = Math.min(g.maxHull, g.hull + amount) } +function addRelic(g: Game, preferred?: string): string | undefined { const available = RELICS.filter(r => !g.relics.includes(r.id)); if (!available.length) { g.credits += 35; return undefined } const found = available.find(r => r.id === preferred) ?? pick(g, available); g.relics.push(found.id); return found.id } +export function jumpCost(g: Game) { return g.relics.includes('compass') && (g.stats.jumps + 1) % 3 === 0 ? 0 : 1 } +export function reachable(g: Game) { return g.nodes.find(n => n.id === g.current)?.links ?? [] } +export function currentIntent(g: Game): Intent | null { const b = g.battle; if (!b) return null; const intent = b.enemy.pattern[(b.turn - 1) % b.enemy.pattern.length]; return { ...intent, value: intent.value + (intent.kind === 'attack' || intent.kind === 'drain' ? b.enemy.power : 0) } } +export function passiveShield(g: Game) { return SHIPS[g.ship].shield + g.upgrades.shields * 3 + (g.relics.includes('plating') ? 4 : 0) } +export function weaponBonus(g: Game) { return SHIPS[g.ship].weapon + g.upgrades.weapons * 2 + (g.relics.includes('lens') ? 2 : 0) } +export function upgradeCost(g: Game, upgrade: Upgrade) { return 45 + g.upgrades[upgrade] * 35 } +export function generateMap(g: Game): MapNode[] { + const nodes: MapNode[] = [{ id: 'start', col: 0, row: 1, x: 7, y: 50, kind: 'start', name: 'Arrival beacon', links: [], visited: true, cleared: true }] + const pool: NodeKind[] = ['battle', 'battle', 'salvage', 'event', 'event', 'station', 'elite', 'relay'] + for (let col = 1; col <= 5; col++) for (let row = 0; row < 3; row++) { + let kind = pick(g, pool) + if (col === 1) kind = (['salvage', 'battle', 'event'] as const)[row] + if (col === 3 && row === 1) kind = 'station' + if (col === 4 && row === 0) kind = 'relay' + nodes.push({ id: `${col}-${row}`, col, row, x: 7 + col * 14, y: 23 + row * 27 + (random(g) - 0.5) * 12, kind, name: NODE_NAMES[g.sector][(col - 1) * 3 + row], links: [], visited: false, cleared: false }) + } + nodes.push({ id: 'boss', col: 6, row: 1, x: 93, y: 50, kind: 'boss', name: SECTORS[g.sector].boss, links: [], visited: false, cleared: false }) + for (const node of nodes) node.links = nodes.filter(next => next.col === node.col + 1 && (node.col === 0 || next.kind === 'boss' || Math.abs(next.row - node.row) <= 1)).map(next => next.id) + return nodes +} +export function newGame(seed = 'ORPHEUS', ship: ShipKey = 'wayfarer', difficulty: Game['difficulty'] = 'explorer'): Game { + seed = seed.trim().slice(0, 24).toUpperCase() || 'ORPHEUS' + const g: Game = { version: 1, seed: seed.trim().slice(0, 24).toUpperCase() || 'ORPHEUS', rng: hash(seed || 'ORPHEUS'), serial: 0, ship, difficulty, phase: 'map', sector: 0, hull: SHIPS[ship].hull, maxHull: SHIPS[ship].hull, credits: 90, fuel: 10, reputation: 0, deck: [], relics: [], upgrades: { weapons: 0, shields: 0, reactor: 0 }, nodes: [], current: 'start', battle: null, reward: null, event: 0, shop: [], bought: [], stats: { jumps: 0, battles: 0, damage: 0, cardsPlayed: 0, rescued: 0, creditsEarned: 0 }, journal: [], notice: '' } + const starter: CardKey[] = ['pulse', 'pulse', 'pulse', 'pulse', 'barrier', 'barrier', 'barrier', 'torpedo', 'scan', ship === 'kestrel' ? 'railgun' : 'repair', ship === 'bulwark' ? 'fortify' : 'drone'] + g.deck = starter.map(key => card(g, key)); g.nodes = generateMap(g) + log(g, 'A signal from beyond the Meridian. Three sectors. One way forward.'); return g +} +function draw(g: Game, amount: number) { const b = g.battle!; for (let i = 0; i < amount && b.hand.length < 10; i++) { if (!b.draw.length) { b.draw = shuffle(g, b.discard); b.discard = [] } const c = b.draw.pop(); if (c) b.hand.push(c) } } +function combatLog(g: Game, text: string) { const b = g.battle!; b.log.unshift(text); b.log = b.log.slice(0, 16) } +function hitEnemy(g: Game, amount: number, piercing = false, weapon = true) { + const b = g.battle!; const e = b.enemy + let damage = amount + (weapon ? weaponBonus(g) : 0) + if (e.vulnerable > 0) damage = Math.floor(damage * 1.5) + const absorbed = piercing ? 0 : Math.min(e.shield, damage); e.shield -= absorbed; damage -= absorbed + const actual = Math.min(e.hull, damage); e.hull = Math.max(0, e.hull - damage); g.stats.damage += actual + combatLog(g, `${e.name} takes ${damage} damage${absorbed ? ` (${absorbed} shielded)` : ''}.`) +} +function hitPlayer(g: Game, amount: number) { + const b = g.battle! + if (b.evade > 0) { b.evade--; combatLog(g, 'Ghost maneuver: incoming attack evaded.'); return } + const absorbed = Math.min(b.shield, amount); b.shield -= absorbed; g.hull = Math.max(0, g.hull - (amount - absorbed)); combatLog(g, `Incoming ${amount} damage. ${absorbed} absorbed by shields.`) +} +function makeEnemy(g: Game, kind: NodeKind): Enemy { + const elite = kind === 'elite', boss = kind === 'boss'; const scale = g.difficulty === 'explorer' ? 0.85 : 1 + const names = ['Revenant skirmisher', 'Carrion drone', 'Hollow corsair', 'Echo interceptor'] + const hp = Math.round(((boss ? 85 : elite ? 65 : 39) + g.sector * (boss ? 30 : 15)) * scale) + const damage = Math.round(((boss ? 16 : elite ? 13 : 10) + g.sector * 4) * scale) + const name = boss ? SECTORS[g.sector].boss : elite ? ['Revenant warden', 'Glass executioner', 'Meridian hunter'][g.sector] : pick(g, names) + const pattern: Intent[] = boss ? [{ kind: 'attack', value: damage }, { kind: 'guard', value: 12 + g.sector * 4 }, { kind: 'attack', value: damage + 6 }, { kind: 'charge', value: 3 }, { kind: 'drain', value: damage + 3 }] : pick(g, [ + [{ kind: 'attack', value: damage }, { kind: 'guard', value: 9 }, { kind: 'attack', value: damage + 5 }], + [{ kind: 'guard', value: 7 }, { kind: 'attack', value: damage + 3 }, { kind: 'charge', value: 2 }, { kind: 'attack', value: damage }], + [{ kind: 'attack', value: damage - 2 }, { kind: 'drain', value: damage }, { kind: 'repair', value: 6 }, { kind: 'attack', value: damage + 6 }], + ] as Intent[][]) + return { name, faction: boss ? SECTORS[g.sector].faction : elite ? 'ELITE PATROL' : 'HOSTILE CONTACT', hull: hp, maxHull: hp, shield: elite || boss ? 6 : 0, burn: 0, vulnerable: 0, power: 0, pattern } +} +function startBattle(g: Game, kind: NodeKind) { + const energy = 3 + g.upgrades.reactor + g.battle = { enemy: makeEnemy(g, kind), turn: 1, energy: energy + (g.relics.includes('capacitor') ? 1 : 0), maxEnergy: energy, shield: passiveShield(g), evade: 0, draw: shuffle(g, g.deck), hand: [], discard: [], exhaust: [], crewUsed: false, log: ['Weapons online. The crew awaits your orders.'] } + g.phase = 'combat'; draw(g, g.relics.includes('antenna') ? 6 : 5); log(g, `${g.battle.enemy.name} intercepts your course.`, 'danger') +} +function lose(g: Game) { g.hull = 0; g.phase = 'lost'; log(g, 'The last light on the bridge goes dark.', 'danger') } +function victory(g: Game) { + const node = g.nodes.find(n => n.id === g.current)! + node.cleared = true; g.stats.battles++; const credits = (node.kind === 'boss' ? 60 : node.kind === 'elite' ? 45 : 25) + g.sector * 10 + g.credits += credits; g.stats.creditsEarned += credits + if (g.relics.includes('nanites')) heal(g, 6) + if (g.difficulty === 'explorer') heal(g, 3) + const keys = Object.keys(CARDS) as CardKey[] + const offers = shuffle(g, keys.filter(k => !['pulse', 'barrier', 'repair'].includes(k))).slice(0, 3) + const relic = node.kind === 'boss' || node.kind === 'elite' ? addRelic(g) : undefined + g.reward = { credits, cards: offers, relic, boss: node.kind === 'boss' }; g.phase = 'reward'; log(g, `${g.battle!.enemy.name} neutralized. Recovered ${credits} credits.`, 'success') +} +function checkBattle(g: Game) { if (g.hull <= 0) lose(g); else if (g.battle!.enemy.hull <= 0) victory(g) } +function playCard(g: Game, index: number) { + const b = g.battle!, c = b.hand[index]; if (!c || CARDS[c.key].cost > b.energy) return + b.hand.splice(index, 1); b.energy -= CARDS[c.key].cost; const up = c.rank > 0 + g.stats.cardsPlayed++; combatLog(g, `${CARDS[c.key].name}${up ? '+' : ''} activated.`) + const hit = (n: number) => hitEnemy(g, n) + switch (c.key) { + case 'pulse': hit(up ? 12 : 8); break + case 'barrier': b.shield += up ? 15 : 10; break + case 'torpedo': hitEnemy(g, up ? 27 : 19, true); break + case 'scan': draw(g, up ? 3 : 2); break + case 'repair': heal(g, up ? 9 : 5); break + case 'railgun': hit(up ? 16 : 11); b.enemy.vulnerable += up ? 3 : 2; break + case 'overclock': b.energy += 2; if (!up) g.hull = Math.max(0, g.hull - 3); break + case 'broadside': hit(up ? 13 : 9); hit(up ? 13 : 9); break + case 'drone': b.shield += up ? 11 : 7; hit(up ? 8 : 5); break + case 'cloak': b.evade += up ? 2 : 1; draw(g, 1); break + case 'siphon': hit(up ? 11 : 7); heal(g, up ? 5 : 3); break + case 'flare': b.enemy.burn += up ? 10 : 6; break + case 'barrage': for (let i = 0; i < 4; i++) hit(up ? 11 : 8); break + case 'fortify': b.shield += up ? 34 : 24; break + case 'salvo': hit(up ? 9 : 5); break + case 'vent': b.shield += up ? 10 : 6; draw(g, up ? 3 : 2); break + case 'lance': hit((up ? 22 : 16) + b.hand.filter(c => CARDS[c.key].type === 'weapon').length * (up ? 6 : 4)); break + case 'echo': b.energy += up ? 2 : 1; draw(g, up ? 4 : 3); break + } + if (['scan', 'repair', 'overclock', 'salvo', 'echo'].includes(c.key)) b.exhaust.push(c); else b.discard.push(c) + checkBattle(g) +} +function endTurn(g: Game) { + const b = g.battle!, e = b.enemy, intent = currentIntent(g)! + if (e.burn) { const damage = Math.min(e.hull, e.burn); e.hull -= damage; g.stats.damage += damage; combatLog(g, `Solar burn deals ${damage} hull damage.`); e.burn = Math.max(0, e.burn - 2) } + if (e.hull <= 0) { victory(g); return } + switch (intent.kind) { + case 'attack': hitPlayer(g, intent.value); break + case 'guard': e.shield += intent.value; combatLog(g, `Enemy reinforces ${intent.value} shield.`); break + case 'charge': e.power += intent.value; combatLog(g, `Enemy weapons gain ${intent.value} power.`); break + case 'drain': hitPlayer(g, intent.value); e.shield += 4; break + case 'repair': e.hull = Math.min(e.maxHull, e.hull + intent.value); combatLog(g, `Enemy repairs ${intent.value} hull.`); break + } + if (g.hull <= 0) { lose(g); return } + e.vulnerable = Math.max(0, e.vulnerable - 1); b.turn++; b.energy = b.maxEnergy; b.shield = passiveShield(g) + b.discard.push(...b.hand); b.hand = []; draw(g, g.relics.includes('antenna') ? 6 : 5) +} +export function choiceAvailable(g: Game, index: number) { const c = EVENTS[g.event]?.choices[index]; return !!c && g.credits + (c.credits ?? 0) >= 0 && g.fuel + (c.fuel ?? 0) >= 0 && g.hull + (c.hull ?? 0) > 0 } +export function reducer(state: Game, action: Action): Game { + const g = structuredClone(state) + if (g.phase === 'won' || g.phase === 'lost') return state + if (action.type === 'jump' && g.phase === 'map') { + const node = g.nodes.find(n => n.id === action.id); if (!node || !reachable(g).includes(node.id) || g.fuel < jumpCost(g)) return state + g.fuel -= jumpCost(g); g.stats.jumps++; g.current = node.id; node.visited = true + if (['battle', 'elite', 'boss'].includes(node.kind)) startBattle(g, node.kind) + else if (node.kind === 'station') { g.phase = 'station'; g.shop = shuffle(g, (Object.keys(CARDS) as CardKey[]).filter(k => CARDS[k].rarity !== 'standard')).slice(0, 4); g.bought = []; node.cleared = true; log(g, `Docked at ${node.name}. The air tastes almost like home.`) } + else if (node.kind === 'event') { g.phase = 'event'; g.event = Math.floor(random(g) * EVENTS.length); log(g, `An unfamiliar transmission from ${node.name}.`) } + else if (node.kind === 'relay') { const relic = addRelic(g); node.cleared = true; heal(g, 5); log(g, `Relay synchronized. ${relic ? `Recovered ${RELICS.find(r => r.id === relic)!.name}.` : 'Recovered 35 credits.'} Restored 5 hull.`, 'success') } + else { const credits = 22 + Math.floor(random(g) * 19); g.credits += credits; g.stats.creditsEarned += credits; g.fuel += 2; heal(g, 4); node.cleared = true; log(g, `Salvaged ${credits} credits and 2 fuel. Restored 4 hull.`, 'success') } + } else if (action.type === 'play' && g.phase === 'combat') playCard(g, action.index) + else if (action.type === 'endTurn' && g.phase === 'combat') endTurn(g) + else if (action.type === 'crew' && g.phase === 'combat') { + if (g.battle!.crewUsed || !['mara', 'ada', 'ren'].includes(action.id)) return state + g.battle!.crewUsed = true + if (action.id === 'mara') { g.battle!.evade++; combatLog(g, 'Mara: ghost maneuver ready. Next attack will miss.') } + if (action.id === 'ada') { heal(g, 10); combatLog(g, 'Ada-9: emergency weld restores 10 hull.') } + if (action.id === 'ren') hitEnemy(g, 15, true, false) + checkBattle(g) + } else if (action.type === 'choice' && g.phase === 'event') { + if (!choiceAvailable(g, action.index)) return state + const c = EVENTS[g.event].choices[action.index]; g.credits += c.credits ?? 0; g.fuel += c.fuel ?? 0; g.maxHull += c.maxHull ?? 0; heal(g, c.hull ?? 0); g.reputation += c.reputation ?? 0 + if (c.relic) addRelic(g, g.event === 0 ? 'antenna' : undefined) + if (c.card) g.deck.push(card(g, c.card)) + if ((c.reputation ?? 0) > 0) g.stats.rescued++ + g.nodes.find(n => n.id === g.current)!.cleared = true; g.phase = 'map'; log(g, `${EVENTS[g.event].title}: ${c.title}. ${c.text}`, 'success') + } else if (action.type === 'claim' && g.phase === 'reward') { + if (action.key && !g.reward!.cards.includes(action.key)) return state + if (action.key) { g.deck.push(card(g, action.key)); log(g, `${CARDS[action.key].name} installed in your combat deck.`, 'success') } + const boss = g.reward!.boss; g.reward = null; g.battle = null + if (boss && g.sector === 2) { g.phase = 'won'; log(g, 'The signal was never a distress call. It was an invitation. You answer.', 'success') } + else if (boss) { g.sector++; g.nodes = generateMap(g); g.current = 'start'; g.fuel += 6; heal(g, 25); g.phase = 'map'; log(g, `Entered ${SECTORS[g.sector].name}. Gate transit restores 25 hull and 6 fuel.`, 'success') } + else g.phase = 'map' + } else if (action.type === 'buy' && g.phase === 'station') { + if (action.item === 'repair' && g.credits >= 20 && g.hull < g.maxHull) { g.credits -= 20; heal(g, 30); log(g, 'Repair crew restored 30 hull.', 'success') } + else if (action.item === 'fuel' && g.credits >= 15) { g.credits -= 15; g.fuel += 3; log(g, 'Loaded 3 fuel cells.', 'success') } + else if (['weapons', 'shields', 'reactor'].includes(action.item)) { + const u = action.item as Upgrade, cost = upgradeCost(g, u), limit = u === 'reactor' ? 2 : 3 + if (g.credits < cost || g.upgrades[u] >= limit) return state + g.credits -= cost; g.upgrades[u]++; log(g, `${u[0].toUpperCase() + u.slice(1)} upgraded to level ${g.upgrades[u]}.`, 'success') + } else if (g.shop.includes(action.item as CardKey) && !g.bought.includes(action.item)) { + const key = action.item as CardKey, cost = CARDS[key].rarity === 'rare' ? 50 : 30 + if (g.credits < cost) return state + g.credits -= cost; g.deck.push(card(g, key)); g.bought.push(key); log(g, `${CARDS[key].name} acquired.`, 'success') + } else return state + } else if (action.type === 'upgradeCard' && g.phase === 'station') { + const c = g.deck.find(c => c.uid === action.uid); if (!c || c.rank || g.credits < 25) return state + g.credits -= 25; c.rank = 1; log(g, `${CARDS[c.key].name} enhanced.`, 'success') + } else if (action.type === 'removeCard' && g.phase === 'station') { + if (g.credits < 20 || g.deck.length <= 5 || !g.deck.some(c => c.uid === action.uid)) return state + g.credits -= 20; g.deck = g.deck.filter(c => c.uid !== action.uid); log(g, 'Combat system decommissioned. A leaner deck cycles faster.') + } else if (action.type === 'leave' && g.phase === 'station') { g.phase = 'map'; log(g, 'Undocked. The stars are waiting.') } + else if (action.type === 'sos' && g.phase === 'map' && g.fuel === 0) { + g.fuel = 2 + if (g.credits >= 25) { g.credits -= 25; log(g, 'A passing hauler sells you 2 emergency fuel cells for 25 credits.') } + else { g.hull = Math.max(1, g.hull - 12); log(g, 'Converted hull plating into 2 emergency fuel cells. Lost up to 12 hull.', 'danger') } + } else return state + return g +} + +export function score(g: Game) { return g.stats.battles * 100 + g.sector * 250 + g.relics.length * 80 + g.stats.jumps * 25 + Math.max(0, g.reputation) * 40 + (g.phase === 'won' ? 1000 + g.hull * 5 : 0) } +export function achievements(g: Game) { return [ + { name: 'First contact', text: 'Win your first battle', done: g.stats.battles > 0, icon: 'crosshair' }, + { name: 'Collector of echoes', text: 'Recover 3 artifacts', done: g.relics.length >= 3, icon: 'orbit' }, + { name: 'A little humanity', text: 'Earn 5 crew trust', done: g.reputation >= 5, icon: 'heart' }, + { name: 'Beyond the Reach', text: 'Reach the second sector', done: g.sector >= 1, icon: 'map' }, + { name: 'Weapons hot', text: 'Deal 500 total damage', done: g.stats.damage >= 500, icon: 'bolt' }, + { name: 'The last signal', text: 'Complete the voyage', done: g.phase === 'won', icon: 'signal' }, +] } + +// Saves are checked before use; a malformed or future-version save starts a fresh voyage. +export function parseSave(raw: string | null): Game | null { + try { + if (!raw || raw.length > 200_000) return null + const g = JSON.parse(raw) as Game + const finite = (value: unknown) => typeof value === 'number' && Number.isFinite(value) && value >= 0 + const validCard = (c: Card) => c && Object.hasOwn(CARDS, c.key) && finite(c.uid) && [0, 1].includes(c.rank) + if (g.version !== 1 || !Object.hasOwn(SHIPS, g.ship) || !['explorer', 'captain'].includes(g.difficulty) || !['map', 'combat', 'event', 'station', 'reward', 'won', 'lost'].includes(g.phase)) return null + if (!Number.isInteger(g.sector) || g.sector < 0 || g.sector > 2 || !finite(g.hull) || !finite(g.maxHull) || g.maxHull < 1 || g.hull > g.maxHull || !finite(g.fuel) || !finite(g.credits) || !finite(g.rng) || !finite(g.serial) || typeof g.seed !== 'string') return null + if (!Array.isArray(g.deck) || g.deck.length < 5 || !g.deck.every(validCard) || !Array.isArray(g.nodes) || g.nodes.length !== 17 || !g.nodes.some(n => n.id === g.current)) return null + if (!g.nodes.every(n => n && typeof n.id === 'string' && typeof n.name === 'string' && finite(n.x) && finite(n.y) && finite(n.col) && finite(n.row) && ['start','battle','elite','salvage','event','station','relay','boss'].includes(n.kind) && Array.isArray(n.links) && n.links.every(id => g.nodes.some(other => other.id === id)))) return null + if (!Array.isArray(g.relics) || !g.relics.every(id => RELICS.some(r => r.id === id)) || !g.upgrades || !['weapons','shields','reactor'].every(k => finite(g.upgrades[k as Upgrade])) || !Number.isFinite(g.reputation)) return null + if (!g.stats || !['jumps','battles','damage','cardsPlayed','rescued','creditsEarned'].every(k => finite(g.stats[k as keyof Stats])) || !Array.isArray(g.journal) || !g.journal.every(e => typeof e.text === 'string' && finite(e.day) && typeof e.tone === 'string') || typeof g.notice !== 'string') return null + if (!Array.isArray(g.shop) || !g.shop.every(k => Object.hasOwn(CARDS, k)) || !Array.isArray(g.bought) || !g.bought.every(k => typeof k === 'string') || !Number.isInteger(g.event) || !EVENTS[g.event]) return null + if (g.phase === 'combat' || g.phase === 'reward') { + const b = g.battle + if (!b || !finite(b.turn) || b.turn < 1 || !finite(b.energy) || !finite(b.maxEnergy) || !finite(b.shield) || !finite(b.evade) || ![b.draw,b.hand,b.discard,b.exhaust].every(a => Array.isArray(a) && a.every(validCard)) || !Array.isArray(b.log) || !b.log.every(s => typeof s === 'string')) return null + const e = b.enemy + if (!e || typeof e.name !== 'string' || typeof e.faction !== 'string' || !['hull','maxHull','shield','burn','vulnerable','power'].every(k => finite(e[k as keyof Enemy])) || !Array.isArray(e.pattern) || !e.pattern.length || !e.pattern.every(i => ['attack','guard','charge','drain','repair'].includes(i.kind) && finite(i.value))) return null + } + if (g.phase === 'reward' && (!g.reward || !Array.isArray(g.reward.cards) || !g.reward.cards.every(k => Object.hasOwn(CARDS, k)) || !finite(g.reward.credits) || (g.reward.relic !== undefined && !RELICS.some(r => r.id === g.reward!.relic)))) return null + return g + } catch { return null } +} diff --git a/projects/voidwake-1aa9/src/index.css b/projects/voidwake-1aa9/src/index.css new file mode 100644 index 00000000..5ef01831 --- /dev/null +++ b/projects/voidwake-1aa9/src/index.css @@ -0,0 +1,13 @@ +* { box-sizing: border-box; } +:root { font-family: 'DM Sans', sans-serif; color: #e9e6df; background: #080f19; font-synthesis: none; text-rendering: optimizeLegibility; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; } +body { margin: 0; min-width: 360px; } +button, input { font: inherit; } +button, a, input { -webkit-tap-highlight-color: transparent; } +button { color: inherit; cursor: pointer; touch-action: manipulation; } +button:disabled { cursor: not-allowed; } +button:focus-visible, a:focus-visible, input:focus-visible, summary:focus-visible, [tabindex]:focus-visible { outline: 2px solid #f0ac73; outline-offset: 5px; } +a { color: inherit; } +::selection { background: #a99cce55; color: #fff; } +::-webkit-scrollbar { width: 6px; height: 6px; } +::-webkit-scrollbar-track { background: #0b1521; } +::-webkit-scrollbar-thumb { background: #344355; border-radius: 8px; } diff --git a/projects/voidwake-1aa9/src/main.tsx b/projects/voidwake-1aa9/src/main.tsx new file mode 100644 index 00000000..bef5202a --- /dev/null +++ b/projects/voidwake-1aa9/src/main.tsx @@ -0,0 +1,10 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import './index.css' +import App from './App.tsx' + +createRoot(document.getElementById('root')!).render( + + + , +) diff --git a/projects/voidwake-1aa9/tests/browser.mjs b/projects/voidwake-1aa9/tests/browser.mjs new file mode 100644 index 00000000..2afd91c3 --- /dev/null +++ b/projects/voidwake-1aa9/tests/browser.mjs @@ -0,0 +1,92 @@ +import { chromium, expect } from '@playwright/test' +import { createServer } from 'node:http' +import { readFile, mkdir } from 'node:fs/promises' +import { resolve, extname } from 'node:path' +import { CARDS } from '../src/content.ts' +import { newGame, reducer, score } from '../src/game.ts' +import { chooseAction } from './pilot.mjs' + +const prefix='/agent-experiments/projects/voidwake-1aa9/' +const root=resolve('dist'),artifacts=resolve('tests/artifacts') +await mkdir(artifacts,{recursive:true}) +const types={'.html':'text/html','.js':'text/javascript','.css':'text/css','.svg':'image/svg+xml','.woff':'font/woff','.woff2':'font/woff2'} +const server=createServer(async(req,res)=>{ + try{ + const path=decodeURIComponent(new URL(req.url,'http://localhost').pathname) + if(!path.startsWith(prefix)){res.writeHead(404);res.end();return} + const file=resolve(root,path.slice(prefix.length)||'index.html') + if(!file.startsWith(root+'/')){res.writeHead(403);res.end();return} + res.writeHead(200,{'Content-Type':types[extname(file)]??'application/octet-stream'});res.end(await readFile(file)) + }catch{res.writeHead(404);res.end()} +}) +await new Promise(r=>server.listen(0,'127.0.0.1',r)) +const url=`http://127.0.0.1:${server.address().port}${prefix}` +const browser=await chromium.launch({headless:true,...(process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE?{executablePath:process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE}:{})}) +const page=await browser.newPage({viewport:{width:1536,height:1000},reducedMotion:'reduce'}) +const errors=[],failed=[] +page.on('pageerror',e=>errors.push(e.message));page.on('response',r=>{if(r.status()>=400)failed.push(`${r.status()} ${r.url()}`)}) +const saveKey='voidwake-1aa9-save-v1' +const read=async()=>page.evaluate(key=>JSON.parse(localStorage.getItem(key)),saveKey) +async function settled(expected){await page.waitForFunction(({key,state})=>localStorage.getItem(key)===JSON.stringify(state),{key:saveKey,state:expected})} +async function load(g){await page.evaluate(({key,state})=>localStorage.setItem(key,JSON.stringify(state)),{key:saveKey,state:g});await page.reload();await settled(g)} +async function noOverflow(label){const sizes=await page.evaluate(()=>({page:document.documentElement.scrollWidth,width:innerWidth}));expect(sizes.page,`${label} overflows`).toBeLessThanOrEqual(sizes.width)} +const shots={} +try{ + await page.goto(url);await page.evaluate(()=>document.fonts.ready);await expect(page).toHaveTitle('Voidwake — The Last Signal') + await settled(newGame());await noOverflow('desktop map');await page.screenshot({path:`${artifacts}/map-desktop.png`,fullPage:true}) + await page.getByRole('button',{name:'The Tollkeeper, Sector guardian'}).click();await expect(page.getByRole('button',{name:'No direct route'})).toBeDisabled() + await page.getByRole('button',{name:'Pale Anchorage, Salvage field, reachable'}).click();await page.getByRole('button',{name:'Engage jump drive'}).click() + const salvaged=reducer(newGame(),{type:'jump',id:'1-0'});await settled(salvaged);await page.reload();await settled(salvaged) + await page.getByRole('button',{name:'How to play'}).click();await expect(page.getByRole('dialog')).toBeVisible();await page.keyboard.press('Escape');await expect(page.getByRole('dialog')).toHaveCount(0) + await page.getByRole('button',{name:'Ship & crew'}).click();await expect(page.getByRole('heading',{name:'A ship. A crew. A chance.'})).toBeVisible() + await page.getByRole('button',{name:'Inspect 11 combat systems'}).click();await expect(page.locator('dialog .game-card')).toHaveCount(11);await page.keyboard.press('Escape') + await page.getByRole('button',{name:'Captain’s log',exact:true}).click();await expect(page.getByRole('heading',{name:'Every light leaves a trace.'})).toBeVisible() + await page.getByRole('button',{name:'New voyage',exact:true}).click();await page.getByLabel('Voyage seed').fill('BROWSER');await page.getByRole('button',{name:'Launch new voyage'}).click();await settled(newGame('BROWSER')) + // Complete a real campaign using UI controls only. Each action must match the pure engine. + let g=await read(),steps=0 + while(!['won','lost'].includes(g.phase)&&steps++<300){ + shots[g.phase]??=structuredClone(g) + const a=chooseAction(g),expected=reducer(g,a) + if(a.type==='jump'){ + const n=g.nodes.find(n=>n.id===a.id);await page.getByRole('button',{name:new RegExp(`^${n.name.replace(/[.*+?^${}()|[\]\\]/g,'\\$&')},`)}).click();await page.getByRole('button',{name:'Engage jump drive'}).click() + }else if(a.type==='play'){ + const cards=page.locator('.hand-cards button.game-card');await cards.nth(a.index).click() + }else if(a.type==='crew'){ + await page.getByRole('button',{name:new RegExp(a.id==='mara'?'Ghost maneuver':a.id==='ada'?'Emergency weld':'Precision shot')}).click() + }else if(a.type==='endTurn')await page.keyboard.press('e') + else if(a.type==='choice')await page.locator('.event-choices button').nth(a.index).click() + else if(a.type==='claim')await page.locator('.reward-cards>div').nth(g.reward.cards.indexOf(a.key)).getByRole('button').click() + else if(a.type==='buy'){ + const name=a.item==='repair'?'Hull repair':a.item==='fuel'?'Refuel':a.item[0].toUpperCase()+a.item.slice(1) + await page.locator('.service').filter({has:page.getByRole('heading',{name:new RegExp(`^${name}`)})}).getByRole('button').click() + }else if(a.type==='upgradeCard'){ + const details=page.locator('.refit-deck');if(!await details.evaluate(e=>e.open))await details.locator('summary').click() + await page.locator('.refit-list>div').nth(g.deck.findIndex(c=>c.uid===a.uid)).getByRole('button',{name:'Enhance · 25'}).click() + }else if(a.type==='leave')await page.getByRole('button',{name:'Undock'}).click() + else if(a.type==='sos')await page.getByRole('button',{name:/Emergency refuel/}).click() + await settled(expected);g=await read();await noOverflow(g.phase) + } + expect(g.phase).toBe('won');expect(g.sector).toBe(2);shots.won=g + await expect(page.getByRole('heading',{name:'You were never alone.'})).toBeVisible();expect(await page.evaluate(()=>Number(localStorage.getItem('voidwake-best')))).toBe(score(g)) + // Screenshots and responsive checks for every encounter phase, using genuine reached states. + for(const [phase,state] of Object.entries(shots)){ + await load(state);await page.setViewportSize({width:1536,height:1000});await noOverflow(`${phase} desktop`);await page.screenshot({path:`${artifacts}/${phase}-desktop.png`,fullPage:true}) + await page.setViewportSize({width:390,height:844});await noOverflow(`${phase} mobile`);await page.screenshot({path:`${artifacts}/${phase}-mobile.png`,fullPage:true}) + } + // Numeric card shortcut, disabled cards, persistence in the middle of battle. + let battle=reducer(newGame(),{type:'jump',id:'1-1'});await load(battle) + const index=battle.battle.hand.findIndex(c=>CARDS[c.key].cost<=battle.battle.energy) + await page.keyboard.press(String(index+1));battle=reducer(battle,{type:'play',index});await settled(battle);await page.reload();await settled(battle) + await page.getByRole('button',{name:'How to play'}).click();await page.getByRole('button',{name:'Enable sound'}).click();await expect(page.getByRole('button',{name:'Mute sound'})).toBeVisible() + await page.getByRole('button',{name:'New voyage',exact:true}).click();await expect(page.getByRole('button',{name:'Launch new voyage'})).toBeVisible();await page.getByRole('button',{name:/The Kestrel/}).click();await page.getByLabel('Voyage seed').fill('MOBILE');await page.getByRole('button',{name:'Launch new voyage'}).click();await settled(newGame('MOBILE','kestrel')) + await page.setViewportSize({width:360,height:800});await noOverflow('smallest supported map') + await page.getByRole('button',{name:'How to play'}).click();await noOverflow('smallest modal');await page.keyboard.press('Escape') + await page.evaluate(key=>localStorage.setItem(key,'{"version":1}'),saveKey);await page.reload();await settled(newGame()) + // A failing storage backend still allows play and clearly reports the limitation. + const blocked=await browser.newPage({viewport:{width:1280,height:900}}) + await blocked.addInitScript(()=>{Storage.prototype.setItem=function(){throw new DOMException('Blocked','SecurityError')}}) + await blocked.goto(url);await expect(blocked.getByText('Storage unavailable · keep this tab open')).toBeVisible() + await blocked.getByRole('button',{name:'Cinder Drift, Hostile contact, reachable'}).click();await blocked.getByRole('button',{name:'Engage jump drive'}).click();await expect(blocked.getByRole('heading',{name:'Battle stations'})).toBeVisible();await blocked.close() + expect(errors).toEqual([]);expect(failed).toEqual([]) + console.log(JSON.stringify({result:'passed',campaignActions:steps,phases:Object.keys(shots),productionSubpath:prefix,consoleErrors:errors,failedRequests:failed,screenshots:artifacts},null,2)) +}finally{await browser.close();await new Promise(r=>server.close(r))} diff --git a/projects/voidwake-1aa9/tests/engine.test.mjs b/projects/voidwake-1aa9/tests/engine.test.mjs new file mode 100644 index 00000000..43bb39a5 --- /dev/null +++ b/projects/voidwake-1aa9/tests/engine.test.mjs @@ -0,0 +1,114 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { CARDS, EVENTS } from '../src/content.ts' +import { newGame, reducer, parseSave, reachable, passiveShield, currentIntent, score, generateMap } from '../src/game.ts' +import {chooseAction} from './pilot.mjs' +const combat=()=>reducer(newGame(),{type:'jump',id:'1-1'}) +const withHand=(keys)=>{const g=combat();g.battle.hand=keys.map((key,i)=>({key,uid:100+i,rank:0}));g.battle.draw=[];g.battle.discard=[];return g} + +test('seed normalization is reproducible and every route reaches a guardian',()=>{ + assert.deepEqual(newGame(' orpheus '),newGame('ORPHEUS')) + for(let seed=0;seed<120;seed++)for(let sector=0;sector<3;sector++){ + const g=newGame(String(seed));g.sector=sector;g.nodes=generateMap(g) + assert.equal(g.nodes.length,17) + for(const n of g.nodes){assert.equal(n.kind==='boss',n.links.length===0);for(const id of n.links)assert.equal(g.nodes.find(v=>v.id===id).col,n.col+1)} + let frontier=['start'];for(let col=0;col<6;col++)frontier=[...new Set(frontier.flatMap(id=>g.nodes.find(n=>n.id===id).links))] + assert.deepEqual(frontier,['boss']) + } +}) +test('illegal travel, purchases, and phase changes preserve the voyage',()=>{ + const g=newGame();assert.equal(reducer(g,{type:'jump',id:'boss'}),g) + assert.equal(reducer(g,{type:'buy',item:'reactor'}),g) + assert.equal(reducer(g,{type:'endTurn'}),g) + const noFuel={...g,fuel:0};assert.equal(reducer(noFuel,{type:'jump',id:'1-0'}),noFuel) +}) +test('salvage, fuel recovery, and map actions never mutate the prior state',()=>{ + const g=newGame(),before=JSON.stringify(g),n=reducer(g,{type:'jump',id:'1-0'}) + assert.equal(JSON.stringify(g),before);assert.equal(n.fuel,11);assert.ok(n.credits>g.credits);assert.equal(n.current,'1-0');assert.ok(n.nodes[1].cleared) + const stuck={...g,fuel:0,credits:0,hull:1},rescued=reducer(stuck,{type:'sos'});assert.equal(rescued.fuel,2);assert.equal(rescued.hull,1) + assert.equal(reducer({...g,fuel:0},{type:'sos'}).credits,65) +}) +test('weapons consume energy; unaffordable cards have no effect',()=>{ + const g=withHand(['pulse','torpedo']);const n=reducer(g,{type:'play',index:0}) + assert.equal(n.battle.energy,2);assert.equal(n.battle.enemy.hull,g.battle.enemy.hull-8);assert.equal(n.battle.hand.length,1);assert.equal(n.battle.discard.length,1) + n.battle.energy=0;assert.deepEqual(reducer(n,{type:'play',index:0}),n) +}) +test('torpedoes ignore shields; normal weapons consume them',()=>{ + const g=withHand(['torpedo']);g.battle.enemy.shield=50 + const n=reducer(g,{type:'play',index:0});assert.equal(n.battle.enemy.shield,50);assert.equal(n.battle.enemy.hull,g.battle.enemy.hull-19) + const p=withHand(['pulse']);p.battle.enemy.shield=5;const q=reducer(p,{type:'play',index:0});assert.equal(q.battle.enemy.shield,0);assert.equal(q.battle.enemy.hull,p.battle.enemy.hull-3) +}) +test('defense blocks damage, turns refill energy and reset shields',()=>{ + const g=withHand(['barrier']);g.battle.enemy.pattern=[{kind:'attack',value:14}];const n=reducer(reducer(g,{type:'play',index:0}),{type:'endTurn'}) + assert.equal(n.hull,g.hull);assert.equal(n.battle.shield,passiveShield(g));assert.equal(n.battle.energy,3);assert.equal(n.battle.turn,2) +}) +test('crew abilities are mutually exclusive and reset next battle',()=>{ + const g=combat();g.battle.enemy.pattern=[{kind:'attack',value:100}] + const n=reducer(g,{type:'crew',id:'mara'});assert.ok(n.battle.crewUsed);assert.equal(reducer(n,{type:'crew',id:'ren'}),n) + const safe=reducer(n,{type:'endTurn'});assert.equal(safe.hull,g.hull);assert.equal(safe.battle.evade,0) +}) +test('exhausted cards do not return when the draw pile reshuffles',()=>{ + const g=withHand(['repair','pulse']);g.hull=60 + const n=reducer(g,{type:'play',index:0});assert.equal(n.hull,65);assert.equal(n.battle.exhaust[0].key,'repair') + const next=reducer(n,{type:'endTurn'});assert.deepEqual(next.battle.hand.map(c=>c.key),['pulse']) +}) +test('burn resolves before intent and wins without a final enemy hit',()=>{ + const g=withHand(['flare']);g.battle.enemy.hull=5;g.battle.enemy.pattern=[{kind:'attack',value:200}] + const n=reducer(reducer(g,{type:'play',index:0}),{type:'endTurn'}) + assert.equal(n.phase,'reward');assert.equal(n.hull,90);assert.equal(n.stats.battles,1) +}) +test('vulnerability and upgrades increase damage; repeated hits count correctly',()=>{ + const g=withHand(['broadside']);g.battle.enemy.hull=100;g.battle.enemy.maxHull=100;g.battle.enemy.vulnerable=2;g.upgrades.weapons=1 + const n=reducer(g,{type:'play',index:0});assert.equal(n.battle.enemy.hull,68);assert.equal(n.stats.damage,32) +}) +test('every card and enhancement has a legal, finite resolution',()=>{ + for(const key of Object.keys(CARDS))for(const rank of [0,1]){ + const g=withHand([key]);g.battle.hand[0].rank=rank;g.battle.energy=10;g.battle.enemy.hull=200;g.battle.enemy.maxHull=200;g.hull=40 + const n=reducer(g,{type:'play',index:0});assert.equal(n.stats.cardsPlayed,1,key);assert.ok(Number.isFinite(n.hull));assert.ok(Number.isFinite(n.battle.enemy.hull));assert.ok(n.battle.energy>=0) + } +}) +test('shop upgrade caps, stock, enhancements, and deck minimum are enforced',()=>{ + let g=newGame();g.phase='station';g.shop=['lance'];g.credits=1000 + g=reducer(g,{type:'buy',item:'lance'});assert.equal(g.deck.at(-1).key,'lance');assert.equal(g.credits,950) + assert.equal(reducer(g,{type:'buy',item:'lance'}),g) + for(let i=0;i<2;i++)g=reducer(g,{type:'buy',item:'reactor'}) + assert.equal(g.upgrades.reactor,2);assert.equal(reducer(g,{type:'buy',item:'reactor'}),g) + const uid=g.deck[0].uid;g=reducer(g,{type:'upgradeCard',uid});assert.equal(g.deck[0].rank,1);assert.equal(reducer(g,{type:'upgradeCard',uid}),g) + g.deck=g.deck.slice(0,5);assert.equal(reducer(g,{type:'removeCard',uid}),g) +}) +test('event affordability prevents negative resources; every event has an escape',()=>{ + for(let event=0;eventreducer(g,{type:'choice',index})) + assert.ok(results.some(n=>n.phase==='map'),`event ${event} stranded the player`) + for(const n of results){assert.ok(n.hull>0);assert.ok(n.fuel>=0);assert.ok(n.credits>=0)} + } +}) +test('death and final victory are terminal; guardian gates restore resources',()=>{ + let g=combat();g.hull=1;g.battle.shield=0;g.battle.enemy.pattern=[{kind:'attack',value:50}] + g=reducer(g,{type:'endTurn'});assert.equal(g.phase,'lost');assert.equal(g.hull,0);assert.equal(reducer(g,{type:'crew',id:'ada'}),g) + const bossReward=()=>{const g=combat();g.current='boss';g.nodes.find(n=>n.id==='boss').visited=true;g.battle.enemy.hull=1;return reducer(g,{type:'crew',id:'ren'})} + const first=bossReward(),next=reducer(first,{type:'claim',key:null});assert.equal(next.sector,1);assert.equal(next.current,'start');assert.equal(next.fuel,first.fuel+6);assert.equal(next.battle,null) + const last=bossReward();last.sector=2;const won=reducer(last,{type:'claim',key:last.reward.cards[0]});assert.equal(won.phase,'won');assert.ok(score(won)>=1500);assert.equal(reducer(won,{type:'jump',id:'1-1'}),won) +}) +test('save validation rejects corrupted structures and resumes valid battles exactly',()=>{ + const g=combat();assert.deepEqual(parseSave(JSON.stringify(g)),g) + for(const raw of [null,'{','null','{}',JSON.stringify({...g,version:2}),JSON.stringify({...g,hull:-1}),JSON.stringify({...g,battle:{}}),JSON.stringify({...g,deck:[{key:'bad'}]}),JSON.stringify({...g,nodes:[]})])assert.equal(parseSave(raw),null) + const a=reducer(g,{type:'endTurn'}),b=reducer(parseSave(JSON.stringify(g)),{type:'endTurn'});assert.deepEqual(a,b) +}) +test('36 complete simulated campaigns stay valid and all ships can win',()=>{ + const wins={wayfarer:0,kestrel:0,bulwark:0};let actions=0 + for(const ship of Object.keys(wins))for(let i=0;i<12;i++){ + let g=newGame(`PILOT-${i}`,ship,i%2?'captain':'explorer'),steps=0 + while(!['won','lost'].includes(g.phase)&&steps++<800){ + const action=chooseAction(g);assert.ok(action);const before=g;g=reducer(g,action);assert.notEqual(g,before,JSON.stringify(action));assert.ok(parseSave(JSON.stringify(g)),`${ship} ${i}: invalid save in ${g.phase}`) + if(g.phase==='combat'){ + const all=[...g.battle.hand,...g.battle.draw,...g.battle.discard,...g.battle.exhaust];assert.equal(all.length,g.deck.length);assert.equal(new Set(all.map(c=>c.uid)).size,g.deck.length);assert.ok(currentIntent(g)) + } + actions++ + } + assert.ok(steps<800,'campaign did not terminate');assert.ok(['won','lost'].includes(g.phase));if(g.phase==='won')wins[ship]++ + } + for(const count of Object.values(wins))assert.ok(count>0) + console.log('Campaign simulation:',JSON.stringify({wins,actions})) +}) diff --git a/projects/voidwake-1aa9/tests/pilot.mjs b/projects/voidwake-1aa9/tests/pilot.mjs new file mode 100644 index 00000000..5e5336f2 --- /dev/null +++ b/projects/voidwake-1aa9/tests/pilot.mjs @@ -0,0 +1,49 @@ +import { CARDS, EVENTS } from '../src/content.ts' +import { currentIntent, reachable, reducer, upgradeCost } from '../src/game.ts' + +// A deliberately simple pilot exercises real legal actions without modifying game state. +export function chooseAction(g) { + if (g.phase === 'map') { + if (g.fuel === 0) return {type:'sos'} + const weight={boss:1,relay:9,station:g.credits>=45?10:3,salvage:g.fuel<5?12:6,event:5,battle:3,elite:2} + const nodes=g.nodes.filter(n=>reachable(g).includes(n.id)).sort((a,b)=>weight[b.kind]-weight[a.kind]) + return {type:'jump',id:nodes[0].id} + } + if (g.phase === 'event') { + const choices=EVENTS[g.event].choices.map((c,index)=>({c,index})).filter(({c})=>g.credits+(c.credits??0)>=0&&g.fuel+(c.fuel??0)>=0&&g.hull+(c.hull??0)>0) + const value=c=>(c.relic?40:0)+(c.card?15:0)+(c.hull??0)*2+(c.credits??0)*.4+(c.fuel??0)*5+(c.reputation??0)*3 + return {type:'choice',index:choices.sort((a,b)=>value(b.c)-value(a.c))[0].index} + } + if (g.phase === 'station') { + if(g.hull=20) return {type:'buy',item:'repair'} + for(const item of ['reactor','weapons','shields']) if(g.upgrades[item]<(item==='reactor'?2:3)&&g.credits>=upgradeCost(g,item))return{type:'buy',item} + if(g.fuel<4&&g.credits>=15)return{type:'buy',item:'fuel'} + const c=g.deck.find(c=>!c.rank&&['torpedo','pulse','railgun','siphon'].includes(c.key)) + if(c&&g.credits>=25)return{type:'upgradeCard',uid:c.uid} + return{type:'leave'} + } + if(g.phase==='reward') { + const rank=['railgun','siphon','broadside','echo','torpedo','lance','overclock','flare','barrage','vent','salvo','cloak','drone','fortify','scan'] + return{type:'claim',key:[...g.reward.cards].sort((a,b)=>rank.indexOf(a)-rank.indexOf(b))[0]} + } + if(g.phase==='combat') { + const b=g.battle, intent=currentIntent(g), damage=['attack','drain'].includes(intent.kind)?intent.value:0 + if(!b.crewUsed) { + if(b.enemy.hull<=15)return{type:'crew',id:'ren'} + if(damage-b.shield>12)return{type:'crew',id:'mara'} + if(g.hull<=g.maxHull-10)return{type:'crew',id:'ada'} + } + let best=null,bestScore=-Infinity + for(let index=0;indexb.energy)continue + const next=reducer(g,{type:'play',index});if(next.phase==='lost')continue + if(next.phase==='reward')return{type:'play',index} + const n=next.battle + const blocked=Math.min(damage,n.evade?damage:n.shield)-Math.min(damage,b.evade?damage:b.shield) + const gain=(b.enemy.hull-n.enemy.hull)*1.1+(next.hull-g.hull)*1.7+blocked*1.5+(n.hand.length-b.hand.length+1)*3+(n.energy-b.energy)*4+(n.enemy.burn-b.enemy.burn)*1.9+(n.enemy.vulnerable-b.enemy.vulnerable)*3 + if(gain>bestScore){bestScore=gain;best={type:'play',index}} + } + return best&&bestScore>0?best:{type:'endTurn'} + } + return null +} diff --git a/projects/voidwake-1aa9/tsconfig.app.json b/projects/voidwake-1aa9/tsconfig.app.json new file mode 100644 index 00000000..d6ca4e74 --- /dev/null +++ b/projects/voidwake-1aa9/tsconfig.app.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "es2023", + "lib": ["ES2023", "DOM"], + "module": "esnext", + "types": ["vite/client"], + "skipLibCheck": true, + "strict": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"] +} diff --git a/projects/voidwake-1aa9/tsconfig.json b/projects/voidwake-1aa9/tsconfig.json new file mode 100644 index 00000000..1ffef600 --- /dev/null +++ b/projects/voidwake-1aa9/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/projects/voidwake-1aa9/tsconfig.node.json b/projects/voidwake-1aa9/tsconfig.node.json new file mode 100644 index 00000000..a0267b23 --- /dev/null +++ b/projects/voidwake-1aa9/tsconfig.node.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "es2023", + "lib": ["ES2023"], + "module": "esnext", + "types": ["node"], + "skipLibCheck": true, + "strict": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["vite.config.ts"] +} diff --git a/projects/voidwake-1aa9/vite.config.ts b/projects/voidwake-1aa9/vite.config.ts new file mode 100644 index 00000000..50f5e243 --- /dev/null +++ b/projects/voidwake-1aa9/vite.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +// https://vite.dev/config/ +export default defineConfig({ + base: './', // required: relative base so the build works under /agent-experiments/projects// + plugins: [react()], +})