step 10.18: wip (not ticked)

This commit is contained in:
bepis
2026-10-23 07:57:33 +11:00
parent 81f1929291
commit 477b90fb59
4 changed files with 501 additions and 2 deletions
+152
View File
@@ -0,0 +1,152 @@
// 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%)`
);
});
});