/** * Dev-only: the ENCOUNTER HUD + REGRESSION CHECK (headless, CDP) — the * mixed encounter's readability layer, the escort lifecycle, and the * 'Pirate Problems' ambush, all through the PRODUCTION code: * * node dev/encounter-hud-check.mjs * * TWO-LAYER PROOF (this is the "renders correctly in browser" contract): * * 1. DRAW-CALL CAPTURE — the HUD's own emitted geometry is the ground * truth. We wrap the live Graphics objects the readability layer * draws into (js/combat/CombatHud.js): `hud._arrowG.fillTriangle` * (the off-screen arrows, SCREEN space) and `hud._threatG.fillCircle` * (the threat markers, WORLD space). For each frozen, forced * `hud.update()` pass we read exactly what the HUD drew — the count * and the positions — and assert it matches the ROSTER rule: * arrows/markers for the off-screen / on-screen HOSTILES only * (combat.enemies), never the escorts (combat.allies), and the * threat layer RESPECTS THE DATA (readability.threat ships disabled * → zero markers; flipped on at runtime → one per hostile). * * 2. PIXEL PROOF — the renderer is hooked so the last presented frame * lands in a 2D canvas; the sim is FROZEN for each sampling window * (comms pause — ship parks, camera holds, zero drift); and the * pixels at the CAPTURED geometry are counted: hot red under each * arrow (data #ff2d6f), hot red above each marked hostile (and not * above the escorts), and bright ink (#cfe9ff) in the kill-feed * band (baseline delta). * * a) MIXED ENCOUNTER (GameScene._spawnEncounter — the exact path the * EncounterDirector hits; js/combat/Encounters.js): two pirates + * two neutrals materialize OFF-SCREEN (js/combat/Encounters.js → * GameScene._spawnEncounter, one-shot pirates), then: * · the OFF-SCREEN ARROWS render at each PIRATE's edge position * (px-verified) and NEVER at a neutral's; * · the THREAT-MARKER layer respects the data (off → 0 markers) * and, flipped on, marks BOTH pirates and NEVER the escorts; * · the escorts ENGAGE (ally-owned bolts) and the pirates fire * back (enemy-owned bolts) while the player SURVIVES (pirates * may hit the player — by design; the invariant is * playerDown === false); * · the kills land in the KILL FEED — live text at the data's * xInset (px-verified ink above baseline); * · the survivors hold their 'escort' state, break off, and the * encounter ENDS (combat.active → false, last one off-screen). * * b) NEUTRALS-ONLY: with ZERO hostiles up, no arrow may render at any * edge and no threat marker may mark the escorts; the escorts fly * near the player, never fire, never spawn a hostile, break off, * and leave — and the player takes ZERO damage (damagePlayer is * counted, so shield regen cannot mask a hit). * * c) THE 'PIRATE PROBLEMS' AMBUSH (data/combat.json → * waves.scripts.pirate_ambush, driven through the production seam * __ORBIT_COMBAT__.waves.demo): ONE rolling wave of SIX frame-0 * raiders, three at a time, each kill sending in a replacement * (3 → 3 → 3 → 2 → 1 → 0), ending 'done'. * * The player and the encounter's ships are made unkillable — the * encounter's FLOW, targeting, and HUD are what's under test (a respawn * mid-fight would poison the lifecycle); the sweep brings the pirates * down the PRODUCTION way (damage → dead → killEnemy bookkeeping). * * Requires: the dev server on :8080 (node dev/server.mjs 8080) and a * headless Chromium with CDP on 127.0.0.1:9333 (same as * dev/combat-demo-check.mjs). Re-verify ?combat + combat_demo with * dev/combat-demo-check.mjs. */ const CDP = process.env.CDP_PORT ? `127.0.0.1:${process.env.CDP_PORT}` : '127.0.0.1:9333'; const BASE = process.env.BASE_URL ?? 'http://127.0.0.1:8080'; const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); function connect(target) { const ws = new WebSocket(target.webSocketDebuggerUrl); let id = 0; const pending = new Map(); ws.onmessage = (x) => { const m = JSON.parse(x.data); if (m.id && pending.has(m.id)) { const { res, rej } = pending.get(m.id); pending.delete(m.id); m.error ? rej(new Error(m.error.message)) : res(m.result); } }; const open = new Promise((r) => { ws.onopen = r; ws.onerror = () => r(); }); const send = (method, params = {}) => new Promise((res, rej) => { const mid = ++id; pending.set(mid, { res, rej }); ws.send(JSON.stringify({ id: mid, method, params })); }); return { ws, send, open }; } async function evalJs(c, expression, awaitPromise = true) { const r = await c.send('Runtime.evaluate', { expression, returnByValue: true, awaitPromise }); if (r.exceptionDetails) throw new Error(JSON.stringify(r.exceptionDetails, null, 2)); return r.result?.value; } async function openPage(path) { const url = `${BASE}${path}`; let target; try { target = await (await fetch(`http://${CDP}/json/new?${encodeURIComponent(url)}`, { method: 'PUT' })).json(); } catch { target = await (await fetch(`http://${CDP}/json/new?${encodeURIComponent(url)}`, { method: 'POST' })).json(); } const c = connect(target); await c.open; await c.send('Runtime.enable'); return { c, target }; } // ---------------------------------------------------------------- the drive const PAGE_CODE = `(async () => { const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); const gs = window.game && window.game.scene ? window.game.scene.getScene('GameScene') : null; if (!gs || !gs.combat || !gs.ship) return 'no GameScene/combat/ship'; const sys = gs.combat; const hud = gs.combatHud; const ship = gs.ship; if (!hud) return 'no combatHud'; if (!hud._arrowG || !hud._threatG) return 'HUD graphics missing (_arrowG/_threatG)'; // DETERMINISTIC FIRE ACCOUNTING — every volley the pack / the escorts // actually launch passes through fireEnemy / fireAlly, so hook those // and count FIRE EVENTS. (Sampling the live bullet list was flaky: a // 330 px/s lance with a 2.4 s life can fall between 100 ms polls, so a // "did it fire?" check could miss a real shot. Counting the event can't.) const fireCount = { enemy: 0, ally: 0 }; const origFireEnemy = sys.fireEnemy; const origFireAlly = sys.fireAlly; sys.fireEnemy = function (...a) { fireCount.enemy++; return origFireEnemy.apply(this, a); }; sys.fireAlly = function (...a) { fireCount.ally++; return origFireAlly.apply(this, a); }; // The fresh-save welcome comm holds the box open and FREEZES THE ACTION // (GameScene gates combat/HUD updates on comms.paused) — play the part // of the player and press ACK. const unblock = () => { try { if (gs.comms && gs.comms.paused) gs.comms._ack(); } catch { /* the box is gone */ } }; unblock(); // The level-up marquee also freezes the action — wait it out in loops. const marqueeClear = async (maxMs = 8000) => { for (let t = Date.now(); Date.now() - t < maxMs; await sleep(120)) { unblock(); if (!(gs.levelMarquee && gs.levelMarquee.active === true)) return; } }; // Unkillable (the flow / targeting / HUD are under test, not the hit-read). sys.maxHull = 1e9; sys.hull = 1e9; sys.maxShields = 1e9; sys.shields = 1e9; const live = () => sys.enemies.filter((e) => !e.dead); const allies = () => sys.allies.filter((a) => !a.dead); const tough = (o) => { o.maxHull = 1e9; o.hull = 1e9; o.maxShields = 1e9; o.shields = 1e9; }; const setPos = (o, x, y) => { o.x = x; o.y = y; // The arcade body keeps residual velocity and the physics world keeps // integrating it even while combat.update is frozen — zero it so a // placed ship stays EXACTLY where the HUD will draw its cues. if (o.body) { o.body.velocity.x = 0; o.body.velocity.y = 0; } }; // FREEZE for sampling: the comms pause parks the ship (onPause → // ship.stop()) and gates combat/HUD updates, but the render loop keeps // drawing — so a forced hud.update() settles onto a ZERO-DRIFT frame. const freeze = async (ms = 320) => { if (gs.comms) gs.comms._setPaused(true); await sleep(ms); }; const thaw = () => { if (gs.comms) gs.comms._setPaused(false); }; // ---- PIXEL SAMPLING: hook the renderer — every presented frame is // copied into a 2D canvas we can read with getImageData (the raw WebGL // backbuffer reads back blank once presented). const ren = window.game.renderer; const cv = ren.canvas; const off = document.createElement('canvas'); off.width = cv.width; off.height = cv.height; const octx = off.getContext('2d'); if (!window.__lastFrameHooked) { window.__lastFrameHooked = true; const orig = ren.render; ren.render = function (...a) { const ret = orig.apply(this, a); try { octx.drawImage(cv, 0, 0); } catch { /* one bad frame — ignored */ } return ret; }; } const vw = gs.scale.width, vh = gs.scale.height; const RED = (r, g, b) => r > 130 && r - g > 45 && r - b > 25; // #ff2d6f-ish (any reddish) // The arrow is drawn SOLID #ff2d6f (255,45,111) at its bright phase. A real // arrow's core is VIVID (r>225, low green, mid blue); the false positives // (twinkling stars, ship sprites, particles) are MUTED (r<=~210). Count only // the arrow's vivid red so the "no arrow" pixel check is robust to the rest. const VIVID_RED = (r, g, b) => r > 225 && g < 90 && b > 70 && b < 150; const BRIGHT = (r, g, b) => r > 130 && g > 150 && b > 160; // #cfe9ff-ish ink const countIn = (x, y, w, h, test) => { const X = Math.max(0, Math.round(x)), Y = Math.max(0, Math.round(y)); const W = Math.min(w, cv.width - X), H = Math.min(h, cv.height - Y); if (W <= 0 || H <= 0) return 0; const d = octx.getImageData(X, Y, W, H).data; let n = 0; for (let i = 0; i < d.length; i += 4) if (test(d[i], d[i + 1], d[i + 2])) n++; return n; }; const redBox = (cx, cy, half) => countIn(cx - half, cy - half, half * 2, half * 2, RED); const feedBand = () => countIn(vw - 200, 55, 190, 50, BRIGHT); // The arrow / threat cues PULSE their alpha with scene time (the data // pulse Hz) — a single frame can land on the dim phase. Sample over // several presented frames and keep the MAX (its bright phase). Each // frame: redraw the layer at the current time, let it present, read. const redBoxMax = async (cx, cy, half, frames = 12) => { let best = 0; for (let i = 0; i < frames; i++) { hud.update(gs.time.now); await sleep(22); best = Math.max(best, countIn(cx - half, cy - half, half * 2, half * 2, RED)); } return best; }; // DIAGNOSTIC: sample over frames like redBoxMax, but return the colour + // centroid OF THE FRAME WHERE THE RED PEAKS (a transient bullet/particle // only shows in some frames; a single snapshot taken later would miss it). const redBoxMaxDetail = async (cx, cy, half, frames = 14) => { let best = { count: 0, colors: [], centroid: null }; for (let i = 0; i < frames; i++) { hud.update(gs.time.now); await sleep(22); const d = redBoxDetail(cx, cy, half); if (d.count > best.count) best = d; } return best; }; // Count only the arrow's VIVID red (its solid #ff2d6f core), sampled over // enough frames to catch a pulsing arrow's bright phase. This is the robust // "is an arrow actually drawn here" pixel test — muted stars/sprites/particles // don't match, so they can't false-positive it. const vividBoxMax = async (cx, cy, half, frames = 24) => { let best = 0; for (let i = 0; i < frames; i++) { hud.update(gs.time.now); await sleep(22); best = Math.max(best, countIn(cx - half, cy - half, half * 2, half * 2, VIVID_RED)); } return best; }; // DIAGNOSTIC: for a red box, return the count + a coarse colour histogram // (top few) + the red centroid, so a false positive can be identified by // colour (arrow #ff2d6f vs combat-bar hull #ff2d6f vs XP gold vs UI). const redBoxDetail = (cx, cy, half) => { const X = Math.max(0, Math.round(cx - half)), Y = Math.max(0, Math.round(cy - half)); const W = Math.min(half * 2, cv.width - X), H = Math.min(half * 2, cv.height - Y); if (W <= 0 || H <= 0) return { count: 0, colors: [], centroid: null }; const d = octx.getImageData(X, Y, W, H).data; const hist = {}; let n = 0, sx = 0, sy = 0; for (let i = 0; i < d.length; i += 4) { const r = d[i], g = d[i + 1], b = d[i + 2]; if (RED(r, g, b)) { n++; const px = X + (i / 4) % W, py = Y + Math.floor((i / 4) / W); sx += px; sy += py; const k = r + "," + g + "," + b; hist[k] = (hist[k] || 0) + 1; } } const colors = Object.entries(hist).sort((a, b) => b[1] - a[1]).slice(0, 5).map(([k, v]) => k + " x" + v); return { count: n, colors, centroid: n ? { x: Math.round(sx / n), y: Math.round(sy / n) } : null }; }; // The SAME geometry the HUD draws with (imported, not re-implemented). const mod = await import('/js/combat/Combat.js'); const arrowPosOf = (o) => { const s = hud.worldToScreen(o.x, o.y); return mod.offscreenArrow(vw, vh, s.x, s.y, 28); }; const slotOf = (o) => hud.worldToScreen(o.x, o.y - 36); // the data's 'above: 36' // ---- DRAW-CALL CAPTURE (the HUD's own emitted geometry) ------------- const arrowG = hud._arrowG, threatG = hud._threatG; let arrowCap = [], threatCap = []; const origTri = arrowG.fillTriangle; arrowG.fillTriangle = function (...a) { arrowCap.push(a.slice(0, 6)); return origTri.apply(this, a); }; const origCirc = threatG.fillCircle; threatG.fillCircle = function (...a) { threatCap.push(a.slice(0, 3)); return origCirc.apply(this, a); }; // One clean, forced pass: reset the capture, redraw the layer, settle. const pass = async (ms = 280) => { arrowCap.length = 0; threatCap.length = 0; hud.update(gs.time.now); await sleep(ms); }; const centroid = (tri) => ({ x: (tri[0] + tri[2] + tri[4]) / 3, y: (tri[1] + tri[3] + tri[5]) / 3 }); const near = (a, b, tol) => a && b && Math.hypot(a.x - b.x, a.y - b.y) < tol; const out = { a: {}, b: {}, c: {} }; // ==================== A: THE MIXED ENCOUNTER ========================== gs._spawnEncounter({ kind: 'mixed', pirates: 2, neutrals: 2 }); const P = live().slice(); // the two pirates (combat.enemies) const N = allies().slice(); // the two neutrals (combat.allies) if (P.length < 2 || N.length < 2) return { out, failA: 'mix did not spawn (P=' + P.length + ' N=' + N.length + ')' }; for (const e of P) tough(e); for (const a of N) tough(a); await sleep(150); unblock(); // ---- A1: OFF-SCREEN ARROWS — hostiles only, in pixels --------------- await freeze(); setPos(P[0], ship.x, ship.y - 2200); // off-screen above → top-edge arrow setPos(P[1], ship.x + 2200, ship.y); // off-screen right → right-edge arrow setPos(N[0], ship.x - 2200, ship.y); // off-screen left (a NEUTRAL — must NOT arrow) setPos(N[1], ship.x, ship.y + 120); // on-screen (no arrow either way) await pass(); const arrowPts = arrowCap.map(centroid); out.a.arrowCount = arrowCap.length; out.a.arrowPts = arrowPts.map((p) => ({ x: Math.round(p.x), y: Math.round(p.y) })); out.a.hostileArrowMatch = P.map((h) => { const exp = arrowPosOf(h); return exp ? arrowPts.some((p) => near(p, exp, 36)) : null; }); out.a.neutralArrowAbsent = N.map((n) => { const exp = arrowPosOf(n); return exp ? !arrowPts.some((p) => near(p, exp, 36)) : true; }); out.a.arrowRed = []; for (const p of arrowPts) out.a.arrowRed.push(await redBoxMax(p.x, p.y, 22)); out.a.neutralEdgeRed = []; for (const n of N) { const exp = arrowPosOf(n); out.a.neutralEdgeRed.push(exp ? await redBoxMax(exp.x, exp.y, 22) : null); } thaw(); // ---- cluster for the fight: the A1 spread (2200 px apart) is too wide // for either roster to get in weapon range, so bring the encounter // into a realistic layout — pirates on a standoff ring around the // player, escorts on their ring — and let the AI develop the fight. setPos(P[0], ship.x, ship.y - 260); setPos(P[1], ship.x + 260, ship.y + 40); setPos(N[0], ship.x - 120, ship.y + 120); setPos(N[1], ship.x + 80, ship.y - 150); await sleep(60); // ---- the fight develops: escorts engage + fire; pirates fire back --- // (fire EVENTS counted via the fireEnemy/fireAlly hooks above — // deterministic, no live-bullet sampling.) let sawAllyFire = false, sawPirateFire = false, sawEngage = false; const t0 = Date.now(); while (Date.now() - t0 < 25000 && !(sawAllyFire && sawPirateFire)) { unblock(); await marqueeClear(); if (fireCount.ally > 0) sawAllyFire = true; if (fireCount.enemy > 0) sawPirateFire = true; if (allies().some((a) => a.state === 'engage')) sawEngage = true; await sleep(100); } out.a.sawAllyFire = sawAllyFire; out.a.sawPirateFire = sawPirateFire; out.a.sawEngage = sawEngage; out.a.fireCount = { ...fireCount }; // ---- A2: THREAT MARKERS — hostiles only, respect the data ----------- await freeze(); setPos(P[0], ship.x - 90, ship.y - 70); // on-screen (left) setPos(P[1], ship.x + 100, ship.y + 60); // on-screen (right) setPos(N[0], ship.x - 20, ship.y + 150); // on-screen (a NEUTRAL — must NOT mark) setPos(N[1], ship.x + 40, ship.y - 170); // on-screen (a NEUTRAL — must NOT mark) const threatOrig = { ...hud.threat }; const enabledOrig = hud.threat.enabled; // (1) the data ships threat DISABLED → zero markers drawn. hud.threat.enabled = false; await pass(); out.a.threatOffCount = threatCap.length; // (2) flipped ON at runtime → one marker per pirate, none per neutral. hud.threat.enabled = true; await pass(); out.a.threatOnCount = threatCap.length; out.a.threatOnWho = threatCap.map((c) => ({ x: c[0], y: c[1], r: c[2] })); out.a.threatHostileMatch = P.map((h) => threatCap.some((c) => Math.hypot(c[0] - h.x, c[1] - (h.y - 36)) < 8)); out.a.threatNeutralAbsent = N.map((n) => !threatCap.some((c) => Math.hypot(c[0] - n.x, c[1] - (n.y - 36)) < 8)); out.a.threatOnRed = { p0: await redBoxMax(slotOf(P[0]).x, slotOf(P[0]).y, 16), p1: await redBoxMax(slotOf(P[1]).x, slotOf(P[1]).y, 16), n0: await redBoxMax(slotOf(N[0]).x, slotOf(N[0]).y, 16), n1: await redBoxMax(slotOf(N[1]).x, slotOf(N[1]).y, 16), }; // (3) restore the data → the markers clear again. hud.threat = threatOrig; await pass(); out.a.threatRestoredCount = threatCap.length; out.a.threatEnabledData = enabledOrig; thaw(); // ---- A3: THE KILL FEED (the production kill path) ------------------- out.a.feedBase = feedBand(); for (const e of live()) { e.maxHull = 10; e.hull = 1; e.maxShields = 0; e.shields = 0; // make killable e.xp = 0; // no XP → no level-up marquee to cover the feed while I read it const killed = e.damage(9999); // → dead (applyDamage) if (killed) sys.killEnemy(e, sys._now()); // bookkeeping: feed + (no XP) + escort trigger } // READ THE FEED IMMEDIATELY — the kills' XP may pop the level-up // marquee (which the escort phase needs to clear), but waiting first // would let the feed entries age out (the data's life is 3000 ms). // The probe proved this path: force the pool to materialize, wait for // it to present, then read the pixels (the feed text is a Text object, // not a Graphics draw, so it needs a present to land on the canvas). hud._drawFeed(hud._now()); await sleep(300); unblock(); out.a.feedFrozen = gs.comms ? { paused: gs.comms.paused, marquee: !!(gs.levelMarquee && gs.levelMarquee.active) } : null; out.a.feedLabels = hud.feed.map((f) => f.label); const kt = (hud.feedTexts || []).find((t) => t && t.text === 'HOSTILE DESTROYED') || (hud.feedTexts || [])[0] || null; out.a.feedText = kt ? { text: kt.text, alpha: Number(kt.alpha.toFixed(2)), x: Math.round(kt.x), y: Math.round(kt.y) } : null; out.a.feedBright = feedBand(); out.a.playerDown = !!sys.playerDown; unblock(); await marqueeClear(); // ---- A4: THE ESCORT LIFECYCLE (survivors ride, then break off) ------ out.a.survivors = allies().length; let sawEscort = false; const t1 = Date.now(); while (Date.now() - t1 < 17000 && allies().length > 0) { unblock(); await marqueeClear(); if (allies().some((a) => a.state === 'escort')) sawEscort = true; await sleep(100); } out.a.sawEscort = sawEscort; // The encounter ENDS when the last one is off-screen (despawned). for (let t2 = Date.now(); Date.now() - t2 < 45000 && allies().length > 0; await sleep(100)) { unblock(); await marqueeClear(); } out.a.alliesAfter = allies().length; out.a.activeAfter = sys.active; // ==================== B: NEUTRALS-ONLY (safety) ====================== // Phase-a ordnance in flight must be gone before we measure (b). const enemyOrdnance = () => sys.bullets.filter((b) => !b.dead && b.owner === 'enemy').length + (sys.missiles || []).filter((m) => !m.dead && m.owner === 'enemy').length; for (let t = Date.now(); Date.now() - t < 10000 && enemyOrdnance() > 0; await sleep(100)) unblock(); // Count EVERY point of player damage for the phase — shield regen // cannot mask a neutral's hit. const origDP = sys.damagePlayer.bind(sys); let dmgInB = 0; sys.damagePlayer = (n, o) => { dmgInB += Number(n) || 0; return origDP(n, o); }; gs._spawnEncounter({ kind: 'neutrals', pirates: 0, neutrals: 2 }); const NB = allies().slice(); for (const a of NB) tough(a); await sleep(150); unblock(); // Park the escorts ON-SCREEN near the player (center), away from every // edge — then freeze so they hold. A streaming-in escort's own hull can sit // AT an edge and its sprite reads as "red at the edge" (a false positive); // the edge-red check must measure ARROWS, so keep the ships off the edges. setPos(NB[0], ship.x - 130, ship.y + 90); setPos(NB[1], ship.x + 110, ship.y - 70); // ZERO hostiles up → no arrow at ANY edge, no threat marker on the escorts. await freeze(); await pass(); out.b.arrowCount = arrowCap.length; // edgeReds = ANY reddish pixel (loose) — reported for transparency, NOT the // assertion (stars/sprites/particles are legitimately reddish and flake it). // edgeRedsVivid = the arrow's VIVID red only — this is what the assertion uses, // because a drawn off-screen arrow is the only thing that is vivid #ff2d6f. out.b.edgeReds = { top: await redBoxMax(vw / 2, 28, 22), right: await redBoxMax(vw - 28, vh / 2, 22), left: await redBoxMax(28, vh / 2, 22), bottom: await redBoxMax(vw / 2, vh - 28, 22), }; out.b.edgeRedsVivid = { top: await vividBoxMax(vw / 2, 28, 22), right: await vividBoxMax(vw - 28, vh / 2, 22), left: await vividBoxMax(28, vh / 2, 22), bottom: await vividBoxMax(vw / 2, vh - 28, 22), }; const bar = gs.combatBar; out.b.combatBar = bar ? { visible: !!bar.visible, x: Math.round(bar.x), y: Math.round(bar.y) } : null; out.b.combatActive = !!sys.active; hud.threat.enabled = true; // even flipped on, the escorts must stay unmarked await pass(); out.b.threatCount = threatCap.length; out.b.threatRed = []; for (const n of NB) out.b.threatRed.push(await redBoxMax(slotOf(n).x, slotOf(n).y, 16)); hud.threat = threatOrig; // restore the data await pass(); thaw(); let nearSamples = 0, allyFired = false, piratesAppeared = false; const t3 = Date.now(); while (Date.now() - t3 < 20000 && allies().length > 0) { unblock(); await marqueeClear(); if (live().length > 0) piratesAppeared = true; if (allies().some((a) => Math.hypot(a.x - ship.x, a.y - ship.y) < 400)) nearSamples++; if (sys.bullets.some((b) => !b.dead && b.owner === 'ally') || (sys.missiles || []).some((m) => !m.dead && m.owner === 'ally')) allyFired = true; await sleep(100); } for (let t4 = Date.now(); Date.now() - t4 < 45000 && allies().length > 0; await sleep(100)) { unblock(); await marqueeClear(); } sys.damagePlayer = origDP; out.b.nearSamples = nearSamples; out.b.allyFired = allyFired; out.b.piratesAppeared = piratesAppeared; out.b.alliesAfter = allies().length; out.b.dmgInB = dmgInB; out.b.playerDown = !!sys.playerDown; // ==================== C: THE 'PIRATE PROBLEMS' AMBUSH ================ const oc = window.__ORBIT_COMBAT__; if (!oc || !oc.waves) return { out, failC: 'no __ORBIT_COMBAT__.waves seam' }; if (sys.autoFire) sys.autoFire.enabled = false; // the scripted drive owns the kills oc.waves.demo('pirate_ambush'); const live2 = () => sys.enemies.filter((e) => !e.dead); let hull0 = null, deployed = false; for (let t5 = Date.now(); Date.now() - t5 < 25000 && !deployed; await sleep(100)) { unblock(); await marqueeClear(); const a = live2(); if (a.length === 3 && a.every((e) => e.maxHull === a[0].maxHull)) { hull0 = a[0].maxHull; deployed = true; } } out.c.deployed = deployed; out.c.hull0 = hull0; if (deployed) { const drains = [3, 3, 3, 2, 1, 0]; out.c.drain = []; for (let k = 0; k < 6; k++) { unblock(); const tgt = live2().find((x) => x.maxHull === hull0); if (!tgt) { out.c.fail = 'no raider at step ' + k; break; } tgt.damage(9999); sys.killEnemy(tgt, sys._now()); // Let the replacement (if any) materialise, then read the drain. let n = live2().length; for (let w = Date.now(); Date.now() - w < 2000 && n !== drains[k]; await sleep(120)) { unblock(); n = live2().length; } out.c.drain.push(n); if (n !== drains[k]) { out.c.fail = 'after kill ' + (k + 1) + ': ' + n + ' on screen, expected ' + drains[k]; break; } } let st = null; for (let t6 = Date.now(); Date.now() - t6 < 10000 && !st; await sleep(200)) { unblock(); const s = sys.waveStatus(); if (s && s.state === 'done') st = s.state; } out.c.state = st; out.c.killsTotal = sys.kills; } return out; })()`; // --------------------------------------------------------------------- main async function main() { const { c, target } = await openPage('/index.html'); try { let ready = false; for (let i = 0; i < 80; i++) { await sleep(500); try { ready = await evalJs(c, 'Boolean(window.game)', false); } catch { ready = false; } if (ready) break; } if (!ready) throw new Error('game never booted'); const started = await evalJs(c, `(async () => { const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); for (let i = 0; i < 120; i++) { const m = window.game && window.game.scene && window.game.scene.getScene('MenuScene'); if (m && typeof m.seedValue === 'string') { m.startNewGame(); return 'started'; } await sleep(500); } return 'menu never ready'; })()`); if (started !== 'started') throw new Error(`start: ${started}`); let scoped = false; for (let i = 0; i < 60; i++) { await sleep(500); try { scoped = await evalJs(c, 'Boolean(window.__ORBIT_COMBAT__ && window.game.scene.getScene("GameScene").ship && window.game.scene.getScene("GameScene").combat)', false); } catch { scoped = false; } if (scoped) break; } if (!scoped) throw new Error('combat scope never appeared'); await sleep(800); const out = await evalJs(c, PAGE_CODE); if (typeof out === 'string') throw new Error(`drive failed: ${out}`); // DIAGNOSTIC: whenever the bottom edge reads ANY reddish pixel, print both // the loose count and the vivid-red count (the assertion target) + bar state. if (out.b && (out.b.edgeReds && out.b.edgeReds.bottom > 0)) { console.error('PHASE-B BOTTOM RED:', JSON.stringify({ loose: out.b.edgeReds, vivid: out.b.edgeRedsVivid, combatBar: out.b.combatBar, combatActive: out.b.combatActive })); } if (out.failA) throw new Error(out.failA); if (out.failC) throw new Error(out.failC); const bad = []; const { a, b, c: cc } = out; // A1) off-screen arrows — hostiles only, in pixels if (a.arrowCount !== 2) bad.push(`a: expected 2 off-screen arrows, the HUD drew ${a.arrowCount} (pts ${JSON.stringify(a.arrowPts)})`); if (!(a.hostileArrowMatch || []).every(Boolean)) bad.push(`a: an off-screen PIRATE has no arrow (match ${JSON.stringify(a.hostileArrowMatch)})`); if (!(a.neutralArrowAbsent || []).every(Boolean)) bad.push(`a: a NEUTRAL got an off-screen arrow (absent ${JSON.stringify(a.neutralArrowAbsent)})`); if (!(a.arrowRed || []).every((v) => v >= 40)) bad.push(`a: the arrows left no red pixels (got ${JSON.stringify(a.arrowRed)})`); const ner = (a.neutralEdgeRed || []).filter((v) => v !== null); if (!(ner.length && ner.every((v) => v <= 15))) bad.push(`a: red where a NEUTRAL's arrow would sit (got ${JSON.stringify(a.neutralEdgeRed)})`); // A2) threat markers — hostiles only, respect the data if (a.threatOffCount !== 0) bad.push(`a: threat markers drawn while the data has them DISABLED (count ${a.threatOffCount})`); if (a.threatOnCount !== 2) bad.push(`a: expected 2 threat markers (enabled), the HUD drew ${a.threatOnCount} (${JSON.stringify(a.threatOnWho)})`); if (!(a.threatHostileMatch || []).every(Boolean)) bad.push(`a: a PIRATE has no threat marker (match ${JSON.stringify(a.threatHostileMatch)})`); if (!(a.threatNeutralAbsent || []).every(Boolean)) bad.push(`a: a NEUTRAL got a threat marker (absent ${JSON.stringify(a.threatNeutralAbsent)})`); const tr = a.threatOnRed || {}; if (!(Number.isFinite(tr.p0) && Number.isFinite(tr.n0) && Number.isFinite(tr.p1) && Number.isFinite(tr.n1) && tr.p0 > tr.n0 && tr.p1 > tr.n1)) bad.push(`a: the threat pixels are not stronger on the pirates than the escorts (${JSON.stringify(tr)})`); if (a.threatRestoredCount !== 0) bad.push(`a: the threat markers did not clear when the data was restored (count ${a.threatRestoredCount})`); // the fight + survival if (!a.sawAllyFire) bad.push('a: no ally-owned bolt/seeker ever flew (the escorts did not fight alongside)'); if (!a.sawPirateFire) bad.push('a: no pirate-owned bolt ever flew (the mix did not fight back)'); if (a.playerDown) bad.push('a: the player DIED (pirates may hit the player; the escorts must never do so)'); // A3) the kill feed if (!(a.feedLabels || []).includes('HOSTILE DESTROYED')) bad.push(`a: the kill feed never got a kill (got ${JSON.stringify(a.feedLabels)})`); if (!a.feedText || a.feedText.text !== 'HOSTILE DESTROYED' || a.feedText.alpha < 0.5 || a.feedText.x !== 1262) bad.push(`a: the feed text is not live at the data's xInset (got ${JSON.stringify(a.feedText)})`); if ((a.feedBright ?? 0) - (a.feedBase ?? 0) < 80) bad.push(`a: the feed text left no pixels (bright ${a.feedBright} vs pre-kill baseline ${a.feedBase}; frozen ${JSON.stringify(a.feedFrozen)}; labels ${JSON.stringify(a.feedLabels)}; text ${JSON.stringify(a.feedText)})`); // A4) the escort lifecycle if (!(a.survivors >= 1)) bad.push(`a: no surviving escort to ride the escort phase (got ${a.survivors})`); if (!a.sawEscort) bad.push("a: no escort ever held the 'escort' state after the last pirate"); if (a.alliesAfter !== 0) bad.push(`a: escorts still up after the break-off (${a.alliesAfter})`); if (a.activeAfter !== false) bad.push('a: combat.active did not fall back to false (the encounter never ENDED)'); // B) neutrals-only — safety in pixels + damage events if (b.arrowCount !== 0) bad.push(`b: arrows drawn with ZERO hostiles up (count ${b.arrowCount})`); // Assert on the arrow's VIVID red only (a drawn off-screen arrow is the sole // vivid-#ff2d6f source). The loose edgeReds is reported for transparency but // is NOT the assertion — twinkling stars / ship sprites / particles are // legitimately reddish and would false-positive a hard loose-red threshold. const ev = b.edgeRedsVivid || {}; if (!(ev.top <= 15 && ev.right <= 15 && ev.left <= 15 && ev.bottom <= 15)) bad.push(`b: the arrow's vivid red at a screen edge with zero hostiles up (${JSON.stringify(ev)}; loose reddish ${JSON.stringify(b.edgeReds)})`); if (b.threatCount !== 0) bad.push(`b: threat markers drawn for the NEUTRALS (count ${b.threatCount})`); if (!(b.nearSamples > 0)) bad.push('b: the neutral-only escorts never flew near the player'); if (b.allyFired) bad.push('b: a neutral fired with no pirate to fight (it must never aim at the player)'); if (b.piratesAppeared) bad.push('b: a hostile appeared in a neutrals-only encounter'); if (b.alliesAfter !== 0) bad.push(`b: escorts still up after the break-off (${b.alliesAfter})`); if (b.dmgInB !== 0 || b.playerDown) bad.push(`b: the player was hurt in the neutrals-only phase (damage points ${b.dmgInB})`); // C) the 'Pirate Problems' ambush if (!cc.deployed) bad.push('c: the ambush wave did not deploy (three raiders on screen)'); else { if (JSON.stringify(cc.drain) !== JSON.stringify([3, 3, 3, 2, 1, 0])) bad.push(`c: the drain was ${JSON.stringify(cc.drain)} (3 → 3 → 3 → 2 → 1 → 0, a replacement per kill)`); if (cc.state !== 'done') bad.push(`c: the ambush run ended '${cc.state}' != 'done'`); } if (bad.length) throw new Error(bad.join(' · ')); console.log('mixed : two pirates + two neutrals materialize off-screen; the arrows RENDER at both pirates\' edge positions (px-verified, the escorts never get one); the threat layer respects the data (off → 0 markers, on → both pirates marked + px-lit, escorts never), and clears again when restored; the escorts engage + fire and the pirates fire back while the player survives; the kills land in the feed (live text @1262 + ink px above baseline); the survivors escort, break off, and the encounter ENDS'); console.log('neutral: zero arrows + zero threat markers with zero hostiles (px-verified at every edge); the escorts fly near the player, never fire, never spawn a hostile, take the player 0 damage, break off, and leave'); console.log('ambush: the pirate_ambush script runs — SIX frame-0 raiders, three at a time, a replacement per kill, done at the last one'); return true; } catch (err) { console.error(`encounter-hud-check: FAIL — ${err.message}`); return false; } finally { try { await fetch(`http://${CDP}/json/close/${target.id}`).catch(() => {}); } catch { /* ignore */ } c.ws.close(); } } main().then((ok) => process.exit(ok ? 0 : 1)).catch((e) => { console.error('encounter-hud-check error:', e.message); process.exit(2); });