// 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%)` ); }); });