Add periodic random ship encounters with neutral ally escorts

- New EncounterDirector (js/combat/Encounters.js): pure, data-driven clock that rolls pirate / neutral / mixed encounters in neutral non-home systems on a ~45–90s interval at 50–60% chance, one at a time with a 30–60s cooldown, never during a live wave run or on a downed player.
- Neutral ally ships (js/combat/NeutralShip.js) on spritesheet frames 20–27: escort the player for a per-ship 10–15s window, engage the nearest pirate in mixed fights, then break off and fly off-screen; player-safe by construction (never targeted or damaged by ally bolts/seekers).
- Pirate targeting generalized so hostiles can draw aggro onto escorts when the player is out of range; escort and pirate bolts/missiles collide with each other but not the player.
- Group size scales with combat level (1–2 low → 4–5 high, bands in data/combat.json); encounter hostiles are one-shots so the fight ends when the sky clears.
- New 'volley' weapon pattern (wide staggered-speed fan) and owner-tagged bolts/missiles for ally vs enemy routing in Weapons.js.
- Neutral roster config + encounters section added to data/combat.json; EnemyTypes gains neutralTypeConfig/Ids/SheetList.
- Tests: dev/encounters.test.mjs (cadence, gate, weights, size bands), dev/neutral.test.mjs (roster, escort AI, targeting, no friendly fire), plus combat/xp test updates; CDP probes and encounter-hud-check for in-browser HUD verification.
This commit is contained in:
Brian Fertig 2026-09-22 07:19:20 -06:00
parent 4ae70b9aaf
commit 2f80ddfdb1
28 changed files with 4005 additions and 27 deletions

View File

@ -1 +1 @@
{"version":1,"dirMtimeMs":1789956413250.3848,"goals":[]}
{"version":1,"dirMtimeMs":1790080392798.7607,"goals":[]}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

View File

@ -250,6 +250,147 @@
}
},
"neutral": {
"_comment": "The base NEUTRAL CRAFT — the friendly player-side ships (data/combat.json → neutralTypes deep-merges its overrides onto this base, exactly like enemyTypes onto enemy). They ride the SAME enemy spritesheet (assets/images/ships-enemy.png) on frames 20–27, one distinct craft per frame. side 'player' is the seam: they NEVER target or damage the player, the player's auto-fire never targets them, and CombatSystem flies them in its OWN allies roster (not enemies — js/combat/CombatSystem.js). Their AI (js/combat/NeutralShip.js): escort the player for a per-ship window (escortMin–escortMax seconds — the spawner rolls one per ship), then break off and fly off-screen; while any pirate is up they instead fight the NEAREST one with their own weapon pattern (the same Weapons.js dispatch the hostiles run). The stats ramp: hull/shields climb monotonically frame 20 → 27 (xp = (hull+shields)/5 — the same rule of thumb as the pirate roster). respawn false: encounter ships are spent, never reformed; ramDamage 0: a friendly ship's ram can't hurt the player.",
"texture": "assets/images/ships-enemy.png",
"frameWidth": 128,
"frameHeight": 128,
"frame": 20,
"artFacing": "north",
"side": "player",
"size": 54,
"hitRadius": 20,
"speed": 175,
"turn": 3.2,
"orbitRadius": 260,
"orbitSpeed": 0.6,
"escortRadius": 110,
"escortMin": 10,
"escortMax": 15,
"hull": 60,
"shields": 0,
"xp": 12,
"weapon": {
"pattern": "lance",
"range": 500,
"cooldown": 1.6,
"bulletSpeed": 340,
"damage": 9,
"spread": 0.05,
"bulletLife": 2.2,
"color": "#37e0a1",
"size": 16,
"boltLength": 48,
"boltWidth": 10,
"muzzle": 44
},
"ramDamage": 0,
"ramDamageSelf": 0,
"ramGrace": 0.8,
"respawn": false
},
"neutralTypes": {
"_comment": "The NEUTRAL roster (frames 20–27 of assets/images/ships-enemy.png) — the random-encounter director (GameScene → CombatSystem.spawnAlly) deploys them, js/combat/NeutralShip.js flies them. Each entry declares ONLY what differs from the neutral base above (the merge is EnemyTypes.neutralTypeConfig — the same deep-merge contract as the enemyTypes): a DISTINCT weapon pattern (the Weapons.js set — lance/spray/burst/rapid/ring/missile/combo — plus 'volley', the eighth pattern) and the STATS RAMP (hull/shields climb monotonically frame 20 → 27; xp = (hull+shields)/5). side stays 'player' (inherited — never hostile).",
"courier": {
"$comment": "frame 20 — the LANCE: the scout. Fast and light, one aimed bolt; the easy end of the ramp.",
"frame": 20,
"speed": 195,
"turn": 3.4,
"hull": 60,
"shields": 0,
"xp": 12,
"weapon": { "pattern": "lance", "range": 500, "cooldown": 1.6, "bulletSpeed": 340, "damage": 9, "spread": 0.05, "bulletLife": 2.2, "color": "#37e0a1" }
},
"runner": {
"$comment": "frame 21 — the RAPID: the fast gun. Short cooldown, honest per-bolt punch.",
"frame": 21,
"speed": 200,
"turn": 4.0,
"hull": 70,
"shields": 10,
"xp": 16,
"weapon": { "pattern": "rapid", "range": 440, "cooldown": 0.5, "bulletSpeed": 380, "damage": 8, "spread": 0.06, "bulletLife": 1.6, "color": "#7df3c1" }
},
"sloop": {
"$comment": "frame 22 — the SPRAY: a fan of cheap bolts covering an arc.",
"frame": 22,
"speed": 150,
"turn": 2.6,
"hull": 80,
"shields": 15,
"xp": 19,
"weapon": { "pattern": "spray", "range": 460, "cooldown": 2.0, "bulletSpeed": 300, "damage": 8, "spread": 0.03, "bulletLife": 2.2, "bolts": 5, "fan": 1.1, "color": "#37e0a1" }
},
"sally": {
"$comment": "frame 23 — the BURST: a fast, tight 3-shot stinger at close range.",
"frame": 23,
"speed": 180,
"turn": 3.6,
"hull": 90,
"shields": 20,
"xp": 22,
"weapon": { "pattern": "burst", "range": 380, "cooldown": 1.5, "bulletSpeed": 420, "damage": 9, "spread": 0.02, "bulletLife": 1.8, "bolts": 3, "burstFan": 0.10, "color": "#aef27d" }
},
"sentinel": {
"$comment": "frame 24 — the RING: a radial volley; slow, armored, pokes a hole in the line.",
"frame": 24,
"speed": 130,
"turn": 2.4,
"hull": 100,
"shields": 30,
"xp": 26,
"weapon": { "pattern": "ring", "range": 360, "cooldown": 2.2, "bulletSpeed": 260, "damage": 10, "spread": 0.0, "bulletLife": 2.6, "bolts": 8, "color": "#4dd7ff" }
},
"herald": {
"$comment": "frame 25 — the MISSILE: one heat-seeking shot per volley (dodgeable at the capped turn).",
"frame": 25,
"speed": 160,
"turn": 3.0,
"hull": 110,
"shields": 35,
"xp": 29,
"weapon": { "pattern": "missile", "range": 620, "cooldown": 2.4, "missiles": 1, "missileSpeed": 190, "missileTurn": 2.2, "missileDamage": 13, "missileLife": 4.0, "missileColor": "#37e0a1", "missileSize": 16, "color": "#37e0a1" }
},
"vanguard": {
"$comment": "frame 26 — the COMBO: a heavy bolt PLUS a heat-seeker on every volley.",
"frame": 26,
"speed": 110,
"turn": 2.0,
"hull": 130,
"shields": 45,
"xp": 35,
"weapon": { "pattern": "combo", "range": 700, "cooldown": 2.6, "bulletSpeed": 240, "damage": 16, "spread": 0.02, "bulletLife": 3.2, "size": 18, "boltLength": 56, "boltWidth": 12, "missiles": 1, "missileSpeed": 170, "missileTurn": 2.4, "missileDamage": 18, "missileLife": 4.2, "missileColor": "#7df3c1", "missileSize": 18, "color": "#7df3c1" }
},
"bulwark": {
"$comment": "frame 27 — the VOLLEY: the ramp's top — a WIDE fan whose speeds are staggered, so the arc blossoms into a wall over distance (Weapons.js 'volley': the burst's stagger across the spray's fan).",
"frame": 27,
"speed": 95,
"turn": 1.8,
"hull": 150,
"shields": 60,
"xp": 42,
"weapon": { "pattern": "volley", "range": 520, "cooldown": 2.4, "bulletSpeed": 280, "damage": 11, "spread": 0.02, "bulletLife": 2.8, "bolts": 7, "fan": 1.5, "stagger": 0.35, "color": "#c8f54d" }
}
},
"encounters": {
"_comment": "PERIODIC RANDOM ENCOUNTERS (js/combat/Encounters.js → EncounterDirector; GameScene spawns on a hit) — every NEUTRAL non-home system, while flying, no combat live, no wave run in flight, one at a time. The director rolls on the interval schedule (rollIntervalMin–rollIntervalMax s; each attempt fires at chanceMin–chanceMax probability), and after an encounter ENDS it stays quiet for cooldownMin–cooldownMax s before the next roll. KIND rolls at `weights` (shipped even — ~1/3 each). SIZE: the player's combat level (js/combat/XpManager — a fresh run starts at 0) picks a band; mixed keeps both kinds present. Encounter hostiles are one-shots (no respawn); escorts carry the neutral.escortMin/escortMax window and break off on their own (js/combat/NeutralShip.js). Seconds + 0–1 probabilities — no code constants.",
"enabled": true,
"rollIntervalMin": 45,
"rollIntervalMax": 90,
"chanceMin": 0.5,
"chanceMax": 0.6,
"cooldownMin": 30,
"cooldownMax": 60,
"weights": { "pirates": 1, "neutrals": 1, "mixed": 1 },
"bands": [
{ "minLevel": 0, "maxLevel": 2, "pirates": [1, 2], "neutrals": [1, 2], "mixedPirates": [1, 1], "mixedNeutrals": [1, 1] },
{ "minLevel": 3, "maxLevel": 5, "pirates": [2, 3], "neutrals": [2, 3], "mixedPirates": [1, 2], "mixedNeutrals": [1, 2] },
{ "minLevel": 6, "maxLevel": 99, "pirates": [4, 5], "neutrals": [4, 5], "mixedPirates": [2, 3], "mixedNeutrals": [1, 2] }
]
},
"waves": {
"_comment": "WAVE SCRIPTS — the quest-step combat (js/combat/Waves.js + CombatSystem.startWaves; the quest hook that FIRES one lands later, random encounters too — for now the console drives it: window.__ORBIT_COMBAT__.waves). A script is an ordered list of waves. KINDS: 'rolling' = maxConcurrent on screen at a time, each kill sending in replacePerKill more until the force is spent; 'fixed' = the whole force at once, no replacements until exhausted. COMPOSITION: `roster` = enemyTypes id → weight (the mix); `total` = the units deployed (default = the weight sum); `type` + `count` is the single-type shorthand. lead = s before wave 1; gap = s between waves (per-wave `gap` s overrides). The quests reference scripts BY NAME (e.g. {\"combat\": \"raid\"}) when the hook lands.",
"lead": 1.5,

View File

@ -173,9 +173,10 @@ const {
pathToFileURL(join(__dirname, '../js/combat/Combat.js')).href
);
const { EnemyShip } = await import(pathToFileURL(join(__dirname, '../js/combat/EnemyShip.js')).href);
const { enemyTypeConfig, enemyTypeIds, enemySheetList } = await import(
pathToFileURL(join(__dirname, '../js/combat/EnemyTypes.js')).href
);
const {
enemyTypeConfig, enemyTypeIds, enemySheetList,
neutralTypeConfig, neutralTypeIds, neutralSheetList,
} = await import(pathToFileURL(join(__dirname, '../js/combat/EnemyTypes.js')).href);
const { normalizeWaves, resolveRoster, WaveRunner } = await import(
pathToFileURL(join(__dirname, '../js/combat/Waves.js')).href
);
@ -833,6 +834,40 @@ const E = (k, d) => config.get(`combat.enemy.${k}`, d);
b.frame === 1 && b.maxHull === 80 && b.maxShields === 10 && approx(b.scale, E('size', 54) / 128));
}
// ------------------------------------------------------------- neutral types
{
const base = neutralTypeConfig();
check('neutral: the base craft = combat.neutral (the frames 20–27 sheet, player-side)',
base.frame === 20 && base.side === 'player' && base.texture === E('texture', 'assets/images/ships-enemy.png') &&
base.respawn === false && base.ramDamage === 0);
const ramp = neutralTypeIds().slice().sort((a, b) => neutralTypeConfig(a).frame - neutralTypeConfig(b).frame);
check('neutral: the shipped roster = 8 distinct crafts, frames 20–27',
ramp.length === 8 && ramp.every((id, i) => neutralTypeConfig(id).frame === 20 + i));
check('neutral: each type declares a DISTINCT weapon pattern',
new Set(ramp.map((id) => neutralTypeConfig(id).weapon.pattern)).size === 8);
const climbs = (get) => ramp.slice(1).every((id, i) => get(neutralTypeConfig(id)) > get(neutralTypeConfig(ramp[i])));
check('neutral: the stats ramp climbs monotonically frame 20 → 27 (hull + shields)',
climbs((t) => t.hull) && climbs((t) => t.shields));
check('neutral: xp follows the (hull+shields)/5 rule',
ramp.every((id) => {
const t = neutralTypeConfig(id);
return t.xp === Math.round((t.hull + t.shields) / 5);
}));
check('neutral: the merge works (frame 21 = the rapid, inheriting base texture/size/hitRadius)', (() => {
const id = ramp.find((x) => neutralTypeConfig(x).frame === 21);
const t = neutralTypeConfig(id);
return t.weapon.pattern === 'rapid' && t.texture === base.texture && t.size === base.size && t.hitRadius === base.hitRadius;
})());
check('neutral: unknown id → the base craft (warned, not thrown)', neutralTypeConfig('nope') === base);
check('neutral: every type stays player-side (never hostile)',
ramp.every((id) => neutralTypeConfig(id).side === 'player'));
const sheets = neutralSheetList();
check('neutral: the sheet seam resolves (the shared enemy sheet, 128×128 frames)',
sheets.length === 1 && sheets[0].src === base.texture && sheets[0].frameWidth === 128 && sheets[0].frameHeight === 128);
}
// ------------------------------------------------------------------- waves
{
@ -1139,7 +1174,7 @@ const E = (k, d) => config.get(`combat.enemy.${k}`, d);
homingStep(0, 0, 0, 10, 0.5, 2.0, 0.05) === Math.atan2(0.5, 10));
}
// ------------------------------------------------- weapons: the seven patterns
// ------------------------------------------------- weapons: the eight patterns
{
const scene = makeScene({ withSheet: true });
scene.sound = { play: () => {} };
@ -1224,6 +1259,17 @@ const E = (k, d) => config.get(`combat.enemy.${k}`, d);
check('weapons: combo = the heavy bolt AND the missile (warden)',
kind === 'combo' && h.bolts.length === 1 && h.missiles.length === 1);
}
{
const h = mkHost();
const wpn = { pattern: 'volley', bolts: 7, fan: 1.5, stagger: 0.35, bulletSpeed: 280, spread: 0 };
const kind = firePattern(h, ship, player, wpn);
const speeds = h.bolts.map((b) => b.wpn.bulletSpeed);
const span = Math.max(...h.bolts.map((b) => b.a)) - Math.min(...h.bolts.map((b) => b.a));
check('weapons: volley = a WIDE 7-bolt fan with staggered speeds (the 8th pattern)',
kind === 'volley' && h.bolts.length === 7 && h.missiles.length === 0 && new Set(speeds).size === 7);
check('weapons: the volley fan holds its width (± the aim jitter)',
span >= wpn.fan - 0.065 && span <= wpn.fan + 0.065);
}
}
// ------------------------------------------------- weapons: the missile itself

205
dev/enc-probe2.mjs Normal file
View File

