orbit/dev/encounter-hud-check.mjs

648 lines
34 KiB
JavaScript

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