291 lines
13 KiB
JavaScript
291 lines
13 KiB
JavaScript
/**
|
||
* 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);
|
||
}
|