@ -0,0 +1,205 @@
/**
* Dev-only focused PROBE — ground truth for the encounter-HUD pixel checks.
* Answers three questions before I finalize dev/encounter-hud-check.mjs:
*
* 1. Does the Phaser render loop keep presenting frames while the sim is
* FROZEN via the comms pause (game.comms._setPaused(true))? (A render
* counter on the hooked renderer tells us: if it doesn't, a manual
* hud.update() during the freeze never reaches a presented frame, which
* would explain zero threat-marker pixels.)
* 2. WHERE are the red pixels actually (off-screen arrows + threat
* markers)? A full-frame red scan with cluster reporting — no fixed
* boxes — so we see the true geometry instead of guessing.
* 3. Does a kill actually flip e.dead and land in the feed when the pirate
* is made killable (the cascade bug: killEnemy() alone does not set
* e.dead, so toughened pirates survived the sweep and poisoned b/c)?
*
* node dev/enc-probe2.mjs
*
* Needs the dev server on :8080 and headless Chromium CDP on 127.0.0.1:9333.
*/
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 };
}
const PAGE = `(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 || !gs.combatHud) return 'no GameScene/combat/ship/combatHud';
const sys = gs.combat, hud = gs.combatHud, ship = gs.ship;
const unblock = () => { try { if (gs.comms && gs.comms.paused) gs.comms._ack(); } catch {} };
unblock();
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 R = { q1: {}, q2: {}, q3: {} };
// ---- render hook + counter
const ren = window.game.renderer, cv = ren.canvas;
const off = document.createElement('canvas');
off.width = cv.width; off.height = cv.height;
const octx = off.getContext('2d');
window.__renderCount = 0;
const orig = ren.render;
ren.render = function (...a) {
const ret = orig.apply(this, a);
try { octx.drawImage(cv, 0, 0); window.__renderCount++; } catch {}
return ret;
};
const vw = gs.scale.width, vh = gs.scale.height;
const RED = (r, g, b) => r > 130 && r - g > 45 && r - b > 25;
// Full-frame red scan -> 32px-bin clusters (center + count), biggest first.
const redClusters = (max = 12) => {
const bins = new Map();
const d = octx.getImageData(0, 0, cv.width, cv.height).data;
for (let y = 0; y < cv.height; y++) for (let x = 0; x < cv.width; x++) {
const i = (y * cv.width + x) * 4;
if (RED(d[i], d[i+1], d[i+2])) {
const k = ((x >> 5) & 1023) + ',' + ((y >> 5) & 1023);
const b = bins.get(k) || { n: 0, sx: 0, sy: 0 };
b.n++; b.sx += x; b.sy += y; bins.set(k, b);
}
}
return [...bins.values()].map((b) => ({ n: b.n, x: Math.round(b.sx/b.n), y: Math.round(b.sy/b.n) }))
.sort((a, b) => b.n - a.n).slice(0, max);
};
const mod = await import('/js/combat/Combat.js');
const arrowPt = (e) => { const s = hud.worldToScreen(e.x, e.y); return mod.offscreenArrow(vw, vh, s.x, s.y, 28); };
// ---- spawn the mix (tough, so nothing dies mid-probe)
gs._spawnEncounter({ kind: 'mixed', pirates: 2, neutrals: 2 });
for (const e of live()) tough(e);
for (const a of allies()) tough(a);
await sleep(200); unblock();
// ---- Q1: render loop alive? (normal, then comms-paused)
let c0 = window.__renderCount; await sleep(500);
R.q1.renderWhileRunning = window.__renderCount - c0;
const pause = async () => { if (gs.comms) gs.comms._setPaused(true); await sleep(400); };
const thaw = () => { if (gs.comms) gs.comms._setPaused(false); };
await pause();
c0 = window.__renderCount; await sleep(500);
R.q1.renderWhilePaused = window.__renderCount - c0;
R.q1.commsPaused = !!(gs.comms && gs.comms.paused);
// ---- Q2: where is the red? (arrows for off-screen pirates)
R.q2.live = live().length; R.q2.allies = allies().length;
R.q2.pirates = live().map((e) => ({
w: [Math.round(e.x), Math.round(e.y)],
s: (() => { const p = hud.worldToScreen(e.x, e.y); return [Math.round(p.x), Math.round(p.y)]; })(),
arrow: (() => { const p = arrowPt(e); return p ? { x: Math.round(p.x), y: Math.round(p.y), a: Number(p.angle.toFixed(2)) } : null; })(),
}));
R.q2.offscreenCfg = { ...hud.offscreen };
R.q2.redClustersArrows = redClusters();
// ---- Q2b: threat markers (flip on, manual update, rescan)
const threatOrig = hud.threat;
hud.threat = { ...threatOrig, enabled: true };
hud.update(gs.time.now);
await sleep(350);
R.q2b = {
threatCfg: { ...hud.threat },
redClustersThreat: redClusters(),
};
// also: is the marker's graphics actually holding a circle? (commandBuffer)
const tg = hud._threatG;
R.q2b.threatG_cmds = tg ? (tg.commandBuffer ? tg.commandBuffer.length : (tg.commands ? tg.commands.length : 'n/a')) : 'no _threatG';
hud.threat = threatOrig;
hud.update(gs.time.now);
await sleep(120);
thaw();
// ---- Q3: does a real kill flip dead + land in the feed?
const p0 = live()[0];
if (p0) {
R.q3.before = { dead: !!p0.dead, hull: p0.hull, feedLen: hud.feed.length, active: sys.active, kills: sys.kills };
p0.shields = 0; p0.maxShields = 0; p0.hull = 1; p0.maxHull = 1; // make it killable
const killed = p0.damage(9999);
if (killed) sys.killEnemy(p0, sys._now());
await sleep(300); unblock();
R.q3.after = {
dead: !!p0.dead, killed, feedLen: hud.feed.length,
feed: hud.feed.map((f) => f.label), active: sys.active, kills: sys.kills,
liveNow: live().length,
};
} else R.q3.fail = 'no pirate to kill';
return R;
})()`;
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 R = await evalJs(c, PAGE);
if (typeof R === 'string') throw new Error('probe: ' + R);
console.log(JSON.stringify(R, null, 2));
return true;
} catch (e) {
console.error('enc-probe2: FAIL —', e.message);
return false;
} finally {
try { await fetch(`http://${CDP}/json/close/${target.id}`).catch(() => {}); } catch {}
c.ws.close();
}
}
main().then((ok) => process.exit(ok ? 0 : 1)).catch((e) => { console.error('enc-probe2 error:', e.message); process.exit(2); });

120
dev/enc-probe3.mjs Normal file
View File

@ -0,0 +1,120 @@
/**
* Dev-only PROBE: dump the CombatHud graphics commandBuffer structure so I can
* verify exact draw geometry (arrow triangles / threat circles) in-browser —
* the robust "renders correctly" signal, less fragile than pixel-box matching.
* node dev/enc-probe3.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(t) {
const ws = new WebSocket(t.webSocketDebuggerUrl);
let id = 0; const p = new Map();
ws.onmessage = (x) => { const m = JSON.parse(x.data); if (m.id && p.has(m.id)) { const { res, rej } = p.get(m.id); p.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; p.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 t;
try { t = await (await fetch(`http://${CDP}/json/new?${encodeURIComponent(url)}`, { method: 'PUT' })).json(); }
catch { t = await (await fetch(`http://${CDP}/json/new?${encodeURIComponent(url)}`, { method: 'POST' })).json(); }
const c = connect(t); await c.open; await c.send('Runtime.enable');
return { c, target: t };
}
const PAGE = `(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 || !gs.combatHud) return 'no GameScene/combat/ship/combatHud';
const sys = gs.combat, hud = gs.combatHud;
const unblock = () => { try { if (gs.comms && gs.comms.paused) gs.comms._ack(); } catch {} };
unblock();
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 dump = (g) => {
if (!g) return 'null';
const cb = g.commandBuffer ?? g.commands ?? null;
if (!cb) return 'no commandBuffer';
return Array.from(cb).map((c) => {
const o = {};
for (const k of ['type','x','y','width','height','radius','red','green','blue','alpha','src','pt0','pt1','pt2']) if (k in c) o[k] = c[k];
// triangle vertices are often pt0/pt1/pt2 or inline
if (c.pt0) o.pt0 = [c.pt0.x, c.pt0.y];
if (c.pt1) o.pt1 = [c.pt1.x, c.pt1.y];
if (c.pt2) o.pt2 = [c.pt2.x, c.pt2.y];
return o;
});
};
const R = {};
// spawn one off-screen pirate (top) + one off-screen neutral (left) for arrows;
// then one on-screen pirate + one on-screen neutral for threat.
gs._spawnEncounter({ kind: 'mixed', pirates: 2, neutrals: 2 });
for (const e of live()) tough(e); for (const a of allies()) tough(a);
// put pirate[0] off-screen top, pirate[1] on-screen center; neutral[0] off-screen left, neutral[1] on-screen
const [p0, p1] = live(), [n0, n1] = allies();
const px = gs.ship.x, py = gs.ship.y;
if (p0) { p0.x = px; p0.y = py - 1400; } // off-screen above
if (p1) { p1.x = px + 60; p1.y = py + 40; } // on-screen
if (n0) { n0.x = px - 1400; n0.y = py; } // off-screen left (ally — should get NO arrow)
if (n1) { n1.x = px - 120; n1.y = py + 10; } // on-screen ally
await sleep(300); unblock();
// enable threat so the layer draws markers, then let the scene draw normally
const tOrig = hud.threat; hud.threat = { ...tOrig, enabled: true };
await sleep(300); unblock();
R.pirates = live().map((e) => ({ x: Math.round(e.x), y: Math.round(e.y), dead: !!e.dead }));
R.allies = allies().map((a) => ({ x: Math.round(a.x), y: Math.round(a.y), dead: !!a.dead }));
R.arrowG = dump(hud._arrowG);
R.threatG = dump(hud._threatG);
R.feedG = dump(hud._reticleG);
// sample a presented frame for pixel sanity (red + bright)
const ren = window.game.renderer, cv = ren.canvas;
const off = document.createElement('canvas'); off.width = cv.width; off.height = cv.height;
const octx = off.getContext('2d');
if (!window.__hooked3) { window.__hooked3 = true; const orig = ren.render; ren.render = function (...a) { const r = orig.apply(this, a); try { octx.drawImage(cv, 0, 0); } catch {} return r; }; }
await sleep(200);
const RED = (r, g, b) => r > 130 && r - g > 45 && r - b > 25;
const BRIGHT = (r, g, b) => r > 150 && g > 150 && b > 160;
const count = (x, y, w, h, test) => {
const X = Math.max(0, x), Y = Math.max(0, y), W = Math.min(w, cv.width - X), H = Math.min(h, Y + h > cv.height ? cv.height - Y : h);
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;
};
R.pixel = {
arrowTopEdge: count(0, 0, cv.width, 60, RED), // top edge band
feedBand: count(cv.width - 260, 50, 260, 40, BRIGHT), // feed top-right
};
hud.threat = tOrig;
return R;
})()`;
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 R = await evalJs(c, PAGE);
if (typeof R === 'string') throw new Error('probe: ' + R);
console.log(JSON.stringify(R, null, 2));
return true;
} catch (e) { console.error('enc-probe3: FAIL —', e.message); return false; }
finally { try { await fetch(`http://${CDP}/json/close/${target.id}`).catch(() => {}); } catch {} c.ws.close(); }
}
main().then((ok) => process.exit(ok ? 0 : 1)).catch((e) => { console.error('enc-probe3 error:', e.message); process.exit(2); });

122
dev/enc-probe4.mjs Normal file
View File

@ -0,0 +1,122 @@
/**
* Dev-only PROBE 4: instrument the CombatHud draw calls to learn EXACTLY where
* the HUD draws (arrow triangles / threat circles / feed text) and confirm
* pixels land there. This tells me whether "renders correctly" is a geometry
* mismatch in my test or a real rendering gap.
* node dev/enc-probe4.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(t) {
const ws = new WebSocket(t.webSocketDebuggerUrl);
let id = 0; const p = new Map();
ws.onmessage = (x) => { const m = JSON.parse(x.data); if (m.id && p.has(m.id)) { const { res, rej } = p.get(m.id); p.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; p.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 t;
try { t = await (await fetch(`http://${CDP}/json/new?${encodeURIComponent(url)}`, { method: 'PUT' })).json(); }
catch { t = await (await fetch(`http://${CDP}/json/new?${encodeURIComponent(url)}`, { method: 'POST' })).json(); }
const c = connect(t); await c.open; await c.send('Runtime.enable');
return { c, target: t };
}
const PAGE = `(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 || !gs.combatHud) return 'no GameScene/combat/ship/combatHud';
const sys = gs.combat, hud = gs.combatHud, ship = gs.ship;
const unblock = () => { try { if (gs.comms && gs.comms.paused) gs.comms._ack(); } catch {} };
unblock();
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 R = { arrows: [], threats: [], feedTexts: [] };
// ---- instrument the HUD's draw calls (capture every geometry it issues)
const ag = hud._arrowG, tg = hud._threatG;
const origTri = ag.fillTriangle.bind(ag);
ag.fillTriangle = function (x1, y1, x2, y2, x3, y3) { R.arrows.push([Math.round(x1), Math.round(y1), Math.round(x2), Math.round(y2), Math.round(x3), Math.round(y3)]); return origTri(x1, y1, x2, y2, x3, y3); };
const origCirc = tg.fillCircle.bind(tg);
tg.fillCircle = function (x, y, r) { R.threats.push({ x: Math.round(x), y: Math.round(y), r: Math.round(r) }); return origCirc(x, y, r); };
// ---- clean scenario: ONE off-screen pirate (top) + ONE on-screen neutral (ally)
gs._spawnEncounter({ kind: 'mixed', pirates: 1, neutrals: 1 });
const p = live()[0], n = allies()[0];
for (const e of live()) tough(e); for (const a of allies()) tough(a);
if (p) { p.x = ship.x; p.y = ship.y - 1500; } // off-screen above -> should get an ARROW
if (n) { n.x = ship.x - 120; n.y = ship.y + 10; } // on-screen ally -> should get NEITHER
// enable threat so the layer draws
const tOrig = hud.threat; hud.threat = { ...tOrig, enabled: true };
R.arrows.length = 0; R.threats.length = 0;
await sleep(700); unblock(); // let the scene draw several frames
R.arrows = R.arrows.slice(0, 6);
R.threats = R.threats.slice(-6);
R.pirate = p ? { x: Math.round(p.x), y: Math.round(p.y), s: (() => { const q = hud.worldToScreen(p.x, p.y); return [Math.round(q.x), Math.round(q.y)]; })() } : null;
R.neutral = n ? { x: Math.round(n.x), y: Math.round(n.y), s: (() => { const q = hud.worldToScreen(n.x, n.y); return [Math.round(q.x), Math.round(q.y)]; })() } : null;
// ---- pixel at the drawn positions
const ren = window.game.renderer, cv = ren.canvas;
const off = document.createElement('canvas'); off.width = cv.width; off.height = cv.height;
const octx = off.getContext('2d');
if (!window.__hooked4) { window.__hooked4 = true; const orig = ren.render; ren.render = function (...a) { const r = orig.apply(this, a); try { octx.drawImage(cv, 0, 0); } catch {} return r; }; }
await sleep(250);
const px = (x, y, half = 10) => {
const X = Math.max(0, Math.round(x - half)), Y = Math.max(0, Math.round(y - half));
const W = Math.min(half * 2, cv.width - X), H = Math.min(half * 2, cv.height - Y);
if (W <= 0 || H <= 0) return null;
const d = octx.getImageData(X, Y, W, H).data;
let red = 0, maxR = 0, sample = [0, 0, 0];
for (let i = 0; i < d.length; i += 4) {
const r = d[i], g = d[i + 1], b = d[i + 2];
if (r > 130 && r - g > 45 && r - b > 25) red++;
if (r > maxR) { maxR = r; sample = [r, g, b]; }
}
return { red, maxR, sample };
};
if (R.arrows.length) {
const c = R.arrows[R.arrows.length - 1];
const cx = (c[0] + c[2] + c[4]) / 3, cy = (c[1] + c[3] + c[5]) / 3;
R.pixelAtArrow = px(cx, cy, 14);
}
if (R.threats.length) {
const c = R.threats[R.threats.length - 1];
R.pixelAtThreat = px(c.x, c.y, 12);
}
// neutral slot (should be clean)
if (R.neutral) R.pixelAtNeutralSlot = px(R.neutral.s[0], R.neutral.s[1] - 36, 12);
hud.threat = tOrig;
return R;
})()`;
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 R = await evalJs(c, PAGE);
if (typeof R === 'string') throw new Error('probe: ' + R);
console.log(JSON.stringify(R, null, 2));
return true;
} catch (e) { console.error('enc-probe4: FAIL —', e.message); return false; }
finally { try { await fetch(`http://${CDP}/json/close/${target.id}`).catch(() => {}); } catch {} c.ws.close(); }
}
main().then((ok) => process.exit(ok ? 0 : 1)).catch((e) => { console.error('enc-probe4 error:', e.message); process.exit(2); });

128
dev/enc-probe5.mjs Normal file
View File

@ -0,0 +1,128 @@
/**
* Dev-only PROBE 5: decisive test of THREAT-MARKER rendering.
* Moves pirates ON-SCREEN, enables the (data-disabled) threat layer, lets the
* NATURAL scene render draw it (no manual update), then freezes and samples
* pixels at worldToScreen(pirate.x, pirate.y-36). Also sanity-checks that the
* pirate body itself is visible, and that a neutral's slot stays clean.
* node dev/enc-probe5.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(t) {
const ws = new WebSocket(t.webSocketDebuggerUrl);
let id = 0; const p = new Map();
ws.onmessage = (x) => { const m = JSON.parse(x.data); if (m.id && p.has(m.id)) { const { res, rej } = p.get(m.id); p.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; p.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 t;
try { t = await (await fetch(`http://${CDP}/json/new?${encodeURIComponent(url)}`, { method: 'PUT' })).json(); }
catch { t = await (await fetch(`http://${CDP}/json/new?${encodeURIComponent(url)}`, { method: 'POST' })).json(); }
const c = connect(t); await c.open; await c.send('Runtime.enable');
return { c, target: t };
}
const PAGE = `(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 || !gs.combatHud) return 'no GameScene/combat/ship/combatHud';
const sys = gs.combat, hud = gs.combatHud, ship = gs.ship;
const unblock = () => { try { if (gs.comms && gs.comms.paused) gs.comms._ack(); } catch {} };
unblock();
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 vw = gs.scale.width, vh = gs.scale.height;
const R = {};
const ren = window.game.renderer, cv = ren.canvas;
const off = document.createElement('canvas'); off.width = cv.width; off.height = cv.height;
const octx = off.getContext('2d');
if (!window.__hooked5) { window.__hooked5 = true; const orig = ren.render; ren.render = function (...a) { const r = orig.apply(this, a); try { octx.drawImage(cv, 0, 0); } catch {} return r; }; }
const RED = (r, g, b) => r > 130 && r - g > 45 && r - b > 25;
const count = (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 -1; // off-canvas
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) => count(cx - half, cy - half, half * 2, half * 2, RED);
// spawn 2 pirates + 1 neutral; toughen; hold pirates ON-SCREEN near center.
gs._spawnEncounter({ kind: 'mixed', pirates: 2, neutrals: 1 });
for (const e of live()) tough(e); for (const a of allies()) tough(a);
const [p0, p1] = live(), [n0] = allies();
if (p0) { p0.x = ship.x - 40; p0.y = ship.y - 120; } // on-screen, above player
if (p1) { p1.x = ship.x + 120; p1.y = ship.y + 60; } // on-screen, right of player
if (n0) { n0.x = ship.x + 20; n0.y = ship.y + 160; } // on-screen ally (should NOT get a marker)
await sleep(150); unblock();
R.before = { live: live().length, allies: allies().length,
p0: p0 ? (() => { const s = hud.worldToScreen(p0.x, p0.y); return { w: [Math.round(p0.x), Math.round(p0.y)], s: [Math.round(s.x), Math.round(s.y)] }; })() : null,
n0: n0 ? (() => { const s = hud.worldToScreen(n0.x, n0.y); return { w: [Math.round(n0.x), Math.round(n0.y)], s: [Math.round(s.x), Math.round(s.y)] }; })() : null,
threatCfg: { ...hud.threat } };
// Baseline (threat still disabled by data): pirate slot should be clean.
const slot = (o) => { const s = hud.worldToScreen(o.x, o.y - 36); return { x: s.x, y: s.y }; };
R.base = { p0slot: p0 ? redBox(slot(p0).x, slot(p0).y, 16) : null,
n0slot: n0 ? redBox(slot(n0).x, slot(n0).y, 16) : null,
p0body: p0 ? redBox(hud.worldToScreen(p0.x, p0.y).x, hud.worldToScreen(p0.x, p0.y).y, 16) : null };
// ENABLE the threat layer and let the NATURAL scene render draw it.
const tOrig = hud.threat;
hud.threat = { ...tOrig, enabled: true };
await sleep(500); unblock();
// re-pin (AI may have nudged them) then one more natural draw
if (p0) { p0.x = ship.x - 40; p0.y = ship.y - 120; }
if (n0) { n0.x = ship.x + 20; n0.y = ship.y + 160; }
await sleep(300); unblock();
// freeze so the presented frame is stable, then sample
if (gs.comms) gs.comms._setPaused(true);
await sleep(400);
R.after = {
p0slot: p0 ? redBox(slot(p0).x, slot(p0).y, 16) : null,
p1slot: p1 ? redBox(slot(p1).x, slot(p1).y, 16) : null,
n0slot: n0 ? redBox(slot(n0).x, slot(n0).y, 16) : null,
p0body: p0 ? redBox(hud.worldToScreen(p0.x, p0.y).x, hud.worldToScreen(p0.x, p0.y).y, 16) : null,
threatG_cmds: hud._threatG ? (hud._threatG.commandBuffer ? hud._threatG.commandBuffer.length : 'n/a') : 'no _threatG',
};
R.after.p0slotPos = p0 ? (() => { const s = slot(p0); return [Math.round(s.x), Math.round(s.y)]; })() : null;
R.after.n0slotPos = n0 ? (() => { const s = slot(n0); return [Math.round(s.x), Math.round(s.y)]; })() : null;
hud.threat = tOrig;
if (gs.comms) gs.comms._setPaused(false);
return R;
})()`;
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 R = await evalJs(c, PAGE);
if (typeof R === 'string') throw new Error('probe: ' + R);
console.log(JSON.stringify(R, null, 2));
return true;
} catch (e) { console.error('enc-probe5: FAIL —', e.message); return false; }
finally { try { await fetch(`http://${CDP}/json/close/${target.id}`).catch(() => {}); } catch {} c.ws.close(); }
}
main().then((ok) => process.exit(ok ? 0 : 1)).catch((e) => { console.error('enc-probe5 error:', e.message); process.exit(2); });

127
dev/enc-probe6.mjs Normal file
View File

@ -0,0 +1,127 @@
/** Focused probe: where do the threat markers land on screen, and do the
* pixels there read red? Dumps every coordinate in the chain so the
* worldToScreen → pixel mapping can be verified directly. */
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 };
}
const 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, hud = gs.combatHud, ship = gs.ship;
const unblock = () => { try { if (gs.comms && gs.comms.paused) gs.comms._ack(); } catch {} };
unblock();
sys.maxHull = 1e9; sys.hull = 1e9; sys.maxShields = 1e9; sys.shields = 1e9;
// pixel pipeline
const ren = window.game.renderer, cv = ren.canvas;
const off = document.createElement('canvas'); off.width = cv.width; off.height = cv.height;
const octx = off.getContext('2d');
if (!window.__lf) { window.__lf = true; const o = ren.render; ren.render = function (...a) { const r = o.apply(this, a); try { octx.drawImage(cv, 0, 0); } catch {} return r; }; }
const RED = (r, g, b) => r > 130 && r - g > 45 && r - b > 25;
const redAt = (x, y, half) => {
const X = Math.max(0, Math.round(x - half)), Y = Math.max(0, Math.round(y - half));
const W = half * 2, H = half * 2;
const d = octx.getImageData(X, Y, W, H).data; let n = 0;
for (let i = 0; i < d.length; i += 4) if (RED(d[i], d[i+1], d[i+2])) n++;
return n;
};
// capture threat circles
const threatG = hud._threatG;
let cap = [];
const orig = threatG.fillCircle;
threatG.fillCircle = function (...a) { cap.push(a.slice(0, 3)); return orig.apply(this, a); };
const out = {};
gs._spawnEncounter({ kind: 'mixed', pirates: 2, neutrals: 2 });
const P = sys.enemies.filter((e) => !e.dead);
const N = sys.allies.filter((a) => !a.dead);
for (const e of P) { e.maxHull = 1e9; e.hull = 1e9; e.maxShields = 1e9; e.shields = 1e9; }
for (const a of N) { a.maxHull = 1e9; a.hull = 1e9; a.maxShields = 1e9; a.shields = 1e9; }
await sleep(200); unblock();
// camera + worldToScreen reference
const cam = gs.cameras ? gs.cameras.main : null;
out.cam = cam ? { wx: cam.worldView.x, wy: cam.worldView.y, w: cam.worldView.width, h: cam.worldView.height, zoom: cam.zoom } : null;
out.player = { x: Math.round(ship.x), y: Math.round(ship.y) };
// place P[0] at a KNOWN world point, zero velocity, freeze
const TX = 1000, TY = 500;
if (gs.comms) gs.comms._setPaused(true);
await sleep(320);
P[0].x = TX; P[0].y = TY;
if (P[0].body) { P[0].body.velocity.x = 0; P[0].body.velocity.y = 0; }
out.P0 = { x: P[0].x, y: P[0].y };
out.slotWorld = { x: TX, y: TY - 36 };
out.slotScreen = hud.worldToScreen(TX, TY - 36);
hud.threat.enabled = true;
// sample over several frames, track the max + the alpha at each
let best = 0, alphas = [];
for (let i = 0; i < 14; i++) {
const t = gs.time.now;
hud.update(t);
await sleep(24);
const pulse = 0.55 + 0.45 * Math.sin(t * 0.001 * 6.2831853 * 2.2);
alphas.push(Number(pulse.toFixed(2)));
best = Math.max(best, redAt(out.slotScreen.x, out.slotScreen.y, 16));
}
out.redMax = best;
out.alphas = alphas;
out.capturedCircles = cap.slice(-4);
out.threatCfg = { enabled: hud.threat.enabled, size: hud.threat.size, above: hud.threat.above, pulse: hud.threat.pulse };
out.worldToScreenFn = String(hud.worldToScreen).slice(0, 120);
return out;
})()`;
(async () => {
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)', false); } catch { scoped = false; } if (scoped) break; }
if (!scoped) throw new Error('combat scope never appeared');
await sleep(800);
const out = await evalJs(c, CODE);
console.log(JSON.stringify(out, null, 2));
} finally {
try { await fetch(`http://${CDP}/json/close/${target.id}`).catch(() => {}); } catch {}
c.ws.close();
}
})().catch((e) => { console.error('probe err:', e.message); process.exit(2); });

