Files
probability/docs/superpowers/plans/2026-06-12-probability-trading-sim.md
T

2854 lines
98 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Probability</title>
<link rel="stylesheet" href="/src/styles.css" />
</head>
<body>
<div id="stage"></div>
<aside id="panel"></aside>
<script type="module" src="/src/main.js"></script>
</body>
</html>
```
- [ ] **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 '<title>Probability</title>'` then kill the server.
Expected: `<title>Probability</title>`
- [ ] **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 910 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 = `
<h2>Probability</h2>
<div id="status"></div>
<div id="timeCtl"></div>
<h2>Navigation</h2>
<div id="nav"></div>
<h2>Market</h2>
<div id="market" class="muted">Dock at a planet to trade.</div>
<h2>Cargo</h2>
<div id="cargo"></div>
<h2>Intel</h2>
<div id="intel" class="muted">Click a system on the galaxy map.</div>
<h2>Log</h2>
<div id="log"></div>
`;
this.refs = Object.fromEntries(
['status', 'timeCtl', 'nav', 'market', 'cargo', 'intel', 'log'].map((id) => [id, el.querySelector('#' + id)])
);
this.renderTimeControls();
}
renderTimeControls() {
const c = this.refs.timeCtl;
c.innerHTML = '';
for (const [label, value] of [['⏸', 0], ['1×', 1], ['4×', 4], ['16×', 16]]) {
const b = document.createElement('button');
b.textContent = label;
b.onclick = () => { this.controls.setSpeed(value); this.update(); };
b.dataset.speed = value;
c.appendChild(b);
}
const v = document.createElement('button');
v.textContent = 'Map / System';
v.onclick = () => this.controls.setView(this.controls.getView() === 'galaxy' ? 'system' : 'galaxy');
c.appendChild(v);
}
selectSystem(systemId) {
this.selectedSystem = systemId;
}
update() {
const w = this.world;
const p = w.player;
const sys = playerSystem(w);
let place = 'in hyperspace';
if (p.loc.kind === 'docked') place = w.galaxy.planets[p.loc.planetId].name;
else if (sys != null) place = `${w.galaxy.systems[sys].name} space`;
this.refs.status.innerHTML =
`Day ${w.t.toFixed(1)} — <b>${Math.floor(p.credits)} cr</b><br>` +
`${place} — cargo ${cargoUsed(p)}/${p.capacity}` +
(p.won ? '<br><b>VICTORY</b>' : '') + (!p.alive ? '<br><b>SHIP LOST</b>' : '');
for (const b of this.refs.timeCtl.querySelectorAll('button[data-speed]')) {
b.classList.toggle('active', Number(b.dataset.speed) === this.controls.getSpeed());
}
this.updateNav(sys);
this.updateMarket();
this.updateCargo();
this.updateIntel();
this.refs.log.innerHTML = w.log.slice(-10).reverse().map((l) => `<div>${l}</div>`).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 = '<span class="muted">In transit…</span>';
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 = '<span class="muted">Dock at a planet to trade.</span>';
return;
}
const rows = market.map((r) => `
<tr>
<td>${r.good}</td><td>${r.stock}</td><td>${r.price}</td>
<td>
<button data-act="buy" data-good="${r.good}" data-qty="1">+1</button>
<button data-act="buy" data-good="${r.good}" data-qty="10">+10</button>
<button data-act="sell" data-good="${r.good}" data-qty="1">-1</button>
<button data-act="sell" data-good="${r.good}" data-qty="10">-10</button>
</td>
</tr>`).join('');
this.refs.market.innerHTML =
`<table><tr><th>good</th><th>stock</th><th>price</th><th></th></tr>${rows}</table>`;
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('<br>')
: '<span class="muted">empty</span>';
}
updateIntel() {
const w = this.world;
if (this.selectedSystem == null) return;
const sys = w.galaxy.systems[this.selectedSystem];
let html = `<b>${sys.name}</b>`;
for (const planetId of sys.planets) {
const planet = w.galaxy.planets[planetId];
const known = playerKnownPrices(w, planetId);
html += `<br><b>${planet.name}</b> <span class="muted">(${planet.kind})</span>`;
if (!known) { html += '<br><span class="muted">no data</span>'; continue; }
const age = known.ageDays < 0.5 ? 'live' : `${known.ageDays.toFixed(0)}d old`;
html += ` <span class="muted">[${age}]</span><br>` +
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 (1214)** 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.
```