/** * Dev-only visual + behavioural probe for the BOUNTY HUNT side quest. * * node dev/server.mjs 8080 * node dev/bounty-hunt-shot-run.mjs (launches headless Chromium, drives * every stage below, dumps JSON + * PNGs into dev/shots/) * * Stages (location.hash): * #locked — a fresh run (combat level 1) on a NEUTRAL world's job board: the * BOUNTY HUNT row stands in its seeded slot, LOCKED, and the scene's * accept DOOR refuses it (the console speaks data → ui.bountyAcceptDenyToast). * #dossier — level 2: the same board, the hunt selected — the dossier readout * (wanted leader / charges / craft / payout CR + XP) with NO hint of * which leg holds the mark, and the accept lands for real. * #hunt — the hunt ACTIVE in the flight world: legs logged strictly in * sequence (an out-of-order visit is refused), the beacons read as * lanes (no red contact), and the lane's first fight stood up. * #mark — the bounty's own wave deployed: the mark alive on the field at its * ×2 hull / ×2 damage, then put DOWN — deployments halt, the escort * is finished, the contract goes COMPLETE, and the reward claims ONCE. * * The probe drives the SAME seams a player's presses reach (the board's door → * SurfaceScene.acceptSurfaceJob/activateSurfaceJob, the scene's own update loop * for the check-ins, CombatSystem.killEnemy for the shooting), staged only by: * setting XP level through XpManager.restore and teleporting the ship. */ import Phaser from '../js/vendor/phaser.js'; import { config } from '../js/config/Config.js'; import { ConfigLoader } from '../js/config/ConfigLoader.js'; import { createGameConfig } from '../js/config/GameConfig.js'; import { MenuScene } from '../js/scenes/MenuScene.js'; import { GameScene } from '../js/scenes/GameScene.js'; import { SurfaceScene } from '../js/scenes/SurfaceScene.js'; const data = await ConfigLoader.load(); config.init(data); const errors = []; const origErr = console.error.bind(console); console.error = (...a) => { errors.push(a.map(String).join(' ')); origErr(...a); }; window.addEventListener('error', (e) => errors.push(String(e.message))); window.addEventListener('unhandledrejection', (e) => errors.push(`rejection: ${e.reason}`)); const gameConfig = createGameConfig(); gameConfig.scene = [MenuScene, GameScene, SurfaceScene]; const game = new Phaser.Game(gameConfig); window.game = game; const report = document.createElement('pre'); report.style.cssText = 'position:fixed;left:10px;top:10px;z-index:9999;max-width:78%;margin:0;padding:8px 12px;font:13px/1.45 monospace;color:#eaf6ff;background:rgba(6,20,16,0.93);border:1px solid #1b3a5a;white-space:pre-wrap;'; document.body.appendChild(report); const say = (v) => { report.textContent = typeof v === 'string' ? v : JSON.stringify(v, null, 1); }; const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); async function waitFor(fn, tries = 260, everyMs = 100) { for (let i = 0; i < tries; i++) { const v = fn(); if (v) return v; await sleep(everyMs); } return null; } const STAGE = (location.hash || '#locked').slice(1); /** The hunt contracts on the standing world's board (the board is per-world now). */ const huntsOn = (gs) => gs.sideQuestPostings(gs.planet?.discoveryName ?? null, false).filter((p) => p.category === 'bounty'); try { // ── the real boot: menu → NEW GAME on a seed whose home system is Neutral ── const menu = await waitFor(() => { const m = game.scene.getScene('MenuScene'); return m?.newGameBtn ? m : null; }); if (!menu) throw new Error('menu never booted'); menu.seedValue = 'VESTRA'; menu.startNewGame(); const gs = await waitFor(() => { const g = game.scene.getScene('GameScene'); return g?.ship ? g : null; }, 300); if (!gs) throw new Error('GameScene never booted'); await sleep(700); // The XP staged through the run's ONLY ledger (js/combat/XpManager.js). const level = STAGE === 'locked' ? 1 : 2; gs.combat.xp.restore({ level, xp: 0 }); if (STAGE === 'locked' || STAGE === 'dossier') { // ── land on the world and open the JOB BOARD console (the player's path) ── const target = gs.commsTargetFor(gs.planet); gs.isHomeSystem = false; // PROBE STAGING — the EXPLORE drawer's Job Board slot (data/actionbar.json) gs.startLanding(target); const ss = await waitFor(() => { const c = game.scene.getScene('SurfaceScene'); return c?.jobBoardWindow ? c : null; }, 300); if (!ss) throw new Error('surface never booted'); await sleep(600); ss.startSurface(); // Capture every console line the surface speaks (the accept door's note). const notes = []; const note0 = ss.consoleNote.bind(ss); ss.consoleNote = (t, o) => { notes.push({ text: String(t), glyph: o?.glyph }); return note0(t, o); }; await sleep(700); ss.toggleExplore(); await sleep(1100); ss.exploreAction('jobBoard'); await sleep(2000); const win = ss.jobBoardWindow; if (win?.video?.video) { win.video.video.muted = true; win.video.video.play().catch(() => {}); } const h = huntsOn(gs)[0]; if (!h) throw new Error(`no BOUNTY HUNT posted on ${gs.systemRecord.name} at level ${level}`); win.selectedId = h.id; win.refresh(); await sleep(900); const out = { stage: STAGE, system: gs.systemRecord.name, neutralContext: !gs.systemRecord.faction, combatLevel: gs.combatLevel(), board: { openState: win.openState, rows: gs.sideQuestPostings(gs.planet?.discoveryName ?? null, false).map((p) => ({ id: p.id, category: p.category, locked: p.locked === true, minLevel: p.minLevel ?? null })), huntRow: { title: h.title, issuer: h.issuer, reward: h.reward, locked: h.locked === true, minLevel: h.minLevel ?? null, dossier: h.dossier ? { name: h.dossier.name, epithet: h.dossier.epithet, crime: h.dossier.crime, craftLabel: h.dossier.craftLabel } : null, }, // The hidden-plan rule: the posting carries no leg/mark/picket field of any kind. leaks: Object.keys(h).filter((k) => /bountyAt|picket|mark|plan/i.test(k)), }, }; // Press the accept DOOR (the window's own action seam → SurfaceScene.acceptSurfaceJob). const before = gs.sideQuestState.isAccepted(h.id); ss.acceptSurfaceJob(h.id); await sleep(600); out.press = { id: h.id, acceptedBefore: before, acceptedAfter: gs.sideQuestState.isAccepted(h.id), notes, }; if (STAGE === 'dossier') { // The contract taken + set ACTIVE for the flight stages to read. ss.activateSurfaceJob(h.id); await sleep(500); const route = gs.sideQuestState.routeFor(h.id); out.taken = { active: gs.sideQuestState.activeId, kind: route?.kind, waypoints: route?.waypoints?.length ?? null, xpOnRoute: route?.xp ?? null, planFixedOnRecord: !!route?.bounty, // The readout a player sees (the board's own progress seam), plus the fact that // NO body line names the mark or its leg before the lane fires. progressLine: gs.patrolProgress(h.id), }; } window.__BH = out; say(out); } else { // ── the flight stages: accept + set active here, then fly the lanes ────── const h = huntsOn(gs)[0]; if (!h) throw new Error('no BOUNTY HUNT posted at level 2'); const res = gs.acceptSideQuest(h.id); gs.setActiveSideQuest(h.id); const st = gs.sideQuestState; const route = st.routeFor(h.id); const wps = route.waypoints.map((w) => [Math.round(w[0]), Math.round(w[1])]); // The console's own voice, captured (the leg + engagement lines are the story). const toasts = []; const toast0 = gs.consoleToast.bind(gs); gs.consoleToast = (label, o) => { toasts.push({ text: String(label), glyph: o?.glyph }); return toast0(label, o); }; const out = { stage: STAGE, combatLevel: gs.combatLevel(), id: h.id, wps, log: [], waves: [], field: [] }; window.__BH = { stage: 'running' }; say(out); // The staging: the ship is SET at the lane's waypoint (the check-in rule, the // engagement and the payout underneath are all the scene's own update loop). const teleport = (x, y) => { gs.ship.setPosition(x, y); }; const settle = async (frames = 26) => { for (let i = 0; i < frames; i++) await sleep(45); }; const waveSnap = () => { const r = gs.combat.waveRun; if (!r) return null; const w = r.script?.waves?.[0] ?? {}; return { state: r.state, total: w.total ?? null, maxConcurrent: w.maxConcurrent ?? null, roster: w.roster ?? null, elite: w.elite ? { type: w.elite.type, name: w.elite.name, healthScale: w.elite.healthScale, damageScale: w.elite.damageScale } : null, live: gs.combat.enemies.filter((e) => e.wave && !e.dead).length, }; }; if (STAGE === 'hunt') { // The SEQUENTIAL LAW in the live world: leg 3 first is refused. teleport(wps[2][0], wps[2][1]); await settle(14); out.log.push({ probe: 'out-of-order (leg 3 first)', visited: [...(st.routeFor(h.id).visited ?? [])] }); // Then the lane, in order. for (let i = 0; i < wps.length; i++) { teleport(wps[i][0], wps[i][1]); await settle(24); const r = st.routeFor(h.id); out.waves.push({ leg: i + 1, wave: waveSnap() }); out.log.push({ probe: `leg ${i + 1} logged`, visited: [...(r.visited ?? [])], compass: gs.activeRouteCompassTarget?.() ?? null, skirmishCleared: [...((r.bounty.skirmishCleared) ?? [])], }); if ((gs.combat.waveRun && gs.combat.waveRun.state !== 'done' && gs.combat.waveRun.state !== 'cancelled')) break; // a fight stood up here } out.toasts = toasts; out.claimedAt = null; window.__BH = out; say(out); } else if (STAGE === 'mark') { // Fly the lane until the MARK itself deploys (wall-clock bounded — the lane's // pickets are shot down as they stand so the flight keeps reaching the mark). const deadline = Date.now() + 45000; let fired = false; while (!fired && Date.now() < deadline) { for (let i = 0; i < wps.length && !fired; i++) { teleport(wps[i][0], wps[i][1]); await settle(8); const r = gs.combat.waveRun; if (r && r.state !== 'done' && r.state !== 'cancelled') { if (r.script?.waves?.[0]?.elite) { fired = true; break; } for (const e of [...gs.combat.enemies]) if (e.wave && !e.dead) gs.combat.killEnemy(e, gs.time.now); } } } out.markWave = waveSnap(); const elite = gs.combat.enemies.find((e) => e.elite && !e.dead); out.buff = elite ? { name: elite.markName ?? null, type: elite.type, maxHull: elite.maxHull, maxShields: elite.maxShields, damageScale: elite.damageScale ?? null, } : null; out.fieldBeforeKill = gs.combat.enemies.filter((e) => e.wave && !e.dead).length; if (elite) gs.combat.killEnemy(elite, gs.time.now); await settle(12); const r1 = st.routeFor(h.id); out.afterMarkKill = { waveState: gs.combat.waveRun?.state ?? null, down: r1.bounty.down === true, visited: [...(r1.visited ?? [])], liveEscort: gs.combat.enemies.filter((e) => e.wave && !e.dead).length, completedBeforeEscortKilled: st.isCompleted(h.id), }; // The survivors must be put down before the contract reads COMPLETE. const clearBy = Date.now() + 25000; while (Date.now() < clearBy) { const live = gs.combat.enemies.filter((e) => e.wave && !e.dead); if (!live.length && gs.combat.waveRun?.state !== 'halted') break; for (const e of live) gs.combat.killEnemy(e, gs.time.now); await settle(6); } await settle(20); const r2 = st.routeFor(h.id); out.afterFieldClear = { waveState: gs.combat.waveRun?.state ?? null, completed: st.isCompleted(h.id), visitedAll: (r2.visited ?? []).length === wps.length, creditsHold: gs.ship.credits ?? null, }; // The claim — ONCE. const hold0 = gs.ship.credits ?? 0; const xp0 = { ...gs.combat.xp }; const c1 = gs.claimSideQuest(h.id); const c2 = gs.claimSideQuest(h.id); await settle(6); out.claim = { first: c1, second: c2, creditsBefore: hold0, creditsAfter: gs.ship.credits ?? null, xpBankAfter: { level: gs.combat.xp.level, xp: Math.round(gs.combat.xp.xp) }, claimedFlag: st.isClaimed(h.id), }; out.toasts = toasts; window.__BH = out; say(out); } } } catch (err) { window.__BH = { stage: 'FAILED', error: String(err?.message ?? err), errors: errors.slice(-6) }; say(window.__BH); }