317 lines
14 KiB
JavaScript
317 lines
14 KiB
JavaScript
/**
|
||
* 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);
|