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 });