128
dev/enc-probe7.mjs Normal file
View File

@ -0,0 +1,128 @@
/** Focused probe: the kill feed. Kill a hostile and dump the feed state,
* the text object's alpha/position/visibility, the live game freeze state,
* and the actual pixel values at the feed band. */
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 };
}
const 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, hud = gs.combatHud, ship = gs.ship;
const unblock = () => { try { if (gs.comms && gs.comms.paused) gs.comms._ack(); } catch {} };
unblock();
sys.maxHull = 1e9; sys.hull = 1e9; sys.maxShields = 1e9; sys.shields = 1e9;
const ren = window.game.renderer, cv = ren.canvas;
const off = document.createElement('canvas'); off.width = cv.width; off.height = cv.height;
const octx = off.getContext('2d');
if (!window.__lf) { window.__lf = true; const o = ren.render; ren.render = function (...a) { const r = o.apply(this, a); try { octx.drawImage(cv, 0, 0); } catch {} return r; }; }
const BRIGHT = (r, g, b) => r > 130 && g > 150 && b > 160;
const out = {};
gs._spawnEncounter({ kind: 'mixed', pirates: 2, neutrals: 2 });
await sleep(200); unblock();
const P = sys.enemies.filter((e) => !e.dead);
const v = window.game.scale ? window.game.scale.width : cv.width;
const vw = cv.width, vh = cv.height;
// KILL one pirate (make it killable first)
const e = P[0];
e.maxHull = 10; e.hull = 1; e.maxShields = 0; e.shields = 0;
const killed = e.damage(9999);
if (killed) sys.killEnemy(e, sys._now());
out.killed = killed;
out.dead = e.dead;
out._killFx = e._killFx;
// let the feed ramp up (alpha fade-in is 250 ms)
await sleep(400);
unblock();
out.frozen = gs.comms ? { paused: gs.comms.paused, marquee: !!(gs.levelMarquee && gs.levelMarquee.active) } : null;
out.canText = typeof hud.scene.add?.text === 'function';
out.feedCfgEnabled = hud.feedCfg && hud.feedCfg.enabled;
out.sceneAddTextType = typeof hud.scene.add?.text;
// force a draw pass directly and see if the pool fills
try { hud._drawFeed(hud._now()); out.afterManualCount = hud.feedTexts.length; }
catch (err) { out.manualErr = String(err && err.message || err); }
// let several render frames land after the text object exists
await sleep(300);
unblock();
out.feedCount = hud.feed.length;
out.feedLabels = hud.feed.map((f) => f.label);
out.feed_e_t = hud.feed.map((f) => f.t);
out.now = (hud._now && hud._now()) || null;
out.feedTexts = (hud.feedTexts || []).map((t) => t ? { text: t.text, alpha: Number(t.alpha.toFixed(3)), x: Math.round(t.x), y: Math.round(t.y), visible: t.visible } : null);
// pixel sample at the feed band
const band = (x, y, w, h) => { const d = octx.getImageData(x, y, w, h).data; let n = 0; for (let i = 0; i < d.length; i += 4) if (BRIGHT(d[i], d[i+1], d[i+2])) n++; return n; };
out.feedBand = band(vw - 200, 55, 190, 50);
// dump a coarse grid of the feed band: any non-dark pixel (r+g+b > 90)
const grid = [];
for (let gy = 58; gy < 92; gy += 6) {
let row = '';
for (let gx = 1090; gx < 1270; gx += 8) {
const d = octx.getImageData(gx, gy, 1, 1).data;
const sum = d[0] + d[1] + d[2];
row += sum > 90 ? '#' : (sum > 40 ? '.' : ' ');
}
grid.push(row);
}
out.grid = grid;
out.nonDark = (function () { let n = 0; const d = octx.getImageData(1090, 58, 170, 34).data; for (let i = 0; i < d.length; i += 4) if (d[i] + d[i + 1] + d[i + 2] > 90) n++; return n; })();
// a few specific pixels near where the text should be (right-aligned at vw-18)
out.pixels = [1150, 1200, 1240, 1255].map((x) => { const d = octx.getImageData(x, 70, 1, 1).data; return [d[0], d[1], d[2], d[3]]; });
return out;
})()`;
(async () => {
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)', false); } catch { scoped = false; } if (scoped) break; }
if (!scoped) throw new Error('combat scope never appeared');
await sleep(800);
const out = await evalJs(c, CODE);
console.log(JSON.stringify(out, null, 2));
} finally {
try { await fetch(`http://${CDP}/json/close/${target.id}`).catch(() => {}); } catch {}
c.ws.close();
}
})().catch((e) => { console.error('probe err:', e.message); process.exit(2); });

127
dev/enc-probe8.mjs Normal file
View File

