diff --git a/tools/balance/sim-balance.mjs b/tools/balance/sim-balance.mjs index afb81d2..69a2573 100644 --- a/tools/balance/sim-balance.mjs +++ b/tools/balance/sim-balance.mjs @@ -43,36 +43,36 @@ export function applyDamage(hp, block, amount) { export function loadData() { const cardsData = JSON.parse(readFileSync('data/cards.json', 'utf8')); const enemiesData = JSON.parse(readFileSync('data/enemies.json', 'utf8')); - const enemy = enemiesData.enemies[enemiesData.activeEnemy]; - if (!enemy) throw new Error(`activeEnemy 없음: ${enemiesData.activeEnemy}`); - return { cards: cardsData.cards, starterDeck: cardsData.starterDeck, enemy }; + const ids = enemiesData.simEncounter || [enemiesData.activeEnemy]; + const monsters = ids.map((id) => { + const e = enemiesData.enemies[id]; + if (!e) throw new Error(`simEncounter 적 없음: ${id}`); + return { name: e.name, maxHp: e.maxHp, intents: e.intents }; + }); + return { cards: cardsData.cards, starterDeck: cardsData.starterDeck, monsters }; } -// 손패에서 다음에 낼 카드의 인덱스 반환(-1=턴 종료). hand=카드 id 배열. -export function chooseAction(hand, cards, energy, enemyHp, enemyBlock, enemyIntent) { +// 손패에서 낼 카드 인덱스(-1=종료). 공격 우선, 없으면 스킬. +export function chooseAction(hand, cards, energy) { const entries = hand.map((id, i) => ({ id, i })).filter((x) => cards[x.id].cost <= energy); const attacks = entries.filter((x) => cards[x.id].kind === 'Attack'); const skills = entries.filter((x) => cards[x.id].kind === 'Skill'); const dmgEff = (x) => (cards[x.id].damage || 0) / cards[x.id].cost; const blkEff = (x) => (cards[x.id].block || 0) / cards[x.id].cost; const bestBy = (list, fn) => list.slice().sort((a, b) => fn(b) - fn(a))[0]; - - // 1) 치사: 에너지 한도 내 효율순 공격 데미지 합 >= 적 유효 hp? - let e = energy, lethalDmg = 0; - for (const x of attacks.slice().sort((a, b) => dmgEff(b) - dmgEff(a))) { - if (cards[x.id].cost <= e) { e -= cards[x.id].cost; lethalDmg += cards[x.id].damage || 0; } - } - if (attacks.length && lethalDmg >= enemyHp + enemyBlock) return bestBy(attacks, dmgEff).i; - - // 2) 적 공격 의도면 방어 우선 - if (enemyIntent && enemyIntent.kind === 'Attack' && skills.length) return bestBy(skills, blkEff).i; - - // 3) 공격 우선, 없으면 스킬, 없으면 종료 if (attacks.length) return bestBy(attacks, dmgEff).i; if (skills.length) return bestBy(skills, blkEff).i; return -1; } +// 공격 타겟 선택: 이번 타격으로 처치 가능한 최소 유효체력, 없으면 유효체력 최소. +export function chooseTarget(aliveMonsters, plannedDamage) { + const eff = (m) => m.hp + m.block; + const killable = aliveMonsters.filter((m) => eff(m) <= plannedDamage); + const pool = killable.length ? killable : aliveMonsters; + return pool.slice().sort((a, b) => eff(a) - eff(b))[0]; +} + function bump(s, cost, dmg, blk) { s = s || { plays: 0, energy: 0, damage: 0, block: 0 }; s.plays++; s.energy += cost; s.damage += dmg; s.block += blk; @@ -82,12 +82,15 @@ function bump(s, cost, dmg, blk) { // 단일 전투 시뮬. stats(선택): {cardId: {plays,energy,damage,block}} 누적. // 반환: { win, turns, playerHpRemaining, draw? } export function simulateCombat(data, rng, stats) { - const { cards, starterDeck, enemy } = data; + const { cards, starterDeck, monsters } = data; let drawPile = shuffle(starterDeck, rng); let discard = []; let hand = []; let pHp = PLAYER_HP, pBlock = 0; - let eHp = enemy.maxHp, eBlock = 0, intentIdx = 0; + const mob = monsters.map((m) => ({ + name: m.name, hp: m.maxHp, maxHp: m.maxHp, block: 0, + intents: m.intents, intentIdx: 0, alive: true, + })); let turns = 0; function draw(n) { @@ -97,33 +100,43 @@ export function simulateCombat(data, rng, stats) { hand.push(drawPile.pop()); } } + const aliveList = () => mob.filter((m) => m.alive); while (turns < MAX_TURNS) { turns++; let energy = ENERGY; pBlock = 0; hand = []; draw(HAND_SIZE); while (true) { - const intent = enemy.intents[intentIdx]; - const idx = chooseAction(hand, cards, energy, eHp, eBlock, intent); + const alive = aliveList(); + if (alive.length === 0) break; + const idx = chooseAction(hand, cards, energy); if (idx < 0) break; const id = hand[idx], c = cards[id]; energy -= c.cost; if (c.kind === 'Attack') { - const r = applyDamage(eHp, eBlock, c.damage || 0); eHp = r.hp; eBlock = r.block; + const target = chooseTarget(alive, c.damage || 0); + const r = applyDamage(target.hp, target.block, c.damage || 0); + target.hp = r.hp; target.block = r.block; + if (target.hp <= 0) target.alive = false; if (stats) stats[id] = bump(stats[id], c.cost, c.damage || 0, 0); } else { pBlock += c.block || 0; if (stats) stats[id] = bump(stats[id], c.cost, 0, c.block || 0); } hand.splice(idx, 1); discard.push(id); - if (eHp <= 0) return { win: true, turns, playerHpRemaining: pHp }; + if (aliveList().length === 0) return { win: true, turns, playerHpRemaining: pHp }; } discard.push(...hand); hand = []; - eBlock = 0; - const intent = enemy.intents[intentIdx]; - if (intent.kind === 'Attack') { const r = applyDamage(pHp, pBlock, intent.value); pHp = r.hp; pBlock = r.block; } - else if (intent.kind === 'Defend') { eBlock += intent.value; } - intentIdx = (intentIdx + 1) % enemy.intents.length; - if (pHp <= 0) return { win: false, turns, playerHpRemaining: 0 }; + for (const m of mob) { + if (!m.alive) continue; + m.block = 0; + const it = m.intents[m.intentIdx]; + if (it) { + if (it.kind === 'Attack') { const r = applyDamage(pHp, pBlock, it.value); pHp = r.hp; pBlock = r.block; } + else if (it.kind === 'Defend') { m.block += it.value; } + } + m.intentIdx = (m.intentIdx + 1) % m.intents.length; + if (pHp <= 0) return { win: false, turns, playerHpRemaining: 0 }; + } } return { win: false, turns, playerHpRemaining: pHp, draw: true }; } @@ -152,13 +165,13 @@ export function runBatch(N, seed) { winRate: wins / N, avgTurns: mean(turnsArr), medianTurns: median(turnsArr), avgHpOnWin: mean(hpArr), - cardStats, cards: data.cards, enemy: data.enemy, seed, + cardStats, cards: data.cards, monsters: data.monsters, seed, }; } export function formatReport(r) { const L = []; - L.push(`=== 밸런스 시뮬레이션 (적: ${r.enemy.name} HP ${r.enemy.maxHp}) ===`); + L.push(`=== 밸런스 시뮬레이션 (인카운터: ${r.monsters.map((m) => `${m.name}(${m.maxHp})`).join(', ')}) ===`); L.push(`시뮬 ${r.N}회 (seed=${r.seed})`); L.push(`승률: ${(r.winRate * 100).toFixed(1)}% (승 ${r.wins} / 패 ${r.losses}${r.draws ? ` / 무 ${r.draws}` : ''})`); L.push(`평균 턴: ${r.avgTurns.toFixed(2)} 중앙값 턴: ${r.medianTurns}`); diff --git a/tools/balance/sim-balance.test.mjs b/tools/balance/sim-balance.test.mjs index 303e63f..ed6f220 100644 --- a/tools/balance/sim-balance.test.mjs +++ b/tools/balance/sim-balance.test.mjs @@ -1,7 +1,7 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { - mulberry32, applyDamage, chooseAction, simulateCombat, runBatch, + mulberry32, applyDamage, chooseAction, chooseTarget, simulateCombat, runBatch, } from './sim-balance.mjs'; test('applyDamage: 방어 우선 차감 후 hp', () => { @@ -23,34 +23,46 @@ const CARDS = { Bash: { name: '강타', cost: 2, kind: 'Attack', damage: 10 }, }; -test('chooseAction: 치사 가능하면 공격 선택', () => { - const idx = chooseAction(['Strike', 'Defend'], CARDS, 3, 5, 0, { kind: 'Attack', value: 10 }); +test('chooseAction: 공격을 스킬보다 먼저 선택', () => { + const idx = chooseAction(['Defend', 'Strike'], CARDS, 3); + assert.equal(idx, 1); // Strike +}); + +test('chooseAction: 공격 없으면 스킬 선택', () => { + const idx = chooseAction(['Defend'], CARDS, 3); assert.equal(idx, 0); }); -test('chooseAction: 치사 불가 + 적 공격 의도면 방어 선택', () => { - const idx = chooseAction(['Strike', 'Defend'], CARDS, 3, 40, 0, { kind: 'Attack', value: 10 }); - assert.equal(idx, 1); -}); - -test('chooseAction: 적 방어 의도면 공격 우선', () => { - const idx = chooseAction(['Defend', 'Strike'], CARDS, 3, 40, 0, { kind: 'Defend', value: 8 }); - assert.equal(idx, 1); -}); - test('chooseAction: 사용 가능 카드 없으면 -1', () => { - const idx = chooseAction(['Bash'], CARDS, 1, 40, 0, { kind: 'Attack', value: 10 }); + const idx = chooseAction(['Bash'], CARDS, 1); assert.equal(idx, -1); }); +test('chooseTarget: 이번 타격으로 처치 가능한 최소 체력 우선', () => { + const mob = [ + { hp: 20, block: 0, alive: true }, + { hp: 5, block: 0, alive: true }, + { hp: 8, block: 0, alive: true }, + ]; + assert.equal(chooseTarget(mob, 6), mob[1]); // 5<=6 처치 가능, 최소 +}); + +test('chooseTarget: 처치 불가면 유효체력 최소 선택', () => { + const mob = [ + { hp: 20, block: 0, alive: true }, + { hp: 12, block: 5, alive: true }, + { hp: 14, block: 0, alive: true }, + ]; + assert.equal(chooseTarget(mob, 6), mob[2]); // 유효 14 < 17 < 20 +}); + const DATA = { cards: CARDS, starterDeck: ['Strike', 'Strike', 'Strike', 'Strike', 'Strike', 'Defend', 'Defend', 'Defend', 'Defend', 'Bash'], - enemy: { - name: '슬라임', maxHp: 45, intents: [ - { kind: 'Attack', value: 10 }, { kind: 'Attack', value: 6 }, { kind: 'Defend', value: 8 }, - ], - }, + monsters: [ + { name: '주황버섯', maxHp: 16, intents: [{ kind: 'Attack', value: 5 }, { kind: 'Defend', value: 4 }] }, + { name: '파란버섯', maxHp: 12, intents: [{ kind: 'Attack', value: 8 }] }, + ], }; test('simulateCombat: 결정적 결과(동일 시드)', () => { @@ -61,12 +73,23 @@ test('simulateCombat: 결정적 결과(동일 시드)', () => { assert.ok(r1.turns >= 1); }); -test('simulateCombat: 약한 적이면 대체로 승리', () => { +test('simulateCombat: 모든 몬스터 처치 시 승리', () => { let wins = 0; for (let i = 0; i < 50; i++) if (simulateCombat(DATA, mulberry32(i + 1)).win) wins++; assert.ok(wins >= 40, `예상 승리 다수, 실제 ${wins}/50`); }); +test('simulateCombat: 강한 다수 적이면 패배 가능', () => { + const hard = { + cards: CARDS, + starterDeck: DATA.starterDeck, + monsters: Array.from({ length: 4 }, () => ({ name: '슬라임', maxHp: 60, intents: [{ kind: 'Attack', value: 12 }] })), + }; + let losses = 0; + for (let i = 0; i < 30; i++) if (!simulateCombat(hard, mulberry32(i + 1)).win) losses++; + assert.ok(losses >= 1, `강한 적엔 패배가 나와야 함, 실제 패 ${losses}/30`); +}); + test('runBatch: 집계 필드·승률 범위', () => { const r = runBatch(100, 1); assert.equal(r.N, 100);