Compare commits
10 Commits
06957c65e1
...
36838efa73
| Author | SHA1 | Date | |
|---|---|---|---|
| 36838efa73 | |||
| 051a111855 | |||
| 395c0e851d | |||
| a32749c14a | |||
| 7726651c69 | |||
| 881518417a | |||
| 61244453e3 | |||
| ca32407c43 | |||
| f563934314 | |||
| 2da0bee712 |
@@ -1,2 +1,4 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.playwright-mcp/
|
||||
*.png
|
||||
|
||||
@@ -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.
|
||||
@@ -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>
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
+77
-1
@@ -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();
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
build: { target: 'esnext' }, // modern output target
|
||||
});
|
||||
Reference in New Issue
Block a user