fix: prod-bundle boot, cloud-existence leak, observation gating (final review)

- Wrap main.js body in async main() to eliminate top-level await deadlock in Rollup
- Add npc.lastPlan so clouds persist after hidden arrival/destruction until analytic envelope expires
- Add maxEnvelopeArrival() helper; rewrite playerClouds() to use lastPlan for envelope-gated clouds
- Block markSeen() while player is mid-lane (no observation from hyperspace)
- Pass obsSys=null to playerClouds when player is in a lane (don't exclude visible band from hyperspace)
- Galaxy map clicks now select-only (intel); jumps via Nav buttons / gate diamonds only
- Add preview script, favicon no-op, .playwright-mcp/ and *.png to .gitignore, update README controls text
This commit is contained in:
2026-06-12 15:35:20 +00:00
parent 395c0e851d
commit 051a111855
11 changed files with 116 additions and 78 deletions
+2
View File
@@ -1,2 +1,4 @@
node_modules/ node_modules/
dist/ dist/
.playwright-mcp/
*.png
+3 -2
View File
@@ -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. - You start docked. Buy what a planet produces cheap, sell where it's consumed.
- Sidebar: navigation, market (when docked), cargo, intel, log. Space = pause, - Sidebar: navigation, market (when docked), cargo, intel, log. Space = pause,
Tab = map/system view. Click planets/gates/systems to move; click systems on Tab = map/system view. Move by clicking planets and gate diamonds in the
the map for price intel — note the age tag, old news lies. 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 - Lanes are tinted by pirate danger. Dangerous shortcuts are profitable until
they aren't. they aren't.
- Orange smears on the map are ships you've met: where they probably are now. - Orange smears on the map are ships you've met: where they probably are now.
+1
View File
@@ -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>
+1
View File
@@ -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": {
+27 -24
View File
@@ -5,40 +5,41 @@ import { SystemView } from './render/systemView.js';
import { Panel } from './ui/panel.js'; import { Panel } from './ui/panel.js';
import { goTo, playerSystem } from './sim/player.js'; import { goTo, playerSystem } from './sim/player.js';
const world = createWorld(new URLSearchParams(location.search).get('seed') ?? 'neon-1'); async function main() {
const renderer = await createRenderer(document.getElementById('stage')); const world = createWorld(new URLSearchParams(location.search).get('seed') ?? 'neon-1');
const renderer = await createRenderer(document.getElementById('stage'));
let view = 'system'; let view = 'system';
let speed = 1; let speed = 1;
const controls = { const controls = {
getSpeed: () => speed, getSpeed: () => speed,
setSpeed: (v) => { speed = v; }, setSpeed: (v) => { speed = v; },
getView: () => view, getView: () => view,
setView: (name) => setView(name), setView: (name) => setView(name),
}; };
const panel = new Panel(document.getElementById('panel'), world, controls); const panel = new Panel(document.getElementById('panel'), world, controls);
const handlers = { const handlers = {
onSelectSystem: (id) => { panel.selectSystem(id); panel.update(); }, onSelectSystem: (id) => { panel.selectSystem(id); panel.update(); },
onJump: (laneId) => goTo(world, { laneId }), onJump: (laneId) => goTo(world, { laneId }),
onGotoPlanet: (planetId) => goTo(world, { planetId }), onGotoPlanet: (planetId) => goTo(world, { planetId }),
}; };
const galaxyView = new GalaxyView(renderer, world, handlers); const galaxyView = new GalaxyView(renderer, world, handlers);
const systemView = new SystemView(renderer, world, handlers); const systemView = new SystemView(renderer, world, handlers);
// Rebuild static geometry when the canvas resizes (review: cached transforms went stale). // Rebuild static geometry when the canvas resizes (review: cached transforms went stale).
renderer.app.renderer.on('resize', () => { renderer.app.renderer.on('resize', () => {
galaxyView.buildStatic(); galaxyView.buildStatic();
systemView.builtFor = null; systemView.builtFor = null;
}); });
function setView(name) { function setView(name) {
view = name; view = name;
galaxyView.setVisible(name === 'galaxy'); galaxyView.setVisible(name === 'galaxy');
systemView.setVisible(name === 'system'); systemView.setVisible(name === 'system');
} }
setView('system'); setView('system');
window.addEventListener('keydown', (e) => { window.addEventListener('keydown', (e) => {
if (e.target !== document.body) return; // don't hijack keys from panel buttons (review) if (e.target !== document.body) return; // don't hijack keys from panel buttons (review)
if (e.key === 'Tab') { if (e.key === 'Tab') {
e.preventDefault(); e.preventDefault();
@@ -48,12 +49,12 @@ window.addEventListener('keydown', (e) => {
e.preventDefault(); e.preventDefault();
speed = speed === 0 ? 1 : 0; speed = speed === 0 ? 1 : 0;
} }
}); });
const DAYS_PER_SEC = 0.25; const DAYS_PER_SEC = 0.25;
let banner = null; let banner = null;
let uiClock = 0; let uiClock = 0;
renderer.app.ticker.add(() => { renderer.app.ticker.add(() => {
const dt = renderer.app.ticker.deltaMS / 1000; const dt = renderer.app.ticker.deltaMS / 1000;
if (speed > 0 && world.player.alive && !world.player.won) { if (speed > 0 && world.player.alive && !world.player.won) {
advance(world, world.t + dt * DAYS_PER_SEC * speed); advance(world, world.t + dt * DAYS_PER_SEC * speed);
@@ -71,4 +72,6 @@ renderer.app.ticker.add(() => {
banner.textContent = world.player.won ? 'VICTORY' : 'SHIP LOST'; banner.textContent = world.player.won ? 'VICTORY' : 'SHIP LOST';
document.getElementById('stage').appendChild(banner); document.getElementById('stage').appendChild(banner);
} }
}); });
}
main();
+4 -9
View File
@@ -5,7 +5,7 @@ import { playerSystem } from '../sim/player.js';
import { otherEnd } from '../sim/galaxy.js'; import { otherEnd } from '../sim/galaxy.js';
export class GalaxyView { export class GalaxyView {
// onSelectSystem(systemId) -> UI intel panel; onJump(laneId) -> goTo lane // onSelectSystem(systemId) -> UI intel panel
constructor(renderer, world, { onSelectSystem, onJump }) { constructor(renderer, world, { onSelectSystem, onJump }) {
this.renderer = renderer; this.renderer = renderer;
this.world = world; this.world = world;
@@ -56,13 +56,6 @@ export class GalaxyView {
} }
handleSystemTap(systemId) { 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); this.onSelectSystem(systemId);
} }
@@ -77,11 +70,13 @@ export class GalaxyView {
const sim = this.world; const sim = this.world;
const v = this.v; const v = this.v;
const cur = playerSystem(sim); 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. // Probability clouds: glowing strokes along lane segments, alpha ~ weight.
const cg = this.cloudG; const cg = this.cloudG;
cg.clear(); cg.clear();
for (const cloud of playerClouds(sim, cur)) { for (const cloud of playerClouds(sim, obsSys)) {
for (const seg of cloud.segs) { for (const seg of cloud.segs) {
const lane = sim.galaxy.lanes[seg.laneId]; const lane = sim.galaxy.lanes[seg.laneId];
const p0 = this.lanePoint(lane, seg.fromSys, seg.f0); const p0 = this.lanePoint(lane, seg.fromSys, seg.f0);
+18 -3
View File
@@ -86,13 +86,28 @@ function normalize(segs) {
return segs.map((s) => ({ ...s, w: s.w / m })); 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) { export function playerClouds(world, observedSystemId) {
const out = []; const out = [];
for (const npcId of world.player.seenNpcs) { for (const npcId of world.player.seenNpcs) {
const npc = world.npcs[npcId]; const npc = world.npcs[npcId];
if (!npc.alive || npc.state !== 'transit' || !npc.plan) continue; // Active journey, or a finished/doomed one whose envelope hasn't expired:
let segs = cloudFor(world.galaxy, npc.plan, world.t); // 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 (observedSystemId != null) segs = excludeVisibleBand(world.galaxy, segs, observedSystemId);
if (segs.length > 0) out.push({ npcId, name: npc.name, segs }); if (segs.length > 0) out.push({ npcId, name: npc.name, segs });
} }
+1
View File
@@ -22,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);
+3
View File
@@ -102,6 +102,7 @@ export function planNpc(world, npc) {
routeLanes: best.route.lanes, startSys: origin.systemId, 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 });
} }
@@ -113,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';
@@ -128,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 });
+17 -1
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest'; 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. // Synthetic 3-lane chain: systems 0-1-2-3, each lane 2 days.
function chainGalaxy() { function chainGalaxy() {
@@ -88,4 +88,20 @@ describe('clouds', () => {
expect(JSON.stringify(cloudFor(g, doomed, t))).toBe(JSON.stringify(cloudFor(g, intact, t))); 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([]);
});
}); });
+1 -1
View File
@@ -1,5 +1,5 @@
import { defineConfig } from 'vite'; import { defineConfig } from 'vite';
export default defineConfig({ export default defineConfig({
build: { target: 'esnext' }, // top-level await in src/main.js build: { target: 'esnext' }, // modern output target
}); });