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