@ -0,0 +1,127 @@
/**
* PROBE 8 (minimal, CDP-safe): in the phase-B state (neutrals-only, escorts
* parked at centre, sim frozen), what is RED at the bottom-centre edge?
* Returns ONLY the red-pixel count + a coarse colour histogram — no Phaser
* objects (avoids the CDP "reference chain too long" serialisation error).
* Run: node dev/enc-probe8.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 };
}
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 { err: 'no game' };
const sys = gs.combat; const ship = gs.ship;
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', { willReadFrequently: true });
if (!window.__p8hook) {
window.__p8hook = true; const orig = ren.render;
ren.render = function (...a) { const ret = orig.apply(this, a); try { octx.drawImage(cv, 0, 0); } catch {} return ret; };
}
const vw = gs.scale.width, vh = gs.scale.height;
for (const e of sys.enemies.slice()) e.dead = true;
for (const a of (sys.allies || [])) a.dead = true;
gs._spawnEncounter({ kind: 'neutrals', pirates: 0, neutrals: 2 });
const NB = (sys.allies || []).filter((a) => !a.dead);
for (const a of NB) { a.maxHull = 1e9; a.hull = 1e9; a.maxShields = 1e9; a.shields = 1e9; }
const setPos = (o, x, y) => { o.x = x; o.y = y; if (o.body) { o.body.velocity.x = 0; o.body.velocity.y = 0; } };
setPos(NB[0], ship.x - 130, ship.y + 90);
setPos(NB[1], ship.x + 110, ship.y - 70);
if (gs.comms) gs.comms._setPaused(true);
await sleep(250);
const RED = (r, g, b) => r > 130 && r - g > 45 && r - b > 25;
const hist = () => {
const x = Math.round(vw/2 - 22), y = Math.round(vh - 28 - 22), w = 44, h = 44;
const d = octx.getImageData(x, y, w, h).data;
const h2 = {}; let n = 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 k = r + ',' + g + ',' + b; h2[k] = (h2[k] || 0) + 1; }
}
return { n, h2 };
};
let best = { n: 0, h2: {} };
for (let f = 0; f < 12; f++) { await sleep(22); const c = hist(); if (c.n > best.n) best = c; }
// Also sample the OTHER three edges for context.
const edge = (cx, cy) => {
const x = Math.round(cx - 22), y = Math.round(cy - 22), w = 44, h = 44;
const d = octx.getImageData(x, y, w, h).data; let n = 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++; }
return n;
};
const out = { vw, vh, bottom: best, top: edge(vw/2, 28), right: edge(vw-28, vh/2), left: edge(28, vh/2), escortCount: NB.length };
// Report the two most common red colours as plain strings.
out.topColors = Object.entries(best.h2).sort((a, b) => b[1] - a[1]).slice(0, 4).map(([k, v]) => k + ' x' + v);
if (gs.comms) gs.comms._setPaused(false);
return out;
})()
`;
async function main() {
const { c } = 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);
console.log(JSON.stringify(out, null, 2));
} finally { try { c.ws.close(); } catch {} }
}
main().catch((e) => { console.error('probe8 error:', e.message); process.exit(1); });

647
dev/encounter-hud-check.mjs Normal file
View File

@ -0,0 +1,647 @@
/**
* 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);
});

288
dev/encounters.test.mjs Normal file
View File

@ -0,0 +1,288 @@
/**
* ENCOUNTER DIRECTOR test (dev tool, run with Node — no browser needed):
*
* node dev/encounters.test.mjs
*
* Exercises the pure clock (js/combat/Encounters.js) against the shipped
* data/combat.json → encounters defaults: the cadence (interval schedule
* + chance gate), the one-at-a-time + post-encounter QUIET, the
* allowed-space gate (neutral non-home, no wave run in flight, the
* player upright), the EVEN kind weights (~1/3 each), and the
* level-scaled group size (1–2 low → 4–5 high; mixed keeps both kinds).
*
* The deterministic blocks run the director on a degenerate config
* (min == max, so every roll lands on an exact value) with a fixed rng —
* the cadence math is testable to the millisecond.
*/
import { pathToFileURL } from 'node:url';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const __dirname = dirname(fileURLToPath(import.meta.url));
const fs = await import('node:fs');
const { config } = await import(pathToFileURL(join(__dirname, '../js/config/Config.js')).href);
const dataDir = join(__dirname, '../data');
const configData = {};
for (const f of fs.readdirSync(dataDir)) {
if (!f.endsWith('.json') || f === 'manifest.json') continue;
configData[f.replace(/\.json$/i, '')] = JSON.parse(fs.readFileSync(join(dataDir, f), 'utf8'));
}
config.init(configData);
console.log('config loaded from data/:', Object.keys(configData).join(', '));
const { EncounterDirector, encounterAllowed, rollKind, compose, bandFor } = await import(
pathToFileURL(join(__dirname, '../js/combat/Encounters.js')).href
);
let failures = 0;
const check = (label, cond) => {
console.log(`${cond ? '✔' : '✘ FAIL'} ${label}`);
if (!cond) failures++;
};
const cfg = () => config.get('combat.encounters', {});
/** A deterministic LCG (Numerical Recipes') — the stats checks are reproducible. */
const lcg = (seed = 123456789) => {
let s = seed % 2147483647;
if (s <= 0) s += 2147483646;
return () => {
s = (s * 48271) % 2147483647;
return (s - 1) / 2147483646;
};
};
/** The degenerate config — min == max, so the rolls land on exact values. */
const fixed = {
enabled: true,
rollIntervalMin: 50,
rollIntervalMax: 50,
chanceMin: 1,
chanceMax: 1,
cooldownMin: 30,
cooldownMax: 30,
weights: { pirates: 1, neutrals: 1, mixed: 1 },
bands: cfg().bands,
};
// -------------------------------------------------------------------- data
{
const e = cfg();
check('data: the encounters section ships (data/combat.json)', !!e && typeof e === 'object');
check('data: enabled by default', e.enabled === true);
check('data: the roll interval is the ~45–90 s band', e.rollIntervalMin === 45 && e.rollIntervalMax === 90);
check(
'data: the chance sits inside the ~50–60% band',
e.chanceMin >= 0.5 && e.chanceMax <= 0.6 && e.chanceMin <= e.chanceMax,
);
check('data: the post-encounter quiet is the ~30–60 s band', e.cooldownMin === 30 && e.cooldownMax === 60);
check(
'data: the kind weights are EVEN (~1/3 each)',
e.weights.pirates === 1 && e.weights.neutrals === 1 && e.weights.mixed === 1,
);
check(
'data: the size bands cover the whole level range (0–99, exactly one each)',
Array.from({ length: 100 }, (_, lv) => lv).every((lv) => {
const list = e.bands.filter((b) => lv >= b.minLevel && lv <= b.maxLevel);
return list.length === 1;
}),
);
const lo = bandFor(e.bands, 1);
check('data: the low band is 1–2 ships', lo.pirates[0] === 1 && lo.pirates[1] === 2 && lo.neutrals[0] === 1 && lo.neutrals[1] === 2);
const hi = bandFor(e.bands, 80);
check(
'data: the high band scales up to 4–5 ships',
hi.pirates[0] === 4 && hi.pirates[1] === 5 && hi.neutrals[0] === 4 && hi.neutrals[1] === 5,
);
check(
'data: mixed keeps BOTH kinds present (every band)',
e.bands.every((b) => b.mixedPirates[0] >= 1 && b.mixedNeutrals[0] >= 1),
);
check(
'data: the mixed high end is 2–3 pirates + 1–2 neutrals (the objective\'s example)',
hi.mixedPirates[0] === 2 && hi.mixedPirates[1] === 3 && hi.mixedNeutrals[0] === 1 && hi.mixedNeutrals[1] === 2,
);
check(
'bands: 0–2 → low, 3–5 → mid, 6+ → high (and a level past the top stays on it)',
bandFor(e.bands, 0) === e.bands[0] &&
bandFor(e.bands, 5) === e.bands[1] &&
bandFor(e.bands, 6) === e.bands[2] &&
bandFor(e.bands, 120) === e.bands[2],
);
}
// -------------------------------------------------------------------- gate
{
const ok = { ship: { x: 0, y: 0 }, isHomeSystem: false, faction: null, waveRunLive: false, playerDown: false };
check('gate: neutral wild space, flying, no run, upright — ALLOWED', encounterAllowed(ok) === true);
check('gate: the HOME system is safe space (never)', encounterAllowed({ ...ok, isHomeSystem: true }) === false);
check('gate: a FACTION system is off-limits (neutral only)', encounterAllowed({ ...ok, faction: 'apex' }) === false);
check('gate: a wave run IN FLIGHT holds the rolls', encounterAllowed({ ...ok, waveRunLive: true }) === false);
check('gate: a run that is already DONE holds no roll (its ships are spent)', encounterAllowed({ ...ok, waveRunLive: false }) === true);
check('gate: no spawn on a downed ship', encounterAllowed({ ...ok, playerDown: true }) === false);
check('gate: no ship, no encounter', encounterAllowed({ ...ok, ship: null }) === false);
}
// ------------------------------------------------------------------ cadence
{
// The full lifecycle, deterministic: first hit at exactly the interval,
// no rolls while live, the next hit exactly the cooldown after the end.
const d = new EncounterDirector(fixed, { rng: () => 0 });
const specs = [];
const tick = (t, live = false, allowed = true, level = 1) => {
const s = d.update(t, { allowed, live, xpLevel: level });
if (s) specs.push({ t, s });
return s;
};
for (let t = 0; t <= 49000; t += 1000) tick(t);
check('cadence: quiet until the first roll window (a full interval out)', specs.length === 0);
const s1 = tick(50000);
check('cadence: the first hit lands ON the interval (50 s here)', !!s1 && s1.pirates + s1.neutrals >= 1);
check('cadence: ONE AT A TIME — no roll while the encounter is live', tick(100000, true) === null && tick(199000, true) === null);
check('cadence: the fight ends → the quiet window holds (no early roll)', tick(200000) === null && tick(229000) === null);
const s2 = tick(230000);
check('cadence: the next hit lands exactly on the cooldown (30 s after the end; 229 s still null)', !!s2);
check('cadence: still one at a time on the second fight', tick(300000, true) === null);
tick(310000); // the second fight ends
const s3 = tick(340000); // 30 s later
check('cadence: the third hit keeps the same rhythm', !!s3);
check('cadence: the director ended its third fight ENGAGED (state bookkeeping)', d.state === 'engaged');
}
{
// REGRESSION: ms-scaling must clamp max to >= min in the SAME unit. The
// old code did `max = Math.max(min_ms, rawSeconds) * 1000`, which collapsed
// the band's TOP to min*1000 (a 45–90 s roll became 45 s–12.5 h, a 30–60 s
// cooldown became 30 s–8.3 h). rng=0 lands the LOWER bound, rng=1 the UPPER
// bound — both must sit inside the documented band. The deterministic
// cadence test above uses rng=() => 0 (always the lower bound) and so CANNOT
// see the corrupted upper bound; this block does.
const e = cfg();
const dLo = new EncounterDirector(e, { rng: () => 0 });
const dHi = new EncounterDirector(e, { rng: () => 1 });
const iLo = dLo._intervalMs(), iHi = dHi._intervalMs();
const cLo = dLo._cooldownMs(), cHi = dHi._cooldownMs();
check('cadence (regression): the roll interval spans 45–90 s (ms), NOT 45 s–12.5 h', iLo === 45000 && iHi === 90000);
check('cadence (regression): the cooldown spans 30–60 s (ms), NOT 30 s–8.3 h', cLo === 30000 && cHi === 60000);
}
{
// STATISTICAL: with the shipped config + a real rng, the FIRST encounter
// must land within a few minutes — periodic, not hours. The old upper-bound
// corruption made the median ~9 h (a player waits forever); a few-minutes
// median proves the cadence is genuinely "periodic-not-constant".
const firstHit = (seed) => {
const d = new EncounterDirector(cfg(), { rng: lcg(seed) });
let t = 0;
while (t < 3600000) {
if (d.update(t, { allowed: true, live: false, xpLevel: 1 })) return t;
t += 1000;
}
return null;
};
const hits = [];
for (let i = 0; i < 2000; i++) {
const h = firstHit(1000 + i);
if (h !== null) hits.push(h);
}
hits.sort((a, b) => a - b);
const median = hits.length ? hits[Math.floor(hits.length / 2)] : Infinity;
check(
'cadence (statistical): the median first encounter is a few minutes, not hours (got ' + Math.round(median / 1000) + ' s over ' + hits.length + '/2000 runs)',
hits.length > 1900 && median < 300000,
);
}
{
// The chance gate: a 0% chance never fires — but the schedule keeps
// ticking (the roll windows still pass, 50 s apart).
const d = new EncounterDirector({ ...fixed, chanceMin: 0, chanceMax: 0 }, { rng: () => 0 });
const all = [0, 50000, 100000, 150000].map((t) => d.update(t, { allowed: true, live: false, xpLevel: 1 }));
check('chance: a 0% chance never fires (four windows pass)', all.every((s) => s === null));
check('chance: ...and the schedule kept ticking (the next window is 200 s out)', d.nextRollAt === 200000);
}
{
// The allowed-space gate: out of allowed space the clock SLIDES — the
// first roll here lands a full interval AFTER allowed opens, never on
// the arrival frame.
const d = new EncounterDirector(fixed, { rng: () => 0 });
d.update(0, { allowed: false, live: false, xpLevel: 1 });
d.update(100000, { allowed: false, live: false, xpLevel: 1 });
d.update(190000, { allowed: false, live: false, xpLevel: 1 });
check('gate (director): out of allowed space, no roll on arrival', d.update(191000, { allowed: false, live: false, xpLevel: 1 }) === null);
check('gate (director): ...and no roll the moment allowed opens', d.update(192000, { allowed: true, live: false, xpLevel: 1 }) === null);
const s = d.update(242000, { allowed: true, live: false, xpLevel: 1 });
check('gate (director): the first roll here lands ~a full interval after the open', !!s);
}
{
// enabled=false (the data can hold the whole feature off).
const d = new EncounterDirector({ ...fixed, enabled: false }, { rng: () => 0 });
check(
'data: enabled=false holds the director off (every window)',
d.update(0, { allowed: true, live: false, xpLevel: 1 }) === null &&
d.update(50000, { allowed: true, live: false, xpLevel: 1 }) === null,
);
}
// -------------------------------------------------------------------- kinds
{
// EVEN weights: with a uniform rng each kind lands ~1/3 (a wide,
// deterministic tolerance — the LCG is fixed, so this is a repeat).
const n = 30000;
const rng = lcg();
const counts = { pirates: 0, neutrals: 0, mixed: 0 };
for (let i = 0; i < n; i++) counts[rollKind(cfg().weights, rng)]++;
for (const k of ['pirates', 'neutrals', 'mixed']) {
const share = counts[k] / n;
check(`kind: "${k}" rolls roughly 1/3 (got ${(share * 100).toFixed(1)}%)`, share > 0.28 && share < 0.37);
}
// A zero weight is never rolled.
check('kind: a zero weight is never rolled', Array.from({ length: 200 }, (_, i) => rollKind({ pirates: 0, neutrals: 1, mixed: 0 }, () => i / 200)).every((k) => k === 'neutrals'));
}
// -------------------------------------------------------------------- size
{
const e = cfg();
const only = (kind) => ({
...e,
weights: { pirates: kind === 'pirates' ? 1 : 0, neutrals: kind === 'neutrals' ? 1 : 0, mixed: kind === 'mixed' ? 1 : 0 },
});
const count = (kind, level, rng) => {
const c = compose(only(kind), level, rng);
return kind === 'pirates' ? c.pirates : kind === 'neutrals' ? c.neutrals : c.pirates + c.neutrals;
};
const r0 = () => 0;
const r1 = () => 0.999;
check('size: level 1 — 1–2 ships (the low band)', count('pirates', 1, r0) === 1 && count('pirates', 1, r1) === 2);
check('size: level 4 (the mid band) — 2–3 ships', count('neutrals', 4, r0) === 2 && count('neutrals', 4, r1) === 3);
check('size: level 8 (the high band) — 4–5 ships', count('pirates', 8, r0) === 4 && count('pirates', 8, r1) === 5);
check('size: the group GROWS with the level (high min > low max)', count('pirates', 8, r0) > count('pirates', 1, r1));
const mixLo = compose(only('mixed'), 1, () => 0.5);
check('size: mixed keeps BOTH kinds present (the low end)', mixLo.pirates >= 1 && mixLo.neutrals >= 1);
check(
'size: mixed at the high end — 2–3 pirates + 1–2 neutrals',
compose(only('mixed'), 8, r0).pirates === 2 &&
compose(only('mixed'), 8, r0).neutrals === 1 &&
compose(only('mixed'), 8, r1).pirates === 3 &&
compose(only('mixed'), 8, r1).neutrals === 2,
);
check('size: a single kind leaves the OTHER at zero', compose(only('pirates'), 8, r0).neutrals === 0 && compose(only('neutrals'), 8, r0).pirates === 0);
// The director composes at the caller's LEVEL (xpLevel rides the ctx).
const d = new EncounterDirector(fixed, { rng: () => 0 });
d.update(0, { allowed: true, live: false, xpLevel: 8 });
const s = d.update(50000, { allowed: true, live: false, xpLevel: 8 });
check('size (director): the level-8 hit deploys the high band (4 pirates)', !!s && s.kind === 'pirates' && s.pirates === 4 && s.neutrals === 0);
// The degenerate-band fallback (a config without bands still composes).
const bare = compose({ weights: { pirates: 1, neutrals: 0, mixed: 0 } }, 1, r0);
check('size: a config without bands falls back to 1 ship', bare.kind === 'pirates' && bare.pirates === 1);
}
// --------------------------------------------------------------------- done
if (failures) {
console.log(`\n✘ ${failures} ENCOUNTER-DIRECTOR CHECK(S) FAILED`);
process.exit(1);
}
console.log('\n✔ ALL ENCOUNTER-DIRECTOR CHECKS PASSED');

121
dev/graphics-probe.mjs Normal file
View File

