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
+2 -2
View File
@@ -8,9 +8,9 @@
## Current Focus ## Current Focus
- **Phase:** 10 - **Phase:** 10
- **Step:** 10.18 — Balance pass via NEW migration - **Step:** 10.19 — Repo hygiene
- **Blocked?** No - **Blocked?** No
- **Last verified working command:** `npm test` — 284/284 passing (97 unit + 187 integration incl. 11 step-10.17 tests), 2026-10-23 step 10.17 - **Last verified working command:** `npm test` — 287/287 passing (97 unit + 190 integration incl. 3 step-10.18 tests), 2026-10-23 step 10.18
## Project Health ## Project Health
| Item | State | | Item | State |
+322
View File
@@ -0,0 +1,322 @@
// 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();
+25
View File
@@ -0,0 +1,25 @@
-- 008_balance.sql — Balance pass: tune monster stats and XP to meet design targets.
-- 10.18: Never edit 003_content.sql; create a NEW migration.
--
-- Targets:
-- Level-1 vs Meadows: 70–90% wins
-- Level-1 vs Crags: ≤15% wins
-- Level-5 vs Forest: 50–75% wins
-- Level-10 vs Crags: 50–75% wins
-- Hunts L1→5: 40–70
-- Hunts L1→10: 150–300
-- ===== MEADOWS MONSTERS (increased HP/attack, increased XP to slow leveling) =====
UPDATE monsters SET hp = 55, attack = 15, defense = 5, speed = 10, xp = 28, gold_min = 4, gold_max = 9 WHERE id = 1; -- Giant Rat
UPDATE monsters SET hp = 85, attack = 22, defense = 9, speed = 14, xp = 40, gold_min = 6, gold_max = 14 WHERE id = 2; -- Meadow Wolf
UPDATE monsters SET hp = 130, attack = 28, defense = 12, speed = 10, xp = 60, gold_min = 10, gold_max = 22 WHERE id = 3; -- Goblin Scrapper
-- ===== FOREST MONSTERS (high attack/defense to challenge L5 heroes, moderate HP) =====
UPDATE monsters SET hp = 110, attack = 42, defense = 26, speed = 13, xp = 56, gold_min = 14, gold_max = 28 WHERE id = 4; -- Webspinner
UPDATE monsters SET hp = 155, attack = 48, defense = 30, speed = 12, xp = 80, gold_min = 20, gold_max = 40 WHERE id = 5; -- Bandit Cutthroat
UPDATE monsters SET hp = 240, attack = 54, defense = 38, speed = 6, xp = 108, gold_min = 26, gold_max = 52 WHERE id = 6; -- Mossback Treant
-- ===== CRAGS MONSTERS (very tough, high XP) =====
UPDATE monsters SET hp = 200, attack = 44, defense = 22, speed = 18, xp = 170, gold_min = 32, gold_max = 65 WHERE id = 7; -- Ember Imp
UPDATE monsters SET hp = 420, attack = 50, defense = 52, speed = 5, xp = 260, gold_min = 48, gold_max = 90 WHERE id = 8; -- Stone Golem
UPDATE monsters SET hp = 480, attack = 62, defense = 38, speed = 16, xp = 400, gold_min = 72, gold_max = 145 WHERE id = 9; -- Ashfang Drake
+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%)`
);
});
});