From e96d903bf841b074b77fadf9e59dd92945e4f3f0 Mon Sep 17 00:00:00 2001 From: EugeneTes Date: Fri, 12 Jun 2026 13:45:08 +0000 Subject: [PATCH] Add implementation plan for probability trading sim --- .../2026-06-12-probability-trading-sim.md | 2853 +++++++++++++++++ 1 file changed, 2853 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-12-probability-trading-sim.md diff --git a/docs/superpowers/plans/2026-06-12-probability-trading-sim.md b/docs/superpowers/plans/2026-06-12-probability-trading-sim.md new file mode 100644 index 0000000..d43a803 --- /dev/null +++ b/docs/superpowers/plans/2026-06-12-probability-trading-sim.md @@ -0,0 +1,2853 @@ +# 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. +```