@ -0,0 +1,121 @@
/**
* Dev-only: live CDP PROBE of the Phaser 4.2.1 Graphics `commandBuffer`
* shape (this build has no `.commands`) — one-shot.
*
* node dev/graphics-probe.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 };
}
const PROBE = `(() => {
const gs = window.game && window.game.scene ? window.game.scene.getScene('GameScene') : null;
if (!gs) return 'no scene';
const g = gs.add.graphics();
const desc = (v) => {
if (v === null || v === undefined) return v;
if (Array.isArray(v)) return v.slice(0, 12).map((x) => desc(x));
if (typeof v === 'object') {
const o = {};
for (const k of Object.keys(v).slice(0, 14)) o[k] = desc(v[k]);
return o;
}
return v;
};
const out = { before: desc(g.commandBuffer) };
try { g.fillStyle('#ff2d6f', 1); out.fillStyle = 'ok'; } catch (e) { out.fillStyle = 'ERR:' + e.message; }
try { g.fillCircle(100, 100, 8); out.fillCircle = 'ok'; } catch (e) { out.fillCircle = 'ERR:' + e.message; }
try { g.fillTriangle(0, 0, 10, 0, 5, 10); out.fillTriangle = 'ok'; } catch (e) { out.fillTriangle = 'ERR:' + e.message; }
out.after = desc(g.commandBuffer);
try { g.clear(); out.afterClear = desc(g.commandBuffer); } catch (e) { out.afterClear = 'ERR:' + e.message; }
try { if (g && typeof g.destroy === 'function') g.destroy(); } catch (e) {}
return out;
})()`;
async function main() {
const { c, target } = await openPage('/index.html');
try {
for (let i = 0; i < 80; i++) {
await sleep(500);
let ok = false;
try { ok = await evalJs(c, 'Boolean(window.game)', false); } catch { ok = false; }
if (ok) break;
}
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}`);
for (let i = 0; i < 60; i++) {
await sleep(500);
let ok = false;
try { ok = await evalJs(c, 'Boolean(window.__ORBIT_COMBAT__ && window.game.scene.getScene("GameScene").ship)', false); } catch { ok = false; }
if (ok) break;
}
await sleep(800);
const out = await evalJs(c, PROBE);
console.log(typeof out === 'string' ? out : JSON.stringify(out, null, 1));
return true;
} catch (err) {
console.error(`graphics-probe: 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('graphics-probe error:', e.message);
process.exit(2);
});

152
dev/hud-probe.mjs Normal file
View File

@ -0,0 +1,152 @@
/**
* Dev-only: live CDP PROBE of the CombatHud + Phaser 4.2.1 surface (one-shot).
* Answers: which scene is the hud wired to, does add.text/add.graphics exist,
* what storage does Graphics use (no `.commands` in this build), and does
* `_drawFeed` create its text objects when driven directly.
*
* node dev/hud-probe.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 };
}
const PROBE = `(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 scene/combat/ship yet';
const hud = gs.combatHud;
if (!hud) return 'no combatHud';
const out = {
sameScene: hud.scene === gs,
sceneName: gs.sys && gs.sys.name,
add: {
type: gs.add ? gs.add.constructor && gs.add.constructor.name : String(gs.add),
text: gs.add ? typeof gs.add.text : 'no add',
graphics: gs.add ? typeof gs.add.graphics : 'no add',
},
};
const describe = (g) => {
if (g === null) return 'null';
if (!g) return String(g);
let protoNames = [];
try { protoNames = Object.getOwnPropertyNames(Object.getPrototypeOf(g)).slice(0, 60); } catch (e) { protoNames = ['ERR:' + e.message]; }
let own = [];
try { own = Object.keys(g); } catch ( e) { own = ['ERR:' + e.message]; }
return { proto: protoNames, own };
};
out.threatG = describe(hud._threatG);
out.arrowG = describe(hud._arrowG);
out.reticleG = describe(hud._reticleG);
// Drive the feed draw directly with a synthetic entry; catch anything.
try {
hud.feed.length = 0;
hud.feedTexts.length = 0;
hud.feed.push({ label: 'PROBE ENTRY', t: hud._now() });
let drew = 'ok';
try { hud._drawFeed(hud._now()); } catch (e) { drew = 'THREW: ' + e.message; }
out.feedDrive = { drew, feedLen: hud.feed.length, feedTextsLen: hud.feedTexts.length,
t0: hud.feedTexts[0] ? { text: hud.feedTexts[0].text, x: hud.feedTexts[0].x, visible: hud.feedTexts[0].visible, scene: hud.feedTexts[0].scene ? hud.feedTexts[0].scene.sys && hud.feedTexts[0].scene.sys.name : null } : null };
} catch (e) { out.feedDrive = 'outer THREW: ' + e.message; }
// A plain scene text, the way the rest of the game makes text.
try {
const t = gs.add.text(1, 1, 'x', { fontSize: '10px' });
out.sceneText = t ? (t.constructor && t.constructor.name) + ' w=' + t.width : 'falsy';
if (t && typeof t.destroy === 'function') t.destroy();
} catch (e) { out.sceneText = 'THREW: ' + e.message; }
// Full update() through the real path, with the feed primed.
try {
hud.feed.push({ label: 'SECOND', t: hud._now() });
let upd = 'ok';
try { hud.update(gs.time.now); } catch (e) { upd = 'THREW: ' + e.message; }
out.fullUpdate = { upd, feedTextsLen: hud.feedTexts.length };
} catch (e) { out.fullUpdate = 'outer THREW: ' + e.message; }
return out;
})()`;
async function main() {
const { c, target } = await openPage('/index.html');
try {
for (let i = 0; i < 80; i++) {
await sleep(500);
let ok = false;
try { ok = await evalJs(c, 'Boolean(window.game)', false); } catch { ok = false; }
if (ok) break;
}
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}`);
for (let i = 0; i < 60; i++) {
await sleep(500);
let ok = false;
try { ok = await evalJs(c, 'Boolean(window.__ORBIT_COMBAT__ && window.game.scene.getScene("GameScene").ship)', false); } catch { ok = false; }
if (ok) break;
}
await sleep(800);
const out = await evalJs(c, PROBE);
console.log(typeof out === 'string' ? out : JSON.stringify(out, null, 1));
return true;
} catch (err) {
console.error(`hud-probe: 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('hud-probe error:', e.message);
process.exit(2);
});

449
dev/neutral.test.mjs Normal file
View File

@ -0,0 +1,449 @@
/**
* Neutral-escort test (dev tool — run in Node, no browser):
*
* node dev/neutral.test.mjs
*
* The player-side half of the combat layer, against a stub scene:
* the escort's spawn (the allies roster, side 'player', frames 20–27),
* the escort/engage/break state machine (the 10–15 s window → break-off),
* the player-safety rules (never fires at the player, excluded from
* auto-fire, bolts don't hit either way), the mixed fight (they lance
* the nearest pirate — the kill still banks the pirate's XP; a pirate's
* bolt can drop them, and that loss banks none), and the pirate-target
* generalization (player preferred in range, nearest in-range escort
* otherwise, classic chase unchanged).
*
* Every number comes from the real data/*.json (data/combat.json
* included), so the assertions track the shipped config, not a private
* copy.
*/
import { pathToFileURL } from 'node:url';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const __dirname = dirname(fileURLToPath(import.meta.url));
// A mirror of the vendored build's Angle.Wrap (Wrap(v, -π, π)).
const wrap = (a) => {
const min = -Math.PI;
const range = Math.PI * 2;
return (((a - min) % range) + range) % range + min;
};
class V2 {
constructor(x = 0, y = 0) { this.x = x; this.y = y; }
set(x, y) { this.x = x; this.y = y; return this; }
length() { return Math.hypot(this.x, this.y); }
scale(s) { this.x *= s; this.y *= s; return this; }
}
class Sprite {
constructor(scene, x, y, key, frame) {
this.scene = scene; this.x = x; this.y = y; this.key = key; this.frame = frame;
this.rotation = 0; this.scale = 1; this.alpha = 1; this.visible = true;
this.active = true; this.body = null;
}
setScale(s) { this.scale = s; return this; }
setTint() { return this; }
setBlendMode() { return this; }
setDepth() { return this; }
setOrigin() { return this; }
setAlpha(a) { this.alpha = a; return this; }
setVisible(v) { this.visible = v; return this; }
destroy() { this.active = false; this.destroyed = true; }
}
function makeBody() {
return {
velocity: new V2(),
acceleration: new V2(),
enable: true,
setCircle() { return this; },
setAllowGravity() { return this; },
setVelocity(x, y) { this.velocity.set(x, y); return this; },
};
}
const PhaserStub = {
Physics: { Arcade: { Sprite } },
Math: {
Angle: {
Wrap: wrap,
RotateTo: (cur, tgt, step) =>
Math.abs(wrap(tgt - cur)) <= step ? tgt : cur + Math.sign(wrap(tgt - cur)) * step,
},
},
Display: { Color: { ValueToColor: (v) => ({ color: parseInt(String(v).replace('#', ''), 16) }) } },
BlendModes: { ADD: 'ADD' },
};
globalThis.window = { Phaser: PhaserStub }; // js/vendor/phaser.js reads this
function makeImg(x = 0, y = 0, key = '__dot') {
return {
x, y, key, scale: 1, alpha: 1, visible: true, active: true,
setScale(s) { this.scale = s; return this; },
setAlpha(a) { this.alpha = a; return this; },
setTint() { return this; },
setBlendMode() { return this; },
setDepth() { return this; },
setAngle() { return this; },
setVisible(v) { this.visible = v; return this; },
destroy() { this.active = false; this.destroyed = true; },
};
}
function makeGraphics() {
return {
visible: true,
clear() { return this; },
lineStyle() { return this; },
fillStyle() { return this; },
lineBetween() { return this; },
fillCircle() { return this; },
fillTriangle() { return this; },
setScrollFactor() { return this; },
setDepth() { return this; },
setVisible(v) { this.visible = v; return this; },
destroy() { this.destroyed = true; },
};
}
function makeTextObj() {
return {
text: '', alpha: 0, visible: true,
setText(t) { this.text = String(t); return this; },
setAlpha(a) { this.alpha = a; return this; },
setVisible(v) { this.visible = v; return this; },
setOrigin() { return this; },
setScrollFactor() { return this; },
setDepth() { return this; },
destroy() { this.destroyed = true; },
};
}
function makeRectObj(x = 0, y = 0, w = 0, h = 0, alpha = 0) {
return {
x, y, width: w, height: h, alpha, visible: true,
setAlpha(a) { this.alpha = a; return this; },
setVisible(v) { this.visible = v; return this; },
setOrigin() { return this; },
setScrollFactor() { return this; },
setDepth() { return this; },
destroy() { this.destroyed = true; },
};
}
function makeScene({ withSheet = true } = {}) {
return {
textures: { exists: () => withSheet },
make: { graphics: () => ({ fillStyle() {}, beginPath() {}, moveTo() {}, lineTo() {}, closePath() {}, fillPath() {}, fillCircle() {}, generateTexture() {}, destroy() {} }) },
add: {
existing: (o) => o,
image: (x, y, key) => makeImg(x, y, key),
graphics: () => makeGraphics(),
text: (x, y, s) => makeTextObj(),
rectangle: (x, y, w, h, c, a) => makeRectObj(x, y, w, h, a ?? 0),
},
physics: { add: { existing: (o) => { o.body = makeBody(); return o; } } },
scale: { width: 1280, height: 720 },
time: { now: 0 },
};
}
// The real data/*.json (the combat numbers come from data/combat.json).
const { config } = await import(pathToFileURL(join(__dirname, '../js/config/Config.js')).href);
const fs = await import('node:fs');
const dataDir = join(__dirname, '../data');
const configData = {};
for (const f of fs.readdirSync(dataDir)) {
if (!f.endsWith('.json') || f === 'manifest.json') continue;
configData[f.replace(/\.json$/i, '')] = JSON.parse(fs.readFileSync(join(dataDir, f), 'utf8'));
}
config.init(configData);
console.log('config loaded from data/:', Object.keys(configData).join(', '));
const { neutralTypeConfig, neutralTypeIds } = await import(
pathToFileURL(join(__dirname, '../js/combat/EnemyTypes.js')).href
);
const { firePattern, missilesHitPlayer } = await import(
pathToFileURL(join(__dirname, '../js/combat/Weapons.js')).href
);
const { CombatSystem } = await import(
pathToFileURL(join(__dirname, '../js/combat/CombatSystem.js')).href
);
let failures = 0;
const check = (label, cond) => {
console.log(`${cond ? '✔' : '✘ FAIL'} ${label}`);
if (!cond) failures++;
};
// A fresh harness per block: stub scene + player + real CombatSystem.
function harness() {
const scene = makeScene();
const ship = new PhaserStub.Physics.Arcade.Sprite(scene, 0, 0, '__ship', 0);
scene.physics.add.existing(ship);
ship.radius = 12;
ship.stop = () => {};
const combat = new CombatSystem(scene, { ship });
return { scene, ship, combat };
}
const ids = neutralTypeIds();
// ---------------------------------------------------------------- roster + escort
{
const { ship, combat } = harness();
const base = config.section('combat.neutral', {});
check('config: the escort window is data (neutral.escortMin/escortMax = 10–15 s)',
base.escortMin === 10 && base.escortMax === 15);
const a0 = combat.spawnAlly(300, 0, 0, 'courier', { escortMs: 5000, viewFn: () => null });
check("spawn: lands in the system's allies roster (not in enemies)",
combat.allies.length === 1 && combat.enemies.length === 0 && combat.allies[0] === a0);
check('spawn: player-side (side "player" — the roster contract)', a0.side === 'player' && !a0.dead);
check('spawn: frame within 20–27 (the neutral-ship sheet)', a0.frame >= 20 && a0.frame <= 27);
check('active: a live escort keeps combat lit', combat.active === true);
const a1 = combat.spawnAlly(-300, 0, 1, 'runner', { viewFn: () => null });
check('spawn: per-ship escort window rolls inside the data band (10–15 s)',
a1.escortMs >= base.escortMin * 1000 - 1 && a1.escortMs <= base.escortMax * 1000 + 1);
// Escort (no pirates): never fires, never aims at the player.
a0.x = 80; a0.y = 0;
let fired = false;
for (let t = 1000; t <= 4000; t += 16) {
a0.body.velocity.set(0, 0);
const r = a0.update(t, 16, ship, []);
if (r.wantsFire) fired = true;
}
check('escort: never aims at the player (3 s window — no volley, no target)',
!fired && a0.target === null && a0.state === 'escort');
check('escort: no "ally" bolts ever leave the barrel', combat.bullets.length === 0);
// Break-off: window elapses → peel away from the player.
a0.x = 120; a0.y = 0;
a0.state = 'escort';
a0.escortMs = 1000;
a0.escortStart = 0;
a0.update(2000, 16, ship, []);
check('break: the escort window elapses into the break-off', a0.state === 'break');
a0.body.velocity.set(0, 0);
a0.update(2016, 16, ship, []);
check('break: flies OFF (away from the player, not back to the ring)', a0.body.velocity.x > 0);
// Despawn: broken escorts fully off-screen are collected; when the
// last one is gone, combat is over.
a0.gone = true;
a1.gone = true;
combat.update(90000, 16);
check('despawn: broken escorts off-screen are collected (encounter ends)',
combat.allies.length === 0);
check('active: over when the last escort is off-screen', combat.active === false);
}
// ---------------------------------------------------------------- engage
{
const { ship, combat } = harness();
const b = combat.spawnEnemy(200, 60, 0, 'raider');
const c = combat.spawnEnemy(300, 150, 1, 'raider');
b.respawn = false; c.respawn = false;
b.nextFireAt = Infinity; c.nextFireAt = Infinity; // hold them for the test window
const a = combat.spawnAlly(100, 0, 2, 'runner', { escortMs: 60000, viewFn: () => null });
a.state = 'engage'; // skip the stream-in (approach is covered above)
// Run the system (on-screen gate + shared dispatch) until the escort
// has locked the nearest pirate and fired.
let fired = false;
for (let t = 1000; t <= 30000; t += 16) {
combat.update(t, 16);
if (a.target === b && combat.bullets.some((x) => x.owner === 'ally')) { fired = true; break; }
}
check('engage: lances the NEAREST pirate (an "ally" bolt leaves the barrel)',
fired && a.target === b);
check('no friendly fire: the player never takes escort bolts',
combat.hull === combat.maxHull && combat.shields === combat.maxShields);
// The direct seam: an ally bolt crossing the player's seat. (Headless
// has no physics world, so the test advances the tracer itself.)
combat.bullets.length = 0;
const ab = combat.spawnBullet(-30, 0, 0, { bulletSpeed: 300, damage: 9, bulletLife: 5, color: '#37e0a1' }, 'ally');
for (let i = 0; i < 30 && !ab.dead; i++) { ab.x += ab.body.velocity.x * 0.016; combat.update(31000 + i * 16, 16); }
check('no friendly fire: the collide pass skips "ally" bolts at the player',
combat.hull === combat.maxHull && combat.shields === combat.maxShields);
}
// ---------------------------------------------------------------- auto-fire
{
const { ship, combat } = harness();
const a = combat.spawnAlly(0, 100, 0, 'sloop', { escortMs: 60000, viewFn: () => null });
a.state = 'escort';
a.x = 0; a.y = 100;
check('auto-fire: the escort is outside the cannon target pool (no lock when escort-only)',
combat.currentTarget() === null);
const b = combat.spawnEnemy(200, 60, 1, 'raider');
b.respawn = false;
b.nextFireAt = Infinity;
check('auto-fire: the lock sits on the pirate, never the escort', combat.currentTarget() === b);
// The player's bolts pass straight through the escort (no hit). (The
// headless harness advances the tracer by its own velocity.)
const pb = combat.spawnBullet(0, 140, -Math.PI / 2, { bulletSpeed: 300, damage: 20, bulletLife: 5, color: '#00e5ff' }, 'player');
for (let i = 0; i < 30; i++) {
if (pb.dead) break;
pb.x += pb.body.velocity.x * 0.016;
pb.y += pb.body.velocity.y * 0.016;
combat.update(2000 + i * 16, 16);
}
check('no friendly fire: player bolts pass through the escort (no damage)',
a.hull === a.maxHull && a.shields === a.maxShields);
}
// ---------------------------------------------------------------- pirate targeting
{
const { ship, combat } = harness();
const a = combat.spawnAlly(150, 0, 0, 'sentinel', { escortMs: 60000, viewFn: () => null });
a.state = 'escort';
const d = combat.spawnEnemy(300, 0, 1, 'raider');
d.respawn = false;
d.nextFireAt = Infinity;
// The player is within the pirate's weapon range — the player is
// preferred, even with a nearer escort.
check('pirate targeting: player preferred in range (even with a nearer escort)',
combat.pickEnemyTarget(d, ship) === ship);
// The player is out of range; the escort is in range → the escort
// draws aggro.
ship.x = 1000; ship.y = 0;
a.x = 250; a.y = 0;
check('pirate targeting: out of player range, the nearest in-range escort draws aggro',
combat.pickEnemyTarget(d, ship) === a);
// No escort in reach → the classic player chase (pirates-only
// unchanged).
const saved = combat.allies;
combat.allies = [];
check('pirate targeting: with no escort in reach, the classic chase stands',
combat.pickEnemyTarget(d, ship) === ship);
combat.allies = saved;
// The pirate AI actually fires at the escort (the same gate that
// fires at the player — in range, aimed, off cooldown).
ship.x = 0; ship.y = 0;
d.x = 300; d.y = 0;
d.rotation = -Math.PI / 2; // heading = rotation - artOffset → facing west (art faces north)
d.nextFireAt = 0;
const w = d.update(1000, 16, a);
check('pirate targeting: aimed + in range, the pirate lances the escort', w === true);
// And the escort takes the hit (the new collide pass — the headless
// harness advances the tracer by its own velocity).
a.x = 0; a.y = 100;
const eb = combat.spawnBullet(-40, 100, 0, { bulletSpeed: 300, damage: 9, bulletLife: 5, color: '#ff2d6f' }, 'enemy');
const shB = a.shields, huB = a.hull;
for (let i = 0; i < 30; i++) { if (eb.dead) break; eb.x += eb.body.velocity.x * 0.016; combat.update(2000 + i * 16, 16); }
check('mixed: a pirate bolt that strikes the escort lands damage',
a.shields < shB || a.hull < huB || a.dead);
}
// ---------------------------------------------------------------- escort's loss
{
const { ship, combat } = harness();
const a = combat.spawnAlly(0, 100, 0, 'sally', { escortMs: 60000, viewFn: () => null });
a.state = 'escort';
a.x = 0; a.y = 100;
const xpB = combat.xp.xp;
// A fatal pirate bolt: the escort is pulled out of the world — and
// that loss banks no XP (the bounty is the pirate's).
const eb = combat.spawnBullet(-40, 100, 0, { bulletSpeed: 300, damage: 999, bulletLife: 5, color: '#ff2d6f' }, 'enemy');
for (let i = 0; i < 20; i++) { if (eb.dead) break; eb.x += eb.body.velocity.x * 0.016; combat.update(1000 + i * 16, 16); }
combat.update(1000 + 20 * 16 + 16, 16); // flush the cull
check('mixed: a shot-down escort is collected (out of the allies roster)',
a.dead && !combat.allies.includes(a));
check('mixed: a shot-down escort banks no XP (the bounty is the pirate)', combat.xp.xp === xpB);
}
{
const { ship, combat } = harness();
const d = combat.spawnEnemy(400, 0, 0, 'raider');
d.respawn = false;
d.nextFireAt = Infinity;
const xpB = combat.xp.xp;
// An escort bolt across the pirate: one kill path — the XP still
// lands.
const ab = combat.spawnBullet(380, 0, 0, { bulletSpeed: 300, damage: 999, bulletLife: 5, color: '#37e0a1' }, 'ally');
for (let i = 0; i < 20; i++) { if (ab.dead) break; ab.x += ab.body.velocity.x * 0.016; combat.update(1000 + i * 16, 16); }
const gained = Number(config.get('combat.enemy.xp', 0)) || 10;
check('mixed: an escort kill still banks the pirate XP (one grant)',
d.dead === true && combat.xp.xp - xpB >= gained);
}
// ---------------------------------------------------------------- seekers
{
const { ship, combat } = harness();
const heraldId = neutralTypeIds().find((id) => neutralTypeConfig(id).weapon.pattern === 'missile');
check('roster: the neutral roster carries a missile-pattern craft', !!heraldId);
const a = combat.spawnAlly(0, 0, 0, heraldId, { escortMs: 60000, viewFn: () => null });
a.state = 'engage';
const d = combat.spawnEnemy(300, 0, 1, 'raider');
d.respawn = false;
d.nextFireAt = Infinity;
// Fire the seeker (the herald's gate is the same honesty as the rest).
a.rotation = Math.PI / 2; // heading = rotation - artOffset → facing east (art faces north)
a.nextFireAt = 0;
const w = a.update(1000, 16, ship, combat.enemies);
check('escort seeker: engaged + aimed, the herald lances the pirate',
w.wantsFire === true && w.target === d);
combat.fireAlly(a, d, 1000);
const m = combat.missiles.find((x) => x.owner === 'ally');
check('escort seeker: the seeker carries the "ally" owner + its victim lock',
!!m && m.owner === 'ally' && m.targetRef === d);
// Homing: the lock moves → the seeker curves to the new bearing.
d.x = 400; d.y = 120;
const h0 = m.heading;
for (let i = 0; i < 12 && !m.dead; i++) m.update(16, d);
check('escort seeker: the seeker homes on the (moved) pirate', Math.abs(m.heading - h0) > 0.1);
// No friendly fire: an escort seeker crossing the player's seat
// misses.
m.dead = false;
m.x = ship.x + 4; m.y = ship.y;
m.heading = 0;
const hullB = combat.hull, shB = combat.shields;
missilesHitPlayer(combat, ship, 12, 16);
check('escort seeker: "ally" seekers never strike the player',
m.dead === false && combat.hull === hullB && combat.shields === shB);
// A hostile seeker can drop the escort (the mixed-encounter threat).
const hunter = config.section('combat.enemyTypes.hunter', {});
const fake = { x: -100, y: 0, rotation: 0 };
firePattern(combat, fake, a, { ...hunter.weapon });
const em = combat.missiles.find((x) => x.owner === 'enemy');
check('hostile seeker: the pirate fires one too (the shared dispatch)', !!em);
if (em) {
// Keep the escort OFF the player's seat (the hostile seeker's first
// test in the collide order is the player's).
a.x = 200; a.y = 60;
em.dead = false;
em.x = a.x + 2; em.y = a.y;
const aShB = a.shields, aHuB = a.hull;
combat.update(3000, 16);
check('hostile seeker: a pirate seeker can drop the escort (new pass)',
a.shields < aShB || a.hull < aHuB || a.dead);
}
}
// ---------------------------------------------------------------- verdict
console.log('');
if (failures === 0) console.log('✔ ALL NEUTRAL-ESCORT TESTS PASSED');
else {
console.log(`✘ ${failures} NEUTRAL-ESCORT TEST(S) FAILED`);
process.exitCode = 1;
}

