Compare commits
10 Commits
06957c65e1
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 36838efa73 | |||
| 051a111855 | |||
| 395c0e851d | |||
| a32749c14a | |||
| 7726651c69 | |||
| 881518417a | |||
| 61244453e3 | |||
| ca32407c43 | |||
| f563934314 | |||
| 2da0bee712 |
@@ -1,2 +1,4 @@
|
|||||||
node_modules/
|
node_modules/
|
||||||
dist/
|
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 charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Probability</title>
|
<title>Probability</title>
|
||||||
|
<link rel="icon" href="data:," />
|
||||||
<link rel="stylesheet" href="/src/styles.css" />
|
<link rel="stylesheet" href="/src/styles.css" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
"test": "vitest run"
|
"test": "vitest run"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"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;
|
const book = p.priceBook[planetId] ?? null;
|
||||||
let best = book;
|
let best = book;
|
||||||
if (news && (!best || news.time > best.time)) best = news;
|
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;
|
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.
|
// the player's system becomes "seen" — its cloud is now player-visible forever.
|
||||||
export function markSeen(world) {
|
export function markSeen(world) {
|
||||||
if (!world.player.alive) return;
|
if (!world.player.alive) return;
|
||||||
|
if (world.player.loc.kind === 'lane') return; // no observation from hyperspace
|
||||||
const sys = playerSystem(world);
|
const sys = playerSystem(world);
|
||||||
if (sys == null) return;
|
if (sys == null) return;
|
||||||
for (const { npc } of shipsIn(world, sys)) world.player.seenNpcs.add(npc.id);
|
for (const { npc } of shipsIn(world, sys)) world.player.seenNpcs.add(npc.id);
|
||||||
|
|||||||
@@ -97,8 +97,12 @@ export function planNpc(world, npc) {
|
|||||||
npc.plan = {
|
npc.plan = {
|
||||||
id: planId, good: best.good, qty: best.qty,
|
id: planId, good: best.good, qty: best.qty,
|
||||||
originPlanet: origin.id, destPlanet: best.dest.id,
|
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,
|
startTime: t, ...itin,
|
||||||
};
|
};
|
||||||
|
npc.lastPlan = null; // superseded; v1 simplification: a fresh cloud implies the previous run ended
|
||||||
npc.state = 'transit';
|
npc.state = 'transit';
|
||||||
world.queue.push(itin.arriveTime, 'npc-arrive', { npcId: npc.id, planId });
|
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;
|
const plan = npc.plan;
|
||||||
if (plan.finalOutcome === 'destroyed') {
|
if (plan.finalOutcome === 'destroyed') {
|
||||||
npc.alive = false;
|
npc.alive = false;
|
||||||
|
npc.lastPlan = plan; // cloud persists from the envelope until observed/expired
|
||||||
npc.plan = null;
|
npc.plan = null;
|
||||||
npc.cargo = null;
|
npc.cargo = null;
|
||||||
npc.state = 'destroyed';
|
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;
|
npc.credits = Math.round((npc.credits + price * npc.cargo.qty) * 100) / 100;
|
||||||
world.stats.trades++;
|
world.stats.trades++;
|
||||||
}
|
}
|
||||||
|
npc.lastPlan = plan; // unobserved arrival: the player's cloud persists a while
|
||||||
npc.cargo = null;
|
npc.cargo = null;
|
||||||
npc.plan = null;
|
npc.plan = null;
|
||||||
world.queue.push(world.t + DOCK_DELAY, 'npc-replan', { npcId });
|
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