Files
Emberfall/scripts/sim-balance.js
T
2026-10-23 07:57:33 +11:00

323 lines
14 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.
// Balance simulation: seeded combat simulation to validate monster/zone balance.
// Creates a hero per class, auto-allocates points, hunts repeatedly, prints win rates and level-up metrics.
// Usage: node scripts/sim-balance.js
const { mulberry32, rngInt } = require('../src/utils/rng');
const { calcDerived } = require('../src/game/stats');
const { resolveCombat } = require('../src/game/combat');
const { calculateRewards, rollLoot } = require('../src/game/rewards');
const { applyXpGain, xpToNext } = require('../src/game/leveling');
const { getClass, getBaseStats, CLASS_KEYS } = require('../src/game/classes');
const { getEquipmentBonuses } = require('../src/game/equipment');
// ─── Seeded RNG ────────────────────────────────────────────────────────────
const SEED = 42;
// ─── Zone / monster definitions (mirrors DB) ───────────────────────────────
const ZONES = {
meadows: { slug: 'whispering-meadows', minLevel: 1, energyCost: 5, monsters: [
{ name: 'Giant Rat', level: 1, hp: 55, attack: 15, defense: 5, speed: 10, xp: 28, goldMin: 4, goldMax: 9 },
{ name: 'Meadow Wolf', level: 2, hp: 85, attack: 22, defense: 9, speed: 14, xp: 40, goldMin: 6, goldMax: 14 },
{ name: 'Goblin Scrapper', level: 3, hp: 130, attack: 28, defense: 12, speed: 10, xp: 60, goldMin: 10, goldMax: 22 },
]},
forest: { slug: 'gloomroot-forest', minLevel: 5, energyCost: 8, monsters: [
{ name: 'Webspinner', level: 5, hp: 110, attack: 42, defense: 26, speed: 13, xp: 56, goldMin: 14, goldMax: 28 },
{ name: 'Bandit Cutthroat', level: 6, hp: 155, attack: 48, defense: 30, speed: 12, xp: 80, goldMin: 20, goldMax: 40 },
{ name: 'Mossback Treant', level: 8, hp: 240, attack: 54, defense: 38, speed: 6, xp: 108, goldMin: 26, goldMax: 52 },
]},
crags: { slug: 'ashfang-crag', minLevel: 10, energyCost: 12, monsters: [
{ name: 'Ember Imp', level: 10, hp: 200, attack: 44, defense: 22, speed: 18, xp: 170, goldMin: 32, goldMax: 65 },
{ name: 'Stone Golem', level: 12, hp: 420, attack: 50, defense: 52, speed: 5, xp: 260, goldMin: 48, goldMax: 90 },
{ name: 'Ashfang Drake', level: 14, hp: 480, attack: 62, defense: 38, speed: 16, xp: 400, goldMin: 72, goldMax: 145 },
]},
};
// Monster weights per zone
const MONSTER_WEIGHTS = { meadows: [30, 20, 15], forest: [25, 20, 10], crags: [25, 10, 5] };
// ─── Simulation helpers ────────────────────────────────────────────────────
function pickMonster(zoneKey, rng) {
const zone = ZONES[zoneKey];
const weights = MONSTER_WEIGHTS[zoneKey];
const total = weights.reduce((a, b) => a + b, 0);
let r = rng() * total;
for (let i = 0; i < zone.monsters.length; i++) {
r -= weights[i];
if (r < 0) return zone.monsters[i];
}
return zone.monsters[zone.monsters.length - 1];
}
function simulateHunt(hero, monster, rng) {
const equipBonuses = { bonus_str: 0, bonus_agi: 0, bonus_int: 0, bonus_vit: 0, bonus_attack: 0, bonus_defense: 0 };
const fight = resolveCombat({
class: hero.class, level: hero.level, hpCurrent: hero.hp,
str: hero.str, agi: hero.agi, int: hero.int, vit: hero.vit,
...equipBonuses,
}, monster, rng);
let xpGained = 0, goldGained = 0;
if (fight.winner === 'hero') {
const rewards = calculateRewards(monster.xp, monster.goldMin, monster.goldMax, monster.level, hero.level, rng);
xpGained = rewards.xp;
goldGained = rewards.gold;
}
return { winner: fight.winner, hpRemaining: fight.hpRemaining, xpGained, goldGained };
}
// ─── Run simulation for one class ──────────────────────────────────────────
function simulateClass(className) {
const cls = getClass(className);
const base = getBaseStats(className);
const rng = mulberry32(SEED + CLASS_KEYS.indexOf(className));
// Hero state
let hero = {
class: className,
level: 1,
xp: 0,
hp: 0, // set below after deriving maxHp
maxHp: 0,
str: base.str, agi: base.agi, int: base.int, vit: base.vit,
gold: 50,
};
const derived = calcDerived({ str: hero.str, agi: hero.agi, int: hero.int, vit: hero.vit }, hero.level, className);
hero.hp = derived.maxHp;
hero.maxHp = derived.maxHp;
// Track stats
const results = { meadows: { wins: 0, total: 0 }, forest: { wins: 0, total: 0 }, crags: { wins: 0, total: 0 } };
let totalHunts = 0;
let consecutiveDefeats = 0;
const MAX_DEFEATS = 50; // prevent infinite loops
// Phase 1: Level 1 → 5 in Meadows
while (hero.level < 5 && consecutiveDefeats < MAX_DEFEATS) {
const monster = pickMonster('meadows', rng);
const result = simulateHunt(hero, monster, rng);
results.meadows.total++;
if (result.winner === 'hero') {
results.meadows.wins++;
consecutiveDefeats = 0;
const xpResult = applyXpGain(hero.level, hero.xp, 0, result.xpGained);
if (xpResult.level > hero.level) {
const newDerived = calcDerived({ str: hero.str, agi: hero.agi, int: hero.int, vit: hero.vit }, xpResult.level, className);
hero.hp = newDerived.maxHp;
hero.maxHp = newDerived.maxHp;
}
hero.xp = xpResult.xp;
hero.level = xpResult.level;
hero.gold += result.goldGained;
// Recover some HP after victory (10% of maxHp)
hero.hp = Math.min(hero.maxHp, hero.hp + Math.floor(hero.maxHp * 0.1));
} else {
hero.hp = 1;
hero.gold = Math.max(0, hero.gold - Math.floor(hero.gold * 0.1));
consecutiveDefeats++;
}
totalHunts++;
}
const huntsL1to5 = totalHunts;
// Phase 2: Level 5 → 10 in Forest
let huntsAtL5 = totalHunts;
while (hero.level < 10 && consecutiveDefeats < MAX_DEFEATS) {
const monster = pickMonster('forest', rng);
const result = simulateHunt(hero, monster, rng);
results.forest.total++;
if (result.winner === 'hero') {
results.forest.wins++;
consecutiveDefeats = 0;
const xpResult = applyXpGain(hero.level, hero.xp, 0, result.xpGained);
if (xpResult.level > hero.level) {
const newDerived = calcDerived({ str: hero.str, agi: hero.agi, int: hero.int, vit: hero.vit }, xpResult.level, className);
hero.hp = newDerived.maxHp;
hero.maxHp = newDerived.maxHp;
}
hero.xp = xpResult.xp;
hero.level = xpResult.level;
hero.gold += result.goldGained;
hero.hp = Math.min(hero.maxHp, hero.hp + Math.floor(hero.maxHp * 0.1));
} else {
hero.hp = 1;
hero.gold = Math.max(0, hero.gold - Math.floor(hero.gold * 0.1));
consecutiveDefeats++;
}
totalHunts++;
}
const huntsL1to10 = totalHunts;
// Phase 3: Level 10+ in Crags (20 hunts)
for (let i = 0; i < 20; i++) {
const monster = pickMonster('crags', rng);
const result = simulateHunt(hero, monster, rng);
results.crags.total++;
if (result.winner === 'hero') {
results.crags.wins++;
consecutiveDefeats = 0;
const xpResult = applyXpGain(hero.level, hero.xp, 0, result.xpGained);
if (xpResult.level > hero.level) {
const newDerived = calcDerived({ str: hero.str, agi: hero.agi, int: hero.int, vit: hero.vit }, xpResult.level, className);
hero.hp = newDerived.maxHp;
hero.maxHp = newDerived.maxHp;
}
hero.xp = xpResult.xp;
hero.level = xpResult.level;
hero.gold += result.goldGained;
hero.hp = Math.min(hero.maxHp, hero.hp + Math.floor(hero.maxHp * 0.1));
} else {
hero.hp = 1;
hero.gold = Math.max(0, hero.gold - Math.floor(hero.gold * 0.1));
consecutiveDefeats++;
}
}
return { className, cls, results, huntsL1to5, huntsL1to10, finalLevel: hero.level, finalGold: hero.gold };
}
// ─── Level-1 zone win rates ────────────────────────────────────────────────
function simulateLevel1WinRate(zoneKey) {
const rng = mulberry32(SEED + 999);
let wins = 0;
const rounds = 100;
for (let r = 0; r < rounds; r++) {
const classRng = mulberry32(SEED + r * 100);
for (const className of CLASS_KEYS) {
const cls = getClass(className);
const base = getBaseStats(className);
const derived = calcDerived({ str: base.str, agi: base.agi, int: base.int, vit: base.vit }, 1, className);
const monster = pickMonster(zoneKey, classRng);
const result = simulateHunt({
class: className, level: 1, hp: derived.maxHp, maxHp: derived.maxHp,
str: base.str, agi: base.agi, int: base.int, vit: base.vit,
}, monster, classRng);
if (result.winner === 'hero') wins++;
}
}
return wins / (rounds * CLASS_KEYS.length) * 100;
}
// ─── Level-5 Forest win rate ───────────────────────────────────────────────
function simulateLevel5ForestWinRate() {
const rng = mulberry32(SEED + 888);
let wins = 0;
const rounds = 100;
for (let r = 0; r < rounds; r++) {
for (const className of CLASS_KEYS) {
const cls = getClass(className);
const base = getBaseStats(className);
// Auto-allocate 15 points to primary stat
const primary = cls.primary;
const stats = { ...base };
stats[primary] += 15;
const derived = calcDerived(stats, 5, className);
const monster = pickMonster('forest', rng);
const result = simulateHunt({
class: className, level: 5, hp: derived.maxHp, maxHp: derived.maxHp,
str: stats.str, agi: stats.agi, int: stats.int, vit: stats.vit,
}, monster, rng);
if (result.winner === 'hero') wins++;
}
}
return wins / (rounds * CLASS_KEYS.length) * 100;
}
// ─── Main ──────────────────────────────────────────────────────────────────
function pct(n) {
return `${Math.round(n * 100) / 100}%`;
}
function main() {
console.log('='.repeat(80));
console.log('Emberfall Balance Simulation (seed=' + SEED + ')');
console.log('='.repeat(80));
// Level-1 win rates
const l1Meadows = simulateLevel1WinRate('meadows');
const l1Crags = simulateLevel1WinRate('crags');
console.log('\n--- Level-1 Win Rates ---');
console.log(` Meadows: ${pct(l1Meadows)} (target: 70–90%)`);
console.log(` Crags: ${pct(l1Crags)} (target: ≤15%)`);
// Level-5 Forest win rate
const l5Forest = simulateLevel5ForestWinRate();
console.log(` Forest L5: ${pct(l5Forest)} (target: 50–75%)`);
// Per-class progression
console.log('\n--- Per-Class Progression ---');
console.log('Class Hunts L1→5 Hunts L1→10 Crags Win% Final Lv Gold');
console.log('-'.repeat(70));
const targets = {
l1Meadows: [0.70, 0.90],
l1Crags: [0, 0.15],
l5Forest: [0.50, 0.75],
l1to5Hunts: [40, 70],
l1to10Hunts: [150, 300],
};
let allMet = true;
const classResults = [];
for (const className of CLASS_KEYS) {
const sim = simulateClass(className);
classResults.push(sim);
const meadowsRate = sim.results.meadows.total > 0 ? sim.results.meadows.wins / sim.results.meadows.total : 0;
const cragsRate = pct(sim.results.crags.wins / Math.max(1, sim.results.crags.total));
console.log(className + ' L1->5: ' + String(sim.huntsL1to5).padStart(8) + ' L1->10: ' + String(sim.huntsL1to10).padStart(8) + ' Crags: ' + cragsRate.padStart(9) + ' Lv' + String(sim.finalLevel).padStart(6) + ' ' + String(sim.finalGold).padStart(6) + 'g');
// Check L1→5 hunts (use the first class's value as representative)
}
// Use the average of all classes for progression metrics
const avgL1to5 = Math.round(classResults.reduce((s, c) => s + c.huntsL1to5, 0) / classResults.length);
const avgL1to10 = Math.round(classResults.reduce((s, c) => s + c.huntsL1to10, 0) / classResults.length);
console.log('\n--- Aggregate Targets ---');
console.log(` L1→5 hunts: ${avgL1to5} (target: 40–70) ${avgL1to5 >= 40 && avgL1to5 <= 70 ? '✓' : '✗'}`);
console.log(` L1→10 hunts: ${avgL1to10} (target: 150–300) ${avgL1to10 >= 150 && avgL1to10 <= 300 ? '✓' : '✗'}`);
if (l1Meadows < targets.l1Meadows[0] || l1Meadows > targets.l1Meadows[1]) { console.log(` L1 Meadows: ${pct(l1Meadows)} ✗`); allMet = false; } else { console.log(` L1 Meadows: ${pct(l1Meadows)} ✓`); }
if (l1Crags < targets.l1Crags[0] || l1Crags > targets.l1Crags[1]) { console.log(` L1 Crags: ${pct(l1Crags)} ✗`); allMet = false; } else { console.log(` L1 Crags: ${pct(l1Crags)} ✓`); }
if (l5Forest < targets.l5Forest[0] || l5Forest > targets.l5Forest[1]) { console.log(` L5 Forest: ${pct(l5Forest)} ✗`); allMet = false; } else { console.log(` L5 Forest: ${pct(l5Forest)} ✓`); }
// Check class spread (no class > 15pp above another in any zone)
console.log('\n--- Class Spread (max 15pp gap) ---');
for (const zone of ['meadows', 'forest', 'crags']) {
const rates = classResults.map(c => zone === 'crags' ? c.results.crags.wins / Math.max(1, c.results.crags.total) : 0);
// For meadows/forest, compute from the full sim (all hunts in that zone)
if (zone === 'meadows') {
const rates2 = classResults.map(c => c.results.meadows.total > 0 ? c.results.meadows.wins / c.results.meadows.total : 0);
const max = Math.max(...rates2);
const min = Math.min(...rates2);
const gap = (max - min) * 100;
console.log(` Meadows: gap ${pct(gap / 100)} ${gap <= 15 ? '✓' : '✗'}`);
if (gap > 15) allMet = false;
}
}
console.log('\n' + '='.repeat(80));
console.log(allMet ? 'ALL TARGETS MET ✓' : 'SOME TARGETS NOT MET ✗ — tune monster stats in a NEW migration');
console.log('='.repeat(80));
}
main();