155
dev/pixel-probe.mjs Normal file
View File

@ -0,0 +1,155 @@
/**
* Dev-only probe: does IN-BROWSER PIXEL SAMPLING work in headless Brave?
*
* node dev/pixel-probe.mjs
*
* Boots the game, spawns ONE off-screen enemy through the production
* sandbox seam (__ORBIT_COMBAT__.spawn), computes the off-screen arrow
* position with the REAL js/combat/Combat.js function, drives
* combatHud.update(), then copies the game canvas into a 2D canvas and
* samples pixels:
* · center box → the ship must be visible (not a black canvas)
* · corner box → the space background must be dark
* · arrow box → reddish pixels where the arrow should be drawn
*
* Requires: the dev server on :8080 and headless CDP on 127.0.0.1:9333.
*/
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 };
}
const PROBE = `(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 || !gs.combatHud) return 'no GameScene/combat/ship/hud';
const sys = gs.combat, hud = gs.combatHud;
try { if (gs.comms && gs.comms.paused) gs.comms._ack(); } catch {}
const cv = window.game.canvas || document.querySelector('canvas');
if (!cv) return 'no canvas element';
const info = {
renderer: window.game.renderer ? (window.game.renderer.type ?? window.game.renderer.name ?? window.game.renderer.constructor.name) : null,
canvasW: cv.width, canvasH: cv.height, cssW: gs.scale && gs.scale.width, cssH: gs.scale && gs.scale.height,
};
const vw = info.cssW || cv.width, vh = info.cssH || cv.height;
const DPR = cv.width / vw;
info.dpr = DPR;
// One hostile, off-screen (the sandbox seam's own spawn path).
window.__ORBIT_COMBAT__.spawn(1);
await sleep(150);
const e = sys.enemies.filter((x) => !x.dead)[0];
if (!e) return 'no enemy spawned';
const mod = await import('/js/combat/Combat.js');
const s = hud.worldToScreen(e.x, e.y);
const p = mod.offscreenArrow(vw, vh, s.x, s.y, 28);
info.enemyScreen = { x: Math.round(s.x), y: Math.round(s.y) };
info.arrow = p ? { x: Math.round(p.x), y: Math.round(p.y), angle: p.angle.toFixed(3) } : null;
hud.update(gs.time.now); // drive the HUD draw for this frame
const grab = (x0, y0, x1, y1) => {
const off = document.createElement('canvas');
off.width = cv.width; off.height = cv.height;
const ctx = off.getContext('2d');
ctx.drawImage(cv, 0, 0);
const rx = Math.max(0, Math.floor(x0 * DPR)), ry = Math.max(0, Math.floor(y0 * DPR));
const rw = Math.min(cv.width - rx, Math.max(1, Math.ceil((x1 - x0) * DPR)));
const rh = Math.min(cv.height - ry, Math.max(1, Math.ceil((y1 - y0) * DPR)));
const d = ctx.getImageData(rx, ry, rw, rh).data;
return Array.from(d);
};
const count = (arr, test) => { let n = 0; for (let i = 0; i < arr.length; i += 4) if (test(arr[i], arr[i + 1], arr[i + 2])) n++; return n; };
const bright = (r, g, b) => r > 130 && g > 150 && b > 160;
const dark = (r, g, b) => r + g + b < 150;
const red = (r, g, b) => r > 130 && r - g > 45 && r - b > 25;
const center = grab(vw / 2 - 10, vh / 2 - 10, vw / 2 + 10, vh / 2 + 10);
const corner = grab(0, 0, 10, 10);
info.centerBright = count(center, bright);
info.centerTotal = center.length / 4;
info.cornerDark = count(corner, dark);
if (p) {
const box = grab(p.x - 8, p.y - 8, p.x + 8, p.y + 8);
info.arrowRed = count(box, red);
info.arrowSample = box.slice(0, 12);
}
return info;
})()`;
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, PROBE);
console.log(JSON.stringify(out, null, 2));
} finally {
try { await fetch(`http://${CDP}/json/close/${target.id}`).catch(() => {}); } catch { /* ignore */ }
c.ws.close();
}
}
main().catch((e) => { console.error('pixel-probe error:', e.message); process.exit(2); });

View File

@ -302,8 +302,10 @@ const approx = (a, b, eps = 1e-6) => Math.abs(a - b) <= eps;
const raider = combat.spawnEnemy(200, 0, 0, 'raider');
const bomber = combat.spawnEnemy(400, 0, 1, 'bomber');
const baseXp = Number(config.get('combat.enemy.xp', 10)) || 10;
const bomberXp = Number(config.get('combat.enemyTypes.bomber.xp', baseXp)) || baseXp;
check("system: the raider carries the base type's xp (data)", raider.xp === baseXp);
check('system: the bomber carries its own xp (data: 25)', bomber.xp === 25);
check('system: the bomber carries its own xp (its data row, not the base)',
bomberXp !== baseXp && bomber.xp === bomberXp);
// A kill = one grant of the hostile's type's xp (the kill path is the
// single seam — pack kills, wave kills, and rolling replacements alike).
@ -332,8 +334,8 @@ const approx = (a, b, eps = 1e-6) => Math.abs(a - b) <= eps;
// The bomber's kill banks ITS value (the roster sets XP-per-threat).
bomber.damage(999);
combat.killEnemy(bomber, 3000);
check("system: the bomber's kill banks 25 (toward level 2's 200)",
combat.xp.xp === 25 && combat.xp.level === 1 && levels.length === 1);
check("system: the bomber's kill banks ITS value (toward level 2's 200)",
combat.xp.xp === bomberXp && combat.xp.level === 1 && levels.length === 1);
// A RESTORED ledger: the level's armor applies on build — with no
// events (a load is not a level-up) and a full ship.

View File

