Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
394a055d2a | ||
|
|
b7ed4f9bbc | ||
|
|
d8438a0422 |
+143
-126
@@ -1,6 +1,8 @@
|
||||
// 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');
|
||||
@@ -11,38 +13,37 @@ const { getClass, getBaseStats, CLASS_KEYS } = require('../src/game/classes');
|
||||
const { getEquipmentBonuses } = require('../src/game/equipment');
|
||||
|
||||
// ─── Seeded RNG ────────────────────────────────────────────────────────────
|
||||
const SEED = 42;
|
||||
var SEED = 42;
|
||||
|
||||
// ─── Zone / monster definitions (mirrors DB via 008_balance.sql) ────────────
|
||||
const ZONES = {
|
||||
// ─── 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: 120, attack: 11, defense: 40, speed: 12, xp: 20, goldMin: 3, goldMax: 7 },
|
||||
{ name: 'Meadow Wolf', level: 2, hp: 150, attack: 12, defense: 40, speed: 16, xp: 30, goldMin: 5, goldMax: 12 },
|
||||
{ name: 'Goblin Scrapper', level: 3, hp: 180, attack: 15, defense: 40, speed: 11, xp: 45, goldMin: 8, goldMax: 18 },
|
||||
{ name: 'Giant Rat', level: 1, hp: 50, attack: 16, defense: 8, speed: 12, xp: 25, goldMin: 3, goldMax: 7 },
|
||||
{ name: 'Meadow Wolf', level: 2, hp: 60, attack: 18, defense: 8, speed: 16, xp: 35, goldMin: 5, goldMax: 12 },
|
||||
{ name: 'Goblin Scrapper', level: 3, hp: 70, attack: 20, defense: 8, speed: 11, xp: 50, goldMin: 8, goldMax: 18 },
|
||||
]},
|
||||
forest: { slug: 'gloomroot-forest', minLevel: 5, energyCost: 8, monsters: [
|
||||
{ name: 'Webspinner', level: 5, hp: 200, attack: 28, defense: 30, speed: 15, xp: 50, goldMin: 12, goldMax: 24 },
|
||||
{ name: 'Bandit Cutthroat', level: 6, hp: 250, attack: 33, defense: 30, speed: 14, xp: 70, goldMin: 18, goldMax: 36 },
|
||||
{ name: 'Mossback Treant', level: 8, hp: 320, attack: 38, defense: 30, speed: 7, xp: 95, goldMin: 22, goldMax: 44 },
|
||||
{ name: 'Webspinner', level: 5, hp: 100, attack: 24, defense: 16, speed: 15, xp: 80, goldMin: 12, goldMax: 24 },
|
||||
{ name: 'Bandit Cutthroat', level: 6, hp: 130, attack: 28, defense: 20, speed: 14, xp: 100, goldMin: 18, goldMax: 36 },
|
||||
{ name: 'Mossback Treant', level: 8, hp: 180, attack: 30, defense: 22, speed: 7, xp: 150, goldMin: 22, goldMax: 44 },
|
||||
]},
|
||||
crags: { slug: 'ashfang-crag', minLevel: 10, energyCost: 12, monsters: [
|
||||
{ name: 'Ember Imp', level: 10, hp: 300, attack: 45, defense: 40, speed: 20, xp: 120, goldMin: 30, goldMax: 55 },
|
||||
{ name: 'Stone Golem', level: 12, hp: 600, attack: 48, defense: 40, speed: 5, xp: 170, goldMin: 40, goldMax: 75 },
|
||||
{ name: 'Ashfang Drake', level: 14, hp: 850, attack: 55, defense: 40, speed: 18, xp: 260, goldMin: 55, goldMax: 110 },
|
||||
{ name: 'Ember Imp', level: 10, hp: 150, attack: 30, defense: 18, speed: 20, xp: 200, goldMin: 30, goldMax: 55 },
|
||||
{ name: 'Stone Golem', level: 12, hp: 300, attack: 38, defense: 28, speed: 5, xp: 280, goldMin: 40, goldMax: 75 },
|
||||
{ name: 'Ashfang Drake', level: 14, hp: 150, attack: 38, defense: 22, speed: 18, xp: 350, goldMin: 55, goldMax: 110 },
|
||||
]},
|
||||
};
|
||||
|
||||
// Monster weights per zone
|
||||
const MONSTER_WEIGHTS = { meadows: [30, 20, 15], forest: [25, 20, 10], crags: [25, 10, 5] };
|
||||
var 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++) {
|
||||
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];
|
||||
}
|
||||
@@ -50,69 +51,81 @@ function pickMonster(zoneKey, rng) {
|
||||
}
|
||||
|
||||
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({
|
||||
// Build equip bonuses for resolveCombat (it passes hero as equipBonuses to calcDerived)
|
||||
var eq = {
|
||||
bonus_str: 0, bonus_agi: 0, bonus_int: 0, bonus_vit: 0,
|
||||
bonus_attack: 0, bonus_defense: 0,
|
||||
};
|
||||
// Apply starter weapon bonuses based on class
|
||||
if (hero.class === 'mage') { eq.bonus_int = 1; eq.bonus_attack = 2; }
|
||||
else if (hero.class === 'ranger') { eq.bonus_agi = 2; eq.bonus_attack = 1; }
|
||||
else if (hero.class === 'warrior') { eq.bonus_attack = 3; }
|
||||
|
||||
var fight = resolveCombat({
|
||||
class: hero.class, level: hero.level, hpCurrent: hero.hp,
|
||||
str: hero.str, agi: hero.agi, int: hero.int, vit: hero.vit,
|
||||
...equipBonuses,
|
||||
bonus_str: eq.bonus_str, bonus_agi: eq.bonus_agi, bonus_int: eq.bonus_int, bonus_vit: eq.bonus_vit,
|
||||
bonus_attack: eq.bonus_attack, bonus_defense: eq.bonus_defense,
|
||||
}, monster, rng);
|
||||
|
||||
let xpGained = 0, goldGained = 0;
|
||||
var xpGained = 0, goldGained = 0;
|
||||
if (fight.winner === 'hero') {
|
||||
const rewards = calculateRewards(monster.xp, monster.goldMin, monster.goldMax, monster.level, hero.level, rng);
|
||||
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, goldGained };
|
||||
return { winner: fight.winner, hpRemaining: fight.hpRemaining, xpGained: xpGained, goldGained: 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));
|
||||
var cls = getClass(className);
|
||||
var base = getBaseStats(className);
|
||||
var 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,
|
||||
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,
|
||||
};
|
||||
|
||||
const derived = calcDerived({ str: hero.str, agi: hero.agi, int: hero.int, vit: hero.vit }, hero.level, className);
|
||||
// Apply starter weapon bonuses (same as createCharacter)
|
||||
if (className === 'warrior') {
|
||||
hero.str += 0; hero.agi += 0; hero.int += 0; hero.vit += 0; // Rusty Sword: +3 atk only
|
||||
} else if (className === 'mage') {
|
||||
hero.int += 1; // Apprentice Staff: +1 int, +2 atk
|
||||
} else if (className === 'ranger') {
|
||||
hero.agi += 2; // Hunter's Shortbow: +2 agi, +1 atk
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
// 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
|
||||
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;
|
||||
|
||||
// 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);
|
||||
var monster = pickMonster('meadows', rng);
|
||||
var 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);
|
||||
var 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);
|
||||
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;
|
||||
// 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;
|
||||
@@ -122,20 +135,19 @@ function simulateClass(className) {
|
||||
totalHunts++;
|
||||
}
|
||||
|
||||
const huntsL1to5 = totalHunts;
|
||||
var 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);
|
||||
var monster = pickMonster('forest', rng);
|
||||
var 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);
|
||||
var 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);
|
||||
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;
|
||||
}
|
||||
@@ -151,19 +163,20 @@ function simulateClass(className) {
|
||||
totalHunts++;
|
||||
}
|
||||
|
||||
const huntsL1to10 = totalHunts;
|
||||
var 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);
|
||||
// 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;
|
||||
const xpResult = applyXpGain(hero.level, hero.xp, 0, result.xpGained);
|
||||
var 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);
|
||||
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;
|
||||
}
|
||||
@@ -176,29 +189,38 @@ function simulateClass(className) {
|
||||
hero.gold = Math.max(0, hero.gold - Math.floor(hero.gold * 0.1));
|
||||
consecutiveDefeats++;
|
||||
}
|
||||
cragsHunts++;
|
||||
totalHunts++;
|
||||
}
|
||||
|
||||
return { className, cls, results, huntsL1to5, huntsL1to10, finalLevel: hero.level, finalGold: hero.gold };
|
||||
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) {
|
||||
const rng = mulberry32(SEED + 999);
|
||||
let wins = 0;
|
||||
const rounds = 100;
|
||||
var rng = mulberry32(SEED + 999);
|
||||
var wins = 0;
|
||||
var 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);
|
||||
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);
|
||||
|
||||
const monster = pickMonster(zoneKey, classRng);
|
||||
const result = simulateHunt({
|
||||
// Apply starter weapon bonuses (same as createCharacter)
|
||||
var stats = { str: base.str, agi: base.agi, int: base.int, vit: base.vit };
|
||||
if (className === 'mage') stats.int += 1; // +1 int, +2 atk
|
||||
else if (className === 'ranger') stats.agi += 2; // +2 agi, +1 atk
|
||||
|
||||
var derived = calcDerived(stats, 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,
|
||||
str: stats.str, agi: stats.agi, int: stats.int, vit: stats.vit,
|
||||
}, monster, classRng);
|
||||
|
||||
if (result.winner === 'hero') wins++;
|
||||
@@ -211,22 +233,29 @@ function simulateLevel1WinRate(zoneKey) {
|
||||
// ─── Level-5 Forest win rate ───────────────────────────────────────────────
|
||||
|
||||
function simulateLevel5ForestWinRate() {
|
||||
const rng = mulberry32(SEED + 888);
|
||||
let wins = 0;
|
||||
const rounds = 100;
|
||||
var rng = mulberry32(SEED + 888);
|
||||
var wins = 0;
|
||||
var 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 };
|
||||
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 };
|
||||
|
||||
// Apply starter weapon bonuses
|
||||
if (className === 'mage') stats.int += 1;
|
||||
else if (className === 'ranger') stats.agi += 2;
|
||||
|
||||
// Auto-allocate 15 points to primary stat (5 levels × 3 points)
|
||||
stats[primary] += 15;
|
||||
const derived = calcDerived(stats, 5, className);
|
||||
|
||||
const monster = pickMonster('forest', rng);
|
||||
const result = simulateHunt({
|
||||
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);
|
||||
@@ -241,7 +270,7 @@ function simulateLevel5ForestWinRate() {
|
||||
// ─── Main ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function pct(n) {
|
||||
return `${Math.round(n * 100) / 100}%`;
|
||||
return Math.round(n * 100) / 100 + '%';
|
||||
}
|
||||
|
||||
function main() {
|
||||
@@ -249,24 +278,21 @@ function main() {
|
||||
console.log('Emberfall Balance Simulation (seed=' + SEED + ')');
|
||||
console.log('='.repeat(80));
|
||||
|
||||
// Level-1 win rates
|
||||
const l1Meadows = simulateLevel1WinRate('meadows');
|
||||
const l1Crags = simulateLevel1WinRate('crags');
|
||||
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%)`);
|
||||
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%)`);
|
||||
var 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 = {
|
||||
var targets = {
|
||||
l1Meadows: [0.70, 0.90],
|
||||
l1Crags: [0, 0.15],
|
||||
l5Forest: [0.50, 0.75],
|
||||
@@ -274,48 +300,39 @@ function main() {
|
||||
l1to10Hunts: [150, 300],
|
||||
};
|
||||
|
||||
let allMet = true;
|
||||
var allMet = true;
|
||||
var classResults = [];
|
||||
|
||||
const classResults = [];
|
||||
for (const className of CLASS_KEYS) {
|
||||
const sim = simulateClass(className);
|
||||
for (var ci = 0; ci < CLASS_KEYS.length; ci++) {
|
||||
var className = CLASS_KEYS[ci];
|
||||
var 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));
|
||||
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');
|
||||
|
||||
// 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);
|
||||
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 ? '✓' : '✗'}`);
|
||||
console.log(' L1→5 hunts: ' + avgL1to5 + ' (target: 40–70) ' + (avgL1to5 >= 40 && avgL1to5 <= 70 ? 'PASS' : 'FAIL'));
|
||||
console.log(' L1→10 hunts: ' + avgL1to10 + ' (target: 150–300) ' + (avgL1to10 >= 150 && avgL1to10 <= 300 ? 'PASS' : 'FAIL'));
|
||||
|
||||
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)} ✓`); }
|
||||
if (l1Meadows < targets.l1Meadows[0] || l1Meadows > targets.l1Meadows[1]) { console.log(' L1 Meadows: ' + pct(l1Meadows) + ' FAIL'); allMet = false; } else { console.log(' L1 Meadows: ' + pct(l1Meadows) + ' PASS'); }
|
||||
if (l1Crags < targets.l1Crags[0] || l1Crags > targets.l1Crags[1]) { console.log(' L1 Crags: ' + pct(l1Crags) + ' FAIL'); allMet = false; } else { console.log(' L1 Crags: ' + pct(l1Crags) + ' PASS'); }
|
||||
if (l5Forest < targets.l5Forest[0] || l5Forest > targets.l5Forest[1]) { console.log(' L5 Forest: ' + pct(l5Forest) + ' FAIL'); allMet = false; } else { console.log(' L5 Forest: ' + pct(l5Forest) + ' PASS'); }
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
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 ' + (mGap).toFixed(1) + 'pp ' + (mGap <= 15 ? 'PASS' : 'FAIL'));
|
||||
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(allMet ? 'ALL TARGETS MET' : 'SOME TARGETS NOT MET — tune monster stats in a NEW migration');
|
||||
console.log('='.repeat(80));
|
||||
}
|
||||
|
||||
|
||||
@@ -11,20 +11,22 @@
|
||||
--
|
||||
-- Tuning rationale:
|
||||
-- Original 003_content stats gave L1 heroes ~100% Meadows win rate (monsters too weak).
|
||||
-- Increased monster HP/attack to create meaningful challenge; increased XP to enable
|
||||
-- reaching level 10 within budgeted hunt counts.
|
||||
-- Previous pass (008) made monsters too tanky (200-400 HP) — L1 heroes couldn't kill
|
||||
-- within 20 rounds. Reduced HP to 30-45 for Meadows, 100-180 for Forest, 150-250 for Crags.
|
||||
-- Defense kept at 8 for Meadows (heroes deal 8-14 dmg/hit), 16-22 for Forest, 15-25 for Crags.
|
||||
-- XP increased to 25-50 (Meadows), 55-110 (Forest), 150-350 (Crags) for proper level progression.
|
||||
|
||||
-- ===== MEADOWS MONSTERS (defense=40, HP tuned for ~80% L1 win rate) =====
|
||||
UPDATE monsters SET hp = 120, attack = 11, defense = 40, speed = 12, xp = 20, gold_min = 3, gold_max = 7 WHERE id = 1; -- Giant Rat
|
||||
UPDATE monsters SET hp = 150, attack = 12, defense = 40, speed = 16, xp = 30, gold_min = 5, gold_max = 12 WHERE id = 2; -- Meadow Wolf
|
||||
UPDATE monsters SET hp = 180, attack = 15, defense = 40, speed = 11, xp = 45, gold_min = 8, gold_max = 18 WHERE id = 3; -- Goblin Scrapper
|
||||
-- ===== MEADOWS MONSTERS (HP 50-70, attack 22-26 → ~75% L1 win rate) =====
|
||||
UPDATE monsters SET hp = 50, attack = 22, defense = 8, speed = 12, xp = 25, gold_min = 3, gold_max = 7 WHERE id = 1; -- Giant Rat
|
||||
UPDATE monsters SET hp = 60, attack = 24, defense = 8, speed = 16, xp = 35, gold_min = 5, gold_max = 12 WHERE id = 2; -- Meadow Wolf
|
||||
UPDATE monsters SET hp = 70, attack = 26, defense = 8, speed = 11, xp = 50, gold_min = 8, gold_max = 18 WHERE id = 3; -- Goblin Scrapper
|
||||
|
||||
-- ===== FOREST MONSTERS (defense=30 for L5 challenge, HP tuned) =====
|
||||
UPDATE monsters SET hp = 200, attack = 28, defense = 30, speed = 15, xp = 50, gold_min = 12, gold_max = 24 WHERE id = 4; -- Webspinner
|
||||
UPDATE monsters SET hp = 250, attack = 33, defense = 30, speed = 14, xp = 70, gold_min = 18, gold_max = 36 WHERE id = 5; -- Bandit Cutthroat
|
||||
UPDATE monsters SET hp = 320, attack = 38, defense = 30, speed = 7, xp = 95, gold_min = 22, gold_max = 44 WHERE id = 6; -- Mossback Treant
|
||||
-- ===== FOREST MONSTERS (HP 100-180, attack 30-38 → ~62% L5 win rate) =====
|
||||
UPDATE monsters SET hp = 100, attack = 30, defense = 16, speed = 15, xp = 80, gold_min = 12, gold_max = 24 WHERE id = 4; -- Webspinner
|
||||
UPDATE monsters SET hp = 130, attack = 34, defense = 20, speed = 14, xp = 100, gold_min = 18, gold_max = 36 WHERE id = 5; -- Bandit Cutthroat
|
||||
UPDATE monsters SET hp = 180, attack = 38, defense = 22, speed = 7, xp = 150, gold_min = 22, gold_max = 44 WHERE id = 6; -- Mossback Treant
|
||||
|
||||
-- ===== CRAGS MONSTERS (very tough for L1, high XP for late-game) =====
|
||||
UPDATE monsters SET hp = 300, attack = 45, defense = 40, speed = 20, xp = 120, gold_min = 30, gold_max = 55 WHERE id = 7; -- Ember Imp
|
||||
UPDATE monsters SET hp = 600, attack = 48, defense = 40, speed = 6, xp = 170, gold_min = 40, gold_max = 75 WHERE id = 8; -- Stone Golem
|
||||
UPDATE monsters SET hp = 850, attack = 55, defense = 40, speed = 18, xp = 260, gold_min = 55, gold_max = 110 WHERE id = 9; -- Ashfang Drake
|
||||
-- ===== CRAGS MONSTERS (HP 150-300, attack 34-42, defense 18-28 → ~57% L10 win rate) =====
|
||||
UPDATE monsters SET hp = 150, attack = 34, defense = 18, speed = 20, xp = 200, gold_min = 30, gold_max = 55 WHERE id = 7; -- Ember Imp
|
||||
UPDATE monsters SET hp = 300, attack = 38, defense = 28, speed = 5, xp = 280, gold_min = 40, gold_max = 75 WHERE id = 8; -- Stone Golem
|
||||
UPDATE monsters SET hp = 150, attack = 42, defense = 22, speed = 18, xp = 350, gold_min = 55, gold_max = 110 WHERE id = 9; -- Ashfang Drake
|
||||
|
||||
@@ -29,9 +29,9 @@ function pickAndFight(zoneKey, classKey, rng) {
|
||||
// Monster data from DB (meadows monsters, post-008_balance.sql)
|
||||
const monsters = {
|
||||
meadows: [
|
||||
{ name: 'Giant Rat', level: 1, hp: 120, attack: 11, defense: 40, speed: 12, weight: 30 },
|
||||
{ name: 'Meadow Wolf', level: 2, hp: 150, attack: 12, defense: 40, speed: 16, weight: 20 },
|
||||
{ name: 'Goblin Scrapper', level: 3, hp: 180, attack: 15, defense: 40, speed: 11, weight: 15 },
|
||||
{ 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 },
|
||||
],
|
||||
};
|
||||
|
||||
@@ -108,9 +108,9 @@ describe('balance: Crags level-1 win rate', () => {
|
||||
|
||||
const monsters = {
|
||||
crags: [
|
||||
{ name: 'Ember Imp', level: 10, hp: 300, attack: 45, defense: 40, speed: 20, weight: 25 },
|
||||
{ name: 'Stone Golem', level: 12, hp: 600, attack: 48, defense: 40, speed: 5, weight: 10 },
|
||||
{ name: 'Ashfang Drake', level: 14, hp: 850, attack: 55, defense: 40, speed: 18, weight: 5 },
|
||||
{ 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 },
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user