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 = `
+