@ -1,6 +1,7 @@
import { config } from '../config/Config.js';
import { EnemyShip } from './EnemyShip.js';
import { enemyTypeConfig } from './EnemyTypes.js';
import { NeutralShip } from './NeutralShip.js';
import { enemyTypeConfig, neutralTypeConfig } from './EnemyTypes.js';
import { Bullet } from './Bullet.js';
import { Fx, FX_COLORS, settleCraftTint } from './Fx.js';
import { applyDamage, inView, pickTarget } from './Combat.js';
@ -99,6 +100,7 @@ export class CombatSystem {
this.onComm = typeof o.onComm === 'function' ? o.onComm : null;
this.enemies = [];
this.allies = []; // neutral escorts (a separate roster — js/combat/NeutralShip.js)
this.bullets = [];
// The heat-seeker roster (js/combat/Weapons.js steps + hits them; the
// missiles themselves live in js/combat/Missile.js).
@ -175,7 +177,7 @@ export class CombatSystem {
// ~250 ms) must cover the WHOLE pack — a craft that somehow ends up
// holding the white hit-flash state is re-asserted back to its
// normal colors no matter how the stick got there.
this.fx.purgeRoster = () => [this.ship, ...this.enemies];
this.fx.purgeRoster = () => [this.ship, ...this.enemies, ...this.allies];
this._active = false;
}
@ -190,8 +192,11 @@ export class CombatSystem {
get active() {
const run = this.waveRun;
if (run && run.state !== 'done' && run.state !== 'cancelled') return true;
return this.enemies.some(
(e) => !e.dead || (e._respawnPending && e.respawnAt > 0),
return (
this.enemies.some((e) => !e.dead || (e._respawnPending && e.respawnAt > 0)) ||
// A live escort keeps the fight up too (its lances are live even
// when the pack is spent — the encounter ends when it's OFF).
this.allies.some((a) => !a.dead && !a.gone && !a._removed)
);
}
@ -288,6 +293,42 @@ export class CombatSystem {
return e;
}
/**
* One NEUTRAL ESCORT (data/combat.json → neutralTypes — frames 20–27
* of the enemy sheet, the player-side roster): materializes
* off-screen (the caller places it — the encounter director spawns it
* in the _offscreenRadius band), then streams in on its own escort AI
* (js/combat/NeutralShip.js).
*
* `o.escortMs` — this ship's escort window (ms); omitted = a per-ship
* roll inside neutral.escortMin..escortMax ("each neutral ship flies
* near the player for a random 10–15 s"); `o.viewFn` overrides the
* break-off cull surface (headless tests).
* @returns {NeutralShip}
*/
spawnAlly(x, y, index, type = null, o = {}) {
const cfg = neutralTypeConfig(type);
const base = config.section('combat.neutral', {});
const emin = Math.max(1, Number(base.escortMin) || 10);
const emax = Math.max(emin, Number(base.escortMax) || 15);
const escortMs =
Number.isFinite(Number(o.escortMs)) && Number(o.escortMs) > 0
? Number(o.escortMs)
: (emin + Math.random() * (emax - emin)) * 1000;
const a = new NeutralShip(this.scene, x, y, index, cfg, {
escortMs,
viewFn: typeof o.viewFn === 'function' ? o.viewFn : () => this.visibleView(),
});
a.setDepth(10);
const px = this.ship ? this.ship.x : x;
const py = this.ship ? this.ship.y : y;
a.orbitAngle = Math.atan2(y - py, x - px); // anchor on the spawn bearing
a.nextFireAt = 1000; // the pack's grace (ms clock)
this.allies.push(a);
this._setActive();
return a;
}
// ----------------------------------------------------------------- waves
/**
@ -568,8 +609,13 @@ export class CombatSystem {
if (e._respawnPending && time >= e.respawnAt) this.reviveEnemy(e, time);
continue;
}
const wantsFire = e.update(time, delta, player);
if (wantsFire && player && this.craftVisible(e)) this.fireEnemy(e, player, time);
// THE TARGET (pickEnemyTarget): the player — or, in a mixed
// encounter, the nearest in-weapon-range escort when the player
// isn't (the escort draws aggro; a pirates-only fight resolves to
// exactly the classic behavior).
const target = this.pickEnemyTarget(e, player);
const wantsFire = e.update(time, delta, target);
if (wantsFire && target && this.craftVisible(e)) this.fireEnemy(e, target, time);
// THE ATTACK COMM (the type's comms.attack — data/combat.json): the
// ON-SCREEN roll (50%) fires once the craft first flies into view —
// unless its arrival roll already commed it (a pirate never
@ -581,6 +627,31 @@ export class CombatSystem {
}
}
// 1b) NEUTRAL ESCORTS (js/combat/NeutralShip.js): escort/engage/
// break AI on their own clocks. The on-screen rule gates their
// lances exactly like the pack's (an off-screen escort holds its
// fire until it flies into view), and their target is always a
// pirate — the player is never aimed at.
for (const a of this.allies) {
if (a.dead) continue;
const r = a.update(time, delta, player, this.enemies);
if (r.wantsFire && player && r.target && r.target.dead !== true && this.craftVisible(a)) {
this.fireAlly(a, r.target, time);
}
}
// A broken escort that has flown fully off-screen is gone (the
// encounter ends when the last one is), and a shot-down escort is
// collected (killAlly's mark).
if (this.allies.length > 0) {
for (const a of this.allies) {
if ((a.gone || a._removed) && a.destroy && !a.destroyed) {
this.fx.releaseCraft(a);
a.destroy();
}
}
this.allies = this.allies.filter((a) => !a.gone && !a._removed);
}
// 2) AUTO-FIRE: the nearest VISIBLE hostile in range (game.autoFire).
// The mouse steers the ship; the cannon tracks on its own — but
// only at what's on screen (off-screen hostiles can't be fired
@ -666,6 +737,32 @@ export class CombatSystem {
);
}
/**
* A pirate's target this frame (the mixed-encounter rule): the
* PLAYER while they're inside the pirate's weapon range (a
* pirates-only fight — or the player in range — resolves exactly to
* the classic chase); else the NEAREST live escort within that range
* (a neutral ship draws aggro when the pirate can't reach the
* player); else the player (the classic orbit — the fire gate keeps
* it honest until in range).
* @returns {object|null} the craft to orbit + fire at (null = player down)
*/
pickEnemyTarget(e, player) {
if (!player) return null;
const range = Number((e.wpn && e.wpn.range) || 0);
const inRange = (o) =>
range > 0 ? (o.x - e.x) ** 2 + (o.y - e.y) ** 2 <= range * range : true;
if (inRange(player)) return player;
let best = null;
let bestD = Infinity;
for (const a of this.allies) {
if (a.dead || !inRange(a)) continue;
const d2 = (a.x - e.x) ** 2 + (a.y - e.y) ** 2;
if (d2 < bestD) { bestD = d2; best = a; }
}
return best ?? player;
}
/**
* RE-ANCHOR THE PACK AROUND THE SHIP — in the main game the ship's
* start position settles AFTER create() (the tether barrier / a save
@ -729,6 +826,27 @@ export class CombatSystem {
return firePattern(this, e, player, e.wpn ?? {});
}
/**
* A neutral escort's volley — the same shared pattern dispatch
* (js/combat/Weapons.js) as the hostiles, except the bolts/seekers
* carry the 'ally' owner: they hit the pack (the kill still banks the
* pirate's XP through killEnemy), never the player (the collide
* passes + Weapons.missilesHitPlayer skip them), and the player's
* auto-fire never targets the escort (a different roster).
*/
fireAlly(a, target, time) {
return firePattern(this, a, target, { ...(a.wpn ?? {}), owner: 'ally' });
}
/**
* The re-home seam for escort seekers (Weapons.stepMissiles): the
* nearest live pirate to the missile, or null (fly straight — the
* fuse ends it).
*/
allyMissileTarget(m) {
return pickTarget(this.enemies, m.x, m.y);
}
spawnBullet(x, y, angle, wpn, owner) {
const b = new Bullet(this.scene, {
x,
@ -798,6 +916,28 @@ export class CombatSystem {
}
}
}
// Your escorts' tracers × the pack (the neutral ships' patterns —
// the same hit read as your own; the kill still banks the pirate's
// XP through the one grant).
for (let i = this.bullets.length - 1; i >= 0; i--) {
const b = this.bullets[i];
if (b.dead || b.owner !== 'ally') continue;
for (const e of this.enemies) {
if (e.dead) continue;
if (!this.craftVisible(e)) continue;
if (this.hitsSwept(b, e, e.hitRadius, delta)) {
const preShields = e.shields;
const killed = e.damage(b.damage);
const kind = preShields > 0 ? 'shield' : 'hull';
this.fx.enemyHit(e, b.x, b.y, b.angle0, kind, b.damage);
this.fx.damage(b.x, b.y, b.damage, kind);
combatSfxEnemyHit(this.scene, preShields, b.damage);
if (killed) this.killEnemy(e, this._now());
if (b.consume()) this.bullets.splice(i, 1);
break;
}
}
}
// Their lances × you.
if (player) {
for (let i = this.bullets.length - 1; i >= 0; i--) {
@ -811,9 +951,36 @@ export class CombatSystem {
}
}
}
// Their lances × your escorts (mixed fight: pirate bolts can drop
// the neutral ships — the escort draws fire off the player).
for (let i = this.bullets.length - 1; i >= 0; i--) {
const b = this.bullets[i];
if (b.dead || b.owner !== 'enemy') continue;
for (const a of this.allies) {
if (a.dead || !this.craftVisible(a)) continue;
if (this.hitsSwept(b, a, a.hitRadius, delta)) {
const preShields = a.shields;
const killed = a.damage(b.damage);
const kind = preShields > 0 ? 'shield' : 'hull';
this.fx.enemyHit(a, b.x, b.y, b.angle0, kind, b.damage);
this.fx.damage(b.x, b.y, b.damage, kind);
combatSfxEnemyHit(this.scene, preShields, b.damage);
if (killed) this.killAlly(a, this._now());
if (b.consume()) this.bullets.splice(i, 1);
break;
}
}
}
// 2b) Heat-seekers × you (js/combat/Weapons.js): the missile's damage
// lands from its incoming bearing; the hit blooms + consumes it.
// Escort seekers are excluded inside (no friendly fire).
if (player) missilesHitPlayer(this, player, Number(player.radius) || 12, delta);
// Their heat-seekers × your escorts (mixed fight: a pirate seeker
// can drop a neutral ship).
this.missilesHitAllies(delta);
// Your escorts' heat-seekers × the pack (the escorts' missile
// patterns — one kill path, the XP still lands).
this.missilesHitEnemies(delta);
// Rams (you × the pack) — push apart, bounce, damage both (grace-limited).
if (player) {
for (const e of this.enemies) {
@ -822,6 +989,62 @@ export class CombatSystem {
}
}
/**
* Hostile seekers × live escorts (a mixed fight: a pirate seeker can
* drop a neutral ship) — the hit is real damage, the kill is
* killAlly (no XP — the bounty is the pirate's; no comms — the
* neutrals don't talk).
*/
missilesHitAllies(delta) {
const ms = this.missiles;
if (!ms || ms.length === 0 || this.allies.length === 0) return;
for (let i = ms.length - 1; i >= 0; i--) {
const m = ms[i];
if (m.dead) { ms.splice(i, 1); continue; }
if (m.owner === 'ally') continue;
for (const a of this.allies) {
if (a.dead || !this.craftVisible(a)) continue;
if (this.hitsSwept(m, a, a.hitRadius, delta)) {
const preShields = a.shields;
const killed = a.damage(m.damage);
const kind = preShields > 0 ? 'shield' : 'hull';
this.fx.enemyHit(a, m.x, m.y, m.heading, kind, m.damage);
this.fx.damage(m.x, m.y, m.damage, kind);
combatSfxEnemyHit(this.scene, preShields, m.damage);
if (killed) this.killAlly(a, this._now());
if (m.consume()) ms.splice(i, 1);
break;
}
}
}
}
/** Escort seekers × the pack — the same hit read as the player's. */
missilesHitEnemies(delta) {
const ms = this.missiles;
if (!ms || ms.length === 0 || this.enemies.length === 0) return;
for (let i = ms.length - 1; i >= 0; i--) {
const m = ms[i];
if (m.dead) { ms.splice(i, 1); continue; }
if (m.owner !== 'ally') continue;
for (const e of this.enemies) {
if (e.dead) continue;
if (!this.craftVisible(e)) continue;
if (this.hitsSwept(m, e, e.hitRadius, delta)) {
const preShields = e.shields;
const killed = e.damage(m.damage);
const kind = preShields > 0 ? 'shield' : 'hull';
this.fx.enemyHit(e, m.x, m.y, m.heading, kind, m.damage);
this.fx.damage(m.x, m.y, m.damage, kind);
combatSfxEnemyHit(this.scene, preShields, m.damage);
if (killed) this.killEnemy(e, this._now());
if (m.consume()) ms.splice(i, 1);
break;
}
}
}
}
/** The scene's clock (ms) — used for the kill bookkeeping's timestamps. */
_now() {
return this.scene.time?.now ?? Date.now();
@ -882,6 +1105,27 @@ export class CombatSystem {
}
}
/**
* A neutral escort is down (a pirate's bolt/seeker): the kill read
* and the pull-out. No XP (a lost escort is not a bounty — the bank
* is the pirate's), no comms (the neutrals don't talk), no respawn
* (the encounter's force is spent).
*/
killAlly(a, time) {
if (a._killFx) return;
a._killFx = true;
this.fx.releaseCraft(a);
this.fx.explode(a.x, a.y, '#37e0a1');
combatSfx(this.scene, 'kill');
a.setVisible(false);
if (a.body) {
a.body.velocity.x = 0;
a.body.velocity.y = 0;
}
a._removed = true; // the update() cull collects it
this._setActive();
}
// ----------------------------------------------------- damage / lifecycle
/**
@ -1127,8 +1371,13 @@ export class CombatSystem {
this.fx.releaseCraft(e); // settle flashes before the craft goes
e.destroy();
}
for (const a of this.allies) {
this.fx.releaseCraft(a);
a.destroy();
}
for (const b of this.bullets) if (!b.dead) b.destroy();
this.enemies = [];
this.allies = [];
this.bullets = [];
this.fx.clear();
this.ship = null;

213
js/combat/Encounters.js Normal file
View File

@ -0,0 +1,213 @@
/**
* ENCOUNTER DIRECTOR — the periodic random ship encounters in neutral
* wild space. Pure rules, no Phaser, no scene (the same contract as
* js/combat/XpManager.js — it runs in bare Node; dev/encounters.test.mjs
* drives it with a fake clock + an injected rng).
*
* WHAT IT DECIDES (GameScene does the spawning):
*
* WHEN — while the system allows encounters (NEUTRAL — faction null —,
* not home, no wave run in flight, the player upright; the scene
* passes `ctx.allowed`, `encounterAllowed` is the pure gate), a
* ROLL lands on the interval schedule (data/combat.json →
* encounters.rollIntervalMin..rollIntervalMax, seconds); each roll
* fires at chanceMin..chanceMax probability. ONE AT A TIME: the
* schedule holds while an encounter is engaged.
*
* QUIET — when the encounter ends (its ships all dead or off-screen →
* the scene reports ctx.live = false) the next roll waits out
* cooldownMin..cooldownMax seconds, and the cadence resumes from
* there.
*
* WHAT — `rollKind` picks the kind at `weights` (shipped even — ~1/3
* each), and `compose` sizes the group off the player's combat
* level band (shipped: 1–2 ships at low levels, 4–5 at high; mixed
* keeps BOTH kinds present).
*
* IT OWNS NO SHIPS, NO AUDIO, NO SAVE STATE — scene-local, like the
* 'Pirate Problems' ambush: a fresh GameScene starts a fresh director.
*/
/** Uniform float in [min, max] (a degenerate band just returns the value). */
function rand(rng, min, max) {
const a = Math.min(min, max);
const b = Math.max(min, max);
return a + rng() * (b - a);
}
/** Uniform INT in [lo, hi] inclusive (a degenerate band is that value). */
function randInt(rng, lo, hi) {
let a = Math.max(0, Math.floor(Number(lo) || 0));
let b = Math.max(0, Math.floor(Number(hi) || 0));
if (b < a) [a, b] = [b, a];
return Math.min(b, a + Math.floor(rng() * (b - a + 1)));
}
const KINDS = ['pirates', 'neutrals', 'mixed'];
/**
* The encounter kind at `weights` (data/combat.json → encounters.weights;
* shipped even — ~1/3 each). A zero/absent weight is just never rolled.
* @param {object} weights kind → weight
* @param {() => number} rng
* @returns {'pirates'|'neutrals'|'mixed'}
*/
export function rollKind(weights, rng) {
const w = weights && typeof weights === 'object' ? weights : {};
const total = KINDS.reduce((s, k) => s + Math.max(0, Number(w[k]) || 0), 0);
if (total <= 0) return KINDS[0];
let x = rng() * total;
for (const k of KINDS) {
x -= Math.max(0, Number(w[k]) || 0);
if (x < 0) return k;
}
return KINDS[KINDS.length - 1];
}
/**
* The size band for a combat level (data/combat.json →
* encounters.bands — minLevel..maxLevel, inclusive). A level past every
* band takes the LAST one (the top of the roster).
* @param {object[]} bands
* @param {number} level
* @returns {object|null}
*/
export function bandFor(bands, level) {
const lv = Math.max(0, Math.floor(Number(level) || 0));
const list = Array.isArray(bands) ? bands : [];
for (const b of list) {
if (b && lv >= (Number(b.minLevel) || 0) && lv <= (Number(b.maxLevel) || 0)) return b;
}
return list.length ? list[list.length - 1] : null;
}
const DEFAULT_BAND = {
pirates: [1, 2],
neutrals: [1, 2],
mixedPirates: [1, 1],
mixedNeutrals: [1, 1],
};
/**
* The encounter's composition — the kind at `weights`, the group sized
* off the level band. `pirates`/`neutrals` are the per-kind counts
* (the OTHER kind is 0); `mixedPirates`/`mixedNeutrals` are the mixed
* per-kind floors/ceilings — BOTH kinds stay present.
* @param {object} cfg the encounters section (data/combat.json)
* @param {number} xpLevel the player's combat level (a fresh run: 0)
* @param {() => number} rng
* @returns {{kind:string, pirates:number, neutrals:number}}
*/
export function compose(cfg, xpLevel, rng) {
cfg = cfg && typeof cfg === 'object' ? cfg : {};
const band = bandFor(cfg.bands, xpLevel) || DEFAULT_BAND;
const ri = (key, fb) =>
Array.isArray(band[key]) ? randInt(rng, band[key][0], band[key][1]) : randInt(rng, fb[0], fb[1]);
const kind = rollKind(cfg.weights, rng);
if (kind === 'pirates') return { kind, pirates: ri('pirates', [1, 2]), neutrals: 0 };
if (kind === 'neutrals') return { kind, pirates: 0, neutrals: ri('neutrals', [1, 2]) };
return { kind: 'mixed', pirates: ri('mixedPirates', [1, 1]), neutrals: ri('mixedNeutrals', [1, 1]) };
}
/**
* The pure gate (GameScene passes its state; the test passes fixtures):
* encounters host in NEUTRAL (faction == null) non-HOME space, with no
* wave run in flight (a run that is already done/cancelled is over —
* its ships are spent), and the player upright (no spawn on a downed
* ship). `ship` must stand — there is no flying without one.
* @param {object} gate { ship, isHomeSystem, faction, waveRunLive, playerDown }
* @returns {boolean}
*/
export function encounterAllowed(gate = {}) {
return (
!!gate.ship &&
gate.isHomeSystem !== true &&
gate.faction == null &&
gate.waveRunLive !== true &&
gate.playerDown !== true
);
}
/**
* The director's clock (one per GameScene — `this.encounters`).
*
* update(time, ctx) → { kind, pirates, neutrals } | null
*
* ctx.allowed — the pure gate above (the scene decides)
* ctx.live — an encounter's ships are still up (CombatSystem.active)
* ctx.xpLevel — the player's combat level (the size bands)
*
* `time` is the scene clock (ms — Phaser's `time`, the same ms clock the
* combat's own nextFireAt/respawn clocks run on).
*/
export class EncounterDirector {
constructor(cfg, opts = {}) {
this.cfg = cfg && typeof cfg === 'object' ? cfg : {};
this.rng = typeof (opts && opts.rng) === 'function' ? opts.rng : Math.random;
this.state = 'idle'; // 'idle' | 'engaged' | 'cooldown'
this.nextRollAt = null; // ms clock; null = the first schedule is pending
this.quietUntil = 0; // ms clock (the cooldown's end)
}
_intervalMs() {
// BOTH bounds in the SAME unit (seconds) before scaling to ms: clamping
// max to >= min must compare like-with-like, or the band's top collapses to
// min*1000 (a 45–90 s roll becomes 45 s–12.5 h). Regression-guarded in
// dev/encounters.test.mjs (rng=0 lower bound, rng=1 upper bound).
const minS = Math.max(0, Number(this.cfg.rollIntervalMin) || 45);
const maxS = Math.max(minS, Number(this.cfg.rollIntervalMax) || 90);
return rand(this.rng, minS * 1000, maxS * 1000);
}
_cooldownMs() {
const minS = Math.max(0, Number(this.cfg.cooldownMin) || 30);
const maxS = Math.max(minS, Number(this.cfg.cooldownMax) || 60);
return rand(this.rng, minS * 1000, maxS * 1000);
}
_chance() {
const min = Math.min(1, Math.max(0, Number(this.cfg.chanceMin) ?? 0.5));
const max = Math.min(1, Math.max(min, Number(this.cfg.chanceMax) ?? 0.6));
return rand(this.rng, min, max);
}
/** One tick of the encounter clock (see the class doc). */
update(time, ctx = {}) {
if (this.cfg.enabled === false) return null; // the data can hold it off
time = Number(time) || 0;
if (this.state === 'engaged') {
if (ctx.live !== true) {
this.state = 'cooldown';
this.quietUntil = time + this._cooldownMs();
}
return null; // one at a time: the schedule holds while it's live
}
if (this.state === 'cooldown') {
if (time < this.quietUntil) return null;
this.state = 'idle';
this.nextRollAt = this.quietUntil; // the cadence resumes on the quiet's end
}
// 'idle'
if (this.nextRollAt === null) {
this.nextRollAt = time + this._intervalMs();
return null;
}
if (ctx.allowed !== true) {
// Out of allowed space (home / a faction system / a wave run):
// the clock SLIDES — the first roll here lands a full interval out,
// never on the arrival frame.
this.nextRollAt = time + this._intervalMs();
return null;
}
if (time < this.nextRollAt) return null;
this.nextRollAt = time + this._intervalMs();
if (this.rng() < this._chance()) {
this.state = 'engaged';
return compose(this.cfg, ctx.xpLevel ?? 0, this.rng);
}
return null;
}
}

View File

@ -97,3 +97,68 @@ export function enemySheetList() {
for (const id of enemyTypeIds()) add(enemyTypeConfig(id));
return out;
}
// ------------------------------------------------------ the neutral roster
//
// The player-side MIRROR of the registry above (data/combat.json →
// neutral + neutralTypes): the friendly NEUTRAL ships (spritesheet frames
// 20–27) the random-encounter director deploys. The same deep-merge
// contract — `neutralTypes.<id>` overrides deep-merged onto the `neutral`
// base — and the same unknown-id rule (the base craft flies, warned once).
// Their `side` is 'player': CombatSystem flies them in its allies roster,
// never the enemies' (js/combat/NeutralShip.js + CombatSystem's allies).
/** The named NEUTRAL type ids (combat.neutralTypes, minus the `_` bookkeeping keys). */
export function neutralTypeIds() {
const t = config.section('combat.neutralTypes', {});
return Object.keys(t).filter((k) => !k.startsWith('_') && isPlain(t[k]));
}
/**
* The FULL config for a friendly neutral type: the base (combat.neutral)
* deep-merged with the type's overrides (combat.neutralTypes[id]) — the
* player-side mirror of enemyTypeConfig.
*
* @param {string|null} [typeId] null/'' = the base neutral craft; a named
* id = base + that type's overrides; an unknown id = the base (warned —
* a roster typo, not a crash).
* @returns {object} the config a NeutralShip builds from
*/
export function neutralTypeConfig(typeId = null) {
const base = config.section('combat.neutral', {});
if (typeId == null || typeId === '') return base;
const t = config.section('combat.neutralTypes', {});
const over = t[typeId];
if (!isPlain(over)) {
if (typeof console !== 'undefined') {
console.warn(`[orbit] neutralTypes: unknown type "${typeId}" — the base neutral craft flies instead`);
}
return base;
}
return deepMerge(base, over);
}
/**
* Every DISTINCT spritesheet the neutral ships use (deduped, base first —
* the same seam contract as enemySheetList; in practice the same enemy
* sheet on different frames, so a scene loading both lists sees one file).
*
* @returns {Array<{src: string, frameWidth: number, frameHeight: number}>}
*/
export function neutralSheetList() {
const out = [];
const seen = new Set();
const add = (c) => {
const src = isPlain(c) ? c.texture : null;
if (typeof src !== 'string' || src === '' || seen.has(src)) return;
seen.add(src);
out.push({
src,
frameWidth: Math.max(1, Number(c.frameWidth) || 128),
frameHeight: Math.max(1, Number(c.frameHeight) || 128),
});
};
add(config.section('combat.neutral', {}));
for (const id of neutralTypeIds()) add(neutralTypeConfig(id));
return out;
}

206
js/combat/NeutralShip.js Normal file
View File

@ -0,0 +1,206 @@
import Phaser from '../vendor/phaser.js';
import { inView, orbitStep } from './Combat.js';
import { EnemyShip } from './EnemyShip.js';
import { neutralTypeConfig } from './EnemyTypes.js';
/**
* A NEUTRAL ESCORT (data/combat.json → neutral / neutralTypes — frames
* 20–27 of the enemy sheet, the player-side roster): the same craft
* body as the hostiles (EnemyShip's art/armor/steering — the config
* just says `side: 'player'`), but the AI is a small state machine
* instead of the chase:
*
* approach → (pirates? engage : escort)
* engage → orbit the NEAREST pirate and lance it (Weapons.js's
* patterns — the same dispatch the hostiles run); the
* moment the last pirate is down it folds into escort
* escort → hold the escort ring around the player for this ship's
* window (neutral.escortMin..escortMax seconds — the
* spawner rolls it per ship); a pirate up while escorting
* flips it back into engage
* break → peel off and fly off-screen (no fire, no U-turn);
* CombatSystem despawns it the frame it is fully out
*
* PLAYER-SAFE BY CONSTRUCTION, not by promise: the only path that sets
* wantsFire is engage-at-pirate; the player never enters the target
* pool (js/combat/CombatSystem.js — auto-fire picks from the enemies
* roster, and this is a different one); the collide passes never route
* an 'ally' bolt/seeker into the player (Weapons.js's
* missilesHitPlayer skips them), and the player's own bolts never touch
* this roster (they hit `enemies`, not `allies`).
*
* `update(time, delta, player, pirates)` returns `{ wantsFire, target }`
* (target = the pirate being fired at, or null) — CombatSystem does the
* actual firing, so the on-screen gate + shared pattern dispatch stay
* in one place, exactly like the pack's.
*/
export class NeutralShip extends EnemyShip {
/**
* @param {object} scene the combat scene
* @param {number} x spawn
* @param {number} y spawn
* @param {number} index slot (orbit direction alternates)
* @param {object} [c] the craft's FULL neutral config (merged — the
* EnemyTypes.neutralTypeConfig contract)
* @param {object} [o]
* @param {number} [o.escortMs] this ship's escort window (ms) — rolled
* per ship by the spawner (neutral.escortMin..escortMax) or the
* director
* @param {Function} [o.viewFn] () => the camera's visible world rect
* (or null) — the break-off cull surface
*/
constructor(scene, x, y, index, c = null, o = {}) {
const cfg = c && typeof c === 'object' ? c : neutralTypeConfig();
super(scene, x, y, index, cfg);
this.side = 'player'; // the roster contract: never hostile to the player
this.state = 'approach';
this.escortMs = Math.max(0, Number(o.escortMs) || 0);
this.escortStart = null;
this.viewFn = typeof o.viewFn === 'function' ? o.viewFn : null;
this.target = null; // the pirate engaged (null while escort/break)
this.gone = false; // fully off-screen after the break-off
this.escortRadius = Math.max(24, Number(cfg.escortRadius) || 110);
}
/**
* Ally AI step (see the class doc).
* @param {number} time scene time (ms)
* @param {number} delta frame delta (ms)
* @param {object|null} player the player's craft (steering/fire anchor)
* @param {object[]} [pirates] the system's live enemy roster
* @returns {{wantsFire: boolean, target: (object|null)}}
*/
update(time, delta, player, pirates = []) {
const out = { wantsFire: false, target: null };
if (this.dead) return out;
const dt = Math.min(delta, 64) / 1000;
const px = player ? player.x : (this.lastPx ?? this.x);
const py = player ? player.y : (this.lastPy ?? this.y);
if (player) {
this.lastPx = px;
this.lastPy = py;
}
const live = (pirates || []).filter((p) => p && !p.dead);
// ---- the state machine: escort ↔ engage on the pirate count (the
// escort clock is stamped once per escort spell and keeps
// running through interruptions); window expiry is the one-way
// break-off.
if (this.state === 'engage' && live.length === 0) {
this.state = 'escort';
if (this.escortStart == null) this.escortStart = time;
} else if (this.state === 'escort' && live.length > 0) {
this.state = 'engage';
}
// ---- break: peel off and fly OUT (no fire, no U-turn)
if (this.state === 'break') {
const dx = this.x - px;
const dy = this.y - py;
const d = Math.hypot(dx, dy);
const dirx = d > 1 ? dx / d : 1;
const diry = d > 1 ? dy / d : 0;
this._steer(px + dirx * 900, py + diry * 900, this.speed, dt);
const body = this.body;
const want = body && body.velocity.length() > 4
? Math.atan2(body.velocity.y, body.velocity.x)
: Math.atan2(diry, dirx);
this.rotateToward(want + this.artOffset, dt);
// Fully off the camera's visible rect → the encounter's end.
const view = this.viewFn ? this.viewFn() : null;
if (view && !inView(this.x, this.y, Math.max(this.hitRadius, (this.size || 0) / 2), view)) {
this.gone = true; // CombatSystem despawns it this frame
}
return out;
}
const wpn = this.wpn ?? {};
const range = Number(wpn.range) || 0;
// ---- approach: stream in from the spawn bearing onto the escort ring
if (this.state === 'approach') {
this._orbit(px, py, this.escortRadius, dt);
if (Math.hypot(px - this.x, py - this.y) < this.escortRadius * 1.6) {
this.state = live.length > 0 ? 'engage' : 'escort';
if (this.state === 'escort' && this.escortStart == null) this.escortStart = time;
}
return out;
}
// ---- engage: orbit the NEAREST pirate and lance it
if (this.state === 'engage') {
let best = null;
let bestD = Infinity;
for (const p of live) {
const d2 = (p.x - this.x) ** 2 + (p.y - this.y) ** 2;
if (d2 < bestD) { bestD = d2; best = p; }
}
this.target = best;
if (best) {
this._orbit(best.x, best.y, this.orbitRadius, dt);
// Fire gate (engage only — the player is NEVER a target): in
// range, roughly aimed, off cooldown — the same honesty as the
// hostiles.
if (range > 0) {
const distT = Math.hypot(best.x - this.x, best.y - this.y);
if (distT < range && time >= this.nextFireAt) {
const toT = Math.atan2(best.y - this.y, best.x - this.x);
if (Math.abs(Phaser.Math.Angle.Wrap(toT - this.heading())) < 0.6) {
this.nextFireAt = time + (Number(wpn.cooldown) || 1.4) * 1000 * (0.85 + Math.random() * 0.4);
out.wantsFire = true;
out.target = best;
}
}
}
return out;
}
// The last pirate went down mid-frame → fold into escort.
this.state = 'escort';
if (this.escortStart == null) this.escortStart = time;
}
// ---- escort: hold the ring around the player until the window lapses
this.target = null;
this._orbit(px, py, this.escortRadius, dt);
if (this.escortStart == null) this.escortStart = time;
if (time - this.escortStart >= this.escortMs) this.state = 'break';
return out;
}
/**
* Steer to a point on the orbit ring around (cx, cy) — the same
* exponential approach the hostiles run (EnemyShip.update); the
* facing rule is the same too: face the motion while closing, face
* the center (aiming) on the ring.
* @private
*/
_orbit(cx, cy, radius, dt) {
const t = orbitStep(cx, cy, this.orbitAngle, this.orbitDir, this.orbitSpeed, dt, radius);
this.orbitAngle = t.angle;
const sp = Math.hypot(t.x - this.x, t.y - this.y) > radius * 0.9 ? this.speed : this.speed * 0.55;
this._steer(t.x, t.y, sp, dt);
const distC = Math.hypot(cx - this.x, cy - this.y);
const toC = Math.atan2(cy - this.y, cx - this.x);
const body = this.body;
const want = distC < radius * 1.5
? toC
: body && body.velocity.length() > 4 ? Math.atan2(body.velocity.y, body.velocity.x) : toC;
this.rotateToward(want + this.artOffset, dt);
}
/** Exponential approach toward (tx, ty) at speed sp (dt in seconds). @private */
_steer(tx, ty, sp, dt) {
const dx = tx - this.x;
const dy = ty - this.y;
const d = Math.hypot(dx, dy) || 1;
const k = 1 - Math.exp(-this.turn * dt);
const body = this.body;
if (body) {
body.velocity.x += ((dx / d) * sp - body.velocity.x) * k;
body.velocity.y += ((dy / d) * sp - body.velocity.y) * k;
}
}
}

View File

@ -20,6 +20,9 @@ import { combatSfx } from './CombatSfx.js';
* the group fan out over distance (the close-range snap)
* 'ring' weapon.bolts fired RADIAL around the craft (random phase
* per volley — break the orbit)
* 'volley' a WIDE fan of weapon.bolts whose speeds are staggered — the
* outer bolts drift apart over distance (the burst's stagger
* across the spray's fan: a blossoming wall)
* 'missile' weapon.missiles HEAT-SEEKERS — the Missile (js/combat/
* Missile.js) homes with a CAPPED turn-rate (weapon.missileTurn)
* so the player can out-turn them (the dodge rule)
@ -55,10 +58,17 @@ export function firePattern(ctx, ship, player, wpn) {
const spread = Math.max(0, num(wpn.spread, 0));
const nose = Math.max(8, num(ship.hitRadius, 20)) + 10;
const jitter = () => (Math.random() * 2 - 1) * spread;
// Bolt owner (the player-safety seam): hostiles run 'enemy'; a neutral
// escort's firePattern call passes its weapon with `owner: 'ally'` and
// every bolt it spawns carries the tag — CombatSystem's collide passes
// route 'ally' bolts into pirates (never the player), and the player's
// auto-fire never targets the escort at all (a different roster).
const owner = typeof (wpn && wpn.owner) === 'string' && wpn.owner !== '' ? wpn.owner : 'enemy';
const spawnBolt = (a, wp = wpn) => {
const mx = ship.x + Math.cos(a) * nose;
const my = ship.y + Math.sin(a) * nose;
ctx.spawnBullet(mx, my, a, wp, 'enemy');
ctx.spawnBullet(mx, my, a, wp, owner);
};
if (kind === 'spray') {
@ -78,6 +88,20 @@ export function firePattern(ctx, ship, player, wpn) {
bulletSpeed: base * (1 + (t - 0.5) * 0.18),
});
}
} else if (kind === 'volley') {
// The VOLLEY: a WIDE fan whose speeds are staggered (the outer bolts
// drift apart over distance, so the arc blossoms into a wall).
const n = Math.max(2, Math.floor(num(wpn.bolts, 7)));
const fan = Math.max(0.05, num(wpn.fan, 1.4));
const stagger = Math.max(0, num(wpn.stagger, 0.35));
const base = Math.max(40, num(wpn.bulletSpeed, 280));
for (let i = 0; i < n; i++) {
const t = i / (n - 1);
spawnBolt(toPlayer - fan / 2 + fan * t + jitter(), {
...wpn,
bulletSpeed: base * (1 + (t - 0.5) * stagger),
});
}
} else if (kind === 'ring') {
// The RADIAL ring: evenly around 2π, a fresh random phase per volley.
const angles = ringAngles(num(wpn.bolts, 8), Math.random() * Math.PI * 2);
@ -102,6 +126,25 @@ export function firePattern(ctx, ship, player, wpn) {
return kind;
}
/**
* Which craft a given missile homes on this frame (stepMissiles's owner
* rule).
*/
function missileTargetOf(m, player, ctx) {
const ref = m.targetRef;
if (ref && ref.x != null && ref.dead !== true) return ref;
if (m.owner === 'ally') {
if (typeof ctx.allyMissileTarget === 'function') {
try {
const t = ctx.allyMissileTarget(m);
if (t && t.dead !== true) return t;
} catch { /* no seam in context: fly straight */ }
}
return null; // straight flight, the fuse ends it — never a player threat
}
return player;
}
/**
* Launch the hostile's HEAT-SEEKING volley (the `missile`/`combo`
* patterns): weapon.missiles shots from the nose, fanned over
@ -124,20 +167,23 @@ export function launchMissiles(ctx, ship, player, wpn) {
life: Math.max(0.5, num(wpn.missileLife, 4)),
color: wpn.missileColor ?? wpn.color ?? '#ff9d3d',
size: Math.max(10, num(wpn.missileSize, 18)),
owner: 'enemy',
// Seeker owner (the player-safety seam, see firePattern): 'enemy' by
// default; 'ally' when a neutral escort fires the pattern (the seeker
// then locks the pirate it was aimed at — see stepMissiles).
owner: typeof wpn.owner === 'string' && wpn.owner !== '' ? wpn.owner : 'enemy',
};
if (!Array.isArray(ctx.missiles)) ctx.missiles = [];
for (let i = 0; i < n; i++) {
const t = n === 1 ? 0.5 : i / (n - 1);
const a = toPlayer - spread / 2 + spread * t;
ctx.missiles.push(
new Missile(scene, {
x: ship.x + Math.cos(a) * nose,
y: ship.y + Math.sin(a) * nose,
angle: a,
...opts,
}),
);
const m = new Missile(scene, {
x: ship.x + Math.cos(a) * nose,
y: ship.y + Math.sin(a) * nose,
angle: a,
...opts,
});
m.targetRef = player; // the intended victim (the seeker's home target)
ctx.missiles.push(m);
}
combatSfx(scene, 'missile_fire'); // the rocket voice (distinct from the laser)
return n;
@ -154,7 +200,12 @@ export function stepMissiles(ctx, delta, player, bounds = null) {
if (!ms || ms.length === 0) return;
for (let i = ms.length - 1; i >= 0; i--) {
const m = ms[i];
m.update(delta, player);
// Home target (owner rule): an escort's seeker locks its intended
// victim (m.targetRef), then re-homes to the nearest live one via the
// context seam (CombatSystem.allyMissileTarget), else flies straight;
// hostile seekers keep the classic player home (or their own intended
// victim, when a mixed-encounter pirate aimed at an escort).
m.update(delta, missileTargetOf(m, player, ctx));
if (m.dead) {
ms.splice(i, 1);
fizzleFx(ctx, m); // the fuse ran out — a small bloom
@ -191,6 +242,7 @@ export function missilesHitPlayer(ctx, player, playerRadius, delta) {
ms.splice(i, 1);
continue;
}
if (m.owner === 'ally') continue; // no friendly fire: escort seekers pass by
if (ctx.hitsSwept(m, player, r, delta)) {
// The shield is struck from the missile's incoming bearing.
ctx.damagePlayer(m.damage, { x: m.x, y: m.y, bearing: m.heading + Math.PI });

View File

@ -13,7 +13,7 @@ import { playSfxOn, sfxPlayingOn, stopSfxOn } from '../utils/Sfx.js';
import { gameTrackKey, startMusicShuffleOn, stopMusicShuffleOn } from '../utils/Music.js';
import { Ship } from '../entities/Ship.js';
import { EnemyShip } from '../combat/EnemyShip.js';
import { enemySheetList } from '../combat/EnemyTypes.js';
import { enemySheetList, enemyTypeIds, neutralTypeIds } from '../combat/EnemyTypes.js';
import { Planet } from '../entities/Planet.js';
import { AsteroidCluster } from '../entities/AsteroidCluster.js';
import { Station } from '../entities/Station.js';
@ -38,6 +38,7 @@ import { CombatBar } from '../ui/CombatBar.js';
import { LevelUpMarquee } from '../ui/LevelUpMarquee.js';
import { CombatSystem } from '../combat/CombatSystem.js';
import { XpManager } from '../combat/XpManager.js';
import { EncounterDirector, encounterAllowed } from '../combat/Encounters.js';
import { CombatHud } from '../combat/CombatHud.js';
import { MineralHud } from '../ui/MineralHud.js';
import { CreditsHud } from '../ui/CreditsHud.js';
@ -1092,6 +1093,16 @@ export class GameScene extends Phaser.Scene {
// every level-up after re-resolves too (the lock lifts at minLevel).
this._refreshGateFactionTints();
this.combat.xp.on('levelup', () => this._refreshGateFactionTints());
// THE ENCOUNTER DIRECTOR (js/combat/Encounters.js — the pure clock;
// the numbers are data/combat.json → encounters): while flying in
// neutral wild space (not home, no wave run in flight, the player
// upright) it rolls a random encounter on the interval schedule —
// pirates, neutrals, or a mix, sized off the combat level. It owns
// no ships: on a hit it returns a composition and _spawnEncounter
// materializes the pack off-screen (the existing spawn path; their
// AI streams them in).
this.encounters = new EncounterDirector(config.get('combat.encounters', {}));
this._encounterSerial = 10000; // off the dev console's 100+i band
// ?combat boot (the tuning sandbox): the SEVEN-TYPE DEMO around the
// ship (data/combat.json → waves.scripts.combat_demo) — a sequence
// of rolling waves, one per new hostile type in difficulty order
@ -2545,6 +2556,24 @@ export class GameScene extends Phaser.Scene {
this.combat.update(_time, delta);
this.combatBar?.setStats(this.combat);
this.combatHud?.update(_time); // the readability layer (lock/threat/feed/flash)
// THE ENCOUNTER DIRECTOR (data/combat.json → encounters): a hit
// returns the composition to spawn — off-screen, streamed in by
// their own AI (the clock is pure; the scene stays the spawner).
const comp = this.encounters.update(_time, {
allowed: encounterAllowed({
ship: this.ship,
isHomeSystem: this.isHomeSystem,
faction: this.systemRecord ? this.systemRecord.faction : null,
waveRunLive:
!!this.combat.waveRun &&
this.combat.waveRun.state !== 'done' &&
this.combat.waveRun.state !== 'cancelled',
playerDown: this.combat.playerDown,
}),
live: this.combat.active,
xpLevel: this.combat.xp.level,
});
if (comp) this._spawnEncounter(comp);
}
}
this.engineFx?.update(_time, delta); // burn fades even through a pause
@ -6155,6 +6184,46 @@ export class GameScene extends Phaser.Scene {
* scene-local) — the step's witness is the completion, not the
* attempt, so the re-fire is the fair read.
*/
/**
* THE ENCOUNTER DIRECTOR'S HIT (js/combat/Encounters.js): the pack
* materializes off-screen around the ship (the _offscreenRadius band —
* the same seam the dev console's spawner uses), fanned around one base
* heading so the encounter streams in from one direction. Encounter
* hostiles are one-shots (respawn off — no mid-fight pops; the fight
* ends when the sky is clear); the escorts carry their own windows
* (neutral.escortMin/escortMax — the break-off is their state machine).
* @param {{kind:string, pirates:number, neutrals:number}} comp
*/
_spawnEncounter(comp) {
const ship = this.ship;
if (!ship || !this.combat) return;
const r = this.combat._offscreenRadius() * (0.9 + Math.random() * 0.2);
const baseA = Math.random() * Math.PI * 2;
const eids = enemyTypeIds();
const nids = neutralTypeIds();
const pick = (list) => (list.length ? list[(Math.random() * list.length) | 0] : null);
const fan = (i, n) => baseA + (n > 1 ? (i - (n - 1) / 2) * 0.5 : 0) + (Math.random() - 0.5) * 0.4;
for (let i = 0; i < (comp.pirates | 0); i++) {
const a = fan(i, comp.pirates);
const e = this.combat.spawnEnemy(
ship.x + Math.cos(a) * r,
ship.y + Math.sin(a) * r,
this._encounterSerial++,
pick(eids),
);
e.respawn = false; // one-shots: the encounter ends with the sky clear
}
for (let i = 0; i < (comp.neutrals | 0); i++) {
const a = fan(i, comp.neutrals);
this.combat.spawnAlly(
ship.x + Math.cos(a) * r,
ship.y + Math.sin(a) * r,
1000 + this._encounterSerial++,
pick(nids),
);
}
}
_maybeStartPirateAmbush() {
if (config.get('combat.enabled', true) === false) return;
if (!this.combat) return;