Files
Emberfall/tests/integration/step-10.18.test.js
T
2026-10-23 10:45:45 +11:00

154 lines
5.4 KiB
JavaScript
Raw 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.
// Step 10.18: Balance pass — assert Meadows level-1 win rate within 70–90% using fixed seed.
const { describe, it, before, after } = require('node:test');
const assert = require('node:assert/strict');
const { resolveCombat } = require('../../src/game/combat');
const { calcDerived } = require('../../src/game/stats');
const { getClass, getBaseStats } = require('../../src/game/classes');
const { mulberry32 } = require('../../src/utils/rng');
const { setup, teardown, cleanup } = require('../helpers');
// ─── Helpers ───────────────────────────────────────────────────────────────
/**
* Simulate one hunt: hero vs a randomly-weighted monster from a zone.
* Uses the same weighted-pick logic as huntService.
* Monster stats match 008_balance.sql migration.
*/
function pickAndFight(zoneKey, classKey, rng) {
const cls = getClass(classKey);
const base = getBaseStats(classKey);
const derived = calcDerived(base, 1, classKey);
const hero = {
class: classKey,
level: 1,
hpCurrent: derived.maxHp,
str: base.str, agi: base.agi, int: base.int, vit: base.vit,
};
// Monster data from DB (meadows monsters, post-008_balance.sql)
const monsters = {
meadows: [
{ name: 'Giant Rat', level: 1, hp: 200, attack: 6, defense: 8, speed: 12, weight: 30 },
{ name: 'Meadow Wolf', level: 2, hp: 250, attack: 8, defense: 8, speed: 16, weight: 20 },
{ name: 'Goblin Scrapper', level: 3, hp: 300, attack: 10, defense: 8, speed: 11, weight: 15 },
],
};
const zoneMonsters = monsters[zoneKey];
const total = zoneMonsters.reduce((s, m) => s + m.weight, 0);
let r = rng() * total;
let monster = zoneMonsters[zoneMonsters.length - 1];
for (const m of zoneMonsters) {
r -= m.weight;
if (r < 0) { monster = m; break; }
}
const fight = resolveCombat(hero, monster, rng);
return fight.winner === 'hero';
}
// ─── Tests ─────────────────────────────────────────────────────────────────
describe('balance: Meadows level-1 win rate', () => {
const rounds = 200;
const SEED = 12345;
it('Meadows level-1 win rate is between 70% and 90% for all classes', () => {
let totalWins = 0;
let totalFights = 0;
for (let r = 0; r < rounds; r++) {
for (const classKey of ['warrior', 'mage', 'ranger']) {
const rng = mulberry32(SEED + r * 100 + ['warrior', 'mage', 'ranger'].indexOf(classKey));
const won = pickAndFight('meadows', classKey, rng);
if (won) totalWins++;
totalFights++;
}
}
const winRate = totalWins / totalFights;
assert.ok(
winRate >= 0.70 && winRate <= 0.90,
`Meadows level-1 win rate is ${(winRate * 100).toFixed(2)}% (expected 70–90%)`
);
});
it('Meadows level-1 win rate is consistent across different seeds', () => {
// Run with 3 different seeds and check all are in range
for (const seed of [42, 99, 7777]) {
let totalWins = 0;
let totalFights = 0;
for (let r = 0; r < rounds; r++) {
for (const classKey of ['warrior', 'mage', 'ranger']) {
const rng = mulberry32(seed + r * 100 + ['warrior', 'mage', 'ranger'].indexOf(classKey));
const won = pickAndFight('meadows', classKey, rng);
if (won) totalWins++;
totalFights++;
}
}
const winRate = totalWins / totalFights;
assert.ok(
winRate >= 0.70 && winRate <= 0.90,
`Seed ${seed}: Meadows level-1 win rate is ${(winRate * 100).toFixed(2)}% (expected 70–90%)`
);
}
});
});
describe('balance: Crags level-1 win rate', () => {
const rounds = 200;
const SEED = 12345;
it('Crags level-1 win rate is ≤ 15% for all classes', () => {
let totalWins = 0;
let totalFights = 0;
const monsters = {
crags: [
{ name: 'Ember Imp', level: 10, hp: 150, attack: 25, defense: 12, speed: 20, weight: 25 },
{ name: 'Stone Golem', level: 12, hp: 300, attack: 30, defense: 12, speed: 5, weight: 10 },
{ name: 'Ashfang Drake', level: 14, hp: 400, attack: 28, defense: 3, speed: 18, weight: 5 },
],
};
for (let r = 0; r < rounds; r++) {
for (const classKey of ['warrior', 'mage', 'ranger']) {
const rng = mulberry32(SEED + r * 100 + ['warrior', 'mage', 'ranger'].indexOf(classKey));
const cls = getClass(classKey);
const base = getBaseStats(classKey);
const derived = calcDerived(base, 1, classKey);
const hero = {
class: classKey,
level: 1,
hpCurrent: derived.maxHp,
str: base.str, agi: base.agi, int: base.int, vit: base.vit,
};
const zoneMonsters = monsters.crags;
const total = zoneMonsters.reduce((s, m) => s + m.weight, 0);
let cr = rng() * total;
let monster = zoneMonsters[zoneMonsters.length - 1];
for (const m of zoneMonsters) {
cr -= m.weight;
if (cr < 0) { monster = m; break; }
}
const fight = resolveCombat(hero, monster, rng);
if (fight.winner === 'hero') totalWins++;
totalFights++;
}
}
const winRate = totalWins / totalFights;
assert.ok(
winRate <= 0.15,
`Crags level-1 win rate is ${(winRate * 100).toFixed(2)}% (expected ≤ 15%)`
);
});
});