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

  // ---- 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}`,
  ];
  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);
}