/** * 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| the mineral load (default half) * &select= open that bay's manifest (cargo tab) * &known= 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
 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= | ?clickExit=&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);
}