Compare commits

..

10 Commits

Author SHA1 Message Date
tes 36838efa73 chore: decline release-notes prompts for this repo 2026-06-12 15:42:23 +00:00
tes 051a111855 fix: prod-bundle boot, cloud-existence leak, observation gating (final review)
- Wrap main.js body in async main() to eliminate top-level await deadlock in Rollup
- Add npc.lastPlan so clouds persist after hidden arrival/destruction until analytic envelope expires
- Add maxEnvelopeArrival() helper; rewrite playerClouds() to use lastPlan for envelope-gated clouds
- Block markSeen() while player is mid-lane (no observation from hyperspace)
- Pass obsSys=null to playerClouds when player is in a lane (don't exclude visible band from hyperspace)
- Galaxy map clicks now select-only (intel); jumps via Nav buttons / gate diamonds only
- Add preview script, favicon no-op, .playwright-mcp/ and *.png to .gitignore, update README controls text
2026-06-12 15:35:20 +00:00
tes 395c0e851d feat: balance checks and README - v1 complete 2026-06-12 15:18:48 +00:00
tes a32749c14a fix: floor gate ring radius for tiny canvases (review) 2026-06-12 15:16:59 +00:00
tes 7726651c69 feat: UI panel, time controls, full playable trading loop
- Add src/ui/panel.js: sidebar with status, time controls (⏸/1×/4×/16×/Map-System toggle), navigation buttons, market table with buy/sell, cargo display, intel panel with price age tags, and a 10-entry log (newest first).
- Replace src/main.js: wires Panel + GalaxyView + SystemView together; game clock driven by PixiJS ticker at configurable DAYS_PER_SEC; panel DOM refresh at 4 Hz; VICTORY/SHIP LOST banner on end state; speed/view state via controls closure.
- Amendment A (systemView.js): gateXY uses canvas-proportional radius (min(GATE_RADIUS, min(c.x,c.y)-40)) so gates stay on-screen on small canvases.
- Amendment B (main.js): keydown handler guards e.target !== document.body to avoid hijacking keys from focused panel buttons.
- Amendment C (main.js): renderer resize event rebuilds GalaxyView static geometry and clears SystemView cache so cached transforms don't go stale.
2026-06-12 15:13:58 +00:00
tes 881518417a feat: system view with star, planets, gates, concrete ships 2026-06-12 14:56:06 +00:00
tes 61244453e3 feat: neon galaxy view with bloom, hazard tints, clouds 2026-06-12 14:47:51 +00:00
tes ca32407c43 fix: compute clouds from planned route so destruction cannot leak (review) 2026-06-12 14:44:24 +00:00
tes f563934314 feat: probability clouds with envelope math and band exclusion 2026-06-12 14:38:50 +00:00
tes 2da0bee712 fix: decouple price book entries from news ledger snapshots (review) 2026-06-12 14:36:39 +00:00
16 changed files with 850 additions and 2 deletions
+2
View File
@@ -1,2 +1,4 @@
node_modules/
dist/
.playwright-mcp/
*.png
+3
View File
@@ -0,0 +1,3 @@
<!-- release-notes-config
declined: true
-->
+30
View File
@@ -0,0 +1,30 @@
# 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. Move by clicking planets and gate diamonds in the
system view, or use the Navigation buttons. Click systems on the galaxy 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.
+1
View File
@@ -4,6 +4,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Probability</title>
<link rel="icon" href="data:," />
<link rel="stylesheet" href="/src/styles.css" />
</head>
<body>
+1
View File
@@ -6,6 +6,7 @@
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"test": "vitest run"
},
"dependencies": {
+77 -1
View File
@@ -1 +1,77 @@
console.log('Probability — boot ok');
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';
async function main() {
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);
// Rebuild static geometry when the canvas resizes (review: cached transforms went stale).
renderer.app.renderer.on('resize', () => {
galaxyView.buildStatic();
systemView.builtFor = null;
});
function setView(name) {
view = name;
galaxyView.setVisible(name === 'galaxy');
systemView.setVisible(name === 'system');
}
setView('system');
window.addEventListener('keydown', (e) => {
if (e.target !== document.body) return; // don't hijack keys from panel buttons (review)
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);
}
});
}
main();
+33
View File
@@ -0,0 +1,33 @@
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 };
}
+123
View File
@@ -0,0 +1,123 @@
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
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) {
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);
// Mid-lane the player observes nothing; don't exclude any visible band.
const obsSys = sim.player.loc.kind === 'lane' ? null : cur;
// Probability clouds: glowing strokes along lane segments, alpha ~ weight.
const cg = this.cloudG;
cg.clear();
for (const cloud of playerClouds(sim, obsSys)) {
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;
}
}
+161
View File
@@ -0,0 +1,161 @@
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();
// Keep gates on-screen on small canvases (review: fixed 290 clipped hit targets).
const r = Math.max(60, Math.min(GATE_RADIUS, Math.min(c.x, c.y) - 40));
return { x: c.x + Math.cos(ang) * r, y: c.y + Math.sin(ang) * r, 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;
}
}
+115
View File
@@ -0,0 +1,115 @@
import { VIS_BAND } from './constants.js';
import { JIT_MIN, JIT_SPAN, DELAY_MULT, hazardProbs } from './npc.js';
import { otherEnd } from './galaxy.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) return [];
// Walk the PLANNED route, not the resolved legs: a destroyed itinerary has
// truncated legs, and using them would leak the hidden outcome via the
// cloud's shape. Synthetic plans without routeLanes fall back to legs.
const laneIds = plan.routeLanes ?? plan.legs.map((l) => l.laneId);
if (laneIds.length === 0) return [];
const e = t - plan.startTime;
if (e <= 0) return [];
let sys = plan.startSys ?? (plan.legs.length > 0 ? plan.legs[0].fromSys : null);
if (sys == null) return [];
let minStart = 0, maxStart = 0, survival = 1;
const raw = [];
for (const laneId of laneIds) {
const lane = galaxy.lanes[laneId];
const toSys = otherEnd(lane, sys);
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, fromSys: sys, toSys, f0: cf0, f1: cf1, w: (cf1 - cf0) * lane.days * survival });
}
const p = hazardProbs(lane.hazard);
survival *= 1 - p.destroyed;
minStart += minD;
maxStart += maxD;
sys = toSys;
}
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;
if (den <= 0) continue; // degenerate segment: drop rather than NaN-poison
// 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 }));
}
// Latest possible arrival under the analytic envelope (slowest legal journey).
export function maxEnvelopeArrival(galaxy, plan) {
const laneIds = plan.routeLanes ?? plan.legs.map((l) => l.laneId);
let t = plan.startTime;
for (const laneId of laneIds) t += galaxy.lanes[laneId].days * (JIT_MIN + JIT_SPAN) * DELAY_MULT;
return t;
}
// All player-visible clouds: seen NPCs whose cloud envelope hasn't yet expired.
export function playerClouds(world, observedSystemId) {
const out = [];
for (const npcId of world.player.seenNpcs) {
const npc = world.npcs[npcId];
// Active journey, or a finished/doomed one whose envelope hasn't expired:
// cloud EXISTENCE must not reveal the hidden outcome either.
let plan = null;
if (npc.alive && npc.state === 'transit' && npc.plan) plan = npc.plan;
else if (npc.lastPlan && world.t < maxEnvelopeArrival(world.galaxy, npc.lastPlan)) plan = npc.lastPlan;
if (!plan) continue;
// A ship the player can concretely see right now has no cloud.
if (npc.alive && npc.state === 'docked' && npc.systemId === observedSystemId) continue;
let segs = cloudFor(world.galaxy, 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;
}
+3 -1
View File
@@ -13,7 +13,8 @@ export function playerKnownPrices(world, planetId) {
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;
// Copy the envelope so the price book never aliases NewsLedger's stored snapshots.
if (best && (!book || best.time > book.time)) p.priceBook[planetId] = { time: best.time, prices: best.prices };
return best ? { time: best.time, prices: best.prices, ageDays: world.t - best.time } : null;
}
@@ -21,6 +22,7 @@ export function playerKnownPrices(world, planetId) {
// the player's system becomes "seen" — its cloud is now player-visible forever.
export function markSeen(world) {
if (!world.player.alive) return;
if (world.player.loc.kind === 'lane') return; // no observation from hyperspace
const sys = playerSystem(world);
if (sys == null) return;
for (const { npc } of shipsIn(world, sys)) world.player.seenNpcs.add(npc.id);
+6
View File
@@ -97,8 +97,12 @@ export function planNpc(world, npc) {
npc.plan = {
id: planId, good: best.good, qty: best.qty,
originPlanet: origin.id, destPlanet: best.dest.id,
// Planned route (public knowledge for clouds) — unlike legs, never
// truncated by a hidden 'destroyed' roll.
routeLanes: best.route.lanes, startSys: origin.systemId,
startTime: t, ...itin,
};
npc.lastPlan = null; // superseded; v1 simplification: a fresh cloud implies the previous run ended
npc.state = 'transit';
world.queue.push(itin.arriveTime, 'npc-arrive', { npcId: npc.id, planId });
}
@@ -110,6 +114,7 @@ export function onNpcArrive(world, { npcId, planId }) {
const plan = npc.plan;
if (plan.finalOutcome === 'destroyed') {
npc.alive = false;
npc.lastPlan = plan; // cloud persists from the envelope until observed/expired
npc.plan = null;
npc.cargo = null;
npc.state = 'destroyed';
@@ -125,6 +130,7 @@ export function onNpcArrive(world, { npcId, planId }) {
npc.credits = Math.round((npc.credits + price * npc.cargo.qty) * 100) / 100;
world.stats.trades++;
}
npc.lastPlan = plan; // unobserved arrival: the player's cloud persists a while
npc.cargo = null;
npc.plan = null;
world.queue.push(world.t + DOCK_DELAY, 'npc-replan', { npcId });
+152
View File
@@ -0,0 +1,152 @@
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;
}
}
+31
View File
@@ -0,0 +1,31 @@
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
});
});
+107
View File
@@ -0,0 +1,107 @@
import { describe, it, expect } from 'vitest';
import { cloudFor, excludeVisibleBand, totalMass, playerClouds } 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([]);
});
it('a hidden destroyed outcome does not change the cloud shape', () => {
const g = chainGalaxy();
const intact = chainPlan();
intact.routeLanes = [0, 1, 2];
intact.startSys = 0;
const doomed = chainPlan();
doomed.routeLanes = [0, 1, 2];
doomed.startSys = 0;
doomed.finalOutcome = 'destroyed';
doomed.legs = doomed.legs.slice(0, 1); // itinerary truncated at the hidden destruction
for (const t of [1, 3, 4.5]) {
expect(JSON.stringify(cloudFor(g, doomed, t))).toBe(JSON.stringify(cloudFor(g, intact, t)));
}
});
it('a cloud persists after a hidden early arrival or destruction until the envelope expires', () => {
const g = chainGalaxy();
const lastPlan = chainPlan();
lastPlan.routeLanes = [0, 1, 2];
lastPlan.startSys = 0;
const world = {
t: 4.5, // past a truncated arrival, inside the envelope (max = 6 * 1.15 * 1.6 = 11.04)
galaxy: g,
npcs: [{ id: 0, name: 'Ghost', alive: false, state: 'destroyed', plan: null, lastPlan }],
player: { seenNpcs: new Set([0]) },
};
expect(playerClouds(world, null).length).toBe(1);
world.t = 12; // envelope exhausted: cloud finally gone
expect(playerClouds(world, null)).toEqual([]);
});
});
+5
View File
@@ -0,0 +1,5 @@
import { defineConfig } from 'vite';
export default defineConfig({
build: { target: 'esnext' }, // modern output target
});