648 lines
34 KiB
JavaScript
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);
|
|
});
|