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

308 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
//
// 10.18: Monster stats must match src/db/migrations/008_balance.sql
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 ────────────────────────────────────────────────────────────
var SEED = 42;
// ─── Zone / monster definitions (MUST match 008_balance.sql) ────────────────
var ZONES = {
meadows: { slug: 'whispering-meadows', minLevel: 1, energyCost: 5, monsters: [
{ name: 'Giant Rat', level: 1, hp: 200, attack: 6, defense: 8, speed: 12, xp: 50, goldMin: 3, goldMax: 7 },
{ name: 'Meadow Wolf', level: 2, hp: 250, attack: 8, defense: 8, speed: 16, xp: 65, goldMin: 5, goldMax: 12 },
{ name: 'Goblin Scrapper', level: 3, hp: 300, attack: 10, defense: 8, speed: 11, xp: 85, goldMin: 8, goldMax: 18 },
]},
forest: { slug: 'gloomroot-forest', minLevel: 5, energyCost: 8, monsters: [
{ name: 'Webspinner', level: 5, hp: 200, attack: 25, defense: 30, speed: 15, xp: 90, goldMin: 12, goldMax: 24 },
{ name: 'Bandit Cutthroat', level: 6, hp: 250, attack: 30, defense: 30, speed: 14, xp: 120, goldMin: 18, goldMax: 36 },
{ name: 'Mossback Treant', level: 8, hp: 320, attack: 35, defense: 30, speed: 7, xp: 160, goldMin: 22, goldMax: 44 },
]},
crags: { slug: 'ashfang-crag', minLevel: 10, energyCost: 12, monsters: [
{ name: 'Ember Imp', level: 10, hp: 150, attack: 25, defense: 12, speed: 20, xp: 200, goldMin: 30, goldMax: 55 },
{ name: 'Stone Golem', level: 12, hp: 300, attack: 30, defense: 12, speed: 5, xp: 280, goldMin: 40, goldMax: 75 },
{ name: 'Ashfang Drake', level: 14, hp: 400, attack: 28, defense: 3, speed: 18, xp: 350, goldMin: 55, goldMax: 110 },
]},
};
var MONSTER_WEIGHTS = { meadows: [30, 20, 15], forest: [25, 20, 10], crags: [25, 10, 5] };
// ─── Simulation helpers ────────────────────────────────────────────────────
function pickMonster(zoneKey, rng) {
var zone = ZONES[zoneKey];
var weights = MONSTER_WEIGHTS[zoneKey];
var total = weights.reduce(function(a, b) { return a + b; }, 0);
var r = rng() * total;
for (var 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) {
var equipBonuses = { bonus_str: 0, bonus_agi: 0, bonus_int: 0, bonus_vit: 0, bonus_attack: 0, bonus_defense: 0 };
var fight = resolveCombat({
class: hero.class, level: hero.level, hpCurrent: hero.hp,
str: hero.str, agi: hero.agi, int: hero.int, vit: hero.vit,
bonus_str: 0, bonus_agi: 0, bonus_int: 0, bonus_vit: 0, bonus_attack: 0, bonus_defense: 0,
}, monster, rng);
var xpGained = 0, goldGained = 0;
if (fight.winner === 'hero') {
var 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: xpGained, goldGained: goldGained };
}
// ─── Run simulation for one class ──────────────────────────────────────────
function simulateClass(className) {
var cls = getClass(className);
var base = getBaseStats(className);
var rng = mulberry32(SEED + CLASS_KEYS.indexOf(className));
var hero = {
class: className, level: 1, xp: 0, totalXpForLevel: 0, hp: 0, maxHp: 0,
str: base.str, agi: base.agi, int: base.int, vit: base.vit,
gold: 50,
};
var 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;
var results = { meadows: { wins: 0, total: 0 }, forest: { wins: 0, total: 0 }, crags: { wins: 0, total: 0 } };
var totalHunts = 0;
var consecutiveDefeats = 0;
var MAX_DEFEATS = 200; // increased from 50 — low-vit classes need more attempts
// Phase 1: Level 1 → 5 in Meadows
while (hero.level < 5 && consecutiveDefeats < MAX_DEFEATS) {
var monster = pickMonster('meadows', rng);
var result = simulateHunt(hero, monster, rng);
results.meadows.total++;
if (result.winner === 'hero') {
results.meadows.wins++;
consecutiveDefeats = 0;
var xpResult = applyXpGain(hero.level, hero.xp, 0, result.xpGained);
if (xpResult.level > hero.level) {
var 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++;
}
var huntsL1to5 = totalHunts;
// Phase 2: Level 5 → 10 in Forest
while (hero.level < 10 && consecutiveDefeats < MAX_DEFEATS) {
var monster = pickMonster('forest', rng);
var result = simulateHunt(hero, monster, rng);
results.forest.total++;
if (result.winner === 'hero') {
results.forest.wins++;
consecutiveDefeats = 0;
var xpResult = applyXpGain(hero.level, hero.xp, 0, result.xpGained);
if (xpResult.level > hero.level) {
var 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++;
}
var huntsL1to10 = totalHunts;
// Phase 3: Level 10+ in Crags (run until max level or 300 hunts)
var cragsHunts = 0;
while (cragsHunts < 300) {
var monster = pickMonster('crags', rng);
var result = simulateHunt(hero, monster, rng);
results.crags.total++;
if (result.winner === 'hero') {
results.crags.wins++;
consecutiveDefeats = 0;
var xpResult = applyXpGain(hero.level, hero.xp, 0, result.xpGained);
if (xpResult.level > hero.level) {
var 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++;
}
cragsHunts++;
totalHunts++;
}
return { className: className, cls: cls, results: results, huntsL1to5: huntsL1to5, huntsL1to10: huntsL1to10, finalLevel: hero.level, finalGold: hero.gold };
}
// ─── Level-1 zone win rates ────────────────────────────────────────────────
function simulateLevel1WinRate(zoneKey) {
var rng = mulberry32(SEED + 999);
var wins = 0;
var rounds = 100;
for (var r = 0; r < rounds; r++) {
var classRng = mulberry32(SEED + r * 100);
for (var ci = 0; ci < CLASS_KEYS.length; ci++) {
var className = CLASS_KEYS[ci];
var cls = getClass(className);
var base = getBaseStats(className);
var derived = calcDerived({ str: base.str, agi: base.agi, int: base.int, vit: base.vit }, 1, className);
var monster = pickMonster(zoneKey, classRng);
var 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() {
var rng = mulberry32(SEED + 888);
var wins = 0;
var rounds = 100;
for (var r = 0; r < rounds; r++) {
for (var ci = 0; ci < CLASS_KEYS.length; ci++) {
var className = CLASS_KEYS[ci];
var cls = getClass(className);
var base = getBaseStats(className);
var primary = cls.primary;
var stats = { str: base.str, agi: base.agi, int: base.int, vit: base.vit };
stats[primary] += 15;
var derived = calcDerived(stats, 5, className);
var monster = pickMonster('forest', rng);
var 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));
var l1Meadows = simulateLevel1WinRate('meadows');
var 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%)');
var l5Forest = simulateLevel5ForestWinRate();
console.log(' Forest L5: ' + pct(l5Forest) + ' (target: 50–75%)');
console.log('\n--- Per-Class Progression ---');
console.log('Class Hunts L1→5 Hunts L1→10 Crags Win% Final Lv Gold');
console.log('-'.repeat(70));
var targets = {
l1Meadows: [0.70, 0.90],
l1Crags: [0, 0.15],
l5Forest: [0.50, 0.75],
l1to5Hunts: [40, 70],
l1to10Hunts: [150, 300],
};
var allMet = true;
var classResults = [];
for (var ci = 0; ci < CLASS_KEYS.length; ci++) {
var className = CLASS_KEYS[ci];
var sim = simulateClass(className);
classResults.push(sim);
var meadowsRate = sim.results.meadows.total > 0 ? sim.results.meadows.wins / sim.results.meadows.total : 0;
var 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');
}
var avgL1to5 = Math.round(classResults.reduce(function(s, c) { return s + c.huntsL1to5; }, 0) / classResults.length);
var avgL1to10 = Math.round(classResults.reduce(function(s, c) { return 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) + ' ✓'); }
console.log('\n--- Class Spread (max 15pp gap) ---');
var meadowsRates = classResults.map(function(c) { return c.results.meadows.total > 0 ? c.results.meadows.wins / c.results.meadows.total : 0; });
var mMax = Math.max.apply(null, meadowsRates);
var mMin = Math.min.apply(null, meadowsRates);
var mGap = (mMax - mMin) * 100;
console.log(' Meadows: gap ' + pct(mGap / 100) + ' ' + (mGap <= 15 ? '✓' : '✗'));
if (mGap > 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();