2854 lines
98 KiB
Markdown
2854 lines
98 KiB
Markdown
# 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 9–10 replace them.
|
||
|
||
Create `src/sim/player.js` (stub):
|
||
|
||
```js
|
||
export function onPlayerArrive() {}
|
||
export function playerSystem(world) {
|
||
const loc = world.player.loc;
|
||
if (loc.kind === 'docked') return world.galaxy.planets[loc.planetId].systemId;
|
||
return loc.systemId ?? null;
|
||
}
|
||
```
|
||
|
||
Create `src/sim/knowledge.js` (stub):
|
||
|
||
```js
|
||
export function markSeen() {}
|
||
```
|
||
|
||
- [ ] **Step 4: Run tests to verify they pass**
|
||
|
||
Run: `npx vitest run tests/world.test.js`
|
||
Expected: 5 passed. (If `trades` comes out below threshold, raise NPC count in the test or check that planNpc finds gradients — planet profiles guarantee them.)
|
||
|
||
- [ ] **Step 5: Run the whole suite**
|
||
|
||
Run: `npx vitest run`
|
||
Expected: all tests pass.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add src/sim/world.js src/sim/player.js src/sim/knowledge.js tests/world.test.js
|
||
git commit -m "feat: world orchestrator with living NPC economy"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 9: Player commands (`sim/player.js`)
|
||
|
||
Movement (in-system legs, lane jumps with hazards), trading, win/lose. Replaces the Task 8 stub.
|
||
|
||
**Files:**
|
||
- Modify: `src/sim/player.js` (replace entirely)
|
||
- Test: `tests/player.test.js`
|
||
|
||
- [ ] **Step 1: Write the failing tests**
|
||
|
||
```js
|
||
import { describe, it, expect } from 'vitest';
|
||
import { createWorld, advance } from '../src/sim/world.js';
|
||
import { goTo, buyGood, sellGood, playerSystem, cargoUsed } from '../src/sim/player.js';
|
||
import { INSYS_LEG_DAYS } from '../src/sim/constants.js';
|
||
|
||
function freshWorld() {
|
||
return createWorld('player-test', { systemCount: 10, npcCount: 5 });
|
||
}
|
||
|
||
describe('player', () => {
|
||
it('buys and sells at the docked planet, conserving value through stock', () => {
|
||
const w = freshWorld();
|
||
const planet = w.galaxy.planets[w.player.loc.planetId];
|
||
const before = w.player.credits;
|
||
const stockBefore = Math.floor(planet.stock.food);
|
||
expect(buyGood(w, 'food', 5)).toBe(true);
|
||
expect(w.player.cargo.food).toBe(5);
|
||
expect(w.player.credits).toBeLessThan(before);
|
||
expect(Math.floor(planet.stock.food)).toBe(stockBefore - 5);
|
||
expect(sellGood(w, 'food', 5)).toBe(true);
|
||
expect(w.player.cargo.food ?? 0).toBe(0);
|
||
});
|
||
|
||
it('rejects buys beyond capacity or credits', () => {
|
||
const w = freshWorld();
|
||
expect(buyGood(w, 'food', 9999)).toBe(true); // clamps to capacity/credits
|
||
expect(cargoUsed(w.player)).toBeLessThanOrEqual(w.player.capacity);
|
||
const w2 = freshWorld();
|
||
w2.player.credits = 0;
|
||
expect(buyGood(w2, 'food', 1)).toBe(false);
|
||
});
|
||
|
||
it('travels to another planet in-system after INSYS_LEG_DAYS', () => {
|
||
const w = freshWorld();
|
||
const sys = playerSystem(w);
|
||
const other = w.galaxy.planets.find((p) => p.systemId === sys && p.id !== w.player.loc.planetId);
|
||
if (!other) return; // single-planet start system on this seed: nothing to assert
|
||
expect(goTo(w, { planetId: other.id })).toBe(true);
|
||
expect(w.player.loc.kind).toBe('insys-leg');
|
||
advance(w, w.t + INSYS_LEG_DAYS + 0.01);
|
||
expect(w.player.loc.kind).toBe('docked');
|
||
expect(w.player.loc.planetId).toBe(other.id);
|
||
});
|
||
|
||
it('jumps to a neighboring system via gate and lane', () => {
|
||
const w = freshWorld();
|
||
const sys = playerSystem(w);
|
||
const laneId = w.galaxy.systems[sys].lanes[0];
|
||
const lane = w.galaxy.lanes[laneId];
|
||
const destSys = lane.a === sys ? lane.b : lane.a;
|
||
expect(goTo(w, { laneId })).toBe(true);
|
||
advance(w, w.t + INSYS_LEG_DAYS + lane.days * 1.01 + 0.01);
|
||
expect(playerSystem(w)).toBe(destSys);
|
||
expect(w.player.loc.kind).toBe('at-gate');
|
||
});
|
||
|
||
it('refuses movement while already moving, and trades while undocked', () => {
|
||
const w = freshWorld();
|
||
const sys = playerSystem(w);
|
||
const laneId = w.galaxy.systems[sys].lanes[0];
|
||
expect(goTo(w, { laneId })).toBe(true);
|
||
expect(goTo(w, { laneId })).toBe(false);
|
||
expect(buyGood(w, 'food', 1)).toBe(false);
|
||
});
|
||
|
||
it('docking refreshes the player price book', () => {
|
||
const w = freshWorld();
|
||
const sys = playerSystem(w);
|
||
const other = w.galaxy.planets.find((p) => p.systemId === sys && p.id !== w.player.loc.planetId);
|
||
if (!other) return;
|
||
goTo(w, { planetId: other.id });
|
||
advance(w, w.t + INSYS_LEG_DAYS + 0.01);
|
||
expect(w.player.priceBook[other.id].time).toBeCloseTo(w.t, 1);
|
||
});
|
||
|
||
it('hazard rolls on player lanes are deterministic per world seed', () => {
|
||
const run = () => {
|
||
const w = freshWorld();
|
||
// brute-force the player across the most dangerous lane repeatedly
|
||
const sys = playerSystem(w);
|
||
const laneId = w.galaxy.systems[sys].lanes[0];
|
||
for (let i = 0; i < 5 && w.player.alive; i++) {
|
||
const cur = playerSystem(w);
|
||
const lane = w.galaxy.lanes[laneId];
|
||
if (lane.a !== cur && lane.b !== cur) break;
|
||
goTo(w, { laneId });
|
||
advance(w, w.t + INSYS_LEG_DAYS + lane.days * 1.01 + 0.05);
|
||
}
|
||
return [w.player.alive, w.player.tripCount, Object.entries(w.player.cargo)];
|
||
};
|
||
expect(JSON.stringify(run())).toBe(JSON.stringify(run()));
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run tests to verify they fail**
|
||
|
||
Run: `npx vitest run tests/player.test.js`
|
||
Expected: FAIL — `goTo` is not exported.
|
||
|
||
- [ ] **Step 3: Replace src/sim/player.js**
|
||
|
||
```js
|
||
import { INSYS_LEG_DAYS, WIN_CREDITS, GOODS } from './constants.js';
|
||
import { advancePlanet, priceOf, applyTrade, marketView } from './economy.js';
|
||
import { otherEnd } from './galaxy.js';
|
||
import { rollFor } from './rng.js';
|
||
import { pushLog } from './world.js';
|
||
|
||
export function playerSystem(world) {
|
||
const loc = world.player.loc;
|
||
if (loc.kind === 'docked') return world.galaxy.planets[loc.planetId].systemId;
|
||
if (loc.kind === 'lane') {
|
||
const f = (world.t - loc.departT) / (loc.arriveT - loc.departT);
|
||
return f < 0.5 ? loc.fromSys : loc.toSys; // nearest end, for news purposes
|
||
}
|
||
return loc.systemId;
|
||
}
|
||
|
||
export function cargoUsed(player) {
|
||
return Object.values(player.cargo).reduce((a, b) => a + b, 0);
|
||
}
|
||
|
||
function fromDescriptor(loc) {
|
||
if (loc.kind === 'docked') return { type: 'planet', id: loc.planetId };
|
||
if (loc.kind === 'at-gate') return { type: 'gate', laneId: loc.laneId };
|
||
return { type: 'planet', id: 0 };
|
||
}
|
||
|
||
// target: { planetId } to fly to a planet in the current system,
|
||
// or { laneId } to jump through a lane touching the current system.
|
||
export function goTo(world, target) {
|
||
const p = world.player;
|
||
if (!p.alive || p.won) return false;
|
||
if (p.loc.kind === 'insys-leg' || p.loc.kind === 'lane') return false;
|
||
const sys = playerSystem(world);
|
||
|
||
if (target.planetId != null) {
|
||
const planet = world.galaxy.planets[target.planetId];
|
||
if (!planet || planet.systemId !== sys) return false;
|
||
if (p.loc.kind === 'docked' && p.loc.planetId === target.planetId) return false;
|
||
p.loc = {
|
||
kind: 'insys-leg', systemId: sys,
|
||
from: fromDescriptor(p.loc), to: { type: 'planet', id: target.planetId },
|
||
departT: world.t, arriveT: world.t + INSYS_LEG_DAYS,
|
||
};
|
||
world.queue.push(p.loc.arriveT, 'player-arrive', {});
|
||
return true;
|
||
}
|
||
|
||
if (target.laneId != null) {
|
||
const lane = world.galaxy.lanes[target.laneId];
|
||
if (!lane || (lane.a !== sys && lane.b !== sys)) return false;
|
||
p.intent = { laneId: target.laneId };
|
||
if (p.loc.kind === 'at-gate' && p.loc.laneId === target.laneId) {
|
||
startLane(world);
|
||
return true;
|
||
}
|
||
p.loc = {
|
||
kind: 'insys-leg', systemId: sys,
|
||
from: fromDescriptor(p.loc), to: { type: 'gate', laneId: target.laneId },
|
||
departT: world.t, arriveT: world.t + INSYS_LEG_DAYS,
|
||
};
|
||
world.queue.push(p.loc.arriveT, 'player-arrive', {});
|
||
return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
function startLane(world) {
|
||
const p = world.player;
|
||
const lane = world.galaxy.lanes[p.intent.laneId];
|
||
const fromSys = playerSystem(world);
|
||
p.loc = {
|
||
kind: 'lane', laneId: lane.id,
|
||
fromSys, toSys: otherEnd(lane, fromSys),
|
||
departT: world.t, arriveT: world.t + lane.days,
|
||
};
|
||
p.intent = null;
|
||
world.queue.push(p.loc.arriveT, 'player-arrive', {});
|
||
pushLog(world, `Jumping to ${world.galaxy.systems[p.loc.toSys].name} (${lane.days}d)`);
|
||
}
|
||
|
||
export function onPlayerArrive(world) {
|
||
const p = world.player;
|
||
const loc = p.loc;
|
||
if (loc.kind === 'insys-leg' && world.t >= loc.arriveT - 1e-9) {
|
||
if (loc.to.type === 'planet') {
|
||
p.loc = { kind: 'docked', planetId: loc.to.id };
|
||
recordDockIntel(world, loc.to.id);
|
||
pushLog(world, `Docked at ${world.galaxy.planets[loc.to.id].name}`);
|
||
} else {
|
||
p.loc = { kind: 'at-gate', systemId: loc.systemId, laneId: loc.to.laneId };
|
||
if (p.intent && p.intent.laneId === loc.to.laneId) startLane(world);
|
||
}
|
||
return;
|
||
}
|
||
if (loc.kind === 'lane' && world.t >= loc.arriveT - 1e-9) {
|
||
const lane = world.galaxy.lanes[loc.laneId];
|
||
const trip = ++p.tripCount;
|
||
const r = rollFor(`w:${world.seed}:player`, `trip${trip}`);
|
||
if (r < lane.hazard * 0.04) {
|
||
p.alive = false;
|
||
pushLog(world, 'Your ship was destroyed by pirates.');
|
||
return;
|
||
}
|
||
if (r < lane.hazard * 0.04 + lane.hazard * 0.15 && cargoUsed(p) > 0) {
|
||
p.cargo = {};
|
||
pushLog(world, 'Pirates! You jettisoned your cargo and escaped.');
|
||
}
|
||
p.loc = { kind: 'at-gate', systemId: loc.toSys, laneId: loc.laneId };
|
||
pushLog(world, `Arrived in ${world.galaxy.systems[loc.toSys].name}`);
|
||
}
|
||
}
|
||
|
||
function recordDockIntel(world, planetId) {
|
||
const planet = world.galaxy.planets[planetId];
|
||
advancePlanet(planet, world.t);
|
||
const prices = Object.fromEntries(GOODS.map((g) => [g, priceOf(g, planet.stock[g])]));
|
||
world.player.priceBook[planetId] = { time: world.t, prices };
|
||
}
|
||
|
||
export function buyGood(world, good, qty) {
|
||
const p = world.player;
|
||
if (p.loc.kind !== 'docked' || !p.alive) return false;
|
||
const planet = world.galaxy.planets[p.loc.planetId];
|
||
advancePlanet(planet, world.t);
|
||
const price = priceOf(good, planet.stock[good]);
|
||
qty = Math.min(qty, Math.floor(planet.stock[good]), Math.floor(p.credits / price), p.capacity - cargoUsed(p));
|
||
if (qty < 1) return false;
|
||
applyTrade(planet, world.t, good, -qty);
|
||
p.credits = Math.round((p.credits - price * qty) * 100) / 100;
|
||
p.cargo[good] = (p.cargo[good] || 0) + qty;
|
||
recordDockIntel(world, planet.id);
|
||
return true;
|
||
}
|
||
|
||
export function sellGood(world, good, qty) {
|
||
const p = world.player;
|
||
if (p.loc.kind !== 'docked' || !p.alive) return false;
|
||
qty = Math.min(qty, p.cargo[good] || 0);
|
||
if (qty < 1) return false;
|
||
const planet = world.galaxy.planets[p.loc.planetId];
|
||
const price = applyTrade(planet, world.t, good, qty);
|
||
p.credits = Math.round((p.credits + price * qty) * 100) / 100;
|
||
p.cargo[good] -= qty;
|
||
if (p.cargo[good] === 0) delete p.cargo[good];
|
||
recordDockIntel(world, planet.id);
|
||
if (p.credits >= WIN_CREDITS && !p.won) {
|
||
p.won = true;
|
||
pushLog(world, `You amassed ${p.credits} credits. Victory!`);
|
||
}
|
||
return true;
|
||
}
|
||
|
||
export function playerMarket(world) {
|
||
const p = world.player;
|
||
if (p.loc.kind !== 'docked') return null;
|
||
return marketView(world.galaxy.planets[p.loc.planetId], world.t);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run tests to verify they pass**
|
||
|
||
Run: `npx vitest run tests/player.test.js`
|
||
Expected: 7 passed.
|
||
|
||
- [ ] **Step 5: Run the whole suite (world tests must still pass)**
|
||
|
||
Run: `npx vitest run`
|
||
Expected: all pass.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add src/sim/player.js tests/player.test.js
|
||
git commit -m "feat: player movement, trading, lane hazards, win/lose"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 10: Knowledge layer (`sim/knowledge.js`)
|
||
|
||
What the player *knows*: best-available price intel (dock visits vs news) and which NPCs have ever been personally observed. Replaces the Task 8 stub.
|
||
|
||
**Files:**
|
||
- Modify: `src/sim/knowledge.js` (replace entirely)
|
||
- Test: `tests/knowledge.test.js`
|
||
|
||
- [ ] **Step 1: Write the failing tests**
|
||
|
||
```js
|
||
import { describe, it, expect } from 'vitest';
|
||
import { createWorld, advance } from '../src/sim/world.js';
|
||
import { playerKnownPrices } from '../src/sim/knowledge.js';
|
||
import { playerSystem } from '../src/sim/player.js';
|
||
|
||
describe('knowledge', () => {
|
||
it('knows every planet at game start via the day-0 snapshot', () => {
|
||
const w = createWorld('know-test', { systemCount: 10, npcCount: 5 });
|
||
for (const planet of w.galaxy.planets) {
|
||
const known = playerKnownPrices(w, planet.id);
|
||
expect(known).not.toBeNull();
|
||
expect(known.time).toBe(0);
|
||
expect(known.ageDays).toBe(0);
|
||
}
|
||
});
|
||
|
||
it('intel ages and refreshes as news travels', () => {
|
||
const w = createWorld('know-test', { systemCount: 10, npcCount: 5 });
|
||
const sys = playerSystem(w);
|
||
const far = w.galaxy.planets.find((p) => w.hops[sys][p.systemId] >= 2);
|
||
advance(w, 5);
|
||
const known = playerKnownPrices(w, far.id);
|
||
const hops = w.hops[sys][far.systemId];
|
||
// freshest possible news is `hops` days old
|
||
expect(known.ageDays).toBeGreaterThanOrEqual(hops - 1e-9);
|
||
expect(known.ageDays).toBeLessThanOrEqual(hops + 1 + 1e-9);
|
||
});
|
||
|
||
it('local planet intel is near-live', () => {
|
||
const w = createWorld('know-test', { systemCount: 10, npcCount: 5 });
|
||
advance(w, 5);
|
||
const local = w.galaxy.planets.find((p) => p.systemId === playerSystem(w));
|
||
expect(playerKnownPrices(w, local.id).ageDays).toBeLessThanOrEqual(1);
|
||
});
|
||
|
||
it('the observe heartbeat marks co-located NPCs as seen', () => {
|
||
const w = createWorld('know-test', { systemCount: 10, npcCount: 40 });
|
||
advance(w, 10);
|
||
// with 40 NPCs over 10 days, some must have passed through the player's system
|
||
expect(w.player.seenNpcs.size).toBeGreaterThan(0);
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run tests to verify they fail**
|
||
|
||
Run: `npx vitest run tests/knowledge.test.js`
|
||
Expected: FAIL — `playerKnownPrices` is not exported.
|
||
|
||
- [ ] **Step 3: Replace src/sim/knowledge.js**
|
||
|
||
```js
|
||
import { playerSystem } from './player.js';
|
||
import { shipsIn } from './npc.js';
|
||
|
||
// Best price intel the player has for a planet: personal dock visit
|
||
// vs. news that has reached the player's current location. Caches the
|
||
// winner in the price book so intel never gets worse.
|
||
export function playerKnownPrices(world, planetId) {
|
||
const p = world.player;
|
||
const sys = playerSystem(world);
|
||
if (sys == null) return p.priceBook[planetId] ?? null;
|
||
const planet = world.galaxy.planets[planetId];
|
||
const news = world.news.knownPrices(planetId, world.hops[sys][planet.systemId], world.t);
|
||
const book = p.priceBook[planetId] ?? null;
|
||
let best = book;
|
||
if (news && (!best || news.time > best.time)) best = news;
|
||
if (best && (!book || best.time > book.time)) p.priceBook[planetId] = best;
|
||
return best ? { time: best.time, prices: best.prices, ageDays: world.t - best.time } : null;
|
||
}
|
||
|
||
// Called by the world's observe heartbeat: every concrete ship sharing
|
||
// the player's system becomes "seen" — its cloud is now player-visible forever.
|
||
export function markSeen(world) {
|
||
if (!world.player.alive) return;
|
||
const sys = playerSystem(world);
|
||
if (sys == null) return;
|
||
for (const { npc } of shipsIn(world, sys)) world.player.seenNpcs.add(npc.id);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run tests to verify they pass**
|
||
|
||
Run: `npx vitest run tests/knowledge.test.js`
|
||
Expected: 4 passed.
|
||
|
||
- [ ] **Step 5: Run the whole suite**
|
||
|
||
Run: `npx vitest run`
|
||
Expected: all pass.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add src/sim/knowledge.js tests/knowledge.test.js
|
||
git commit -m "feat: player knowledge layer - aging price intel and seen ships"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 11: Probability clouds (`sim/clouds.js`)
|
||
|
||
The player-facing wave function: weighted lane segments for where a seen, in-transit NPC *could* be, computed from the plan's analytic envelope (never from the hidden rolls). Includes exclusion of the player's visible band ("it's not here, so it's elsewhere") with renormalization.
|
||
|
||
**Files:**
|
||
- Create: `src/sim/clouds.js`
|
||
- Test: `tests/clouds.test.js`
|
||
|
||
- [ ] **Step 1: Write the failing tests**
|
||
|
||
```js
|
||
import { describe, it, expect } from 'vitest';
|
||
import { cloudFor, excludeVisibleBand, totalMass } from '../src/sim/clouds.js';
|
||
|
||
// Synthetic 3-lane chain: systems 0-1-2-3, each lane 2 days.
|
||
function chainGalaxy() {
|
||
return {
|
||
lanes: [
|
||
{ id: 0, a: 0, b: 1, days: 2, hazard: 0.1 },
|
||
{ id: 1, a: 1, b: 2, days: 2, hazard: 0.1 },
|
||
{ id: 2, a: 2, b: 3, days: 2, hazard: 0.1 },
|
||
],
|
||
};
|
||
}
|
||
|
||
function chainPlan() {
|
||
return {
|
||
id: 'p1', startTime: 0, arriveTime: 6, finalOutcome: 'ok',
|
||
originPlanet: 0, destPlanet: 1,
|
||
legs: [
|
||
{ laneId: 0, fromSys: 0, toSys: 1, depart: 0, arrive: 2, outcome: 'safe' },
|
||
{ laneId: 1, fromSys: 1, toSys: 2, depart: 2, arrive: 4, outcome: 'safe' },
|
||
{ laneId: 2, fromSys: 2, toSys: 3, depart: 4, arrive: 6, outcome: 'safe' },
|
||
],
|
||
};
|
||
}
|
||
|
||
const trackLength = (segs, galaxy) =>
|
||
segs.reduce((sum, s) => sum + (s.f1 - s.f0) * galaxy.lanes[s.laneId].days, 0);
|
||
|
||
describe('clouds', () => {
|
||
it('total mass is 1 while the journey is plausibly in progress', () => {
|
||
const g = chainGalaxy();
|
||
const plan = chainPlan();
|
||
for (const t of [0.5, 1.7, 3, 4.5]) {
|
||
const segs = cloudFor(g, plan, t);
|
||
expect(segs.length).toBeGreaterThan(0);
|
||
expect(totalMass(segs)).toBeCloseTo(1, 5);
|
||
for (const s of segs) {
|
||
expect(s.f0).toBeGreaterThanOrEqual(0);
|
||
expect(s.f1).toBeLessThanOrEqual(1);
|
||
expect(s.f1).toBeGreaterThan(s.f0);
|
||
}
|
||
}
|
||
});
|
||
|
||
it('uncertainty grows with elapsed legs (cloud covers more track later)', () => {
|
||
const g = chainGalaxy();
|
||
const plan = chainPlan();
|
||
const early = trackLength(cloudFor(g, plan, 1.0), g);
|
||
const late = trackLength(cloudFor(g, plan, 4.2), g);
|
||
expect(late).toBeGreaterThan(early);
|
||
});
|
||
|
||
it('mid-journey, mass spans more than one lane', () => {
|
||
const g = chainGalaxy();
|
||
const segs = cloudFor(g, chainPlan(), 3.5);
|
||
expect(new Set(segs.map((s) => s.laneId)).size).toBeGreaterThanOrEqual(2);
|
||
});
|
||
|
||
it('excludeVisibleBand removes the observed band and renormalizes', () => {
|
||
const segs = [
|
||
{ laneId: 0, fromSys: 0, toSys: 1, f0: 0.0, f1: 0.5, w: 0.5 },
|
||
{ laneId: 0, fromSys: 0, toSys: 1, f0: 0.5, f1: 1.0, w: 0.5 },
|
||
];
|
||
// Player sits in system 1: the band f > 0.85 on this lane is visible.
|
||
const out = excludeVisibleBand({ lanes: [{ id: 0, a: 0, b: 1, days: 2 }] }, segs, 1);
|
||
expect(totalMass(out)).toBeCloseTo(1, 5);
|
||
for (const s of out) expect(s.f1).toBeLessThanOrEqual(0.85 + 1e-9);
|
||
});
|
||
|
||
it('returns empty for finished or empty plans', () => {
|
||
const g = chainGalaxy();
|
||
expect(cloudFor(g, chainPlan(), 99)).toEqual([]);
|
||
expect(cloudFor(g, { ...chainPlan(), legs: [] }, 1)).toEqual([]);
|
||
});
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 2: Run tests to verify they fail**
|
||
|
||
Run: `npx vitest run tests/clouds.test.js`
|
||
Expected: FAIL — cannot resolve `../src/sim/clouds.js`.
|
||
|
||
- [ ] **Step 3: Implement src/sim/clouds.js**
|
||
|
||
```js
|
||
import { VIS_BAND } from './constants.js';
|
||
import { JIT_MIN, JIT_SPAN, DELAY_MULT, hazardProbs } from './npc.js';
|
||
|
||
export function totalMass(segs) {
|
||
return segs.reduce((sum, s) => sum + s.w, 0);
|
||
}
|
||
|
||
// The cloud is computed from the ANALYTIC envelope of the plan — possible
|
||
// durations per leg — never from the hidden seed rolls. For each leg we
|
||
// compute the feasible progress interval [f0, f1] at time t:
|
||
// f1: progress if everything so far ran fastest (earliest entry, fastest leg)
|
||
// f0: progress if everything ran slowest (latest entry, slowest leg)
|
||
// Weight ~ feasible track-days on that leg x survival probability, normalized.
|
||
export function cloudFor(galaxy, plan, t) {
|
||
if (!plan || plan.legs.length === 0) return [];
|
||
const e = t - plan.startTime;
|
||
if (e <= 0) return [];
|
||
|
||
let minStart = 0, maxStart = 0, survival = 1;
|
||
const raw = [];
|
||
for (const leg of plan.legs) {
|
||
const lane = galaxy.lanes[leg.laneId];
|
||
const minD = lane.days * JIT_MIN;
|
||
const maxD = lane.days * (JIT_MIN + JIT_SPAN) * DELAY_MULT;
|
||
// fastest possible progress: entered at minStart, moving at max speed
|
||
const f1 = (e - minStart) / minD;
|
||
// slowest possible progress: entered at maxStart, moving at min speed
|
||
const f0 = (e - maxStart) / maxD;
|
||
const cf0 = Math.min(1, Math.max(0, f0));
|
||
const cf1 = Math.min(1, Math.max(0, f1));
|
||
if (cf1 > cf0) {
|
||
raw.push({ laneId: leg.laneId, fromSys: leg.fromSys, toSys: leg.toSys, f0: cf0, f1: cf1, w: (cf1 - cf0) * lane.days * survival });
|
||
}
|
||
const p = hazardProbs(lane.hazard);
|
||
survival *= 1 - p.destroyed;
|
||
minStart += minD;
|
||
maxStart += maxD;
|
||
}
|
||
return normalize(raw);
|
||
}
|
||
|
||
// Player observes the VIS_BAND ends of every lane touching their system.
|
||
// A ship not concretely visible there cannot be there: cut those intervals
|
||
// out of the cloud and renormalize the rest.
|
||
export function excludeVisibleBand(galaxy, segs, observedSystemId) {
|
||
const out = [];
|
||
for (const seg of segs) {
|
||
const lane = galaxy.lanes[seg.laneId];
|
||
if (lane.a !== observedSystemId && lane.b !== observedSystemId) {
|
||
out.push({ ...seg });
|
||
continue;
|
||
}
|
||
// Band near the observed system, in this segment's fromSys->toSys orientation.
|
||
const nearFrom = seg.fromSys === observedSystemId;
|
||
const bandLo = nearFrom ? 0 : 1 - VIS_BAND;
|
||
const bandHi = nearFrom ? VIS_BAND : 1;
|
||
const den = seg.f1 - seg.f0;
|
||
// keep the part below the band
|
||
if (seg.f0 < bandLo) {
|
||
const f1 = Math.min(seg.f1, bandLo);
|
||
out.push({ ...seg, f1, w: seg.w * ((f1 - seg.f0) / den) });
|
||
}
|
||
// keep the part above the band
|
||
if (seg.f1 > bandHi) {
|
||
const f0 = Math.max(seg.f0, bandHi);
|
||
out.push({ ...seg, f0, w: seg.w * ((seg.f1 - f0) / den) });
|
||
}
|
||
}
|
||
return normalize(out);
|
||
}
|
||
|
||
function normalize(segs) {
|
||
const m = totalMass(segs);
|
||
if (m <= 0) return [];
|
||
return segs.map((s) => ({ ...s, w: s.w / m }));
|
||
}
|
||
|
||
// All player-visible clouds: seen, alive, in-transit NPCs on multi-system runs.
|
||
export function playerClouds(world, observedSystemId) {
|
||
const out = [];
|
||
for (const npcId of world.player.seenNpcs) {
|
||
const npc = world.npcs[npcId];
|
||
if (!npc.alive || npc.state !== 'transit' || !npc.plan) continue;
|
||
let segs = cloudFor(world.galaxy, npc.plan, world.t);
|
||
if (observedSystemId != null) segs = excludeVisibleBand(world.galaxy, segs, observedSystemId);
|
||
if (segs.length > 0) out.push({ npcId, name: npc.name, segs });
|
||
}
|
||
return out;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Run tests to verify they pass**
|
||
|
||
Run: `npx vitest run tests/clouds.test.js`
|
||
Expected: 5 passed.
|
||
|
||
- [ ] **Step 5: Run the whole suite**
|
||
|
||
Run: `npx vitest run`
|
||
Expected: all pass.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add src/sim/clouds.js tests/clouds.test.js
|
||
git commit -m "feat: probability clouds with envelope math and band exclusion"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 12: Pixi app and galaxy view (`render/app.js`, `render/galaxyView.js`)
|
||
|
||
The neon canvas: bloom-filtered world layer, label layer without bloom. Galaxy view draws the static graph plus dynamic player marker and clouds. Rendering tasks are verified visually, not unit-tested.
|
||
|
||
**Files:**
|
||
- Create: `src/render/app.js`, `src/render/galaxyView.js`
|
||
- Modify: `src/main.js`
|
||
|
||
- [ ] **Step 1: Implement src/render/app.js**
|
||
|
||
```js
|
||
import { Application, Container } from 'pixi.js';
|
||
import { AdvancedBloomFilter } from 'pixi-filters/advanced-bloom';
|
||
|
||
export async function createRenderer(stageEl) {
|
||
const app = new Application();
|
||
await app.init({ resizeTo: stageEl, background: '#05070d', antialias: true });
|
||
stageEl.appendChild(app.canvas);
|
||
|
||
// Bloomed world geometry; crisp labels above it, outside the filter.
|
||
const world = new Container();
|
||
world.filters = [new AdvancedBloomFilter({ threshold: 0.18, bloomScale: 1.4, blur: 6, quality: 4 })];
|
||
const labels = new Container();
|
||
app.stage.addChild(world, labels);
|
||
|
||
return { app, world, labels };
|
||
}
|
||
|
||
// Map sim hazard [0, 0.7] to a cyan->red neon tint.
|
||
export function hazardColor(h) {
|
||
const t = Math.min(1, h / 0.7);
|
||
const r = Math.round(0 + t * 255);
|
||
const g = Math.round(229 - t * 170);
|
||
const b = Math.round(255 - t * 175);
|
||
return (r << 16) | (g << 8) | b;
|
||
}
|
||
|
||
// Fit sim coords (1000x700 layout space) into the canvas with padding.
|
||
export function makeViewTransform(app) {
|
||
const w = app.renderer.width, h = app.renderer.height;
|
||
const scale = Math.min(w / 1060, h / 740);
|
||
const ox = (w - 1000 * scale) / 2, oy = (h - 700 * scale) / 2;
|
||
return { x: (sx) => ox + sx * scale, y: (sy) => oy + sy * scale, scale };
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Implement src/render/galaxyView.js**
|
||
|
||
```js
|
||
import { Container, Graphics, Text } from 'pixi.js';
|
||
import { hazardColor, makeViewTransform } from './app.js';
|
||
import { playerClouds } from '../sim/clouds.js';
|
||
import { playerSystem } from '../sim/player.js';
|
||
import { otherEnd } from '../sim/galaxy.js';
|
||
|
||
export class GalaxyView {
|
||
// onSelectSystem(systemId) -> UI intel panel; onJump(laneId) -> goTo lane
|
||
constructor(renderer, world, { onSelectSystem, onJump }) {
|
||
this.renderer = renderer;
|
||
this.world = world;
|
||
this.onSelectSystem = onSelectSystem;
|
||
this.onJump = onJump;
|
||
this.container = new Container();
|
||
this.labelContainer = new Container();
|
||
renderer.world.addChild(this.container);
|
||
renderer.labels.addChild(this.labelContainer);
|
||
|
||
this.staticG = new Graphics();
|
||
this.cloudG = new Graphics();
|
||
this.dynamicG = new Graphics();
|
||
this.container.addChild(this.staticG, this.cloudG, this.dynamicG);
|
||
this.hitNodes = new Container();
|
||
this.container.addChild(this.hitNodes);
|
||
this.buildStatic();
|
||
}
|
||
|
||
buildStatic() {
|
||
const { galaxy } = this.world;
|
||
const v = makeViewTransform(this.renderer.app);
|
||
this.v = v;
|
||
const g = this.staticG;
|
||
g.clear();
|
||
for (const lane of galaxy.lanes) {
|
||
const a = galaxy.systems[lane.a], b = galaxy.systems[lane.b];
|
||
g.moveTo(v.x(a.x), v.y(a.y)).lineTo(v.x(b.x), v.y(b.y))
|
||
.stroke({ width: 2, color: hazardColor(lane.hazard), alpha: 0.55 });
|
||
}
|
||
for (const s of galaxy.systems) {
|
||
g.circle(v.x(s.x), v.y(s.y), 7).fill({ color: 0xcfeeff });
|
||
}
|
||
this.labelContainer.removeChildren();
|
||
this.hitNodes.removeChildren();
|
||
for (const s of galaxy.systems) {
|
||
const label = new Text({ text: s.name, style: { fill: '#7fa8c9', fontSize: 11, fontFamily: 'system-ui' } });
|
||
label.position.set(v.x(s.x) + 10, v.y(s.y) - 6);
|
||
this.labelContainer.addChild(label);
|
||
|
||
const hit = new Graphics().circle(0, 0, 16).fill({ color: 0xffffff, alpha: 0.001 });
|
||
hit.position.set(v.x(s.x), v.y(s.y));
|
||
hit.eventMode = 'static';
|
||
hit.cursor = 'pointer';
|
||
hit.on('pointertap', () => this.handleSystemTap(s.id));
|
||
this.hitNodes.addChild(hit);
|
||
}
|
||
}
|
||
|
||
handleSystemTap(systemId) {
|
||
const sim = this.world;
|
||
const cur = playerSystem(sim);
|
||
// Clicking a neighbor = jump intent; clicking anything = intel selection.
|
||
const lane = sim.galaxy.lanes.find(
|
||
(l) => (l.a === cur && l.b === systemId) || (l.b === cur && l.a === systemId)
|
||
);
|
||
if (lane) this.onJump(lane.id);
|
||
this.onSelectSystem(systemId);
|
||
}
|
||
|
||
lanePoint(lane, fromSys, f) {
|
||
const { galaxy } = this.world;
|
||
const a = galaxy.systems[fromSys];
|
||
const b = galaxy.systems[otherEnd(lane, fromSys)];
|
||
return { x: this.v.x(a.x + (b.x - a.x) * f), y: this.v.y(a.y + (b.y - a.y) * f) };
|
||
}
|
||
|
||
update() {
|
||
const sim = this.world;
|
||
const v = this.v;
|
||
const cur = playerSystem(sim);
|
||
|
||
// Probability clouds: glowing strokes along lane segments, alpha ~ weight.
|
||
const cg = this.cloudG;
|
||
cg.clear();
|
||
for (const cloud of playerClouds(sim, cur)) {
|
||
for (const seg of cloud.segs) {
|
||
const lane = sim.galaxy.lanes[seg.laneId];
|
||
const p0 = this.lanePoint(lane, seg.fromSys, seg.f0);
|
||
const p1 = this.lanePoint(lane, seg.fromSys, seg.f1);
|
||
const alpha = Math.min(0.85, 0.18 + seg.w * 0.9);
|
||
cg.moveTo(p0.x, p0.y).lineTo(p1.x, p1.y)
|
||
.stroke({ width: 7, color: 0xffa94d, alpha });
|
||
}
|
||
}
|
||
|
||
// Player marker: bright cyan triangle at current position.
|
||
const dg = this.dynamicG;
|
||
dg.clear();
|
||
const pos = this.playerXY();
|
||
if (pos) {
|
||
dg.poly([pos.x, pos.y - 8, pos.x + 6, pos.y + 6, pos.x - 6, pos.y + 6]).fill({ color: 0x4dffd2 });
|
||
}
|
||
// Halo on the player's current system.
|
||
if (cur != null) {
|
||
const s = sim.galaxy.systems[cur];
|
||
dg.circle(v.x(s.x), v.y(s.y), 12).stroke({ width: 2, color: 0x4dffd2, alpha: 0.8 });
|
||
}
|
||
}
|
||
|
||
playerXY() {
|
||
const sim = this.world;
|
||
const loc = sim.player.loc;
|
||
const v = this.v;
|
||
if (loc.kind === 'lane') {
|
||
const lane = sim.galaxy.lanes[loc.laneId];
|
||
const f = Math.min(1, (sim.t - loc.departT) / (loc.arriveT - loc.departT));
|
||
return this.lanePoint(lane, loc.fromSys, f);
|
||
}
|
||
const sysId = playerSystem(sim);
|
||
if (sysId == null) return null;
|
||
const s = sim.galaxy.systems[sysId];
|
||
return { x: v.x(s.x), y: v.y(s.y) };
|
||
}
|
||
|
||
setVisible(on) {
|
||
this.container.visible = on;
|
||
this.labelContainer.visible = on;
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Wire a temporary main.js to see it**
|
||
|
||
Replace `src/main.js`:
|
||
|
||
```js
|
||
import { createWorld, advance } from './sim/world.js';
|
||
import { createRenderer } from './render/app.js';
|
||
import { GalaxyView } from './render/galaxyView.js';
|
||
import { goTo } from './sim/player.js';
|
||
|
||
const world = createWorld('neon-1');
|
||
const renderer = await createRenderer(document.getElementById('stage'));
|
||
const galaxyView = new GalaxyView(renderer, world, {
|
||
onSelectSystem: (id) => console.log('select system', id),
|
||
onJump: (laneId) => goTo(world, { laneId }),
|
||
});
|
||
|
||
const DAYS_PER_SEC = 0.25;
|
||
renderer.app.ticker.add(() => {
|
||
advance(world, world.t + (renderer.app.ticker.deltaMS / 1000) * DAYS_PER_SEC);
|
||
galaxyView.update();
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 4: Verify visually**
|
||
|
||
Run: `npm run dev`
|
||
Open http://localhost:5173 and check:
|
||
- Dark background, glowing (bloomed) system dots connected by tinted lanes — safe lanes cyan, dangerous ones red.
|
||
- System names render crisply (no bloom on text).
|
||
- A cyan triangle (player) sits on system Arcadia with a halo ring.
|
||
- Clicking a neighboring system sends the player there (triangle creeps along the lane).
|
||
- After some NPCs pass through the player's system (wait ~a minute, or temporarily add a few NPC ids to `world.player.seenNpcs` in the console via a debug export), orange glowing smears appear along lanes.
|
||
|
||
If WebGL is unavailable in your test browser, confirm `npx vitest run` still passes and defer the visual check to the playtest task.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/render/app.js src/render/galaxyView.js src/main.js
|
||
git commit -m "feat: neon galaxy view with bloom, hazard tints, clouds"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 13: System view (`render/systemView.js`)
|
||
|
||
The classical bubble made visible: star, planets, gates, and concrete ship triangles sliding along legs.
|
||
|
||
**Files:**
|
||
- Create: `src/render/systemView.js`
|
||
|
||
- [ ] **Step 1: Implement src/render/systemView.js**
|
||
|
||
```js
|
||
import { Container, Graphics, Text } from 'pixi.js';
|
||
import { shipsIn } from '../sim/npc.js';
|
||
import { playerSystem } from '../sim/player.js';
|
||
import { otherEnd } from '../sim/galaxy.js';
|
||
|
||
const GATE_RADIUS = 290;
|
||
|
||
export class SystemView {
|
||
constructor(renderer, world, { onGotoPlanet, onJump }) {
|
||
this.renderer = renderer;
|
||
this.world = world;
|
||
this.onGotoPlanet = onGotoPlanet;
|
||
this.onJump = onJump;
|
||
this.container = new Container();
|
||
this.labelContainer = new Container();
|
||
renderer.world.addChild(this.container);
|
||
renderer.labels.addChild(this.labelContainer);
|
||
this.staticG = new Graphics();
|
||
this.shipG = new Graphics();
|
||
this.container.addChild(this.staticG, this.shipG);
|
||
this.hitNodes = new Container();
|
||
this.container.addChild(this.hitNodes);
|
||
this.builtFor = null;
|
||
}
|
||
|
||
center() {
|
||
return { x: this.renderer.app.renderer.width / 2, y: this.renderer.app.renderer.height / 2 };
|
||
}
|
||
|
||
planetXY(planet) {
|
||
const c = this.center();
|
||
return { x: c.x + Math.cos(planet.orbit.a) * planet.orbit.r, y: c.y + Math.sin(planet.orbit.a) * planet.orbit.r };
|
||
}
|
||
|
||
gateXY(lane, systemId) {
|
||
const { galaxy } = this.world;
|
||
const here = galaxy.systems[systemId];
|
||
const there = galaxy.systems[otherEnd(lane, systemId)];
|
||
const ang = Math.atan2(there.y - here.y, there.x - here.x);
|
||
const c = this.center();
|
||
return { x: c.x + Math.cos(ang) * GATE_RADIUS, y: c.y + Math.sin(ang) * GATE_RADIUS, ang };
|
||
}
|
||
|
||
build(systemId) {
|
||
this.builtFor = systemId;
|
||
const { galaxy } = this.world;
|
||
const sys = galaxy.systems[systemId];
|
||
const c = this.center();
|
||
const g = this.staticG;
|
||
g.clear();
|
||
this.labelContainer.removeChildren();
|
||
this.hitNodes.removeChildren();
|
||
|
||
g.circle(c.x, c.y, 30).fill({ color: 0xffe9a8 }); // the star, brightest thing on screen
|
||
|
||
for (const planetId of sys.planets) {
|
||
const planet = galaxy.planets[planetId];
|
||
const p = this.planetXY(planet);
|
||
g.circle(c.x, c.y, planet.orbit.r).stroke({ width: 1, color: 0x1b3a55, alpha: 0.6 });
|
||
g.circle(p.x, p.y, 10).fill({ color: 0x7fb4ff });
|
||
const label = new Text({
|
||
text: `${planet.name} (${planet.kind})`,
|
||
style: { fill: '#7fa8c9', fontSize: 11, fontFamily: 'system-ui' },
|
||
});
|
||
label.position.set(p.x + 14, p.y - 6);
|
||
this.labelContainer.addChild(label);
|
||
const hit = new Graphics().circle(0, 0, 18).fill({ color: 0xffffff, alpha: 0.001 });
|
||
hit.position.set(p.x, p.y);
|
||
hit.eventMode = 'static';
|
||
hit.cursor = 'pointer';
|
||
hit.on('pointertap', () => this.onGotoPlanet(planetId));
|
||
this.hitNodes.addChild(hit);
|
||
}
|
||
|
||
for (const laneId of sys.lanes) {
|
||
const lane = galaxy.lanes[laneId];
|
||
const gp = this.gateXY(lane, systemId);
|
||
g.poly([gp.x, gp.y - 8, gp.x + 8, gp.y, gp.x, gp.y + 8, gp.x - 8, gp.y]).fill({ color: 0x9fd8ff });
|
||
const name = galaxy.systems[otherEnd(lane, systemId)].name;
|
||
const label = new Text({ text: `→ ${name}`, style: { fill: '#7fa8c9', fontSize: 11, fontFamily: 'system-ui' } });
|
||
label.position.set(gp.x + 10, gp.y - 6);
|
||
this.labelContainer.addChild(label);
|
||
const hit = new Graphics().circle(0, 0, 16).fill({ color: 0xffffff, alpha: 0.001 });
|
||
hit.position.set(gp.x, gp.y);
|
||
hit.eventMode = 'static';
|
||
hit.cursor = 'pointer';
|
||
hit.on('pointertap', () => this.onJump(laneId));
|
||
this.hitNodes.addChild(hit);
|
||
}
|
||
}
|
||
|
||
// A ship triangle pointing along its heading.
|
||
drawShip(g, x, y, ang, color) {
|
||
const cos = Math.cos(ang), sin = Math.sin(ang);
|
||
const pt = (dx, dy) => [x + dx * cos - dy * sin, y + dx * sin + dy * cos];
|
||
g.poly([...pt(10, 0), ...pt(-7, 6), ...pt(-7, -6)]).fill({ color });
|
||
}
|
||
|
||
update() {
|
||
const sim = this.world;
|
||
const sysId = playerSystem(sim);
|
||
if (sysId == null) return;
|
||
if (this.builtFor !== sysId) this.build(sysId);
|
||
const { galaxy } = sim;
|
||
const c = this.center();
|
||
const g = this.shipG;
|
||
g.clear();
|
||
|
||
// Concrete NPC ships.
|
||
for (const entry of shipsIn(sim, sysId)) {
|
||
if (entry.kind === 'docked') {
|
||
const p = this.planetXY(galaxy.planets[entry.planetId]);
|
||
this.drawShip(g, p.x + 18, p.y - 14, -Math.PI / 2, 0xffa94d);
|
||
} else if (entry.kind === 'arriving' || entry.kind === 'departing') {
|
||
const lane = galaxy.lanes[entry.laneId];
|
||
const gp = this.gateXY(lane, sysId);
|
||
const dest = entry.npc.plan ? galaxy.planets[entry.npc.plan.destPlanet] : null;
|
||
const target = dest && dest.systemId === sysId ? this.planetXY(dest) : c;
|
||
const f = entry.kind === 'arriving' ? entry.progress : 1 - entry.progress;
|
||
const x = gp.x + (target.x - gp.x) * f * 0.5;
|
||
const y = gp.y + (target.y - gp.y) * f * 0.5;
|
||
const ang = entry.kind === 'arriving'
|
||
? Math.atan2(target.y - gp.y, target.x - gp.x)
|
||
: Math.atan2(gp.y - target.y, gp.x - target.x);
|
||
this.drawShip(g, x, y, ang, 0xffa94d);
|
||
} else if (entry.kind === 'local-hop') {
|
||
const from = this.planetXY(galaxy.planets[entry.npc.plan.originPlanet]);
|
||
const to = this.planetXY(galaxy.planets[entry.npc.plan.destPlanet]);
|
||
const x = from.x + (to.x - from.x) * entry.progress;
|
||
const y = from.y + (to.y - from.y) * entry.progress;
|
||
this.drawShip(g, x, y, Math.atan2(to.y - from.y, to.x - from.x), 0xffa94d);
|
||
}
|
||
}
|
||
|
||
// The player.
|
||
const loc = sim.player.loc;
|
||
if (loc.kind === 'docked') {
|
||
const p = this.planetXY(galaxy.planets[loc.planetId]);
|
||
this.drawShip(g, p.x - 18, p.y - 14, -Math.PI / 2, 0x4dffd2);
|
||
} else if (loc.kind === 'at-gate') {
|
||
const gp = this.gateXY(galaxy.lanes[loc.laneId], sysId);
|
||
this.drawShip(g, gp.x, gp.y - 16, -Math.PI / 2, 0x4dffd2);
|
||
} else if (loc.kind === 'insys-leg') {
|
||
const resolve = (d) => d.type === 'planet'
|
||
? this.planetXY(galaxy.planets[d.id])
|
||
: this.gateXY(galaxy.lanes[d.laneId], sysId);
|
||
const from = resolve(loc.from), to = resolve(loc.to);
|
||
const f = Math.min(1, (sim.t - loc.departT) / (loc.arriveT - loc.departT));
|
||
const x = from.x + (to.x - from.x) * f;
|
||
const y = from.y + (to.y - from.y) * f;
|
||
this.drawShip(g, x, y, Math.atan2(to.y - from.y, to.x - from.x), 0x4dffd2);
|
||
}
|
||
}
|
||
|
||
setVisible(on) {
|
||
this.container.visible = on;
|
||
this.labelContainer.visible = on;
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Wire view switching into main.js**
|
||
|
||
Replace `src/main.js`:
|
||
|
||
```js
|
||
import { createWorld, advance } from './sim/world.js';
|
||
import { createRenderer } from './render/app.js';
|
||
import { GalaxyView } from './render/galaxyView.js';
|
||
import { SystemView } from './render/systemView.js';
|
||
import { goTo, playerSystem } from './sim/player.js';
|
||
|
||
const world = createWorld('neon-1');
|
||
const renderer = await createRenderer(document.getElementById('stage'));
|
||
|
||
const handlers = {
|
||
onSelectSystem: (id) => console.log('select system', id),
|
||
onJump: (laneId) => goTo(world, { laneId }),
|
||
onGotoPlanet: (planetId) => goTo(world, { planetId }),
|
||
};
|
||
const galaxyView = new GalaxyView(renderer, world, handlers);
|
||
const systemView = new SystemView(renderer, world, handlers);
|
||
|
||
let view = 'system';
|
||
function setView(name) {
|
||
view = name;
|
||
galaxyView.setVisible(name === 'galaxy');
|
||
systemView.setVisible(name === 'system');
|
||
}
|
||
setView('system');
|
||
window.addEventListener('keydown', (e) => {
|
||
if (e.key === 'Tab') {
|
||
e.preventDefault();
|
||
setView(view === 'galaxy' ? 'system' : 'galaxy');
|
||
}
|
||
});
|
||
|
||
const DAYS_PER_SEC = 0.25;
|
||
renderer.app.ticker.add(() => {
|
||
advance(world, world.t + (renderer.app.ticker.deltaMS / 1000) * DAYS_PER_SEC);
|
||
if (view === 'galaxy') galaxyView.update();
|
||
else if (playerSystem(world) != null) systemView.update();
|
||
});
|
||
```
|
||
|
||
- [ ] **Step 3: Verify visually**
|
||
|
||
Run: `npm run dev`, open http://localhost:5173 and check:
|
||
- System view: glowing star, planets on faint orbit rings with name+kind labels, diamond gates at the rim labeled `→ Neighbor`.
|
||
- Player triangle (cyan) parked at the start planet; orange NPC triangles dock and pop out of gates over time — the arrival-queue mechanic visible on screen.
|
||
- Clicking a planet flies the player there; clicking a gate jumps (screen switches to the new system after the lane transit — while on the lane, system view freezes on nearest system; that's fine for now).
|
||
- Tab toggles galaxy/system view.
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
git add src/render/systemView.js src/main.js
|
||
git commit -m "feat: system view with star, planets, gates, concrete ships"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 14: DOM UI panel (`ui/panel.js`) and full game wiring
|
||
|
||
Status, time controls, destination buttons, market table, intel for a selected system, message log, win/lose banner. This task completes the playable game.
|
||
|
||
**Files:**
|
||
- Create: `src/ui/panel.js`
|
||
- Modify: `src/main.js`
|
||
|
||
- [ ] **Step 1: Implement src/ui/panel.js**
|
||
|
||
```js
|
||
import { GOODS } from '../sim/constants.js';
|
||
import { goTo, buyGood, sellGood, playerSystem, playerMarket, cargoUsed } from '../sim/player.js';
|
||
import { playerKnownPrices } from '../sim/knowledge.js';
|
||
import { otherEnd } from '../sim/galaxy.js';
|
||
|
||
export class Panel {
|
||
constructor(el, world, controls) {
|
||
this.el = el;
|
||
this.world = world;
|
||
this.controls = controls; // { getSpeed, setSpeed, getView, setView }
|
||
this.selectedSystem = null;
|
||
el.innerHTML = `
|
||
<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 (12–14)** have no unit tests by design; the headless suite plus the documented manual checks are the verification. If a headless browser is available, a screenshot of each view is a bonus, not a requirement.
|
||
- **Out of scope, do not add:** combat, named pirates, missions, save/load, sound, free-flight movement. YAGNI.
|
||
```
|