orbit/dev/zenith.test.mjs

317 lines
14 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* ZENITH FACTION test (dev tool, run with Node — no browser needed):
*
* node dev/zenith.test.mjs
*
* The ?combat=zenith roster (data/combat.json → enemyTypes, frames
* 50–57; the factions.zenith arena) and its two new ability hands
* (js/combat/EnemyAbilities.js):
*
* · the DATA contract — 8 types, frames 50–57 each exactly once, the
* 2×/3.5× balance rule (16 damage, 175+35 armor, 42 xp), the 4/4
* combo/elemental split, and the faction roster's rows matching
* the types (the picker's order).
* · THE WING VOLLEY (flank_barrage) — a salvo out of both wings at
* 90° off the heading, on a SLOW capped turn-rate (the dodge rule),
* homing on the player, riding the shared missile roster.
* · THE VOLT LINE (shock_dart + stepShock) — fast darts that
* detonate near the player and bloom an electrified zone that
* GROWS (startRadius→maxRadius, capped), TICKS damage on a player
* inside (the DoT), and DIES (finite life, the roster purged).
*
* All numbers come from the real data/combat.json — the assertions
* track the shipped config, not a private copy.
*/
import { pathToFileURL, fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import fs from 'node:fs';
const __dirname = dirname(fileURLToPath(import.meta.url));
const file = (p) => pathToFileURL(join(__dirname, '../js', p)).href;
const TAU = Math.PI * 2;
const wrap = (a) => {
const min = -Math.PI;
const range = TAU;
return (((a - min) % range) + range) % range + min;
};
// ------------------------------------------------------- the Phaser stub
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; }
}
class Arc {
constructor(scene, x, y, r, a0, a1) {
this.scene = scene; this.x = x; this.y = y;
this.displayRadius = r; this.alpha = 1; this.destroyed = false;
}
setFillStyle() { return this; }
setStrokeStyle() { return this; }
setDepth() { return this; }
setAlpha(a) { this.alpha = a; return this; }
destroy() { 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 } },
GameObjects: { Arc },
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 makeScene() {
return {
textures: { exists: () => true },
add: {
existing: (o) => o,
image: () => ({ setScale() {}, setAlpha() {}, setTint() {}, setBlendMode() {}, setDepth() {}, setAngle() {}, setVisible() {}, destroy() {} }),
graphics: () => ({ fillStyle() {}, beginPath() {}, moveTo() {}, lineTo() {}, closePath() {}, fillPath() {}, fillCircle() {}, generateTexture() {}, destroy() {} }),
text: () => ({ setText() {}, setAlpha() {}, setVisible() {}, destroy() {} }),
rectangle: () => ({ setOrigin() {}, setAlpha() {}, setStrokeStyle() {}, setScrollFactor() {}, destroy() {} }),
},
physics: { add: { existing: (o) => { o.body = makeBody(); return o; } } },
scale: { width: 1280, height: 720 },
time: { now: 0 },
sound: { play: () => {} },
cache: { audio: { has: () => true } },
};
}
// ------------------------------------------------------- the real modules
const { config } = await import(file('config/Config.js'));
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);
const { enemyTypeConfig, factionMeta, factionTypeIds } = await import(file('combat/EnemyTypes.js'));
const { abilitiesOf, stepAbility, stepShock } = await import(file('combat/EnemyAbilities.js'));
let failures = 0;
const check = (label, cond, extra = '') => {
console.log(`${cond ? '✔' : '✘ FAIL'} ${label}${extra && !cond ? ` [${extra}]` : ''}`);
if (!cond) failures += 1;
};
const approx = (a, b, eps = 1e-6) => Math.abs(a - b) <= eps;
// ------------------------------------------------- the data contract (8 types)
{
const ids = [
'zenith_wingblade', 'zenith_divefang', 'zenith_kestrel', 'zenith_reefrunner',
'zenith_voltspire', 'zenith_stormweft', 'zenith_arcpulse', 'zenith_tempest',
];
const cfgs = ids.map((id) => enemyTypeConfig(id));
check('data: all eight types resolve (base + overrides, no unknown-id fallback)',
cfgs.every((c) => typeof c.name === 'string' && c.name !== ''));
const frames = cfgs.map((c) => c.frame);
check('data: frames 50–57, each exactly once (the user\'s new sprites)',
frames.length === 8 && new Set(frames).size === 8 && frames.every((f) => Number.isInteger(f) && f >= 50 && f <= 57),
JSON.stringify(frames));
check('data: the 2×/3.5× balance rule — 175 hull + 35 shields (210 = 60 × 3.5)',
cfgs.every((c) => c.hull === 175 && c.shields === 35),
JSON.stringify(cfgs.map((c) => `${c.hull}+${c.shields}`)));
check('data: xp = (hull+shields)/5 = 42 (the roster\'s rule of thumb)',
cfgs.every((c) => c.xp === 42), JSON.stringify(cfgs.map((c) => c.xp)));
check('data: 2× damage — every bolt/seeker/shell does 16 (the faction rule)',
cfgs.every((c) => {
const w = c.weapon ?? {};
const a = c.abilities ?? [];
const seeker = a.find((s) => s.id === 'flank_barrage');
const dart = a.find((s) => s.id === 'shock_dart');
return (w.damage === 16 || w.shellDamage === 16) &&
(seeker ? seeker.missileDamage === 16 : true) &&
(dart ? dart.tickDamage <= 5 : true);
}));
const combo = cfgs.filter((c) => (c.abilities ?? []).some((a) => a.id === 'flank_barrage'));
const elem = cfgs.filter((c) => (c.abilities ?? []).some((a) => a.id === 'shock_dart'));
check('data: the 4/4 split — 4 combo seekers + 4 elementals, no crossover',
combo.length === 4 && elem.length === 4 && !combo.some((c) => elem.includes(c)),
`${combo.length}/4 combo, ${elem.length}/4 elemental`);
check('data: each ship keeps its own weapon pattern (unique-per-ship attack kit)',
new Set(cfgs.map((c) => `${c.weapon.pattern}|${(c.abilities ?? []).map((a) => a.id).join('+')}`)).size === 8,
JSON.stringify(cfgs.map((c) => `${c.weapon.pattern}+${(c.abilities ?? []).map((a) => a.id).join(',')}`)));
// The faction roster: the picker's rows 1–8 = these types, in order.
const meta = factionMeta('zenith');
const rows = factionTypeIds('zenith');
check('data: factions.zenith lists all 8 (name + tagline + 8 rows, picker order)',
meta && typeof meta.name === 'string' && meta.name !== '' && typeof meta.tagline === 'string' &&
rows.length === 8 && rows[0] === 'zenith_wingblade' && rows[7] === 'zenith_tempest' &&
rows.every((id) => ids.includes(id)), JSON.stringify(rows));
}
// ------------------------------------------------- a host for the abilities
function makeHost(scene) {
return {
scene,
fx: { muzzle: () => {}, flash: () => {}, ring: () => {}, sparks: () => {}, explode: () => {} },
missiles: [],
shockdarts: [],
shockzones: [],
playerHits: [],
playerFacing: () => 0,
teleportCraft() {},
damagePlayer(d, o) { this.playerHits.push({ d, o }); },
};
}
function makeShip(cfg, x, y, heading = 0) {
return {
x, y,
hitRadius: 20,
heading: () => heading,
abilities: abilitiesOf(cfg),
_ab: {}, // the per-ability clocks (EnemyShip's own — the fake ship mirrors it)
};
}
// ------------------------------------------------- THE WING VOLLEY (combo)
{
for (const id of ['zenith_wingblade', 'zenith_divefang', 'zenith_kestrel', 'zenith_reefrunner']) {
const scene = makeScene();
const host = makeHost(scene);
const cfg = enemyTypeConfig(id);
const a = cfg.abilities.find((s) => s.id === 'flank_barrage');
const e = makeShip(cfg, 0, 0, 0); // heading +x — the wings are at ±90°
const player = { x: 200, y: 0 };
const t0 = 1_000_000;
stepAbility(host, e, player, t0); // arms the clock
stepAbility(host, e, player, t0 + a.cooldown * 1000 + 1); // fires
const ms = host.missiles;
const perSide = Math.ceil(a.count / 2);
check(`${id}: the wing volley fires ${a.count} seekers (3+3 / 4+4 style — both wings)`,
ms.length === a.count, `got ${ms.length}`);
check(`${id}: the seekers leave at 90° off the heading (out the wings, not the nose)`,
ms.length > 0 && ms.every((m) =>
Math.abs(wrap(m.heading - Math.PI / 2)) <= a.missileSpread / 2 + 0.02 ||
Math.abs(wrap(m.heading + Math.PI / 2)) <= a.missileSpread / 2 + 0.02),
JSON.stringify(ms.map((m) => Number(wrap(m.heading).toFixed(2)))));
check(`${id}: the SLOW seek — capped turn-rate ≤ 1.3 (the player\'s 6.4 out-turns them)`,
ms.length > 0 && ms.every((m) => m.turnRate <= 1.3 && m.turnRate >= 0.2),
JSON.stringify(ms.map((m) => m.turnRate)));
check(`${id}: the seekers carry the 2× damage and home on the player`,
ms.length > 0 && ms.every((m) => m.damage === a.missileDamage && m.damage === 16 && m.targetRef === player));
}
}
// ------------------------------------------------- THE VOLT LINE (elemental)
{
for (const id of ['zenith_voltspire', 'zenith_stormweft', 'zenith_arcpulse', 'zenith_tempest']) {
const scene = makeScene();
const host = makeHost(scene);
const cfg = enemyTypeConfig(id);
const a = cfg.abilities.find((s) => s.id === 'shock_dart');
const e = makeShip(cfg, -260, 0, 0); // nose toward the player at the origin
const player = { x: 0, y: 0 };
const t0 = 2_000_000;
stepAbility(host, e, player, t0); // arms the clock
stepAbility(host, e, player, t0 + a.cooldown * 1000 + 1); // throws the darts
check(`${id}: the ability throws ${a.count} fast dart(s) (speed ${a.dartSpeed} px/s)`,
host.shockdarts.length === a.count && host.shockdarts.every((d) => Math.abs(Math.hypot(d.vx, d.vy) - a.dartSpeed) < 0.01),
`got ${host.shockdarts.length}`);
// Run the shared frame step: the darts fly, detonate near the player
// (or at the fuse end), and the zones are born.
let t = t0 + a.cooldown * 1000 + 2;
let zone = null;
for (let i = 0; i < 400 && !zone; i++) {
t += 16;
stepShock(host, t, player, 16);
zone = host.shockzones[0];
}
check(`${id}: the dart detonates into an electrified zone (finite life ≤ 5 s)`,
zone !== undefined && zone.end - zone.born <= 5000,
zone ? `life=${zone.end - zone.born}ms` : 'no zone born');
const r0 = zone ? zone.r : 0;
let hitBefore = host.playerHits.length;
let insideEver = false;
// Grow + tick for a while (the bloom is ~1.4–2.4 s; the DoT ticks inside).
for (let i = 0; i < 260; i++) { // ~4.2 s
t += 16;
stepShock(host, t, player, 16);
zone = host.shockzones[0] ?? zone;
if (host.shockzones.some((z) => Math.hypot(player.x - z.x, player.y - z.y) <= z.r)) insideEver = true;
}
const r1 = zone ? zone.r : 0;
check(`${id}: the zone GROWS (start ${a.startRadius} → r now ${Math.round(r1)}, capped at max ${a.maxRadius})`,
r1 > r0 && r1 <= a.maxRadius + 0.01 && r0 >= a.startRadius - 0.01,
`r0=${Math.round(r0)} r1=${Math.round(r1)} max=${a.maxRadius}`);
const ticks = host.playerHits.slice(hitBefore);
check(`${id}: the DoT — ${insideEver ? 'a player INSIDE a zone takes' : 'a player never inside takes none of'} the tick damage (${a.tickDamage}/beat)`,
(insideEver ? ticks.length >= 1 : ticks.length === 0) && ticks.every((h) => h.d === a.tickDamage),
`${ticks.length} tick(s), insideEver=${insideEver}`);
// The roster purges: step past the life and the zone is gone.
for (let i = 0; i < 400; i++) { t += 16; stepShock(host, t, player, 16); }
check(`${id}: the zone DIES (finite life — the roster purges, darts too)`,
host.shockzones.length === 0 && host.shockdarts.length === 0,
`zones=${host.shockzones.length} darts=${host.shockdarts.length}`);
}
}
// ------------------------------------------------- the per-ship distinctness
{
// The four combo ships differ in volley size / seek-rate / the gun;
// the four elementals differ in dart count / zone size / pace.
const combo = ['zenith_wingblade', 'zenith_divefang', 'zenith_kestrel', 'zenith_reefrunner']
.map((id) => enemyTypeConfig(id).abilities.find((s) => s.id === 'flank_barrage'))
.map((a) => `${a.count}|${a.missileTurn}|${a.cooldown}`);
check('distinctness: the 4 combo volleys differ (size / seek-rate / cycle)',
new Set(combo).size === 4, JSON.stringify(combo));
const elem = ['zenith_voltspire', 'zenith_stormweft', 'zenith_arcpulse', 'zenith_tempest']
.map((id) => enemyTypeConfig(id).abilities.find((s) => s.id === 'shock_dart'))
.map((a) => `${a.count}|${a.maxRadius}|${a.growMs}|${a.cooldown}`);
check('distinctness: the 4 elemental lines differ (darts / zone / bloom / cycle)',
new Set(elem).size === 4, JSON.stringify(elem));
}
console.log(failures === 0 ? '\nzenith: ALL CHECKS PASSED' : `\n${failures} check(s) FAILED`);
process.exit(failures === 0 ? 0 : 1);