orbit/dev/bounty-hunt-shot.mjs

291 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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