153 lines
5.4 KiB
JavaScript
153 lines
5.4 KiB
JavaScript
// 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.
|
||
*/
|
||
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 the DB (meadows monsters)
|
||
const monsters = {
|
||
meadows: [
|
||
{ name: 'Giant Rat', level: 1, hp: 55, attack: 15, defense: 5, speed: 10, weight: 30 },
|
||
{ name: 'Meadow Wolf', level: 2, hp: 85, attack: 22, defense: 9, speed: 14, weight: 20 },
|
||
{ name: 'Goblin Scrapper', level: 3, hp: 130, attack: 28, defense: 12, speed: 10, 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: 200, attack: 44, defense: 22, speed: 18, weight: 25 },
|
||
{ name: 'Stone Golem', level: 12, hp: 420, attack: 50, defense: 52, speed: 5, weight: 10 },
|
||
{ name: 'Ashfang Drake', level: 14, hp: 480, attack: 62, defense: 38, speed: 16, 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%)`
|
||
);
|
||
});
|
||
});
|