diff --git a/.gitignore b/.gitignore
index b947077..e9184c1 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,2 +1,4 @@
node_modules/
dist/
+.playwright-mcp/
+*.png
diff --git a/README.md b/README.md
index 0fab21c..9befaca 100644
--- a/README.md
+++ b/README.md
@@ -21,8 +21,9 @@ from per-ship seeds) — the probability is *yours*, not the universe's.
- You start docked. Buy what a planet produces cheap, sell where it's consumed.
- Sidebar: navigation, market (when docked), cargo, intel, log. Space = pause,
- Tab = map/system view. Click planets/gates/systems to move; click systems on
- the map for price intel — note the age tag, old news lies.
+ 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.
diff --git a/index.html b/index.html
index 99defd0..2ad6dc7 100644
--- a/index.html
+++ b/index.html
@@ -4,6 +4,7 @@
Probability
+
diff --git a/package.json b/package.json
index bd9a4ea..c026797 100644
--- a/package.json
+++ b/package.json
@@ -6,6 +6,7 @@
"scripts": {
"dev": "vite",
"build": "vite build",
+ "preview": "vite preview",
"test": "vitest run"
},
"dependencies": {
diff --git a/src/main.js b/src/main.js
index 8edce7e..54b81b2 100644
--- a/src/main.js
+++ b/src/main.js
@@ -5,70 +5,73 @@ import { SystemView } from './render/systemView.js';
import { Panel } from './ui/panel.js';
import { goTo, playerSystem } from './sim/player.js';
-const world = createWorld(new URLSearchParams(location.search).get('seed') ?? 'neon-1');
-const renderer = await createRenderer(document.getElementById('stage'));
+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),
-};
+ 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);
+ 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;
-});
+ // 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');
+ 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);
+ }
+ });
}
-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();
diff --git a/src/render/galaxyView.js b/src/render/galaxyView.js
index 4c6f942..17602fc 100644
--- a/src/render/galaxyView.js
+++ b/src/render/galaxyView.js
@@ -5,7 +5,7 @@ import { playerSystem } from '../sim/player.js';
import { otherEnd } from '../sim/galaxy.js';
export class GalaxyView {
- // onSelectSystem(systemId) -> UI intel panel; onJump(laneId) -> goTo lane
+ // onSelectSystem(systemId) -> UI intel panel
constructor(renderer, world, { onSelectSystem, onJump }) {
this.renderer = renderer;
this.world = world;
@@ -56,13 +56,6 @@ export class GalaxyView {
}
handleSystemTap(systemId) {
- const sim = this.world;
- const cur = playerSystem(sim);
- // Clicking a neighbor = jump intent; clicking anything = intel selection.
- const lane = sim.galaxy.lanes.find(
- (l) => (l.a === cur && l.b === systemId) || (l.b === cur && l.a === systemId)
- );
- if (lane) this.onJump(lane.id);
this.onSelectSystem(systemId);
}
@@ -77,11 +70,13 @@ export class GalaxyView {
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, cur)) {
+ 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);
diff --git a/src/sim/clouds.js b/src/sim/clouds.js
index ce8af3b..273edc5 100644
--- a/src/sim/clouds.js
+++ b/src/sim/clouds.js
@@ -86,13 +86,28 @@ function normalize(segs) {
return segs.map((s) => ({ ...s, w: s.w / m }));
}
-// All player-visible clouds: seen, alive, in-transit NPCs on multi-system runs.
+// 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];
- if (!npc.alive || npc.state !== 'transit' || !npc.plan) continue;
- let segs = cloudFor(world.galaxy, npc.plan, world.t);
+ // 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 });
}
diff --git a/src/sim/knowledge.js b/src/sim/knowledge.js
index 7bde6fa..1a4a2a3 100644
--- a/src/sim/knowledge.js
+++ b/src/sim/knowledge.js
@@ -22,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);
diff --git a/src/sim/npc.js b/src/sim/npc.js
index 908647c..c46712a 100644
--- a/src/sim/npc.js
+++ b/src/sim/npc.js
@@ -102,6 +102,7 @@ export function planNpc(world, npc) {
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 });
}
@@ -113,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';
@@ -128,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 });
diff --git a/tests/clouds.test.js b/tests/clouds.test.js
index a5f890f..eed7f13 100644
--- a/tests/clouds.test.js
+++ b/tests/clouds.test.js
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest';
-import { cloudFor, excludeVisibleBand, totalMass } from '../src/sim/clouds.js';
+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() {
@@ -88,4 +88,20 @@ describe('clouds', () => {
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([]);
+ });
});
diff --git a/vite.config.js b/vite.config.js
index 25b8fe5..3363f90 100644
--- a/vite.config.js
+++ b/vite.config.js
@@ -1,5 +1,5 @@
import { defineConfig } from 'vite';
export default defineConfig({
- build: { target: 'esnext' }, // top-level await in src/main.js
+ build: { target: 'esnext' }, // modern output target
});