314 lines
15 KiB
JavaScript
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);
|
|
}
|