# Probability Trading Sim Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** A browser space-trading sim where unobserved NPC ships are probability clouds on a galaxy graph, collapsing to concrete neon ships when the player observes them. **Architecture:** The engine is a deterministic, event-driven simulation (every random outcome is a lazy lookup from a per-NPC seed — "hidden variables"), so events commit on schedule and consistency is automatic. The *probability* is the knowledge layer: clouds, stale prices, and intel are computed views of what the player could know. `src/sim/` is pure headless JS (no rendering imports, fully unit-tested); `src/render/` is PixiJS v8 with a bloom filter; `src/ui/` is a DOM sidebar. **Tech Stack:** Plain JavaScript (ES modules), Vite, Vitest, PixiJS v8, pixi-filters (AdvancedBloomFilter). **Spec:** `docs/superpowers/specs/2026-06-12-probability-trading-sim-design.md` **Conventions used throughout:** - Sim time unit = game **days** (float). `world.t` is current time. - All randomness goes through `rollFor(seedString, label)` — one deterministic uniform per (seed, label) pair. Never `Math.random()` or `Date.now()` inside `src/sim/`. - Run all tests with `npx vitest run`, dev server with `npm run dev`. --- ### Task 1: Project scaffold **Files:** - Create: `package.json`, `index.html`, `src/styles.css`, `src/main.js` (stub), `tests/smoke.test.js`, `.gitignore` - [ ] **Step 1: Create package.json** ```json { "name": "probability", "private": true, "version": "0.1.0", "type": "module", "scripts": { "dev": "vite", "build": "vite build", "test": "vitest run" }, "dependencies": { "pixi-filters": "^6.1.0", "pixi.js": "^8.6.0" }, "devDependencies": { "vite": "^6.0.0", "vitest": "^3.0.0" } } ``` - [ ] **Step 2: Install dependencies** Run: `npm install` Expected: completes without errors, `node_modules/` created. - [ ] **Step 3: Create .gitignore** ``` node_modules/ dist/ ``` - [ ] **Step 4: Create index.html** ```html Probability
``` - [ ] **Step 5: Create src/styles.css** ```css * { margin: 0; padding: 0; box-sizing: border-box; } html, body { height: 100%; background: #05070d; color: #bfe8ff; font: 13px/1.5 "Segoe UI", system-ui, sans-serif; } body { display: flex; } #stage { flex: 1; height: 100%; position: relative; } #panel { width: 320px; height: 100%; overflow-y: auto; padding: 12px; background: #0a0e18; border-left: 1px solid #1b2940; } #panel h2 { font-size: 13px; color: #69e6ff; text-transform: uppercase; letter-spacing: 1px; margin: 14px 0 6px; } #panel button { background: #11203a; color: #9fd8ff; border: 1px solid #28456e; border-radius: 3px; padding: 3px 9px; margin: 2px; cursor: pointer; font-size: 12px; } #panel button:hover { background: #1b3358; } #panel button.active { background: #0e4a5e; border-color: #2ec5e0; color: #d6f6ff; } #panel table { width: 100%; border-collapse: collapse; margin: 4px 0; } #panel td, #panel th { padding: 2px 4px; text-align: left; border-bottom: 1px solid #15233a; font-size: 12px; } #log { font-size: 11px; color: #6f93b8; max-height: 160px; overflow-y: auto; } .banner { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; font-size: 42px; color: #69e6ff; background: rgba(5,7,13,.75); z-index: 10; text-shadow: 0 0 24px #2ec5e0; } .muted { color: #5d7b9a; } ``` - [ ] **Step 6: Create stub src/main.js** ```js console.log('Probability — boot ok'); ``` - [ ] **Step 7: Create tests/smoke.test.js** ```js import { describe, it, expect } from 'vitest'; describe('toolchain', () => { it('runs tests', () => { expect(1 + 1).toBe(2); }); }); ``` - [ ] **Step 8: Verify test runner and dev server** Run: `npx vitest run` Expected: 1 passed. Run: `npm run dev -- --port 5173 &` then `curl -s http://localhost:5173 | grep -o 'Probability'` then kill the server. Expected: `Probability` - [ ] **Step 9: Commit** ```bash git add package.json package-lock.json .gitignore index.html src/styles.css src/main.js tests/smoke.test.js git commit -m "chore: scaffold Vite + Vitest + PixiJS project" ``` --- ### Task 2: Seeded RNG (`sim/rng.js`) Hidden-variable foundation: every outcome in the game is `rollFor(seed, label)` — deterministic, order-independent, lazily evaluated. **Files:** - Create: `src/sim/rng.js` - Test: `tests/rng.test.js` - [ ] **Step 1: Write the failing tests** ```js import { describe, it, expect } from 'vitest'; import { hash32, makeRng, rollFor } from '../src/sim/rng.js'; describe('rng', () => { it('hash32 is deterministic and spreads', () => { expect(hash32('abc')).toBe(hash32('abc')); expect(hash32('abc')).not.toBe(hash32('abd')); }); it('makeRng produces a deterministic sequence in [0,1)', () => { const a = makeRng(42), b = makeRng(42); for (let i = 0; i < 100; i++) { const v = a(); expect(v).toBe(b()); expect(v).toBeGreaterThanOrEqual(0); expect(v).toBeLessThan(1); } }); it('rollFor depends on seed and label, independently', () => { expect(rollFor('s1', 'x')).toBe(rollFor('s1', 'x')); expect(rollFor('s1', 'x')).not.toBe(rollFor('s1', 'y')); expect(rollFor('s1', 'x')).not.toBe(rollFor('s2', 'x')); }); it('rollFor is roughly uniform', () => { let sum = 0; for (let i = 0; i < 2000; i++) sum += rollFor('u', `l${i}`); expect(sum / 2000).toBeGreaterThan(0.45); expect(sum / 2000).toBeLessThan(0.55); }); }); ``` - [ ] **Step 2: Run tests to verify they fail** Run: `npx vitest run tests/rng.test.js` Expected: FAIL — cannot resolve `../src/sim/rng.js`. - [ ] **Step 3: Implement src/sim/rng.js** ```js // FNV-1a 32-bit string hash. export function hash32(str) { let h = 0x811c9dc5; for (let i = 0; i < str.length; i++) { h ^= str.charCodeAt(i); h = Math.imul(h, 0x01000193); } return h >>> 0; } // mulberry32 PRNG: returns function yielding uniforms in [0,1). export function makeRng(seed) { let a = seed >>> 0; return function () { a |= 0; a = (a + 0x6d2b79f5) | 0; let t = Math.imul(a ^ (a >>> 15), 1 | a); t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; return ((t ^ (t >>> 14)) >>> 0) / 4294967296; }; } // One deterministic uniform in [0,1) for a (seed, label) pair. // This is the whole hidden-variable mechanism: outcomes are fixed // the moment their label exists, revealed whenever first asked for. export function rollFor(seed, label) { return makeRng(hash32(seed + ':' + label))(); } ``` - [ ] **Step 4: Run tests to verify they pass** Run: `npx vitest run tests/rng.test.js` Expected: 4 passed. - [ ] **Step 5: Commit** ```bash git add src/sim/rng.js tests/rng.test.js git commit -m "feat: seeded RNG with per-label deterministic rolls" ``` --- ### Task 3: Constants and economy (`sim/constants.js`, `sim/economy.js`) Prices fall as stock rises; planets produce/consume lazily (no per-tick cost). **Files:** - Create: `src/sim/constants.js`, `src/sim/economy.js` - Test: `tests/economy.test.js` - [ ] **Step 1: Create src/sim/constants.js** (no test — pure data) ```js export const GOODS = ['ore', 'food', 'fuel', 'tech', 'meds', 'luxury']; export const BASE_PRICE = { ore: 10, food: 8, fuel: 12, tech: 40, meds: 30, luxury: 60 }; export const TYPICAL_STOCK = 120; // stock at which price == base price export const MAX_STOCK = 2000; export const NEWS_DAYS_PER_LANE = 1; // price news travels 1 lane per day export const SNAPSHOT_KEEP = 30; // days of price history kept per planet export const DOCK_DELAY = 0.25; // days an NPC idles between arrival and replanning export const INSYS_LEG_DAYS = 0.3; // planet<->planet or planet<->gate hop inside a system export const OBSERVE_INTERVAL = 0.1; // how often the player's system marks ships as seen export const VIS_BAND = 0.15; // fraction of a lane near a system that is "inside" it export const WIN_CREDITS = 100000; export const PLAYER_START_CREDITS = 1000; export const PLAYER_CAPACITY = 30; ``` - [ ] **Step 2: Write the failing tests** ```js import { describe, it, expect } from 'vitest'; import { priceOf, advancePlanet, marketView, applyTrade } from '../src/sim/economy.js'; import { BASE_PRICE, GOODS, TYPICAL_STOCK } from '../src/sim/constants.js'; function makePlanet() { return { id: 0, systemId: 0, name: 'Test Prime', produces: { food: 10 }, consumes: { fuel: 5 }, stock: { ore: 100, food: 100, fuel: 100, tech: 100, meds: 100, luxury: 100 }, stockTime: 0, }; } describe('economy', () => { it('price falls as stock rises, clamped to [0.25x, 4x] base', () => { expect(priceOf('ore', TYPICAL_STOCK)).toBeCloseTo(BASE_PRICE.ore, 1); expect(priceOf('ore', 10)).toBeGreaterThan(priceOf('ore', 500)); expect(priceOf('ore', 0)).toBe(BASE_PRICE.ore * 4); expect(priceOf('ore', 1e9)).toBe(BASE_PRICE.ore * 0.25); }); it('advancePlanet applies production/consumption over elapsed time', () => { const p = makePlanet(); advancePlanet(p, 10); expect(p.stock.food).toBeCloseTo(200, 5); // +10/day expect(p.stock.fuel).toBeCloseTo(50, 5); // -5/day expect(p.stock.ore).toBeCloseTo(100, 5); // untouched expect(p.stockTime).toBe(10); }); it('advancePlanet never goes negative and is idempotent', () => { const p = makePlanet(); advancePlanet(p, 100); expect(p.stock.fuel).toBe(0); const snapshot = { ...p.stock }; advancePlanet(p, 100); // same time again: no change expect(p.stock).toEqual(snapshot); }); it('applyTrade returns pre-trade price and mutates stock', () => { const p = makePlanet(); const price = applyTrade(p, 0, 'ore', -20); // a buyer removes 20 expect(price).toBeCloseTo(priceOf('ore', 100), 5); expect(p.stock.ore).toBe(80); applyTrade(p, 0, 'ore', 50); // a seller delivers 50 expect(p.stock.ore).toBe(130); }); it('marketView lists all goods with integer stock and price', () => { const p = makePlanet(); const view = marketView(p, 5); expect(view.map((r) => r.good)).toEqual(GOODS); for (const row of view) { expect(Number.isInteger(row.stock)).toBe(true); expect(row.price).toBeGreaterThan(0); } }); }); ``` - [ ] **Step 3: Run tests to verify they fail** Run: `npx vitest run tests/economy.test.js` Expected: FAIL — cannot resolve `../src/sim/economy.js`. - [ ] **Step 4: Implement src/sim/economy.js** ```js import { GOODS, BASE_PRICE, TYPICAL_STOCK, MAX_STOCK } from './constants.js'; // Deterministic price curve: scarce -> expensive. Clamped to [0.25x, 4x]. export function priceOf(good, stock) { const ratio = TYPICAL_STOCK / Math.max(stock, 1); const mult = Math.min(4, Math.max(0.25, ratio)); return Math.round(BASE_PRICE[good] * mult * 100) / 100; } // Lazily advance a planet's stocks to time t (no per-tick simulation). export function advancePlanet(planet, t) { const dt = t - planet.stockTime; if (dt <= 0) return; for (const g of GOODS) { const rate = (planet.produces[g] || 0) - (planet.consumes[g] || 0); planet.stock[g] = Math.min(MAX_STOCK, Math.max(0, planet.stock[g] + rate * dt)); } planet.stockTime = t; } // Execute a trade at the current price. deltaQty > 0 delivers goods, // deltaQty < 0 removes them. Returns the unit price the trade executed at. export function applyTrade(planet, t, good, deltaQty) { advancePlanet(planet, t); const price = priceOf(good, planet.stock[good]); planet.stock[good] = Math.min(MAX_STOCK, Math.max(0, planet.stock[good] + deltaQty)); return price; } export function marketView(planet, t) { advancePlanet(planet, t); return GOODS.map((g) => ({ good: g, stock: Math.floor(planet.stock[g]), price: priceOf(g, planet.stock[g]), })); } ``` - [ ] **Step 5: Run tests to verify they pass** Run: `npx vitest run tests/economy.test.js` Expected: 5 passed. - [ ] **Step 6: Commit** ```bash git add src/sim/constants.js src/sim/economy.js tests/economy.test.js git commit -m "feat: economy with lazy stock advancement and price curve" ``` --- ### Task 4: Event queue (`sim/events.js`) **Files:** - Create: `src/sim/events.js` - Test: `tests/events.test.js` - [ ] **Step 1: Write the failing tests** ```js import { describe, it, expect } from 'vitest'; import { EventQueue } from '../src/sim/events.js'; describe('EventQueue', () => { it('pops events in time order regardless of insertion order', () => { const q = new EventQueue(); q.push(5, 'c', {}); q.push(1, 'a', {}); q.push(3, 'b', {}); expect(q.pop().type).toBe('a'); expect(q.pop().type).toBe('b'); expect(q.pop().type).toBe('c'); expect(q.pop()).toBeNull(); }); it('breaks time ties by insertion order', () => { const q = new EventQueue(); q.push(2, 'first', {}); q.push(2, 'second', {}); expect(q.pop().type).toBe('first'); expect(q.pop().type).toBe('second'); }); it('peek does not remove; size tracks contents', () => { const q = new EventQueue(); q.push(1, 'a', { n: 7 }); expect(q.size).toBe(1); expect(q.peek().time).toBe(1); expect(q.size).toBe(1); expect(q.pop().data.n).toBe(7); expect(q.size).toBe(0); expect(q.peek()).toBeNull(); }); it('survives a large randomized load in order', () => { const q = new EventQueue(); const times = Array.from({ length: 500 }, (_, i) => (i * 7919) % 1000); for (const t of times) q.push(t, 'e', {}); let last = -1; while (q.size > 0) { const e = q.pop(); expect(e.time).toBeGreaterThanOrEqual(last); last = e.time; } }); }); ``` - [ ] **Step 2: Run tests to verify they fail** Run: `npx vitest run tests/events.test.js` Expected: FAIL — cannot resolve `../src/sim/events.js`. - [ ] **Step 3: Implement src/sim/events.js** ```js // Binary min-heap keyed by (time, insertion sequence). export class EventQueue { constructor() { this.heap = []; this.seq = 0; } get size() { return this.heap.length; } push(time, type, data) { const e = { time, seq: this.seq++, type, data }; const h = this.heap; h.push(e); let i = h.length - 1; while (i > 0) { const p = (i - 1) >> 1; if (!before(h[i], h[p])) break; [h[i], h[p]] = [h[p], h[i]]; i = p; } return e; } peek() { return this.heap[0] ?? null; } pop() { const h = this.heap; if (h.length === 0) return null; const top = h[0]; const last = h.pop(); if (h.length > 0) { h[0] = last; let i = 0; for (;;) { const l = 2 * i + 1, r = 2 * i + 2; let m = i; if (l < h.length && before(h[l], h[m])) m = l; if (r < h.length && before(h[r], h[m])) m = r; if (m === i) break; [h[i], h[m]] = [h[m], h[i]]; i = m; } } return top; } } function before(a, b) { return a.time < b.time || (a.time === b.time && a.seq < b.seq); } ``` - [ ] **Step 4: Run tests to verify they pass** Run: `npx vitest run tests/events.test.js` Expected: 4 passed. - [ ] **Step 5: Commit** ```bash git add src/sim/events.js tests/events.test.js git commit -m "feat: binary-heap event queue with stable tie-breaking" ``` --- ### Task 5: Galaxy generation (`sim/galaxy.js`) Seeded galaxy: systems placed with minimum spacing, lanes to nearest neighbors plus connectivity repair, planets with economy profiles, hop-distance matrix, Dijkstra routing. **Files:** - Create: `src/sim/galaxy.js` - Test: `tests/galaxy.test.js` - [ ] **Step 1: Write the failing tests** ```js import { describe, it, expect } from 'vitest'; import { generateGalaxy, hopMatrix, findRoute, otherEnd } from '../src/sim/galaxy.js'; import { GOODS } from '../src/sim/constants.js'; describe('galaxy', () => { const g = generateGalaxy('test-seed', { systemCount: 25 }); it('is deterministic for the same seed', () => { const g2 = generateGalaxy('test-seed', { systemCount: 25 }); expect(JSON.stringify(g2)).toBe(JSON.stringify(g)); const g3 = generateGalaxy('other-seed', { systemCount: 25 }); expect(JSON.stringify(g3)).not.toBe(JSON.stringify(g)); }); it('has the requested systems, each with 1-3 planets and at least one lane', () => { expect(g.systems).toHaveLength(25); for (const s of g.systems) { expect(s.planets.length).toBeGreaterThanOrEqual(1); expect(s.planets.length).toBeLessThanOrEqual(3); expect(s.lanes.length).toBeGreaterThanOrEqual(1); } }); it('is fully connected (every system reachable from system 0)', () => { const hops = hopMatrix(g); for (let i = 0; i < g.systems.length; i++) { expect(hops[0][i]).toBeLessThan(Infinity); } }); it('lanes have sane travel days and hazard, and back-reference systems', () => { for (const lane of g.lanes) { expect(lane.days).toBeGreaterThanOrEqual(1); expect(lane.days).toBeLessThanOrEqual(4); expect(lane.hazard).toBeGreaterThanOrEqual(0); expect(lane.hazard).toBeLessThanOrEqual(0.7); expect(g.systems[lane.a].lanes).toContain(lane.id); expect(g.systems[lane.b].lanes).toContain(lane.id); } }); it('planets have profiles, stocks for every good, and an orbit for rendering', () => { for (const p of g.planets) { expect(Object.keys(p.produces).length).toBeGreaterThan(0); expect(Object.keys(p.consumes).length).toBeGreaterThan(0); for (const good of GOODS) expect(p.stock[good]).toBeGreaterThanOrEqual(0); expect(p.stockTime).toBe(0); expect(p.orbit.r).toBeGreaterThan(0); } }); it('findRoute returns a connected lane path with summed days', () => { const from = 0, to = g.systems.length - 1; const route = findRoute(g, from, to); expect(route).not.toBeNull(); let sys = from, days = 0; for (const laneId of route.lanes) { const lane = g.lanes[laneId]; expect([lane.a, lane.b]).toContain(sys); sys = otherEnd(lane, sys); days += lane.days; } expect(sys).toBe(to); expect(route.days).toBeCloseTo(days, 5); expect(findRoute(g, 3, 3)).toEqual({ lanes: [], days: 0 }); }); }); ``` - [ ] **Step 2: Run tests to verify they fail** Run: `npx vitest run tests/galaxy.test.js` Expected: FAIL — cannot resolve `../src/sim/galaxy.js`. - [ ] **Step 3: Implement src/sim/galaxy.js** ```js import { makeRng, hash32 } from './rng.js'; const SYSTEM_NAMES = [ 'Arcadia', 'Boreas', 'Cygni', 'Drakon', 'Eridu', 'Fenrir', 'Gorgon', 'Helix', 'Icarus', 'Juno', 'Kepler', 'Lyra', 'Mira', 'Nyx', 'Orion', 'Pavo', 'Quasar', 'Rigel', 'Sirius', 'Talos', 'Umbra', 'Vega', 'Wraith', 'Xandar', 'Yara', 'Zenith', 'Altair', 'Bellatrix', 'Castor', 'Deneb', ]; const PLANET_SUFFIX = ['Prime', 'II', 'III']; const PROFILES = [ { kind: 'agri', produces: { food: 10, meds: 3 }, consumes: { fuel: 4, tech: 3 } }, { kind: 'mining', produces: { ore: 12, fuel: 6 }, consumes: { food: 6, meds: 2 } }, { kind: 'industrial', produces: { tech: 6 }, consumes: { ore: 8, fuel: 5, food: 5 } }, { kind: 'refinery', produces: { fuel: 10 }, consumes: { ore: 6, food: 4 } }, { kind: 'resort', produces: { luxury: 5 }, consumes: { food: 7, meds: 4, fuel: 3 } }, ]; const ALL_GOODS = ['ore', 'food', 'fuel', 'tech', 'meds', 'luxury']; export function otherEnd(lane, systemId) { return lane.a === systemId ? lane.b : lane.a; } export function generateGalaxy(seed, { systemCount = 25 } = {}) { const rng = makeRng(hash32('galaxy:' + seed)); // 1. Place systems with minimum spacing (relaxes if placement gets tight). const systems = []; let minDist = 95, attempts = 0; while (systems.length < systemCount) { const x = 60 + rng() * 880, y = 50 + rng() * 600; if (systems.every((s) => Math.hypot(s.x - x, s.y - y) > minDist)) { systems.push({ id: systems.length, name: SYSTEM_NAMES[systems.length % SYSTEM_NAMES.length], x, y, planets: [], lanes: [] }); } if (++attempts > 2000) { minDist *= 0.9; attempts = 0; } } // 2. Lanes: each system to its 2 nearest neighbors, deduplicated. const laneKeys = new Set(); const key = (a, b) => (a < b ? `${a}-${b}` : `${b}-${a}`); for (const s of systems) { const near = systems .filter((o) => o.id !== s.id) .sort((p, q) => Math.hypot(p.x - s.x, p.y - s.y) - Math.hypot(q.x - s.x, q.y - s.y)); laneKeys.add(key(s.id, near[0].id)); laneKeys.add(key(s.id, near[1].id)); } // 3. Connectivity repair: link closest pair across components until one component. const parent = systems.map((_, i) => i); const find = (i) => (parent[i] === i ? i : (parent[i] = find(parent[i]))); const union = (a, b) => { parent[find(a)] = find(b); }; for (const k of laneKeys) { const [a, b] = k.split('-').map(Number); union(a, b); } for (;;) { const roots = new Set(systems.map((s) => find(s.id))); if (roots.size === 1) break; let best = null; for (const s of systems) for (const o of systems) { if (find(s.id) === find(o.id)) continue; const d = Math.hypot(s.x - o.x, s.y - o.y); if (!best || d < best.d) best = { a: s.id, b: o.id, d }; } laneKeys.add(key(best.a, best.b)); union(best.a, best.b); } // 4. Materialize lanes with travel time and hazard. const lanes = [...laneKeys].sort().map((k, i) => { const [a, b] = k.split('-').map(Number); const dist = Math.hypot(systems[a].x - systems[b].x, systems[a].y - systems[b].y); const days = Math.min(4, Math.max(1, Math.round(dist / 80))); const hazard = rng() < 0.2 ? +(0.3 + rng() * 0.4).toFixed(3) : +(rng() * 0.15).toFixed(3); return { id: i, a, b, days, hazard }; }); for (const lane of lanes) { systems[lane.a].lanes.push(lane.id); systems[lane.b].lanes.push(lane.id); } // 5. Planets with economy profiles and a render orbit. const planets = []; for (const s of systems) { const n = 1 + Math.floor(rng() * 3); for (let i = 0; i < n; i++) { const profile = PROFILES[Math.floor(rng() * PROFILES.length)]; const stock = {}; for (const g of ALL_GOODS) { if (profile.produces[g]) stock[g] = Math.floor(150 + rng() * 150); else if (profile.consumes[g]) stock[g] = Math.floor(40 + rng() * 80); else stock[g] = Math.floor(80 + rng() * 70); } const planet = { id: planets.length, systemId: s.id, name: `${s.name} ${PLANET_SUFFIX[i]}`, kind: profile.kind, produces: { ...profile.produces }, consumes: { ...profile.consumes }, stock, stockTime: 0, orbit: { r: 90 + i * 55, a: rng() * Math.PI * 2 }, }; planets.push(planet); s.planets.push(planet.id); } } return { systems, lanes, planets }; } // All-pairs hop counts (BFS per system). hops[a][b] = lanes to cross. export function hopMatrix(galaxy) { const n = galaxy.systems.length; const m = Array.from({ length: n }, () => Array(n).fill(Infinity)); for (let src = 0; src < n; src++) { m[src][src] = 0; const queue = [src]; while (queue.length) { const cur = queue.shift(); for (const laneId of galaxy.systems[cur].lanes) { const next = otherEnd(galaxy.lanes[laneId], cur); if (m[src][next] === Infinity) { m[src][next] = m[src][cur] + 1; queue.push(next); } } } } return m; } // Dijkstra by travel days. Returns { lanes: [laneId...], days } or null. export function findRoute(galaxy, fromSys, toSys) { if (fromSys === toSys) return { lanes: [], days: 0 }; const dist = new Map([[fromSys, 0]]); const prevLane = new Map(); const visited = new Set(); for (;;) { let cur = null, best = Infinity; for (const [s, d] of dist) if (!visited.has(s) && d < best) { best = d; cur = s; } if (cur === null) return null; if (cur === toSys) break; visited.add(cur); for (const laneId of galaxy.systems[cur].lanes) { const lane = galaxy.lanes[laneId]; const next = otherEnd(lane, cur); const nd = best + lane.days; if (nd < (dist.get(next) ?? Infinity)) { dist.set(next, nd); prevLane.set(next, laneId); } } } const laneIds = []; let s = toSys; while (s !== fromSys) { const laneId = prevLane.get(s); laneIds.unshift(laneId); s = otherEnd(galaxy.lanes[laneId], s); } return { lanes: laneIds, days: dist.get(toSys) }; } ``` - [ ] **Step 4: Run tests to verify they pass** Run: `npx vitest run tests/galaxy.test.js` Expected: 6 passed. - [ ] **Step 5: Commit** ```bash git add src/sim/galaxy.js tests/galaxy.test.js git commit -m "feat: seeded galaxy generation with lanes, hazards, routing" ``` --- ### Task 6: News ledger (`sim/news.js`) Price snapshots recorded daily; knowledge of a planet from N lanes away is the snapshot from N days ago. The t=0 snapshot is universally known (everyone starts with a map). **Files:** - Create: `src/sim/news.js` - Test: `tests/news.test.js` - [ ] **Step 1: Write the failing tests** ```js import { describe, it, expect } from 'vitest'; import { NewsLedger } from '../src/sim/news.js'; import { generateGalaxy } from '../src/sim/galaxy.js'; import { priceOf } from '../src/sim/economy.js'; describe('NewsLedger', () => { it('records snapshots and serves them with lane delay', () => { const galaxy = generateGalaxy('news-test', { systemCount: 8 }); const news = new NewsLedger(); news.record(galaxy, 0); news.record(galaxy, 1); news.record(galaxy, 2); const pid = galaxy.planets[0].id; // 0 hops away at t=2: freshest snapshot (t=2) expect(news.knownPrices(pid, 0, 2).time).toBe(2); // 2 hops away at t=2: only the t=0 snapshot has reached us expect(news.knownPrices(pid, 2, 2).time).toBe(0); // far away early on: clamps to the universally-known t=0 snapshot expect(news.knownPrices(pid, 5, 1).time).toBe(0); }); it('snapshot prices match the live price curve at record time', () => { const galaxy = generateGalaxy('news-test', { systemCount: 8 }); const news = new NewsLedger(); news.record(galaxy, 0); const p = galaxy.planets[0]; const snap = news.knownPrices(p.id, 0, 0); expect(snap.prices.ore).toBeCloseTo(priceOf('ore', p.stock.ore), 5); }); it('trims history beyond SNAPSHOT_KEEP entries', () => { const galaxy = generateGalaxy('news-test', { systemCount: 8 }); const news = new NewsLedger(); for (let t = 0; t <= 50; t++) news.record(galaxy, t); // a 45-day-old snapshot is gone; we get the oldest retained one instead const snap = news.knownPrices(galaxy.planets[0].id, 45, 50); expect(snap.time).toBeGreaterThanOrEqual(50 - 30); }); }); ``` - [ ] **Step 2: Run tests to verify they fail** Run: `npx vitest run tests/news.test.js` Expected: FAIL — cannot resolve `../src/sim/news.js`. - [ ] **Step 3: Implement src/sim/news.js** ```js import { GOODS, NEWS_DAYS_PER_LANE, SNAPSHOT_KEEP } from './constants.js'; import { advancePlanet, priceOf } from './economy.js'; // Daily price snapshots per planet. Knowledge from `hops` lanes away // is the snapshot from hops * NEWS_DAYS_PER_LANE days ago. export class NewsLedger { constructor() { this.history = new Map(); // planetId -> [{time, prices}] } record(galaxy, t) { for (const planet of galaxy.planets) { advancePlanet(planet, t); const prices = Object.fromEntries(GOODS.map((g) => [g, priceOf(g, planet.stock[g])])); let arr = this.history.get(planet.id); if (!arr) this.history.set(planet.id, (arr = [])); arr.push({ time: t, prices }); if (arr.length > SNAPSHOT_KEEP) arr.shift(); } } // Latest snapshot of planetId that has had time to travel `hops` lanes by time t. // Clamped to t=0: the initial state is universally known. knownPrices(planetId, hops, t) { const arr = this.history.get(planetId); if (!arr || arr.length === 0) return null; const asOf = Math.max(0, t - hops * NEWS_DAYS_PER_LANE); let best = null; for (const snap of arr) { if (snap.time <= asOf) best = snap; else break; } return best ?? arr[0]; } } ``` - [ ] **Step 4: Run tests to verify they pass** Run: `npx vitest run tests/news.test.js` Expected: 3 passed. - [ ] **Step 5: Commit** ```bash git add src/sim/news.js tests/news.test.js git commit -m "feat: news ledger with lane-delayed price snapshots" ``` --- ### Task 7: NPC planner and itinerary (`sim/npc.js`) The heart of the hidden-variable engine. Planning scores arbitrage opportunities using *stale news*; `buildItinerary` resolves the entire journey (route timing jitter, hazard outcomes) from the NPC's seed at plan time — fixed-but-unknown until observed. **Files:** - Create: `src/sim/npc.js` - Test: `tests/npc.test.js` - [ ] **Step 1: Write the failing tests** ```js import { describe, it, expect } from 'vitest'; import { planNpc, buildItinerary, hazardProbs, shipsIn, JIT_MIN, JIT_SPAN, DELAY_MULT } from '../src/sim/npc.js'; import { generateGalaxy, hopMatrix } from '../src/sim/galaxy.js'; import { NewsLedger } from '../src/sim/news.js'; import { EventQueue } from '../src/sim/events.js'; function makeFixtureWorld(seed = 'npc-test') { const galaxy = generateGalaxy(seed, { systemCount: 8 }); const news = new NewsLedger(); news.record(galaxy, 0); return { seed, t: 0, galaxy, hops: hopMatrix(galaxy), news, queue: new EventQueue(), npcs: [], stats: { trades: 0, npcDestroyed: 0 } }; } function makeNpc(world, planetId = 0) { const npc = { id: 0, seed: 'w:test:npc:0', name: 'Tester', alive: true, credits: 5000, capacity: 30, cargo: null, planetId, systemId: world.galaxy.planets[planetId].systemId, state: 'docked', plan: null, planCount: 0, }; world.npcs.push(npc); return npc; } describe('npc planner', () => { it('plans a profitable trade when an obvious gradient exists', () => { const world = makeFixtureWorld(); const origin = world.galaxy.planets[0]; // A destination within 3 jumps that is starving for ore: const dest = world.galaxy.planets.find( (p) => p.id !== 0 && world.hops[origin.systemId][p.systemId] <= 3 ); origin.stock.ore = 800; // ore dirt cheap here dest.stock.ore = 5; // ore precious there world.news.record(world.galaxy, 0); // refresh news to reflect the gradient const npc = makeNpc(world); planNpc(world, npc); expect(npc.plan).not.toBeNull(); expect(npc.state).toBe('transit'); expect(npc.cargo.qty).toBeGreaterThanOrEqual(1); expect(npc.credits).toBeLessThan(5000); // paid for the cargo expect(npc.plan.arriveTime).toBeGreaterThan(world.t); expect(world.queue.size).toBe(1); // npc-arrive scheduled expect(world.queue.peek().type).toBe('npc-arrive'); }); it('is deterministic: identical fixture -> identical plan', () => { const mk = () => { const world = makeFixtureWorld(); world.galaxy.planets[0].stock.ore = 800; world.galaxy.planets[1].stock.ore = 5; world.news.record(world.galaxy, 0); const npc = makeNpc(world); planNpc(world, npc); return npc.plan; }; expect(JSON.stringify(mk())).toBe(JSON.stringify(mk())); }); it('schedules a retry instead of a plan when nothing is profitable', () => { const world = makeFixtureWorld(); const npc = makeNpc(world); npc.credits = 0; // cannot afford anything planNpc(world, npc); expect(npc.plan).toBeNull(); expect(world.queue.peek().type).toBe('npc-replan'); }); it('buildItinerary timing stays inside the analytic envelope', () => { const world = makeFixtureWorld(); const lane = world.galaxy.lanes[0]; for (let i = 0; i < 50; i++) { const npc = { seed: `env:${i}` }; const itin = buildItinerary(world.galaxy, npc, [lane.id], lane.a, 0, 'p1'); const leg = itin.legs[0]; expect(leg.arrive - leg.depart).toBeGreaterThanOrEqual(lane.days * JIT_MIN - 1e-9); expect(leg.arrive - leg.depart).toBeLessThanOrEqual(lane.days * (JIT_MIN + JIT_SPAN) * DELAY_MULT + 1e-9); } }); it('hazard outcomes over many seeds match hazardProbs statistically', () => { const world = makeFixtureWorld(); const lane = { ...world.galaxy.lanes[0], hazard: 0.5 }; world.galaxy.lanes[0] = lane; const probs = hazardProbs(0.5); let destroyed = 0, lost = 0; const N = 800; for (let i = 0; i < N; i++) { const itin = buildItinerary(world.galaxy, { seed: `hz:${i}` }, [lane.id], lane.a, 0, 'p1'); if (itin.finalOutcome === 'destroyed') destroyed++; if (itin.finalOutcome === 'cargoLost') lost++; } expect(destroyed / N).toBeGreaterThan(probs.destroyed * 0.5); expect(destroyed / N).toBeLessThan(probs.destroyed * 1.8); expect(lost / N).toBeGreaterThan(probs.cargoLost * 0.5); expect(lost / N).toBeLessThan(probs.cargoLost * 1.8); }); it('shipsIn reports docked ships and ships in the visibility band of a lane', () => { const world = makeFixtureWorld(); const npc = makeNpc(world); expect(shipsIn(world, npc.systemId).map((s) => s.npc.id)).toContain(0); // Put the npc in transit at 5% along a lane leaving its system. const laneId = world.galaxy.systems[npc.systemId].lanes[0]; const lane = world.galaxy.lanes[laneId]; const toSys = lane.a === npc.systemId ? lane.b : lane.a; npc.state = 'transit'; npc.plan = { id: 'p1', legs: [{ laneId, fromSys: npc.systemId, toSys, depart: 0, arrive: 10, outcome: 'safe' }], startTime: 0, arriveTime: 10, finalOutcome: 'ok', originPlanet: 0, destPlanet: 1, }; world.t = 0.5; // f = 0.05, inside departure band const seen = shipsIn(world, npc.systemId); expect(seen.some((s) => s.npc.id === 0 && s.kind === 'departing')).toBe(true); world.t = 5; // mid-lane: visible nowhere expect(shipsIn(world, npc.systemId)).toHaveLength(0); expect(shipsIn(world, toSys)).toHaveLength(0); world.t = 9.5; // f = 0.95: arriving band of the far system expect(shipsIn(world, toSys).some((s) => s.npc.id === 0 && s.kind === 'arriving')).toBe(true); }); }); ``` - [ ] **Step 2: Run tests to verify they fail** Run: `npx vitest run tests/npc.test.js` Expected: FAIL — cannot resolve `../src/sim/npc.js`. - [ ] **Step 3: Implement src/sim/npc.js** ```js import { GOODS, DOCK_DELAY, VIS_BAND } from './constants.js'; import { advancePlanet, priceOf, applyTrade } from './economy.js'; import { findRoute, otherEnd } from './galaxy.js'; import { rollFor } from './rng.js'; // Leg duration = lane.days * uniform(JIT_MIN, JIT_MIN+JIT_SPAN); delayed legs x DELAY_MULT. export const JIT_MIN = 0.85; export const JIT_SPAN = 0.3; export const DELAY_MULT = 1.6; const LOCAL_HOP_DAYS = 0.4; // same-system planet-to-planet trade run const MAX_JUMPS = 3; export function hazardProbs(h) { return { destroyed: h * 0.06, cargoLost: h * 0.2, delayed: h * 0.3 }; } // Resolve the whole journey from the NPC's seed: route timing and hazard // outcomes are fixed at plan time, revealed only when something observes them. export function buildItinerary(galaxy, npc, routeLanes, startSys, t0, planId) { let t = t0, sys = startSys, finalOutcome = 'ok'; const legs = []; for (let i = 0; i < routeLanes.length; i++) { const lane = galaxy.lanes[routeLanes[i]]; const toSys = otherEnd(lane, sys); let dur = lane.days * (JIT_MIN + JIT_SPAN * rollFor(npc.seed, `${planId}:leg${i}:jit`)); const p = hazardProbs(lane.hazard); const r = rollFor(npc.seed, `${planId}:leg${i}:hazard`); let outcome = 'safe'; if (r < p.destroyed) outcome = 'destroyed'; else if (r < p.destroyed + p.cargoLost) outcome = 'cargoLost'; else if (r < p.destroyed + p.cargoLost + p.delayed) { outcome = 'delayed'; dur *= DELAY_MULT; } legs.push({ laneId: lane.id, fromSys: sys, toSys, depart: t, arrive: t + dur, outcome }); t += dur; sys = toSys; if (outcome === 'destroyed') { finalOutcome = 'destroyed'; break; } if (outcome === 'cargoLost') finalOutcome = 'cargoLost'; } return { legs, finalOutcome, arriveTime: t }; } // Score arbitrage options using stale news, buy cargo, fix the itinerary, // and schedule the arrival event. Falls back to a retry if nothing pays. export function planNpc(world, npc) { const t = world.t; npc.planCount = (npc.planCount || 0) + 1; const planId = `p${npc.planCount}`; const origin = world.galaxy.planets[npc.planetId]; advancePlanet(origin, t); let best = null; for (const dest of world.galaxy.planets) { if (dest.id === origin.id) continue; const hops = world.hops[origin.systemId][dest.systemId]; if (hops > MAX_JUMPS) continue; const known = world.news.knownPrices(dest.id, hops, t); if (!known) continue; const route = hops === 0 ? { lanes: [], days: LOCAL_HOP_DAYS } : findRoute(world.galaxy, origin.systemId, dest.systemId); if (!route) continue; let survival = 1; for (const laneId of route.lanes) { const p = hazardProbs(world.galaxy.lanes[laneId].hazard); survival *= 1 - p.destroyed - p.cargoLost; } for (const good of GOODS) { const buyP = priceOf(good, origin.stock[good]); const sellP = known.prices[good]; if (sellP <= buyP * 1.15) continue; // demand a margin over price noise const qty = Math.min(npc.capacity, Math.floor(npc.credits / buyP), Math.floor(origin.stock[good] * 0.5)); if (qty < 1) continue; const profit = (sellP - buyP) * qty * survival; if (profit <= 0) continue; const days = Math.max(LOCAL_HOP_DAYS, route.days); const noise = 0.9 + 0.2 * rollFor(npc.seed, `${planId}:noise:${dest.id}:${good}`); const score = (profit / days) * noise; if (!best || score > best.score) best = { score, dest, good, qty, route }; } } if (!best) { npc.plan = null; npc.state = 'docked'; world.queue.push(t + 2, 'npc-replan', { npcId: npc.id }); return; } const price = applyTrade(origin, t, best.good, -best.qty); npc.credits = Math.round((npc.credits - price * best.qty) * 100) / 100; npc.cargo = { good: best.good, qty: best.qty }; const itin = best.route.lanes.length > 0 ? buildItinerary(world.galaxy, npc, best.route.lanes, origin.systemId, t, planId) : { legs: [], finalOutcome: 'ok', arriveTime: t + LOCAL_HOP_DAYS }; npc.plan = { id: planId, good: best.good, qty: best.qty, originPlanet: origin.id, destPlanet: best.dest.id, startTime: t, ...itin, }; npc.state = 'transit'; world.queue.push(itin.arriveTime, 'npc-arrive', { npcId: npc.id, planId }); } // Arrival: reveal the journey's outcome and commit its market effects. export function onNpcArrive(world, { npcId, planId }) { const npc = world.npcs[npcId]; if (!npc.alive || !npc.plan || npc.plan.id !== planId) return; const plan = npc.plan; if (plan.finalOutcome === 'destroyed') { npc.alive = false; npc.plan = null; world.stats.npcDestroyed++; return; } const dest = world.galaxy.planets[plan.destPlanet]; npc.planetId = dest.id; npc.systemId = dest.systemId; npc.state = 'docked'; if (plan.finalOutcome !== 'cargoLost' && npc.cargo) { const price = applyTrade(dest, world.t, npc.cargo.good, npc.cargo.qty); npc.credits = Math.round((npc.credits + price * npc.cargo.qty) * 100) / 100; world.stats.trades++; } npc.cargo = null; npc.plan = null; world.queue.push(world.t + DOCK_DELAY, 'npc-replan', { npcId }); } // Concrete ships present in a system right now: docked ones, local hops, // and ships within the VIS_BAND fraction of a lane adjacent to the system. export function shipsIn(world, systemId) { const out = []; for (const npc of world.npcs) { if (!npc.alive) continue; if (npc.state === 'docked') { if (npc.systemId === systemId) out.push({ npc, kind: 'docked', planetId: npc.planetId }); continue; } if (!npc.plan) continue; if (npc.plan.legs.length === 0) { // same-system hop between planets if (world.galaxy.planets[npc.plan.originPlanet].systemId === systemId) { const f = (world.t - npc.plan.startTime) / (npc.plan.arriveTime - npc.plan.startTime); out.push({ npc, kind: 'local-hop', progress: Math.min(1, Math.max(0, f)) }); } continue; } const leg = npc.plan.legs.find((l) => l.depart <= world.t && world.t < l.arrive); if (!leg) continue; const f = (world.t - leg.depart) / (leg.arrive - leg.depart); if (leg.fromSys === systemId && f < VIS_BAND) { out.push({ npc, kind: 'departing', laneId: leg.laneId, progress: f / VIS_BAND }); } else if (leg.toSys === systemId && f > 1 - VIS_BAND) { out.push({ npc, kind: 'arriving', laneId: leg.laneId, progress: (f - (1 - VIS_BAND)) / VIS_BAND }); } } return out; } ``` - [ ] **Step 4: Run tests to verify they pass** Run: `npx vitest run tests/npc.test.js` Expected: 6 passed. - [ ] **Step 5: Commit** ```bash git add src/sim/npc.js tests/npc.test.js git commit -m "feat: NPC planner with seeded branch itineraries and arrivals" ``` --- ### Task 8: World orchestrator (`sim/world.js`) Creates the world, runs the event loop, wires NPC lifecycle + daily snapshots + the observe heartbeat. After this task the galaxy *lives* headlessly. **Files:** - Create: `src/sim/world.js` - Test: `tests/world.test.js` - [ ] **Step 1: Write the failing tests** ```js import { describe, it, expect } from 'vitest'; import { createWorld, advance } from '../src/sim/world.js'; describe('world', () => { it('creates a populated world with player at a planet', () => { const w = createWorld('alpha', { systemCount: 12, npcCount: 30 }); expect(w.npcs).toHaveLength(30); expect(w.galaxy.systems).toHaveLength(12); expect(w.player.loc.kind).toBe('docked'); expect(w.player.credits).toBeGreaterThan(0); for (const npc of w.npcs) { expect(npc.alive).toBe(true); expect(w.galaxy.planets[npc.planetId]).toBeTruthy(); } }); it('advance processes events; NPCs actually trade', () => { const w = createWorld('alpha', { systemCount: 12, npcCount: 30 }); advance(w, 40); expect(w.t).toBe(40); expect(w.stats.trades).toBeGreaterThan(30); // every npc averages >1 completed trade }); it('is deterministic end-to-end', () => { const run = () => { const w = createWorld('alpha', { systemCount: 12, npcCount: 30 }); advance(w, 40); return w.npcs.map((n) => [n.credits, n.planetId, n.alive]); }; expect(JSON.stringify(run())).toBe(JSON.stringify(run())); }); it('different seeds diverge', () => { const w1 = createWorld('alpha', { systemCount: 12, npcCount: 30 }); const w2 = createWorld('beta', { systemCount: 12, npcCount: 30 }); advance(w1, 20); advance(w2, 20); expect(JSON.stringify(w1.npcs.map((n) => n.credits))) .not.toBe(JSON.stringify(w2.npcs.map((n) => n.credits))); }); it('advance is incremental: many small steps equal one big step', () => { const a = createWorld('alpha', { systemCount: 12, npcCount: 30 }); const b = createWorld('alpha', { systemCount: 12, npcCount: 30 }); advance(a, 20); for (let t = 0.5; t <= 20.0001; t += 0.5) advance(b, t); expect(JSON.stringify(b.npcs.map((n) => [n.credits, n.planetId]))) .toBe(JSON.stringify(a.npcs.map((n) => [n.credits, n.planetId]))); }); }); ``` - [ ] **Step 2: Run tests to verify they fail** Run: `npx vitest run tests/world.test.js` Expected: FAIL — cannot resolve `../src/sim/world.js`. - [ ] **Step 3: Implement src/sim/world.js** ```js import { OBSERVE_INTERVAL, PLAYER_START_CREDITS, PLAYER_CAPACITY } from './constants.js'; import { generateGalaxy, hopMatrix } from './galaxy.js'; import { NewsLedger } from './news.js'; import { EventQueue } from './events.js'; import { makeRng, hash32 } from './rng.js'; import { planNpc, onNpcArrive } from './npc.js'; import { onPlayerArrive } from './player.js'; import { markSeen } from './knowledge.js'; const NPC_FIRST_NAMES = ['Vask', 'Orla', 'Dex', 'Mira', 'Joss', 'Kade', 'Nyra', 'Tarn', 'Sela', 'Bram']; export function createWorld(seed, { systemCount = 25, npcCount = 120 } = {}) { const galaxy = generateGalaxy(seed, { systemCount }); const rng = makeRng(hash32('world:' + seed)); const news = new NewsLedger(); news.record(galaxy, 0); const npcs = []; for (let i = 0; i < npcCount; i++) { const planet = galaxy.planets[Math.floor(rng() * galaxy.planets.length)]; npcs.push({ id: i, seed: `w:${seed}:npc:${i}`, name: `${NPC_FIRST_NAMES[i % NPC_FIRST_NAMES.length]}-${i}`, alive: true, credits: Math.floor(1500 + rng() * 2000), capacity: 20 + Math.floor(rng() * 40), cargo: null, planetId: planet.id, systemId: planet.systemId, state: 'docked', plan: null, planCount: 0, }); } const startPlanet = galaxy.planets[0]; const world = { seed, t: 0, galaxy, hops: hopMatrix(galaxy), news, npcs, queue: new EventQueue(), stats: { trades: 0, npcDestroyed: 0 }, log: [], player: { credits: PLAYER_START_CREDITS, capacity: PLAYER_CAPACITY, cargo: {}, loc: { kind: 'docked', planetId: startPlanet.id }, intent: null, tripCount: 0, alive: true, won: false, priceBook: {}, seenNpcs: new Set(), }, }; // Stagger initial NPC planning across the first day. for (const npc of npcs) world.queue.push(rng(), 'npc-replan', { npcId: npc.id }); world.queue.push(1, 'snapshot', {}); world.queue.push(OBSERVE_INTERVAL, 'observe', {}); return world; } export function pushLog(world, msg) { world.log.push(`[day ${world.t.toFixed(1)}] ${msg}`); if (world.log.length > 50) world.log.shift(); } const HANDLERS = { 'npc-replan': (world, data) => planNpc(world, world.npcs[data.npcId]), 'npc-arrive': (world, data) => onNpcArrive(world, data), 'player-arrive': (world, data) => onPlayerArrive(world, data), snapshot: (world) => { world.news.record(world.galaxy, world.t); world.queue.push(world.t + 1, 'snapshot', {}); }, observe: (world) => { markSeen(world); world.queue.push(world.t + OBSERVE_INTERVAL, 'observe', {}); }, }; // Advance the world to absolute time toTime, processing all due events in order. export function advance(world, toTime) { while (world.queue.size > 0 && world.queue.peek().time <= toTime) { const ev = world.queue.pop(); world.t = ev.time; HANDLERS[ev.type](world, ev.data); } world.t = toTime; } ``` Note: `player.js` and `knowledge.js` don't exist yet. Create minimal stubs in this task so the world module loads; Tasks 9–10 replace them. Create `src/sim/player.js` (stub): ```js export function onPlayerArrive() {} export function playerSystem(world) { const loc = world.player.loc; if (loc.kind === 'docked') return world.galaxy.planets[loc.planetId].systemId; return loc.systemId ?? null; } ``` Create `src/sim/knowledge.js` (stub): ```js export function markSeen() {} ``` - [ ] **Step 4: Run tests to verify they pass** Run: `npx vitest run tests/world.test.js` Expected: 5 passed. (If `trades` comes out below threshold, raise NPC count in the test or check that planNpc finds gradients — planet profiles guarantee them.) - [ ] **Step 5: Run the whole suite** Run: `npx vitest run` Expected: all tests pass. - [ ] **Step 6: Commit** ```bash git add src/sim/world.js src/sim/player.js src/sim/knowledge.js tests/world.test.js git commit -m "feat: world orchestrator with living NPC economy" ``` --- ### Task 9: Player commands (`sim/player.js`) Movement (in-system legs, lane jumps with hazards), trading, win/lose. Replaces the Task 8 stub. **Files:** - Modify: `src/sim/player.js` (replace entirely) - Test: `tests/player.test.js` - [ ] **Step 1: Write the failing tests** ```js import { describe, it, expect } from 'vitest'; import { createWorld, advance } from '../src/sim/world.js'; import { goTo, buyGood, sellGood, playerSystem, cargoUsed } from '../src/sim/player.js'; import { INSYS_LEG_DAYS } from '../src/sim/constants.js'; function freshWorld() { return createWorld('player-test', { systemCount: 10, npcCount: 5 }); } describe('player', () => { it('buys and sells at the docked planet, conserving value through stock', () => { const w = freshWorld(); const planet = w.galaxy.planets[w.player.loc.planetId]; const before = w.player.credits; const stockBefore = Math.floor(planet.stock.food); expect(buyGood(w, 'food', 5)).toBe(true); expect(w.player.cargo.food).toBe(5); expect(w.player.credits).toBeLessThan(before); expect(Math.floor(planet.stock.food)).toBe(stockBefore - 5); expect(sellGood(w, 'food', 5)).toBe(true); expect(w.player.cargo.food ?? 0).toBe(0); }); it('rejects buys beyond capacity or credits', () => { const w = freshWorld(); expect(buyGood(w, 'food', 9999)).toBe(true); // clamps to capacity/credits expect(cargoUsed(w.player)).toBeLessThanOrEqual(w.player.capacity); const w2 = freshWorld(); w2.player.credits = 0; expect(buyGood(w2, 'food', 1)).toBe(false); }); it('travels to another planet in-system after INSYS_LEG_DAYS', () => { const w = freshWorld(); const sys = playerSystem(w); const other = w.galaxy.planets.find((p) => p.systemId === sys && p.id !== w.player.loc.planetId); if (!other) return; // single-planet start system on this seed: nothing to assert expect(goTo(w, { planetId: other.id })).toBe(true); expect(w.player.loc.kind).toBe('insys-leg'); advance(w, w.t + INSYS_LEG_DAYS + 0.01); expect(w.player.loc.kind).toBe('docked'); expect(w.player.loc.planetId).toBe(other.id); }); it('jumps to a neighboring system via gate and lane', () => { const w = freshWorld(); const sys = playerSystem(w); const laneId = w.galaxy.systems[sys].lanes[0]; const lane = w.galaxy.lanes[laneId]; const destSys = lane.a === sys ? lane.b : lane.a; expect(goTo(w, { laneId })).toBe(true); advance(w, w.t + INSYS_LEG_DAYS + lane.days * 1.01 + 0.01); expect(playerSystem(w)).toBe(destSys); expect(w.player.loc.kind).toBe('at-gate'); }); it('refuses movement while already moving, and trades while undocked', () => { const w = freshWorld(); const sys = playerSystem(w); const laneId = w.galaxy.systems[sys].lanes[0]; expect(goTo(w, { laneId })).toBe(true); expect(goTo(w, { laneId })).toBe(false); expect(buyGood(w, 'food', 1)).toBe(false); }); it('docking refreshes the player price book', () => { const w = freshWorld(); const sys = playerSystem(w); const other = w.galaxy.planets.find((p) => p.systemId === sys && p.id !== w.player.loc.planetId); if (!other) return; goTo(w, { planetId: other.id }); advance(w, w.t + INSYS_LEG_DAYS + 0.01); expect(w.player.priceBook[other.id].time).toBeCloseTo(w.t, 1); }); it('hazard rolls on player lanes are deterministic per world seed', () => { const run = () => { const w = freshWorld(); // brute-force the player across the most dangerous lane repeatedly const sys = playerSystem(w); const laneId = w.galaxy.systems[sys].lanes[0]; for (let i = 0; i < 5 && w.player.alive; i++) { const cur = playerSystem(w); const lane = w.galaxy.lanes[laneId]; if (lane.a !== cur && lane.b !== cur) break; goTo(w, { laneId }); advance(w, w.t + INSYS_LEG_DAYS + lane.days * 1.01 + 0.05); } return [w.player.alive, w.player.tripCount, Object.entries(w.player.cargo)]; }; expect(JSON.stringify(run())).toBe(JSON.stringify(run())); }); }); ``` - [ ] **Step 2: Run tests to verify they fail** Run: `npx vitest run tests/player.test.js` Expected: FAIL — `goTo` is not exported. - [ ] **Step 3: Replace src/sim/player.js** ```js import { INSYS_LEG_DAYS, WIN_CREDITS, GOODS } from './constants.js'; import { advancePlanet, priceOf, applyTrade, marketView } from './economy.js'; import { otherEnd } from './galaxy.js'; import { rollFor } from './rng.js'; import { pushLog } from './world.js'; export function playerSystem(world) { const loc = world.player.loc; if (loc.kind === 'docked') return world.galaxy.planets[loc.planetId].systemId; if (loc.kind === 'lane') { const f = (world.t - loc.departT) / (loc.arriveT - loc.departT); return f < 0.5 ? loc.fromSys : loc.toSys; // nearest end, for news purposes } return loc.systemId; } export function cargoUsed(player) { return Object.values(player.cargo).reduce((a, b) => a + b, 0); } function fromDescriptor(loc) { if (loc.kind === 'docked') return { type: 'planet', id: loc.planetId }; if (loc.kind === 'at-gate') return { type: 'gate', laneId: loc.laneId }; return { type: 'planet', id: 0 }; } // target: { planetId } to fly to a planet in the current system, // or { laneId } to jump through a lane touching the current system. export function goTo(world, target) { const p = world.player; if (!p.alive || p.won) return false; if (p.loc.kind === 'insys-leg' || p.loc.kind === 'lane') return false; const sys = playerSystem(world); if (target.planetId != null) { const planet = world.galaxy.planets[target.planetId]; if (!planet || planet.systemId !== sys) return false; if (p.loc.kind === 'docked' && p.loc.planetId === target.planetId) return false; p.loc = { kind: 'insys-leg', systemId: sys, from: fromDescriptor(p.loc), to: { type: 'planet', id: target.planetId }, departT: world.t, arriveT: world.t + INSYS_LEG_DAYS, }; world.queue.push(p.loc.arriveT, 'player-arrive', {}); return true; } if (target.laneId != null) { const lane = world.galaxy.lanes[target.laneId]; if (!lane || (lane.a !== sys && lane.b !== sys)) return false; p.intent = { laneId: target.laneId }; if (p.loc.kind === 'at-gate' && p.loc.laneId === target.laneId) { startLane(world); return true; } p.loc = { kind: 'insys-leg', systemId: sys, from: fromDescriptor(p.loc), to: { type: 'gate', laneId: target.laneId }, departT: world.t, arriveT: world.t + INSYS_LEG_DAYS, }; world.queue.push(p.loc.arriveT, 'player-arrive', {}); return true; } return false; } function startLane(world) { const p = world.player; const lane = world.galaxy.lanes[p.intent.laneId]; const fromSys = playerSystem(world); p.loc = { kind: 'lane', laneId: lane.id, fromSys, toSys: otherEnd(lane, fromSys), departT: world.t, arriveT: world.t + lane.days, }; p.intent = null; world.queue.push(p.loc.arriveT, 'player-arrive', {}); pushLog(world, `Jumping to ${world.galaxy.systems[p.loc.toSys].name} (${lane.days}d)`); } export function onPlayerArrive(world) { const p = world.player; const loc = p.loc; if (loc.kind === 'insys-leg' && world.t >= loc.arriveT - 1e-9) { if (loc.to.type === 'planet') { p.loc = { kind: 'docked', planetId: loc.to.id }; recordDockIntel(world, loc.to.id); pushLog(world, `Docked at ${world.galaxy.planets[loc.to.id].name}`); } else { p.loc = { kind: 'at-gate', systemId: loc.systemId, laneId: loc.to.laneId }; if (p.intent && p.intent.laneId === loc.to.laneId) startLane(world); } return; } if (loc.kind === 'lane' && world.t >= loc.arriveT - 1e-9) { const lane = world.galaxy.lanes[loc.laneId]; const trip = ++p.tripCount; const r = rollFor(`w:${world.seed}:player`, `trip${trip}`); if (r < lane.hazard * 0.04) { p.alive = false; pushLog(world, 'Your ship was destroyed by pirates.'); return; } if (r < lane.hazard * 0.04 + lane.hazard * 0.15 && cargoUsed(p) > 0) { p.cargo = {}; pushLog(world, 'Pirates! You jettisoned your cargo and escaped.'); } p.loc = { kind: 'at-gate', systemId: loc.toSys, laneId: loc.laneId }; pushLog(world, `Arrived in ${world.galaxy.systems[loc.toSys].name}`); } } function recordDockIntel(world, planetId) { const planet = world.galaxy.planets[planetId]; advancePlanet(planet, world.t); const prices = Object.fromEntries(GOODS.map((g) => [g, priceOf(g, planet.stock[g])])); world.player.priceBook[planetId] = { time: world.t, prices }; } export function buyGood(world, good, qty) { const p = world.player; if (p.loc.kind !== 'docked' || !p.alive) return false; const planet = world.galaxy.planets[p.loc.planetId]; advancePlanet(planet, world.t); const price = priceOf(good, planet.stock[good]); qty = Math.min(qty, Math.floor(planet.stock[good]), Math.floor(p.credits / price), p.capacity - cargoUsed(p)); if (qty < 1) return false; applyTrade(planet, world.t, good, -qty); p.credits = Math.round((p.credits - price * qty) * 100) / 100; p.cargo[good] = (p.cargo[good] || 0) + qty; recordDockIntel(world, planet.id); return true; } export function sellGood(world, good, qty) { const p = world.player; if (p.loc.kind !== 'docked' || !p.alive) return false; qty = Math.min(qty, p.cargo[good] || 0); if (qty < 1) return false; const planet = world.galaxy.planets[p.loc.planetId]; const price = applyTrade(planet, world.t, good, qty); p.credits = Math.round((p.credits + price * qty) * 100) / 100; p.cargo[good] -= qty; if (p.cargo[good] === 0) delete p.cargo[good]; recordDockIntel(world, planet.id); if (p.credits >= WIN_CREDITS && !p.won) { p.won = true; pushLog(world, `You amassed ${p.credits} credits. Victory!`); } return true; } export function playerMarket(world) { const p = world.player; if (p.loc.kind !== 'docked') return null; return marketView(world.galaxy.planets[p.loc.planetId], world.t); } ``` - [ ] **Step 4: Run tests to verify they pass** Run: `npx vitest run tests/player.test.js` Expected: 7 passed. - [ ] **Step 5: Run the whole suite (world tests must still pass)** Run: `npx vitest run` Expected: all pass. - [ ] **Step 6: Commit** ```bash git add src/sim/player.js tests/player.test.js git commit -m "feat: player movement, trading, lane hazards, win/lose" ``` --- ### Task 10: Knowledge layer (`sim/knowledge.js`) What the player *knows*: best-available price intel (dock visits vs news) and which NPCs have ever been personally observed. Replaces the Task 8 stub. **Files:** - Modify: `src/sim/knowledge.js` (replace entirely) - Test: `tests/knowledge.test.js` - [ ] **Step 1: Write the failing tests** ```js import { describe, it, expect } from 'vitest'; import { createWorld, advance } from '../src/sim/world.js'; import { playerKnownPrices } from '../src/sim/knowledge.js'; import { playerSystem } from '../src/sim/player.js'; describe('knowledge', () => { it('knows every planet at game start via the day-0 snapshot', () => { const w = createWorld('know-test', { systemCount: 10, npcCount: 5 }); for (const planet of w.galaxy.planets) { const known = playerKnownPrices(w, planet.id); expect(known).not.toBeNull(); expect(known.time).toBe(0); expect(known.ageDays).toBe(0); } }); it('intel ages and refreshes as news travels', () => { const w = createWorld('know-test', { systemCount: 10, npcCount: 5 }); const sys = playerSystem(w); const far = w.galaxy.planets.find((p) => w.hops[sys][p.systemId] >= 2); advance(w, 5); const known = playerKnownPrices(w, far.id); const hops = w.hops[sys][far.systemId]; // freshest possible news is `hops` days old expect(known.ageDays).toBeGreaterThanOrEqual(hops - 1e-9); expect(known.ageDays).toBeLessThanOrEqual(hops + 1 + 1e-9); }); it('local planet intel is near-live', () => { const w = createWorld('know-test', { systemCount: 10, npcCount: 5 }); advance(w, 5); const local = w.galaxy.planets.find((p) => p.systemId === playerSystem(w)); expect(playerKnownPrices(w, local.id).ageDays).toBeLessThanOrEqual(1); }); it('the observe heartbeat marks co-located NPCs as seen', () => { const w = createWorld('know-test', { systemCount: 10, npcCount: 40 }); advance(w, 10); // with 40 NPCs over 10 days, some must have passed through the player's system expect(w.player.seenNpcs.size).toBeGreaterThan(0); }); }); ``` - [ ] **Step 2: Run tests to verify they fail** Run: `npx vitest run tests/knowledge.test.js` Expected: FAIL — `playerKnownPrices` is not exported. - [ ] **Step 3: Replace src/sim/knowledge.js** ```js import { playerSystem } from './player.js'; import { shipsIn } from './npc.js'; // Best price intel the player has for a planet: personal dock visit // vs. news that has reached the player's current location. Caches the // winner in the price book so intel never gets worse. export function playerKnownPrices(world, planetId) { const p = world.player; const sys = playerSystem(world); if (sys == null) return p.priceBook[planetId] ?? null; const planet = world.galaxy.planets[planetId]; const news = world.news.knownPrices(planetId, world.hops[sys][planet.systemId], world.t); const book = p.priceBook[planetId] ?? null; let best = book; if (news && (!best || news.time > best.time)) best = news; if (best && (!book || best.time > book.time)) p.priceBook[planetId] = best; return best ? { time: best.time, prices: best.prices, ageDays: world.t - best.time } : null; } // Called by the world's observe heartbeat: every concrete ship sharing // the player's system becomes "seen" — its cloud is now player-visible forever. export function markSeen(world) { if (!world.player.alive) return; const sys = playerSystem(world); if (sys == null) return; for (const { npc } of shipsIn(world, sys)) world.player.seenNpcs.add(npc.id); } ``` - [ ] **Step 4: Run tests to verify they pass** Run: `npx vitest run tests/knowledge.test.js` Expected: 4 passed. - [ ] **Step 5: Run the whole suite** Run: `npx vitest run` Expected: all pass. - [ ] **Step 6: Commit** ```bash git add src/sim/knowledge.js tests/knowledge.test.js git commit -m "feat: player knowledge layer - aging price intel and seen ships" ``` --- ### Task 11: Probability clouds (`sim/clouds.js`) The player-facing wave function: weighted lane segments for where a seen, in-transit NPC *could* be, computed from the plan's analytic envelope (never from the hidden rolls). Includes exclusion of the player's visible band ("it's not here, so it's elsewhere") with renormalization. **Files:** - Create: `src/sim/clouds.js` - Test: `tests/clouds.test.js` - [ ] **Step 1: Write the failing tests** ```js import { describe, it, expect } from 'vitest'; import { cloudFor, excludeVisibleBand, totalMass } from '../src/sim/clouds.js'; // Synthetic 3-lane chain: systems 0-1-2-3, each lane 2 days. function chainGalaxy() { return { lanes: [ { id: 0, a: 0, b: 1, days: 2, hazard: 0.1 }, { id: 1, a: 1, b: 2, days: 2, hazard: 0.1 }, { id: 2, a: 2, b: 3, days: 2, hazard: 0.1 }, ], }; } function chainPlan() { return { id: 'p1', startTime: 0, arriveTime: 6, finalOutcome: 'ok', originPlanet: 0, destPlanet: 1, legs: [ { laneId: 0, fromSys: 0, toSys: 1, depart: 0, arrive: 2, outcome: 'safe' }, { laneId: 1, fromSys: 1, toSys: 2, depart: 2, arrive: 4, outcome: 'safe' }, { laneId: 2, fromSys: 2, toSys: 3, depart: 4, arrive: 6, outcome: 'safe' }, ], }; } const trackLength = (segs, galaxy) => segs.reduce((sum, s) => sum + (s.f1 - s.f0) * galaxy.lanes[s.laneId].days, 0); describe('clouds', () => { it('total mass is 1 while the journey is plausibly in progress', () => { const g = chainGalaxy(); const plan = chainPlan(); for (const t of [0.5, 1.7, 3, 4.5]) { const segs = cloudFor(g, plan, t); expect(segs.length).toBeGreaterThan(0); expect(totalMass(segs)).toBeCloseTo(1, 5); for (const s of segs) { expect(s.f0).toBeGreaterThanOrEqual(0); expect(s.f1).toBeLessThanOrEqual(1); expect(s.f1).toBeGreaterThan(s.f0); } } }); it('uncertainty grows with elapsed legs (cloud covers more track later)', () => { const g = chainGalaxy(); const plan = chainPlan(); const early = trackLength(cloudFor(g, plan, 1.0), g); const late = trackLength(cloudFor(g, plan, 4.2), g); expect(late).toBeGreaterThan(early); }); it('mid-journey, mass spans more than one lane', () => { const g = chainGalaxy(); const segs = cloudFor(g, chainPlan(), 3.5); expect(new Set(segs.map((s) => s.laneId)).size).toBeGreaterThanOrEqual(2); }); it('excludeVisibleBand removes the observed band and renormalizes', () => { const segs = [ { laneId: 0, fromSys: 0, toSys: 1, f0: 0.0, f1: 0.5, w: 0.5 }, { laneId: 0, fromSys: 0, toSys: 1, f0: 0.5, f1: 1.0, w: 0.5 }, ]; // Player sits in system 1: the band f > 0.85 on this lane is visible. const out = excludeVisibleBand({ lanes: [{ id: 0, a: 0, b: 1, days: 2 }] }, segs, 1); expect(totalMass(out)).toBeCloseTo(1, 5); for (const s of out) expect(s.f1).toBeLessThanOrEqual(0.85 + 1e-9); }); it('returns empty for finished or empty plans', () => { const g = chainGalaxy(); expect(cloudFor(g, chainPlan(), 99)).toEqual([]); expect(cloudFor(g, { ...chainPlan(), legs: [] }, 1)).toEqual([]); }); }); ``` - [ ] **Step 2: Run tests to verify they fail** Run: `npx vitest run tests/clouds.test.js` Expected: FAIL — cannot resolve `../src/sim/clouds.js`. - [ ] **Step 3: Implement src/sim/clouds.js** ```js import { VIS_BAND } from './constants.js'; import { JIT_MIN, JIT_SPAN, DELAY_MULT, hazardProbs } from './npc.js'; export function totalMass(segs) { return segs.reduce((sum, s) => sum + s.w, 0); } // The cloud is computed from the ANALYTIC envelope of the plan — possible // durations per leg — never from the hidden seed rolls. For each leg we // compute the feasible progress interval [f0, f1] at time t: // f1: progress if everything so far ran fastest (earliest entry, fastest leg) // f0: progress if everything ran slowest (latest entry, slowest leg) // Weight ~ feasible track-days on that leg x survival probability, normalized. export function cloudFor(galaxy, plan, t) { if (!plan || plan.legs.length === 0) return []; const e = t - plan.startTime; if (e <= 0) return []; let minStart = 0, maxStart = 0, survival = 1; const raw = []; for (const leg of plan.legs) { const lane = galaxy.lanes[leg.laneId]; const minD = lane.days * JIT_MIN; const maxD = lane.days * (JIT_MIN + JIT_SPAN) * DELAY_MULT; // fastest possible progress: entered at minStart, moving at max speed const f1 = (e - minStart) / minD; // slowest possible progress: entered at maxStart, moving at min speed const f0 = (e - maxStart) / maxD; const cf0 = Math.min(1, Math.max(0, f0)); const cf1 = Math.min(1, Math.max(0, f1)); if (cf1 > cf0) { raw.push({ laneId: leg.laneId, fromSys: leg.fromSys, toSys: leg.toSys, f0: cf0, f1: cf1, w: (cf1 - cf0) * lane.days * survival }); } const p = hazardProbs(lane.hazard); survival *= 1 - p.destroyed; minStart += minD; maxStart += maxD; } return normalize(raw); } // Player observes the VIS_BAND ends of every lane touching their system. // A ship not concretely visible there cannot be there: cut those intervals // out of the cloud and renormalize the rest. export function excludeVisibleBand(galaxy, segs, observedSystemId) { const out = []; for (const seg of segs) { const lane = galaxy.lanes[seg.laneId]; if (lane.a !== observedSystemId && lane.b !== observedSystemId) { out.push({ ...seg }); continue; } // Band near the observed system, in this segment's fromSys->toSys orientation. const nearFrom = seg.fromSys === observedSystemId; const bandLo = nearFrom ? 0 : 1 - VIS_BAND; const bandHi = nearFrom ? VIS_BAND : 1; const den = seg.f1 - seg.f0; // keep the part below the band if (seg.f0 < bandLo) { const f1 = Math.min(seg.f1, bandLo); out.push({ ...seg, f1, w: seg.w * ((f1 - seg.f0) / den) }); } // keep the part above the band if (seg.f1 > bandHi) { const f0 = Math.max(seg.f0, bandHi); out.push({ ...seg, f0, w: seg.w * ((seg.f1 - f0) / den) }); } } return normalize(out); } function normalize(segs) { const m = totalMass(segs); if (m <= 0) return []; return segs.map((s) => ({ ...s, w: s.w / m })); } // All player-visible clouds: seen, alive, in-transit NPCs on multi-system runs. export function playerClouds(world, observedSystemId) { const out = []; for (const npcId of world.player.seenNpcs) { const npc = world.npcs[npcId]; if (!npc.alive || npc.state !== 'transit' || !npc.plan) continue; let segs = cloudFor(world.galaxy, npc.plan, world.t); if (observedSystemId != null) segs = excludeVisibleBand(world.galaxy, segs, observedSystemId); if (segs.length > 0) out.push({ npcId, name: npc.name, segs }); } return out; } ``` - [ ] **Step 4: Run tests to verify they pass** Run: `npx vitest run tests/clouds.test.js` Expected: 5 passed. - [ ] **Step 5: Run the whole suite** Run: `npx vitest run` Expected: all pass. - [ ] **Step 6: Commit** ```bash git add src/sim/clouds.js tests/clouds.test.js git commit -m "feat: probability clouds with envelope math and band exclusion" ``` --- ### Task 12: Pixi app and galaxy view (`render/app.js`, `render/galaxyView.js`) The neon canvas: bloom-filtered world layer, label layer without bloom. Galaxy view draws the static graph plus dynamic player marker and clouds. Rendering tasks are verified visually, not unit-tested. **Files:** - Create: `src/render/app.js`, `src/render/galaxyView.js` - Modify: `src/main.js` - [ ] **Step 1: Implement src/render/app.js** ```js import { Application, Container } from 'pixi.js'; import { AdvancedBloomFilter } from 'pixi-filters/advanced-bloom'; export async function createRenderer(stageEl) { const app = new Application(); await app.init({ resizeTo: stageEl, background: '#05070d', antialias: true }); stageEl.appendChild(app.canvas); // Bloomed world geometry; crisp labels above it, outside the filter. const world = new Container(); world.filters = [new AdvancedBloomFilter({ threshold: 0.18, bloomScale: 1.4, blur: 6, quality: 4 })]; const labels = new Container(); app.stage.addChild(world, labels); return { app, world, labels }; } // Map sim hazard [0, 0.7] to a cyan->red neon tint. export function hazardColor(h) { const t = Math.min(1, h / 0.7); const r = Math.round(0 + t * 255); const g = Math.round(229 - t * 170); const b = Math.round(255 - t * 175); return (r << 16) | (g << 8) | b; } // Fit sim coords (1000x700 layout space) into the canvas with padding. export function makeViewTransform(app) { const w = app.renderer.width, h = app.renderer.height; const scale = Math.min(w / 1060, h / 740); const ox = (w - 1000 * scale) / 2, oy = (h - 700 * scale) / 2; return { x: (sx) => ox + sx * scale, y: (sy) => oy + sy * scale, scale }; } ``` - [ ] **Step 2: Implement src/render/galaxyView.js** ```js import { Container, Graphics, Text } from 'pixi.js'; import { hazardColor, makeViewTransform } from './app.js'; import { playerClouds } from '../sim/clouds.js'; import { playerSystem } from '../sim/player.js'; import { otherEnd } from '../sim/galaxy.js'; export class GalaxyView { // onSelectSystem(systemId) -> UI intel panel; onJump(laneId) -> goTo lane constructor(renderer, world, { onSelectSystem, onJump }) { this.renderer = renderer; this.world = world; this.onSelectSystem = onSelectSystem; this.onJump = onJump; this.container = new Container(); this.labelContainer = new Container(); renderer.world.addChild(this.container); renderer.labels.addChild(this.labelContainer); this.staticG = new Graphics(); this.cloudG = new Graphics(); this.dynamicG = new Graphics(); this.container.addChild(this.staticG, this.cloudG, this.dynamicG); this.hitNodes = new Container(); this.container.addChild(this.hitNodes); this.buildStatic(); } buildStatic() { const { galaxy } = this.world; const v = makeViewTransform(this.renderer.app); this.v = v; const g = this.staticG; g.clear(); for (const lane of galaxy.lanes) { const a = galaxy.systems[lane.a], b = galaxy.systems[lane.b]; g.moveTo(v.x(a.x), v.y(a.y)).lineTo(v.x(b.x), v.y(b.y)) .stroke({ width: 2, color: hazardColor(lane.hazard), alpha: 0.55 }); } for (const s of galaxy.systems) { g.circle(v.x(s.x), v.y(s.y), 7).fill({ color: 0xcfeeff }); } this.labelContainer.removeChildren(); this.hitNodes.removeChildren(); for (const s of galaxy.systems) { const label = new Text({ text: s.name, style: { fill: '#7fa8c9', fontSize: 11, fontFamily: 'system-ui' } }); label.position.set(v.x(s.x) + 10, v.y(s.y) - 6); this.labelContainer.addChild(label); const hit = new Graphics().circle(0, 0, 16).fill({ color: 0xffffff, alpha: 0.001 }); hit.position.set(v.x(s.x), v.y(s.y)); hit.eventMode = 'static'; hit.cursor = 'pointer'; hit.on('pointertap', () => this.handleSystemTap(s.id)); this.hitNodes.addChild(hit); } } handleSystemTap(systemId) { const sim = this.world; const cur = playerSystem(sim); // Clicking a neighbor = jump intent; clicking anything = intel selection. const lane = sim.galaxy.lanes.find( (l) => (l.a === cur && l.b === systemId) || (l.b === cur && l.a === systemId) ); if (lane) this.onJump(lane.id); this.onSelectSystem(systemId); } lanePoint(lane, fromSys, f) { const { galaxy } = this.world; const a = galaxy.systems[fromSys]; const b = galaxy.systems[otherEnd(lane, fromSys)]; return { x: this.v.x(a.x + (b.x - a.x) * f), y: this.v.y(a.y + (b.y - a.y) * f) }; } update() { const sim = this.world; const v = this.v; const cur = playerSystem(sim); // Probability clouds: glowing strokes along lane segments, alpha ~ weight. const cg = this.cloudG; cg.clear(); for (const cloud of playerClouds(sim, cur)) { for (const seg of cloud.segs) { const lane = sim.galaxy.lanes[seg.laneId]; const p0 = this.lanePoint(lane, seg.fromSys, seg.f0); const p1 = this.lanePoint(lane, seg.fromSys, seg.f1); const alpha = Math.min(0.85, 0.18 + seg.w * 0.9); cg.moveTo(p0.x, p0.y).lineTo(p1.x, p1.y) .stroke({ width: 7, color: 0xffa94d, alpha }); } } // Player marker: bright cyan triangle at current position. const dg = this.dynamicG; dg.clear(); const pos = this.playerXY(); if (pos) { dg.poly([pos.x, pos.y - 8, pos.x + 6, pos.y + 6, pos.x - 6, pos.y + 6]).fill({ color: 0x4dffd2 }); } // Halo on the player's current system. if (cur != null) { const s = sim.galaxy.systems[cur]; dg.circle(v.x(s.x), v.y(s.y), 12).stroke({ width: 2, color: 0x4dffd2, alpha: 0.8 }); } } playerXY() { const sim = this.world; const loc = sim.player.loc; const v = this.v; if (loc.kind === 'lane') { const lane = sim.galaxy.lanes[loc.laneId]; const f = Math.min(1, (sim.t - loc.departT) / (loc.arriveT - loc.departT)); return this.lanePoint(lane, loc.fromSys, f); } const sysId = playerSystem(sim); if (sysId == null) return null; const s = sim.galaxy.systems[sysId]; return { x: v.x(s.x), y: v.y(s.y) }; } setVisible(on) { this.container.visible = on; this.labelContainer.visible = on; } } ``` - [ ] **Step 3: Wire a temporary main.js to see it** Replace `src/main.js`: ```js import { createWorld, advance } from './sim/world.js'; import { createRenderer } from './render/app.js'; import { GalaxyView } from './render/galaxyView.js'; import { goTo } from './sim/player.js'; const world = createWorld('neon-1'); const renderer = await createRenderer(document.getElementById('stage')); const galaxyView = new GalaxyView(renderer, world, { onSelectSystem: (id) => console.log('select system', id), onJump: (laneId) => goTo(world, { laneId }), }); const DAYS_PER_SEC = 0.25; renderer.app.ticker.add(() => { advance(world, world.t + (renderer.app.ticker.deltaMS / 1000) * DAYS_PER_SEC); galaxyView.update(); }); ``` - [ ] **Step 4: Verify visually** Run: `npm run dev` Open http://localhost:5173 and check: - Dark background, glowing (bloomed) system dots connected by tinted lanes — safe lanes cyan, dangerous ones red. - System names render crisply (no bloom on text). - A cyan triangle (player) sits on system Arcadia with a halo ring. - Clicking a neighboring system sends the player there (triangle creeps along the lane). - After some NPCs pass through the player's system (wait ~a minute, or temporarily add a few NPC ids to `world.player.seenNpcs` in the console via a debug export), orange glowing smears appear along lanes. If WebGL is unavailable in your test browser, confirm `npx vitest run` still passes and defer the visual check to the playtest task. - [ ] **Step 5: Commit** ```bash git add src/render/app.js src/render/galaxyView.js src/main.js git commit -m "feat: neon galaxy view with bloom, hazard tints, clouds" ``` --- ### Task 13: System view (`render/systemView.js`) The classical bubble made visible: star, planets, gates, and concrete ship triangles sliding along legs. **Files:** - Create: `src/render/systemView.js` - [ ] **Step 1: Implement src/render/systemView.js** ```js import { Container, Graphics, Text } from 'pixi.js'; import { shipsIn } from '../sim/npc.js'; import { playerSystem } from '../sim/player.js'; import { otherEnd } from '../sim/galaxy.js'; const GATE_RADIUS = 290; export class SystemView { constructor(renderer, world, { onGotoPlanet, onJump }) { this.renderer = renderer; this.world = world; this.onGotoPlanet = onGotoPlanet; this.onJump = onJump; this.container = new Container(); this.labelContainer = new Container(); renderer.world.addChild(this.container); renderer.labels.addChild(this.labelContainer); this.staticG = new Graphics(); this.shipG = new Graphics(); this.container.addChild(this.staticG, this.shipG); this.hitNodes = new Container(); this.container.addChild(this.hitNodes); this.builtFor = null; } center() { return { x: this.renderer.app.renderer.width / 2, y: this.renderer.app.renderer.height / 2 }; } planetXY(planet) { const c = this.center(); return { x: c.x + Math.cos(planet.orbit.a) * planet.orbit.r, y: c.y + Math.sin(planet.orbit.a) * planet.orbit.r }; } gateXY(lane, systemId) { const { galaxy } = this.world; const here = galaxy.systems[systemId]; const there = galaxy.systems[otherEnd(lane, systemId)]; const ang = Math.atan2(there.y - here.y, there.x - here.x); const c = this.center(); return { x: c.x + Math.cos(ang) * GATE_RADIUS, y: c.y + Math.sin(ang) * GATE_RADIUS, ang }; } build(systemId) { this.builtFor = systemId; const { galaxy } = this.world; const sys = galaxy.systems[systemId]; const c = this.center(); const g = this.staticG; g.clear(); this.labelContainer.removeChildren(); this.hitNodes.removeChildren(); g.circle(c.x, c.y, 30).fill({ color: 0xffe9a8 }); // the star, brightest thing on screen for (const planetId of sys.planets) { const planet = galaxy.planets[planetId]; const p = this.planetXY(planet); g.circle(c.x, c.y, planet.orbit.r).stroke({ width: 1, color: 0x1b3a55, alpha: 0.6 }); g.circle(p.x, p.y, 10).fill({ color: 0x7fb4ff }); const label = new Text({ text: `${planet.name} (${planet.kind})`, style: { fill: '#7fa8c9', fontSize: 11, fontFamily: 'system-ui' }, }); label.position.set(p.x + 14, p.y - 6); this.labelContainer.addChild(label); const hit = new Graphics().circle(0, 0, 18).fill({ color: 0xffffff, alpha: 0.001 }); hit.position.set(p.x, p.y); hit.eventMode = 'static'; hit.cursor = 'pointer'; hit.on('pointertap', () => this.onGotoPlanet(planetId)); this.hitNodes.addChild(hit); } for (const laneId of sys.lanes) { const lane = galaxy.lanes[laneId]; const gp = this.gateXY(lane, systemId); g.poly([gp.x, gp.y - 8, gp.x + 8, gp.y, gp.x, gp.y + 8, gp.x - 8, gp.y]).fill({ color: 0x9fd8ff }); const name = galaxy.systems[otherEnd(lane, systemId)].name; const label = new Text({ text: `→ ${name}`, style: { fill: '#7fa8c9', fontSize: 11, fontFamily: 'system-ui' } }); label.position.set(gp.x + 10, gp.y - 6); this.labelContainer.addChild(label); const hit = new Graphics().circle(0, 0, 16).fill({ color: 0xffffff, alpha: 0.001 }); hit.position.set(gp.x, gp.y); hit.eventMode = 'static'; hit.cursor = 'pointer'; hit.on('pointertap', () => this.onJump(laneId)); this.hitNodes.addChild(hit); } } // A ship triangle pointing along its heading. drawShip(g, x, y, ang, color) { const cos = Math.cos(ang), sin = Math.sin(ang); const pt = (dx, dy) => [x + dx * cos - dy * sin, y + dx * sin + dy * cos]; g.poly([...pt(10, 0), ...pt(-7, 6), ...pt(-7, -6)]).fill({ color }); } update() { const sim = this.world; const sysId = playerSystem(sim); if (sysId == null) return; if (this.builtFor !== sysId) this.build(sysId); const { galaxy } = sim; const c = this.center(); const g = this.shipG; g.clear(); // Concrete NPC ships. for (const entry of shipsIn(sim, sysId)) { if (entry.kind === 'docked') { const p = this.planetXY(galaxy.planets[entry.planetId]); this.drawShip(g, p.x + 18, p.y - 14, -Math.PI / 2, 0xffa94d); } else if (entry.kind === 'arriving' || entry.kind === 'departing') { const lane = galaxy.lanes[entry.laneId]; const gp = this.gateXY(lane, sysId); const dest = entry.npc.plan ? galaxy.planets[entry.npc.plan.destPlanet] : null; const target = dest && dest.systemId === sysId ? this.planetXY(dest) : c; const f = entry.kind === 'arriving' ? entry.progress : 1 - entry.progress; const x = gp.x + (target.x - gp.x) * f * 0.5; const y = gp.y + (target.y - gp.y) * f * 0.5; const ang = entry.kind === 'arriving' ? Math.atan2(target.y - gp.y, target.x - gp.x) : Math.atan2(gp.y - target.y, gp.x - target.x); this.drawShip(g, x, y, ang, 0xffa94d); } else if (entry.kind === 'local-hop') { const from = this.planetXY(galaxy.planets[entry.npc.plan.originPlanet]); const to = this.planetXY(galaxy.planets[entry.npc.plan.destPlanet]); const x = from.x + (to.x - from.x) * entry.progress; const y = from.y + (to.y - from.y) * entry.progress; this.drawShip(g, x, y, Math.atan2(to.y - from.y, to.x - from.x), 0xffa94d); } } // The player. const loc = sim.player.loc; if (loc.kind === 'docked') { const p = this.planetXY(galaxy.planets[loc.planetId]); this.drawShip(g, p.x - 18, p.y - 14, -Math.PI / 2, 0x4dffd2); } else if (loc.kind === 'at-gate') { const gp = this.gateXY(galaxy.lanes[loc.laneId], sysId); this.drawShip(g, gp.x, gp.y - 16, -Math.PI / 2, 0x4dffd2); } else if (loc.kind === 'insys-leg') { const resolve = (d) => d.type === 'planet' ? this.planetXY(galaxy.planets[d.id]) : this.gateXY(galaxy.lanes[d.laneId], sysId); const from = resolve(loc.from), to = resolve(loc.to); const f = Math.min(1, (sim.t - loc.departT) / (loc.arriveT - loc.departT)); const x = from.x + (to.x - from.x) * f; const y = from.y + (to.y - from.y) * f; this.drawShip(g, x, y, Math.atan2(to.y - from.y, to.x - from.x), 0x4dffd2); } } setVisible(on) { this.container.visible = on; this.labelContainer.visible = on; } } ``` - [ ] **Step 2: Wire view switching into main.js** Replace `src/main.js`: ```js import { createWorld, advance } from './sim/world.js'; import { createRenderer } from './render/app.js'; import { GalaxyView } from './render/galaxyView.js'; import { SystemView } from './render/systemView.js'; import { goTo, playerSystem } from './sim/player.js'; const world = createWorld('neon-1'); const renderer = await createRenderer(document.getElementById('stage')); const handlers = { onSelectSystem: (id) => console.log('select system', id), onJump: (laneId) => goTo(world, { laneId }), onGotoPlanet: (planetId) => goTo(world, { planetId }), }; const galaxyView = new GalaxyView(renderer, world, handlers); const systemView = new SystemView(renderer, world, handlers); let view = 'system'; function setView(name) { view = name; galaxyView.setVisible(name === 'galaxy'); systemView.setVisible(name === 'system'); } setView('system'); window.addEventListener('keydown', (e) => { if (e.key === 'Tab') { e.preventDefault(); setView(view === 'galaxy' ? 'system' : 'galaxy'); } }); const DAYS_PER_SEC = 0.25; renderer.app.ticker.add(() => { advance(world, world.t + (renderer.app.ticker.deltaMS / 1000) * DAYS_PER_SEC); if (view === 'galaxy') galaxyView.update(); else if (playerSystem(world) != null) systemView.update(); }); ``` - [ ] **Step 3: Verify visually** Run: `npm run dev`, open http://localhost:5173 and check: - System view: glowing star, planets on faint orbit rings with name+kind labels, diamond gates at the rim labeled `→ Neighbor`. - Player triangle (cyan) parked at the start planet; orange NPC triangles dock and pop out of gates over time — the arrival-queue mechanic visible on screen. - Clicking a planet flies the player there; clicking a gate jumps (screen switches to the new system after the lane transit — while on the lane, system view freezes on nearest system; that's fine for now). - Tab toggles galaxy/system view. - [ ] **Step 4: Commit** ```bash git add src/render/systemView.js src/main.js git commit -m "feat: system view with star, planets, gates, concrete ships" ``` --- ### Task 14: DOM UI panel (`ui/panel.js`) and full game wiring Status, time controls, destination buttons, market table, intel for a selected system, message log, win/lose banner. This task completes the playable game. **Files:** - Create: `src/ui/panel.js` - Modify: `src/main.js` - [ ] **Step 1: Implement src/ui/panel.js** ```js import { GOODS } from '../sim/constants.js'; import { goTo, buyGood, sellGood, playerSystem, playerMarket, cargoUsed } from '../sim/player.js'; import { playerKnownPrices } from '../sim/knowledge.js'; import { otherEnd } from '../sim/galaxy.js'; export class Panel { constructor(el, world, controls) { this.el = el; this.world = world; this.controls = controls; // { getSpeed, setSpeed, getView, setView } this.selectedSystem = null; el.innerHTML = `

Probability

Navigation

Market

Dock at a planet to trade.

Cargo

Intel

Click a system on the galaxy map.

Log

`; this.refs = Object.fromEntries( ['status', 'timeCtl', 'nav', 'market', 'cargo', 'intel', 'log'].map((id) => [id, el.querySelector('#' + id)]) ); this.renderTimeControls(); } renderTimeControls() { const c = this.refs.timeCtl; c.innerHTML = ''; for (const [label, value] of [['⏸', 0], ['1×', 1], ['4×', 4], ['16×', 16]]) { const b = document.createElement('button'); b.textContent = label; b.onclick = () => { this.controls.setSpeed(value); this.update(); }; b.dataset.speed = value; c.appendChild(b); } const v = document.createElement('button'); v.textContent = 'Map / System'; v.onclick = () => this.controls.setView(this.controls.getView() === 'galaxy' ? 'system' : 'galaxy'); c.appendChild(v); } selectSystem(systemId) { this.selectedSystem = systemId; } update() { const w = this.world; const p = w.player; const sys = playerSystem(w); let place = 'in hyperspace'; if (p.loc.kind === 'docked') place = w.galaxy.planets[p.loc.planetId].name; else if (sys != null) place = `${w.galaxy.systems[sys].name} space`; this.refs.status.innerHTML = `Day ${w.t.toFixed(1)} — ${Math.floor(p.credits)} cr
` + `${place} — cargo ${cargoUsed(p)}/${p.capacity}` + (p.won ? '
VICTORY' : '') + (!p.alive ? '
SHIP LOST' : ''); for (const b of this.refs.timeCtl.querySelectorAll('button[data-speed]')) { b.classList.toggle('active', Number(b.dataset.speed) === this.controls.getSpeed()); } this.updateNav(sys); this.updateMarket(); this.updateCargo(); this.updateIntel(); this.refs.log.innerHTML = w.log.slice(-10).reverse().map((l) => `
${l}
`).join(''); } updateNav(sys) { const w = this.world; const nav = this.refs.nav; nav.innerHTML = ''; if (sys == null || w.player.loc.kind === 'insys-leg' || w.player.loc.kind === 'lane') { nav.innerHTML = 'In transit…'; return; } for (const planetId of w.galaxy.systems[sys].planets) { const b = document.createElement('button'); b.textContent = `Fly to ${w.galaxy.planets[planetId].name}`; b.onclick = () => goTo(w, { planetId }); nav.appendChild(b); } for (const laneId of w.galaxy.systems[sys].lanes) { const lane = w.galaxy.lanes[laneId]; const name = w.galaxy.systems[otherEnd(lane, sys)].name; const b = document.createElement('button'); b.textContent = `Jump → ${name} (${lane.days}d, danger ${(lane.hazard * 100).toFixed(0)}%)`; b.onclick = () => goTo(w, { laneId }); nav.appendChild(b); } } updateMarket() { const w = this.world; const market = playerMarket(w); if (!market) { this.refs.market.innerHTML = 'Dock at a planet to trade.'; return; } const rows = market.map((r) => ` ${r.good}${r.stock}${r.price} `).join(''); this.refs.market.innerHTML = `${rows}
goodstockprice
`; for (const b of this.refs.market.querySelectorAll('button')) { b.onclick = () => { const fn = b.dataset.act === 'buy' ? buyGood : sellGood; fn(w, b.dataset.good, Number(b.dataset.qty)); this.update(); }; } } updateCargo() { const entries = Object.entries(this.world.player.cargo); this.refs.cargo.innerHTML = entries.length ? entries.map(([g, q]) => `${g}: ${q}`).join('
') : 'empty'; } updateIntel() { const w = this.world; if (this.selectedSystem == null) return; const sys = w.galaxy.systems[this.selectedSystem]; let html = `${sys.name}`; for (const planetId of sys.planets) { const planet = w.galaxy.planets[planetId]; const known = playerKnownPrices(w, planetId); html += `
${planet.name} (${planet.kind})`; if (!known) { html += '
no data'; continue; } const age = known.ageDays < 0.5 ? 'live' : `${known.ageDays.toFixed(0)}d old`; html += ` [${age}]
` + GOODS.map((g) => `${g} ${known.prices[g]}`).join(', '); } this.refs.intel.innerHTML = html; } } ``` - [ ] **Step 2: Final main.js** Replace `src/main.js`: ```js import { createWorld, advance } from './sim/world.js'; import { createRenderer } from './render/app.js'; import { GalaxyView } from './render/galaxyView.js'; import { SystemView } from './render/systemView.js'; import { Panel } from './ui/panel.js'; import { goTo, playerSystem } from './sim/player.js'; const world = createWorld(new URLSearchParams(location.search).get('seed') ?? 'neon-1'); const renderer = await createRenderer(document.getElementById('stage')); let view = 'system'; let speed = 1; const controls = { getSpeed: () => speed, setSpeed: (v) => { speed = v; }, getView: () => view, setView: (name) => setView(name), }; const panel = new Panel(document.getElementById('panel'), world, controls); const handlers = { onSelectSystem: (id) => { panel.selectSystem(id); panel.update(); }, onJump: (laneId) => goTo(world, { laneId }), onGotoPlanet: (planetId) => goTo(world, { planetId }), }; const galaxyView = new GalaxyView(renderer, world, handlers); const systemView = new SystemView(renderer, world, handlers); function setView(name) { view = name; galaxyView.setVisible(name === 'galaxy'); systemView.setVisible(name === 'system'); } setView('system'); window.addEventListener('keydown', (e) => { if (e.key === 'Tab') { e.preventDefault(); setView(view === 'galaxy' ? 'system' : 'galaxy'); } if (e.key === ' ') { e.preventDefault(); speed = speed === 0 ? 1 : 0; } }); const DAYS_PER_SEC = 0.25; let banner = null; let uiClock = 0; renderer.app.ticker.add(() => { const dt = renderer.app.ticker.deltaMS / 1000; if (speed > 0 && world.player.alive && !world.player.won) { advance(world, world.t + dt * DAYS_PER_SEC * speed); } // Switch to system view automatically when arriving somewhere new. if (view === 'galaxy') galaxyView.update(); else if (playerSystem(world) != null) systemView.update(); uiClock += dt; if (uiClock > 0.25) { uiClock = 0; panel.update(); } // 4 Hz DOM refresh if (!banner && (world.player.won || !world.player.alive)) { banner = document.createElement('div'); banner.className = 'banner'; banner.textContent = world.player.won ? 'VICTORY' : 'SHIP LOST'; document.getElementById('stage').appendChild(banner); } }); ``` - [ ] **Step 3: Verify visually — full loop playtest** Run: `npm run dev`, open http://localhost:5173 and play: - Sidebar shows day, credits, location, cargo; time buttons work (⏸/1×/4×/16×, Space toggles pause). - Dock → market table appears → buy a cheap produced good (e.g., ore at a mining planet for under base price). - Jump to a neighbor (button or gate click), watch hazard warnings in the log if any. - Dock where it's consumed → sell higher → credits up. The complete trading loop works. - Galaxy map: click systems → intel panel shows planet prices with age tags that grow stale. - After encountering NPCs, their orange clouds appear on the galaxy map and stretch over time. - [ ] **Step 4: Run the whole suite** Run: `npx vitest run` Expected: all pass. - [ ] **Step 5: Commit** ```bash git add src/ui/panel.js src/main.js git commit -m "feat: UI panel, time controls, full playable trading loop" ``` --- ### Task 15: Balance pass, README, final verification **Files:** - Create: `README.md` - Possibly modify: `src/sim/constants.js` (tuning only) - [ ] **Step 1: Headless balance check** Create `tests/balance.test.js`: ```js import { describe, it, expect } from 'vitest'; import { createWorld, advance } from '../src/sim/world.js'; import { priceOf, advancePlanet } from '../src/sim/economy.js'; import { GOODS, BASE_PRICE } from '../src/sim/constants.js'; describe('balance', () => { it('the NPC economy stays alive over a long horizon', () => { const w = createWorld('balance-1'); advance(w, 120); const alive = w.npcs.filter((n) => n.alive); expect(alive.length).toBeGreaterThan(w.npcs.length * 0.5); expect(w.stats.trades).toBeGreaterThan(w.npcs.length * 3); }); it('price gradients survive NPC arbitrage (the player can still profit)', () => { const w = createWorld('balance-1'); advance(w, 60); let bestSpread = 0; for (const good of GOODS) { let lo = Infinity, hi = 0; for (const planet of w.galaxy.planets) { advancePlanet(planet, w.t); const price = priceOf(good, planet.stock[good]); lo = Math.min(lo, price); hi = Math.max(hi, price); } bestSpread = Math.max(bestSpread, (hi - lo) / BASE_PRICE[good]); } expect(bestSpread).toBeGreaterThan(0.5); // at least one good still has a fat margin somewhere }); }); ``` - [ ] **Step 2: Run and tune if needed** Run: `npx vitest run tests/balance.test.js` Expected: 2 passed. If the economy dies (trades dry up): lower hazard multipliers in `hazardProbs` or raise PROFILES production rates. If gradients vanish: reduce `npcCount` default or widen `MAX_JUMPS` asymmetries. Tune constants only; re-run the full suite after any change. - [ ] **Step 3: Write README.md** ```markdown # Probability A neon space-trading sim where unobserved ships are probability waves. Inspired by Space Rangers. Every NPC trader plans real routes and real trades, but between observations its position is a probability cloud smeared along the jump lanes — and so is your knowledge of every market more than a news-day away. Observation collapses the cloud: enter a system and ships condense out of the static; dock at a planet and its prices become real. Under the hood the universe is deterministic ("hidden variables" drawn lazily from per-ship seeds) — the probability is *yours*, not the universe's. ## Run npm install npm run dev # play at http://localhost:5173 (add ?seed=anything for a new galaxy) npm test # headless sim test suite ## How to play - You start docked. Buy what a planet produces cheap, sell where it's consumed. - Sidebar: navigation, market (when docked), cargo, intel, log. Space = pause, Tab = map/system view. Click planets/gates/systems to move; click systems on the map for price intel — note the age tag, old news lies. - Lanes are tinted by pirate danger. Dangerous shortcuts are profitable until they aren't. - Orange smears on the map are ships you've met: where they probably are now. - Reach 100,000 credits to win. Lose your ship and it's over. ``` - [ ] **Step 4: Full verification** Run: `npx vitest run` Expected: all tests pass. Run: `npm run build` Expected: Vite builds without errors. - [ ] **Step 5: Commit** ```bash git add README.md tests/balance.test.js src/sim/constants.js git commit -m "feat: balance checks and README - v1 complete" ``` --- ## Post-plan notes for the executor - **Statistical tests** (hazard outcomes, balance) use fixed seeds, so they are deterministic in practice; if a threshold trips after an intentional constant change, re-derive the expected band rather than deleting the test. - **Visual tasks (12–14)** have no unit tests by design; the headless suite plus the documented manual checks are the verification. If a headless browser is available, a screenshot of each view is a bonus, not a requirement. - **Out of scope, do not add:** combat, named pirates, missions, save/load, sound, free-flight movement. YAGNI. ```