496 lines
22 KiB
JavaScript
496 lines
22 KiB
JavaScript
/**
|
|
* Combat rules test (dev tool, run with Node — no browser needed):
|
|
*
|
|
* node dev/combat.test.mjs
|
|
*
|
|
* Stubs just enough of Phaser + a scene to run the REAL combat modules
|
|
* from js/combat/ — the pure rules (Combat.js: armor damage, render
|
|
* heading, orbit-ring step), the hostile craft (EnemyShip.js: config
|
|
* scaling + north-facing art offset, shields→hull damage, the
|
|
* standoff-ring AI, the lance decision), the tracer (Bullet.js:
|
|
* velocity from angle/speed, world radius, one-shot consume, life
|
|
* expiry), and the system (CombatSystem.js: the pack's lifecycle,
|
|
* the auto-fire, swept hits, rams, the player's armor + respawn,
|
|
* the energy) — then asserts the behavior the range
|
|
* (js/combat/CombatScene.js) and the main game (GameScene) are built on.
|
|
*
|
|
* All numbers come 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));
|
|
const TAU = Math.PI * 2;
|
|
|
|
// Mirror of Phaser.Math.Angle.Wrap — the vendored build's Angle.Wrap is
|
|
// Wrap(value, -π, π): ((v - min) % range + range) % range + min.
|
|
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') {
|
|
const o = {
|
|
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; },
|
|
};
|
|
return o;
|
|
}
|
|
|
|
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),
|
|
},
|
|
physics: { add: { existing: (o) => { o.body = makeBody(); return o; } } },
|
|
scale: { width: 1280, height: 720 },
|
|
time: { now: 0 },
|
|
};
|
|
}
|
|
|
|
// The real data/*.json (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 { applyDamage, headingOf, orbitStep, handleFeel } = await import(
|
|
pathToFileURL(join(__dirname, '../js/combat/Combat.js')).href
|
|
);
|
|
const { EnemyShip } = await import(pathToFileURL(join(__dirname, '../js/combat/EnemyShip.js')).href);
|
|
const { Bullet } = await import(pathToFileURL(join(__dirname, '../js/combat/Bullet.js')).href);
|
|
const { CombatSystem } = await import(pathToFileURL(join(__dirname, '../js/combat/CombatSystem.js')).href);
|
|
const {
|
|
COMBAT_SFX, combatSfx, combatSfxEnemyHit,
|
|
} = await import(pathToFileURL(join(__dirname, '../js/combat/CombatSfx.js')).href);
|
|
|
|
let failures = 0;
|
|
const check = (label, cond) => {
|
|
console.log(`${cond ? '✔' : '✘ FAIL'} ${label}`);
|
|
if (!cond) failures++;
|
|
};
|
|
const approx = (a, b, eps = 1e-6) => Math.abs(a - b) <= eps;
|
|
const E = (k, d) => config.get(`combat.enemy.${k}`, d);
|
|
|
|
// ------------------------------------------------------------------ armor
|
|
|
|
{
|
|
const armor = { shields: 40, hull: 100 };
|
|
const r1 = applyDamage(armor, 30);
|
|
check('damage: 30 hits shields only (40→10, hull 100)', armor.shields === 10 && armor.hull === 100 && !r1.dead && r1.shieldHit === 30);
|
|
const r2 = applyDamage(armor, 50);
|
|
check('damage: 50 drains shields then hull (0/60)', armor.shields === 0 && armor.hull === 60 && !r2.dead);
|
|
const r3 = applyDamage(armor, 999);
|
|
check('damage: overkill kills (hull 0, dead)', armor.hull === 0 && r3.dead);
|
|
applyDamage(armor, 5);
|
|
check('damage: dead armor takes no more', armor.hull === 0 && armor.shields === 0);
|
|
const a2 = { shields: 0, hull: 25 };
|
|
applyDamage(a2, 9);
|
|
check('damage: zero shields → hull straight', a2.hull === 16 && !a2.dead);
|
|
const a3 = { shields: 10, hull: 10 };
|
|
const r4 = applyDamage(a3, 'nope');
|
|
check('damage: non-numeric = 0', a3.shields === 10 && a3.hull === 10 && !r4.dead);
|
|
}
|
|
|
|
{
|
|
check('headingOf: rotation minus artOffset', approx(headingOf({ rotation: Math.PI / 2, artOffset: Math.PI / 2 }), 0));
|
|
check('headingOf: no offset', approx(headingOf({ rotation: 1.2 }), 1.2));
|
|
}
|
|
|
|
{
|
|
// player at origin, orbiting at radius 100
|
|
const t = orbitStep(0, 0, 0, 1, 1, 1, 100);
|
|
check('orbitStep: angle advances dir*speed*dt', approx(t.angle, 1));
|
|
check('orbitStep: target on the ring', approx(t.x, Math.cos(1) * 100) && approx(t.y, Math.sin(1) * 100));
|
|
const t2 = orbitStep(0, 0, 0, -1, 2, 0.5, 100);
|
|
check('orbitStep: counter-direction', approx(t2.angle, -1));
|
|
}
|
|
|
|
// ------------------------------------------------------------------ feel
|
|
|
|
{
|
|
const P = (k, d) => config.get(`combat.player.${k}`, d);
|
|
const cfg = {
|
|
rotSpeed: P('rotSpeed', 5.6),
|
|
rotApproach: P('rotApproach', 16),
|
|
thrustRamp: P('thrustRamp', 14),
|
|
thrustDecay: P('thrustDecay', 26),
|
|
brake: P('brake', 0.8),
|
|
};
|
|
check('feel cfg ships: all five knobs present + sane',
|
|
cfg.rotSpeed > 0 && cfg.rotApproach > 0 && cfg.thrustRamp > 0 &&
|
|
cfg.thrustDecay > 0 && cfg.brake > 0 && cfg.brake <= 1);
|
|
|
|
// Turn: a key BUILDS the turn rate (a tap is a nudge, a hold a spin) —
|
|
// no instant full rate on the first frame, and it settles at ±rotSpeed.
|
|
const st = { rot: 0, turnVel: 0, throttle: 0, brake: 0 };
|
|
handleFeel(st, { right: true }, 1 / 60, cfg);
|
|
check('feel: first frame builds turn (no instant full rate)',
|
|
st.turnVel > 0 && st.turnVel < cfg.rotSpeed && st.rot > 0);
|
|
for (let i = 0; i < 60; i++) handleFeel(st, { right: true }, 1 / 60, cfg);
|
|
check('feel: turn settles at rotSpeed (no overshoot)',
|
|
st.turnVel <= cfg.rotSpeed && Math.abs(st.turnVel - cfg.rotSpeed) < 0.05);
|
|
|
|
const sFlip = { rot: 0, turnVel: cfg.rotSpeed, throttle: 0, brake: 0 };
|
|
for (let i = 0; i < 3; i++) handleFeel(sFlip, { left: true }, 1 / 60, cfg);
|
|
check('feel: left flips the turn (eases toward -rotSpeed)', sFlip.turnVel < 0);
|
|
|
|
const s0 = { rot: 0, turnVel: cfg.rotSpeed, throttle: 0, brake: 0 };
|
|
for (let i = 0; i < 120; i++) handleFeel(s0, {}, 1 / 60, cfg);
|
|
check('feel: released turn decays to 0', s0.turnVel === 0);
|
|
|
|
// Throttle: ramps up toward 1 (bounded), cuts to 0 when released.
|
|
const s1 = { rot: 0, turnVel: 0, throttle: 0, brake: 0 };
|
|
for (let i = 0; i < 30; i++) handleFeel(s1, { up: true }, 1 / 60, cfg);
|
|
check('feel: throttle ramps up, bounded by 1', s1.throttle > 0.5 && s1.throttle <= 1);
|
|
for (let i = 0; i < 60; i++) handleFeel(s1, {}, 1 / 60, cfg);
|
|
check('feel: throttle cuts to 0 on release', s1.throttle === 0);
|
|
|
|
// Brake: instant while down (a fraction of full thrust), gone on release.
|
|
const s2 = { rot: 0, turnVel: 0, throttle: 0, brake: 0 };
|
|
handleFeel(s2, { down: true }, 1 / 60, cfg);
|
|
check('feel: brake is instant (no ramp)', s2.brake === cfg.brake);
|
|
handleFeel(s2, {}, 1 / 60, cfg);
|
|
check('feel: brake drops when released', s2.brake === 0);
|
|
|
|
// Frame-rate independence: two half-steps ≈ one full step.
|
|
const a = { rot: 0, turnVel: 0, throttle: 0, brake: 0 };
|
|
handleFeel(a, { right: true, up: true }, 1 / 60, cfg);
|
|
const b = { rot: 0, turnVel: 0, throttle: 0, brake: 0 };
|
|
handleFeel(b, { right: true, up: true }, 1 / 120, cfg);
|
|
handleFeel(b, { right: true, up: true }, 1 / 120, cfg);
|
|
check('feel: frame-rate independent (≈)',
|
|
Math.abs(a.rot - b.rot) < 1e-3 && Math.abs(a.throttle - b.throttle) < 1e-3);
|
|
|
|
// dt clamp: a backgrounded tab (dt = 5 s) can't fast-forward the feel.
|
|
const c = { rot: 0, turnVel: 0, throttle: 0, brake: 0 };
|
|
handleFeel(c, { right: true, up: true }, 5, cfg);
|
|
const d = { rot: 0, turnVel: 0, throttle: 0, brake: 0 };
|
|
handleFeel(d, { right: true, up: true }, 0.1, cfg);
|
|
check('feel: dt clamped at 0.1 s (no tab-switch jump)',
|
|
Math.abs(c.rot - d.rot) < 1e-9 && Math.abs(c.throttle - d.throttle) < 1e-9);
|
|
}
|
|
|
|
// ---------------------------------------------------------------- enemy
|
|
|
|
{
|
|
const scene = makeScene({ withSheet: true });
|
|
const e = new EnemyShip(scene, 200, 360, 0);
|
|
|
|
const fw = E('frameWidth', 128);
|
|
check('enemy: scale = size / frameWidth', approx(e.scale, E('size', 54) / fw));
|
|
check('enemy: north-facing art → offset +π/2', approx(e.artOffset, Math.PI / 2));
|
|
check('enemy: hitRadius from config', e.hitRadius === E('hitRadius', 20));
|
|
check('enemy: armor from config', e.maxHull === E('hull', 60) && e.maxShields === E('shields', 0));
|
|
|
|
// Damage model (shields → hull → dead).
|
|
e.shields = 30;
|
|
check('enemy.damage: shields first', e.damage(40) === false && e.shields === 0 && e.hull === E('hull', 60) - 10);
|
|
check('enemy.damage: killing blow', e.damage(999) === true && e.dead && e.hull === 0);
|
|
check('enemy.damage: dead is a wall', e.damage(10) === false);
|
|
e.revive();
|
|
check('enemy: revive restores armor + liveness', !e.dead && e.hull === e.maxHull && e.shields === e.maxShields);
|
|
|
|
// The AI steers toward its point on the standoff ring around the player.
|
|
const player = { x: 640, y: 360 };
|
|
e.x = 200; e.y = 360;
|
|
e.body.velocity.set(0, 0);
|
|
e.orbitAngle = Math.PI; // "behind" the enemy relative to the player (west side)
|
|
e.update(0, 16, player);
|
|
check('enemy AI: velocity heads for the ring (toward the player side)', e.body.velocity.x > 0 && Math.abs(e.body.velocity.y) < Math.abs(e.body.velocity.x));
|
|
|
|
// The lance: in range + aimed + off cooldown → fires once.
|
|
e.nextFireAt = 0;
|
|
e.x = 840; e.y = 360; // 200 px EAST of the player (range is 560)
|
|
const toPlayer = Math.PI; // facing west, straight at the player
|
|
e.rotation = toPlayer + e.artOffset;
|
|
const fired = e.update(0, 16, player);
|
|
check('enemy AI: fires when in range + aimed + armed', fired === true);
|
|
check('enemy AI: cooldown set after firing', e.nextFireAt > 0);
|
|
const firedAgain = e.update(0, 16, player);
|
|
check('enemy AI: no instant re-fire', firedAgain === false);
|
|
|
|
// Out of range → no fire (even aimed + armed).
|
|
e.nextFireAt = 0;
|
|
e.x = 1500; e.y = 360;
|
|
e.rotation = Math.atan2(360 - 360, 640 - 1500) + e.artOffset;
|
|
check('enemy AI: out of range, no fire', e.update(0, 16, player) === false);
|
|
|
|
// Player down → no fire, keeps orbiting the last position.
|
|
e.nextFireAt = 0;
|
|
e.x = 840; e.y = 360;
|
|
e.rotation = toPlayer + e.artOffset;
|
|
check('enemy AI: player down, no fire', e.update(0, 16, null) === false);
|
|
}
|
|
|
|
{
|
|
// No spritesheet → the procedural wedge (east-facing, offset 0).
|
|
const scene = makeScene({ withSheet: false });
|
|
const e = new EnemyShip(scene, 100, 100, 1);
|
|
check('enemy fallback: east-facing wedge, offset 0', e.artOffset === 0);
|
|
check('enemy fallback: scale from the wedge width', approx(e.scale, E('size', 54) / 76));
|
|
}
|
|
|
|
// ---------------------------------------------------------------- bullet
|
|
|
|
{
|
|
const scene = makeScene({ withSheet: true });
|
|
const b = new Bullet(scene, { x: 100, y: 100, angle: 0, speed: 680, damage: 10, life: 1.2, color: '#00e5ff', size: 16, owner: 'player' });
|
|
check('bullet: velocity = speed along angle', approx(b.body.velocity.x, 680) && approx(b.body.velocity.y, 0));
|
|
check('bullet: world radius > 0', b.radius > 0 && b.radius < 32);
|
|
check('bullet: owner + damage carried', b.owner === 'player' && b.damage === 10);
|
|
|
|
check('bullet: consume is one-shot', b.consume() === true && b.consume() === false);
|
|
check('bullet: consume destroys', b.active === false);
|
|
|
|
const b2 = new Bullet(scene, { x: 0, y: 0, angle: Math.PI / 2, speed: 330, damage: 8, life: 1.0, color: '#ff2d6f', size: 18, owner: 'enemy' });
|
|
check('bullet: down-angle velocity', approx(b2.body.velocity.x, 0) && approx(b2.body.velocity.y, 330));
|
|
b2.update(1000);
|
|
check('bullet: life expiry consumes', b2.dead === true);
|
|
}
|
|
|
|
// ------------------------------------------------------------- combat system
|
|
|
|
{
|
|
const scene = makeScene({ withSheet: true });
|
|
// A stand-in player ship: position + radius + body (the system reads
|
|
// exactly that — Ship's full API isn't needed for the rules).
|
|
const ship = scene.physics.add.existing(new PhaserStub.Physics.Arcade.Sprite(scene, 0, 0, '__ship', 0));
|
|
ship.radius = 12;
|
|
ship.stop = () => {}; // Ship API the system leans on (brake + hide on death)
|
|
|
|
const events = { down: 0, up: 0, kills: 0, active: [] };
|
|
const combat = new CombatSystem(scene, {
|
|
ship,
|
|
onPlayerDown: () => events.down++,
|
|
onPlayerUp: () => events.up++,
|
|
onKill: () => events.kills++,
|
|
onActiveChange: (a) => events.active.push(a),
|
|
});
|
|
|
|
check('system: starts with full armor + full energy',
|
|
combat.hull === 100 && combat.shields === 40 && combat.energy === 100 && combat.maxEnergy === 100);
|
|
|
|
// The pack: game.spawnCount hostiles around the ship, combat live.
|
|
combat.spawnPack(0, 0);
|
|
check('system: pack spawns (game.spawnCount = 3)', combat.enemies.length === 3);
|
|
check('system: combat is active with the pack up', combat.active === true);
|
|
check('system: onActiveChange fired true', events.active[0] === true);
|
|
|
|
// AUTO-FIRE: the nearest hostile is within game.autoFire.range → a
|
|
// player tracer is born (the scene's world steps it in the real game).
|
|
let t = 1000;
|
|
combat.update(t, 16);
|
|
const playerShots = combat.bullets.filter((b) => b.owner === 'player');
|
|
check('system: auto-fires at a hostile in range', playerShots.length >= 1);
|
|
t += 16;
|
|
const before = playerShots.length;
|
|
combat.update(t, 16);
|
|
check('system: auto-fire honors the weapon cooldown',
|
|
combat.bullets.filter((b) => b.owner === 'player').length === before);
|
|
|
|
// Out of range → no auto-fire (the cannon waits for contact).
|
|
for (const e of combat.enemies) { e.x = 5000; e.y = 5000; }
|
|
combat.nextFireAt = 0;
|
|
combat.update(t + 16, 16);
|
|
check('system: no auto-fire out of range',
|
|
combat.bullets.filter((b) => b.owner === 'player').length === before);
|
|
for (const e of combat.enemies) { e.x = 100; e.y = 0; }
|
|
|
|
// Swept hit: a player tracer on the enemy's segment kills it (damage
|
|
// over the enemy's hull) → the kill bookkeeping + kill event fire.
|
|
const target = combat.nearestEnemy(ship.x, ship.y);
|
|
combat.spawnBullet(target.x - 18, 0, 0, { bulletSpeed: 500, damage: 999, life: 2, color: '#fff', size: 16 }, 'player');
|
|
combat.update(t + 32, 16);
|
|
check('system: player tracer kills (swept hit)', target.dead === true && combat.kills === 1 && events.kills === 1);
|
|
|
|
// The enemy's respawn (enemy.respawn = true): down, then back.
|
|
check('system: killed enemy is on its respawn clock', target._respawnPending === true && target.respawnAt > 0);
|
|
combat.update(target.respawnAt + 10, 16);
|
|
check('system: enemy reforms (armor full, alive)', !target.dead && target.hull === target.maxHull);
|
|
|
|
// RAM: the ship inside the enemy's combined radius → both take damage.
|
|
const ram = combat.enemies[0];
|
|
ship.x = 0; ship.y = 0;
|
|
ram.x = 20; ram.y = 0;
|
|
const armorBefore = combat.hull + combat.shields;
|
|
combat.update(t + 64, 16);
|
|
check('system: ram damages the player (shields first)',
|
|
combat.hull + combat.shields < armorBefore);
|
|
|
|
// PLAYER DEATH + RESPAWN (playerShip.respawn = true, 2 s).
|
|
combat.damagePlayer(9999);
|
|
check('system: hull broke → player down', combat.playerDown === true && events.down === 1);
|
|
const respawnAt = combat.respawnAt;
|
|
combat.update(respawnAt + 10, 16);
|
|
check('system: player reformed (full armor)', !combat.playerDown && combat.hull === 100 && combat.shields === 40 && events.up === 1);
|
|
|
|
// cast(): the skill stub — no crash, no energy change (skills land later).
|
|
const energyBefore = combat.energy;
|
|
combat.cast(3);
|
|
check('system: cast is a safe no-op for now', combat.energy === energyBefore);
|
|
|
|
// Active flips off when the pack is gone (respawn clocks cleared).
|
|
for (const e of combat.enemies) { e.dead = true; e._respawnPending = false; e.respawnAt = 0; }
|
|
combat._setActive();
|
|
check('system: combat over → active off + event', combat.active === false && events.active[events.active.length - 1] === false);
|
|
|
|
// destroy(): the pack + tracers are gone.
|
|
combat.destroy();
|
|
check('system: destroy clears pack + tracers', combat.enemies.length === 0 && combat.bullets.length === 0);
|
|
}
|
|
|
|
// ------------------------------------------------------------------ SFX
|
|
|
|
{
|
|
// A scene with just enough of a sound manager for the shared guards to
|
|
// pass (js/utils/Sfx.js) — every play lands in `played`.
|
|
const makeSfxScene = () => {
|
|
const played = [];
|
|
return {
|
|
played,
|
|
sound: { play: (key, o) => played.push({ key, volume: o.volume }) },
|
|
cache: { audio: { has: () => true } },
|
|
};
|
|
};
|
|
const sfxNames = config.get('sfx') ?? {};
|
|
|
|
check('sfx: the seven combat events, each mapped',
|
|
Object.keys(COMBAT_SFX).sort().join(',') ===
|
|
'down,fire,hull,impact,kill,respawn,shield');
|
|
|
|
check('sfx: every mapped voice exists in data/sfx.json',
|
|
Object.values(COMBAT_SFX).flat().every((n) => typeof sfxNames[n] === 'string'));
|
|
|
|
const sets = Object.values(COMBAT_SFX).map((ns) => new Set(ns));
|
|
check('sfx: the events\u2019 voices are pairwise distinct',
|
|
sets.every((a, i) => sets.every((b, j) => i === j || ![...a].some((n) => b.has(n)))));
|
|
|
|
const s1 = makeSfxScene();
|
|
check('sfx: fire → the scan ping, base volume',
|
|
combatSfx(s1, 'fire') === 'fire' &&
|
|
s1.played.length === 1 &&
|
|
s1.played[0].key === 'sfx_scan' &&
|
|
s1.played[0].volume === Number(config.get('sfx.volume', 0.55)));
|
|
|
|
const s2 = makeSfxScene();
|
|
combatSfx(s2, 'fire', { vol: 0.45 }); // enemy lances play quieter
|
|
check('sfx: vol scales the volume',
|
|
Math.abs(s2.played[0].volume - Number(config.get('sfx.volume', 0.55)) * 0.45) < 1e-9);
|
|
|
|
const killKeys = new Set();
|
|
for (let i = 0; i < 60; i++) {
|
|
const sc = makeSfxScene();
|
|
combatSfx(sc, 'kill');
|
|
killKeys.add(sc.played[0]?.key);
|
|
}
|
|
check('sfx: kill picks one of the thunder set',
|
|
killKeys.size >= 2 && [...killKeys].every((k) => /^sfx_thunder_0[1-4]$/.test(k)));
|
|
|
|
const sU = makeSfxScene();
|
|
check('sfx: unknown event is a no-op', combatSfx(sU, 'nope') === null && sU.played.length === 0);
|
|
|
|
// The shield/hull decision (one shared helper, both contexts):
|
|
// in front of the hit the craft has `preShields` shield points.
|
|
const hit = (pre, dmg) => {
|
|
const sc = makeSfxScene();
|
|
combatSfxEnemyHit(sc, pre, dmg);
|
|
return sc.played.map((p) => p.key.replace('sfx_', ''));
|
|
};
|
|
check('sfx: full shields → the shield voice only',
|
|
JSON.stringify(hit(40, 10)) === JSON.stringify(['solar_buzz']));
|
|
check('sfx: no shields → the hull voice only',
|
|
JSON.stringify(hit(0, 10)) === JSON.stringify(['mining_split']));
|
|
check('sfx: shield breaks through → BOTH (the pop reads)',
|
|
JSON.stringify(hit(5, 10)) === JSON.stringify(['solar_buzz', 'mining_split']));
|
|
|
|
// The dev log: every call is remembered (capped) + mirrored to window.
|
|
combatSfx(makeSfxScene(), 'down');
|
|
const after = window.__COMBAT_SFX__ ?? null;
|
|
check('sfx: dev log records the call (window.__COMBAT_SFX__)',
|
|
after && after.last === 'down' &&
|
|
after.log.length >= 1 && after.log.length <= 64 &&
|
|
after.log[after.log.length - 1].event === 'down');
|
|
|
|
// The master switch: off → nothing plays, but the log still records
|
|
// the call (headless verification depends on it).
|
|
const saved = config.data.sfx.enabled;
|
|
config.data.sfx.enabled = false;
|
|
const sOff = makeSfxScene();
|
|
combatSfx(sOff, 'kill');
|
|
config.data.sfx.enabled = saved;
|
|
check('sfx: master switch off → silent, still logged',
|
|
sOff.played.length === 0 && (window.__COMBAT_SFX__?.last === 'kill'));
|
|
}
|
|
|
|
// ------------------------------------------------------------------ done
|
|
|
|
console.log(failures === 0 ? '\ncombat rules: all checks passed' : `\n${failures} check(s) FAILED`);
|
|
process.exit(failures === 0 ? 0 : 1);
|