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 @@
+
+
+
{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.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.'}
{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()}
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
+}
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
+}
+
+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()],
+})