orbit/dev/ship-shot.mjs

314 lines
15 KiB
JavaScript

/**
* Dev-only: boot GameScene, force the run's ship state, and open the SHIP
* console (depth 80) so a headless screenshot shows the whole feature — left
* the bay-cam feed plate (assets/videos/ship.mp4; with the clip absent the
* procedural NO SIGNAL plate stands in), right the SHIP STATUS / SKILL TREE
* (STANDBY) / CARGO HOLDS tabs over one plate.
*
* The state is forced from the query string so every shot is one URL:
*
* ?tab=status|skills|cargo which tab is open (default status)
* &level=0…80 the XP ledger's level (default 0)
* &step=0..1 partial progress to next level (default .4)
* &bays=0…16 unlocked bays (default ship.json)
* &goods=a,b,c good ids loaded into the bays ('none' = empty)
* &minerals=0|half|full|<n> the mineral load (default half)
* &select=<bayIndex> open that bay's manifest (cargo tab)
* &known=<n> chart n systems (TradeScout ranks exits only
* among systems the run has entered — a fresh
* run has one, so the list needs this to show)
* &pressLocked=1 press the locked SKILL TREE socket
* &mutate=1 change cargo/ore AFTER opening (live repaint)
* &fight=1 a live combat session with damaged armor
*
* node dev/server.mjs 8082 # static server
* node dev/cdp-shot.mjs \
* "http://127.0.0.1:8082/dev/ship-shot.html?tab=cargo&goods=iron-ore,grain-crates&minerals=half" \
* SHIP_CARGO.png "window.__SHIP_SHOT ? window.__SHIP_SHOT.ready : null" 30000 \
* "document.getElementById('report').style.display='none'; true"
*
* The report (top-left) lists console errors + the console's paint state.
*/
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 { GameScene } from '../js/scenes/GameScene.js';
const data = await ConfigLoader.load();
config.init(data);
// Capture console errors + uncaught exceptions for the report.
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 = [GameScene];
const game = new Phaser.Game(gameConfig);
window.game = game;
// The report lives OUTSIDE the canvas — a DOM <pre> the screenshot can
// always read (headless canvases don't have to cooperate).
const report = document.createElement('pre');
report.id = 'report';
report.style.cssText =
'position:fixed;left:10px;top:10px;z-index:9999;max-width:70%;margin:0;padding:8px 12px;font:13px/1.5 monospace;color:#eaf6ff;background:rgba(6,20,16,0.92);border:1px solid #1b3a5a;white-space:pre-wrap;';
document.body.appendChild(report);
const setReport = (lines) => {
report.textContent = (Array.isArray(lines) ? lines : [lines]).join('\n');
};
setReport('booting…');
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
async function waitScene() {
for (let i = 0; i < 100; i++) {
const s = game.scene.getScene('GameScene');
if (s && s.ship && s.shipWindow) return s;
await sleep(100);
}
throw new Error('GameScene never booted');
}
try {
const s = await waitScene();
await sleep(800); // let the boot settle (dossier decode, deck flicker)
const q = new URLSearchParams(location.search);
const tab = q.get('tab') || 'status';
const level = Math.max(0, Number(q.get('level') ?? 0) || 0);
const step = Math.min(1, Math.max(0, Number(q.get('step') ?? 0.4) || 0));
const goodsRaw = q.get('goods');
const mineralsRaw = q.get('minerals') ?? 'half';
const select = q.has('select') ? Number(q.get('select')) : null;
const fight = q.get('fight') === '1';
// ---- the XP ledger (js/combat/XpManager.js is the only ledger) --------
// restore() writes { level, xp } with no events; the bar's fine fill comes
// from the level's own threshold, so take a data-driven slice of it.
const xp = s.combat?.xp;
if (xp) {
const need = level < xp.maxLevel ? xp.xpForLevel(level + 1) : 0;
xp.restore({ level, xp: Math.round(need * step) });
}
// ---- the cargo hold (CargoHold — one good per bay, fill order) --------
const wantBays = q.has('bays') ? Number(q.get('bays')) : null;
if (wantBays !== null) s.ship.setCargoHold(wantBays);
const goods =
!goodsRaw || goodsRaw === 'none'
? []
: String(goodsRaw)
.split(',')
.map((g) => g.trim())
.filter(Boolean);
s.ship.cargo.clear?.();
if (goods.length) s.ship.cargo.add(goods);
// ---- a LIVE combat session (?fight=1) --------------------------------
// `CombatSystem.active` is true while a hostile is up, and that is exactly
// what flips the plate's armor rows from "nominal maxima" to the session's
// real hull/shields — so spawn hostiles through the system's own seam, then
// take hits hard enough to break the shields and dent the hull.
if (fight && s.combat) {
const c = s.combat;
const sx = s.ship?.x ?? 0;
const sy = s.ship?.y ?? 0;
c.spawnEnemy(sx + 520, sy - 260, 901);
c.spawnEnemy(sx - 480, sy + 300, 902);
c.damagePlayer(Math.round(c.maxShields * 0.55), { bearing: 0 });
c.damagePlayer(Math.round(c.maxShields * 0.6 + c.maxHull * 0.3), { bearing: Math.PI });
}
// ---- the mineral load (aggregate — what the Mineral HUD reads) --------
const cap = s.ship.stats.mineralStorage;
s.ship.setMinerals(
mineralsRaw === 'zero' || mineralsRaw === '0'
? 0
: mineralsRaw === 'half'
? Math.round(cap * 0.5)
: mineralsRaw === 'full'
? cap
: Number(mineralsRaw) || 0,
);
s.ship.credits = Math.max(s.ship.credits, 12_480);
// ---- the chart (visitedSystems is what TradeScout ranks exits across) ---
const known = q.has('known') ? Math.max(0, Number(q.get('known')) || 0) : null;
if (known !== null && s.galaxy) {
let n = 0;
for (const id of s.galaxy.byId.keys()) {
if (n >= known) break;
s.visitedSystems.add(id);
n++;
}
}
// The fresh run's opening ACK comm owns the screen (deckAction swallows the
// press while it holds) — clear the chain through the hub's own finish path
// so the deck is live, exactly like dev/combat-shot.mjs does.
const hub = s.comms;
if (hub) {
if (hub.current) { try { hub._finish(); } catch { /* already ended */ } }
if (Array.isArray(hub.queue)) hub.queue.length = 0;
hub._setPaused(false);
try { hub.box?.close(); } catch { /* no box */ }
hub.box = null;
// _commInputBlocked() holds the deck for 200 ms after a comm ends —
// give that window time to close before the deck press.
await sleep(400);
}
// Open the console (the deck SHIP button does exactly this). The press is a
// no-op while ANY input owner still holds the deck, and that clearance races
// this script — so nudge until the console says it is open (a press while it
// is already open would TOGGLE it shut, hence the guard).
const win = s.shipWindow;
for (let i = 0; i < 12 && !win.isOpen; i++) {
s.deckAction('ship');
await sleep(350);
}
await sleep(1500); // the boot reveal + the first paint
if (tab !== 'status') {
win._setTab(tab);
await sleep(700);
}
if (select !== null) {
win._selectedBay = select;
win.refresh();
await sleep(700);
}
// ---- TAP A BEST EXIT (?tapExit=<row> | ?clickExit=<row>&answer=confirm|cancel)
// The manifest's rows are tap surfaces: pressing one asks the player to lock
// that exit in, and CONFIRM routes the ship through GameScene.setDestination.
// ?tapExit — the row's own press seam (what its hit area calls), so the
// dialog + routing run without needing a live pointer.
// ?clickExit — a REAL pointerdown/up dispatched on the canvas at the row's
// rect, which is the thing that used to do nothing: it proves
// Phaser's hit-test reaches the row (Phaser reads `button`, so
// MouseEvent is enough).
let exitTap = 'n/a';
const rowIdx = q.has('clickExit') ? Number(q.get('clickExit')) : q.has('tapExit') ? Number(q.get('tapExit')) : null;
if (rowIdx !== null && win?.isOpen) {
const rec = (win._exitRows ?? []).filter(Boolean)[Number.isFinite(rowIdx) ? rowIdx : 0];
const before = { sys: s.destination?.systemId ?? null, obj: s.destination?.objectId ?? null };
if (!rec) {
exitTap = `row ${rowIdx}: none drawn`;
} else if (q.has('clickExit')) {
// A REAL mouse press on the row. Phaser binds classic mouse events on the
// canvas and recomputes the pointer's world coords per loop step, so the
// move needs a frame before the down (dev/comms-click.mjs's lesson).
const cv = s.game.canvas;
const r = cv.getBoundingClientRect();
const kx = r.width / s.game.scale.width;
const ky = r.height / s.game.scale.height;
const cx = r.left + (rec.hit.x + rec.hit.width / 2) * kx;
const cy = r.top + (rec.hit.y + rec.hit.height / 2) * ky;
const mk = (type, buttons) =>
new MouseEvent(type, { bubbles: true, composed: true, cancelable: true, view: window, clientX: cx, clientY: cy, button: 0, buttons });
cv.dispatchEvent(mk('mousemove', 0));
s.game.loop.step(performance.now());
cv.dispatchEvent(mk('mousedown', 1));
cv.dispatchEvent(mk('mouseup', 0));
s.game.loop.step(performance.now());
await sleep(700); // the decode-in
exitTap = `CLICKED ${rec.exit?.objectName ?? rec.exit?.systemName} at scene (${Math.round(rec.hit.x + rec.hit.width / 2)},${Math.round(rec.hit.y + rec.hit.height / 2)}) · dialog=${win.isDialogOpen ? 'OPEN' : 'NOT OPEN'}${rec.here ? ' (HERE — inert)' : ''}`;
} else if (typeof rec.onTap !== 'function') {
exitTap = `${rec.exit?.systemName}: no tap surface (HERE)`;
} else {
rec.onTap();
await sleep(700);
exitTap = `tapped ${rec.exit?.objectName ?? rec.exit?.systemName} · dialog=${win.isDialogOpen ? 'OPEN' : 'NOT OPEN'}`;
}
const answer = q.get('answer');
if (answer === 'confirm' && win.isDialogOpen) {
win.dialog.fireConfirm();
await sleep(900); // the collapse — the seam fires when it lands
// The destination is the ONLY stored state — the path derives from it
// (GameScene.routePlan, js/galaxy/Route.js), so check both.
const plan = s.routePlan?.() ?? null;
exitTap +=
` → CONFIRM dest=${s.destination?.systemId ?? '—'}/${s.destination?.objectId ?? '—'} (was ${before.sys ?? '—'}/${before.obj ?? '—'}) · registry=${s.registry.get('destination')?.systemId ?? '—'} · console=${win.isOpen ? 'OPEN' : 'closed'} · route=${plan?.hops ?? 0} hop(s), ${s.routeEdgeKeys?.().length ?? 0} lane(s)`;
} else if (answer === 'cancel' && win.isDialogOpen) {
win.dialog.cancel();
await sleep(600);
exitTap += ` → CANCEL dest=${s.destination?.systemId ?? '—'}/${s.destination?.objectId ?? '—'} (was ${before.sys ?? '—'}/${before.obj ?? '—'}) · dialog=${win.isDialogOpen ? 'OPEN' : 'closed'}`;
}
}
// ---- PARK away from the route gate (?park=1) — the compass arrow shot
// The route arrow is the OFF-SCREEN half of the mark, so park the ship
// on the far side of the home world (inside the tether) from the next
// gate: the gate leaves the view and its ROUTE GATE chip + arrow sit on
// the screen edge (brown for a cargo delivery, orange otherwise).
let park = 'n/a';
if (q.get('park') === '1' && s.destination && typeof s.routeNextGate === 'function' && s.routeNextGate()) {
const gate = s.routeNextGate();
const hx = s.planet?.x ?? 0, hy = s.planet?.y ?? 0;
const gd = Math.hypot(gate.x - hx, gate.y - hy) || 1;
s.ship.setPosition(hx - ((gate.x - hx) / gd) * 3800, hy - ((gate.y - hy) / gd) * 3800);
s.ship.stop();
await sleep(2600); // the camera settles, the compass eases in
const entry = [...s.compass.entries.entries()].find(([id]) => id.startsWith('route:'));
park = entry
? `parked · ${entry[0]} · color=#${entry[1].neon.toString(16)} · mode=${entry[1].mode} · pulse=${gate.routePulse?.on ? 'on' : 'off'}/#${(gate.routePulse?.color ?? 0).toString(16)}`
: 'parked · NO ROUTE ENTRY';
}
// ---- live refresh while open (?mutate=1) -------------------------------
// The console polls the scene's snapshot seam on its own beat; changing the
// hold through the run's OWN api (a buy landing in a bay, ore aboard) must
// repaint it — no reopen, no manual refresh.
let liveUpdate = 'n/a';
if (q.get('mutate') === '1' && win) {
const before = win._fpOf?.(s.shipScreenSnapshot()) ?? null;
s.ship.cargo.add(['iron-ore']);
s.ship.minerals = Math.round((s.ship.stats.mineralStorage || 0) * 0.9);
await sleep(1200); // longer than the window's poll interval
const after = win._fpOf?.(s.shipScreenSnapshot()) ?? null;
liveUpdate = `${before !== after ? 'fingerprint changed → repaint' : 'STALE (no repaint)'} · ${s.ship.cargo.used}/${s.ship.cargo.capacity} bays, ${s.ship.minerals} u`;
}
// The locked SKILL TREE socket must shake + toast, never crash.
let locked = 'n/a';
if (q.get('pressLocked') === '1') {
try {
const t = win._tabs.find((x) => x.id === 'skills');
win._shakeTab(t);
win.onLocked?.('skills');
locked = 'press ok';
} catch (e) {
locked = 'press err: ' + e.message;
}
}
const snap = s.shipScreenSnapshot?.() ?? null;
const lines = [
errors.length === 0 ? 'SMOKE OK — no console errors' : `ERRORS:\n${errors.slice(0, 4).join('\n')}`,
`window: ${win?.openState} · tab=${win?._activeTab} · selected=${win?._selectedBay ?? '—'}`,
`video: ${win?.video ? (win.video.video?.paused ? 'paused' : 'playing') : 'NO SIGNAL (clip absent)'}`,
snap
? `live hull ${snap.live.hull}/${snap.live.hullMax} · shields ${snap.live.shields}/${snap.live.shieldsMax}${fight ? ' (combat ACTIVE)' : ''}`
: 'snapshot: NONE',
`level ${snap?.level?.level ?? '?'} (${Math.round((snap?.level?.progress ?? 0) * 100)}% → next) · ledger=${xp ? 'XpManager' : 'none'}`,
`cargo ${s.ship.cargo.used}/${s.ship.cargo.capacity} bays (${s.ship.cargo.items.join(', ') || 'empty'}) · locked ${s.ship.maxCargoSlots - s.ship.cargo.capacity}`,
`minerals ${s.ship.minerals}/${s.ship.stats.mineralStorage} · credits ${s.ship.credits}`,
`locked tab: ${locked}`,
`live update: ${liveUpdate}`,
`park: ${park}`,
`exit tap: ${exitTap}`,
];
setReport(lines);
window.__SHIP_SHOT = { ready: true, lines, errors };
console.info('ship-shot: report painted');
} catch (err) {
errors.push(`FATAL: ${err.message}`);
setReport(['FATAL: ' + err.message, ...errors.slice(0, 4)]);
window.__SHIP_SHOT = { ready: true, fatal: String(err.message), errors };
console.error('ship-shot: fatal', err);
}