# 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 = `
`).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 =
`
good
stock
price
${rows}
`;
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.
```