From f42e03a006a5e9e78584c611e248749d1fc43205 Mon Sep 17 00:00:00 2001 From: gahusb Date: Tue, 9 Jun 2026 01:39:21 +0900 Subject: [PATCH] =?UTF-8?q?feat(F):=20AI=20=EC=A0=84=ED=88=AC=20=EB=B0=B8?= =?UTF-8?q?=EB=9F=B0=EC=8A=A4=20=EC=8B=9C=EB=AE=AC=EB=A0=88=EC=9D=B4?= =?UTF-8?q?=ED=84=B0=20tools/sim-balance.mjs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit data/*.json을 입력으로 전투를 몬테카를로 N회 시뮬 → 승률·턴·OP 카드 리포트. - 전투 엔진 JS 재현(gen-slaydeck Lua 미러): 에너지3·드로우5·방어우선차감·결정적 의도·승패·턴상한 - 플레이어 휴리스틱 정책: 치사 우선 → 적 공격의도 시 방어 → 공격 우선, 에너지 효율순 - 시드 PRNG(mulberry32)로 재현성, OP 탐지(kind별 효율 중앙값 1.5배↑ 플래그) - CLI: node tools/sim-balance.mjs [N] [--seed S] - node:test 단위 테스트 10종(applyDamage·정책·엔진·집계) - 검증: 현 데이터 승률 100%(슬라임 약함 신호), 적 HP 45→300 시 평균턴 5.6→39.6(데이터 반영) - 전투 규칙은 Lua와 중복이라 동기화 주석 명시 Co-Authored-By: Claude Opus 4.8 (1M context) --- tools/sim-balance.mjs | 199 +++++++++++++++++++++++++++++++++++++ tools/sim-balance.test.mjs | 80 +++++++++++++++ 2 files changed, 279 insertions(+) create mode 100644 tools/sim-balance.mjs create mode 100644 tools/sim-balance.test.mjs diff --git a/tools/sim-balance.mjs b/tools/sim-balance.mjs new file mode 100644 index 0000000..6dba43f --- /dev/null +++ b/tools/sim-balance.mjs @@ -0,0 +1,199 @@ +// AI 전투 밸런스 시뮬레이터 — 오프라인 몬테카를로. +// ⚠️ 전투 규칙은 tools/gen-slaydeck.mjs 의 Lua(SlayDeckController)와 동기화 유지할 것. +// (데이터는 data/*.json 공유, 규칙 로직은 JS로 중복 재현) +import { readFileSync } from 'node:fs'; + +export const PLAYER_HP = 80; // 데이터 미포함 placeholder (codeblock과 일치) +export const ENERGY = 3; +export const HAND_SIZE = 5; +export const MAX_TURNS = 100; + +export function mulberry32(seed) { + let a = seed >>> 0; + return function () { + a |= 0; a = (a + 0x6D2B79F5) | 0; + let t = Math.imul(a ^ (a >>> 15), 1 | a); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +export function shuffle(arr, rng) { + const a = arr.slice(); + for (let i = a.length - 1; i > 0; i--) { + const j = Math.floor(rng() * (i + 1)); + [a[i], a[j]] = [a[j], a[i]]; + } + return a; +} + +// 방어 우선 차감 후 hp 적용 → { hp, block } +export function applyDamage(hp, block, amount) { + let dmg = amount; + if (block > 0) { + const absorbed = Math.min(block, dmg); + block -= absorbed; + dmg -= absorbed; + } + hp -= dmg; + if (hp < 0) hp = 0; + return { hp, block }; +} + +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 }; +} + +// 손패에서 다음에 낼 카드의 인덱스 반환(-1=턴 종료). hand=카드 id 배열. +export function chooseAction(hand, cards, energy, enemyHp, enemyBlock, enemyIntent) { + 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; +} + +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; + return s; +} + +// 단일 전투 시뮬. stats(선택): {cardId: {plays,energy,damage,block}} 누적. +// 반환: { win, turns, playerHpRemaining, draw? } +export function simulateCombat(data, rng, stats) { + const { cards, starterDeck, enemy } = data; + let drawPile = shuffle(starterDeck, rng); + let discard = []; + let hand = []; + let pHp = PLAYER_HP, pBlock = 0; + let eHp = enemy.maxHp, eBlock = 0, intentIdx = 0; + let turns = 0; + + function draw(n) { + for (let k = 0; k < n; k++) { + if (drawPile.length === 0) { drawPile = shuffle(discard, rng); discard = []; } + if (drawPile.length === 0) break; + hand.push(drawPile.pop()); + } + } + + 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); + 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; + 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 }; + } + 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 }; + } + return { win: false, turns, playerHpRemaining: pHp, draw: true }; +} + +function mean(a) { return a.length ? a.reduce((s, x) => s + x, 0) / a.length : 0; } +function median(a) { + if (!a.length) return 0; + const s = a.slice().sort((x, y) => x - y), m = Math.floor(s.length / 2); + return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2; +} + +export function runBatch(N, seed) { + const data = loadData(); + const rng = mulberry32(seed); + const cardStats = {}; + let wins = 0, draws = 0; + const turnsArr = [], hpArr = []; + for (let i = 0; i < N; i++) { + const r = simulateCombat(data, rng, cardStats); + if (r.draw) draws++; + if (r.win) { wins++; hpArr.push(r.playerHpRemaining); } + turnsArr.push(r.turns); + } + return { + N, wins, draws, losses: N - wins - draws, + winRate: wins / N, + avgTurns: mean(turnsArr), medianTurns: median(turnsArr), + avgHpOnWin: mean(hpArr), + cardStats, cards: data.cards, enemy: data.enemy, seed, + }; +} + +export function formatReport(r) { + const L = []; + L.push(`=== 밸런스 시뮬레이션 (적: ${r.enemy.name} HP ${r.enemy.maxHp}) ===`); + 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}`); + L.push(`승리 시 평균 잔여 HP: ${r.avgHpOnWin.toFixed(1)} / ${PLAYER_HP}`); + if (r.draws) L.push(`⚠️ 무승부 ${r.draws}건 (턴 상한 ${MAX_TURNS} 초과)`); + L.push(''); + L.push('카드별:'); + const rows = Object.entries(r.cardStats).map(([id, s]) => { + const kind = r.cards[id].kind; + const eff = kind === 'Attack' ? s.damage / s.energy : s.block / s.energy; + return { id, name: r.cards[id].name, kind, plays: s.plays, eff }; + }); + for (const kind of ['Attack', 'Skill']) { + const kr = rows.filter((x) => x.kind === kind); + if (!kr.length) continue; + const med = median(kr.map((x) => x.eff)); + const unit = kind === 'Attack' ? '뎀/E' : '블록/E'; + for (const x of kr) { + const op = med > 0 && x.eff >= med * 1.5 ? ' ⚠️ OP 의심' : ''; + L.push(` ${x.name}(${x.id}): 사용 ${x.plays}, 효율 ${x.eff.toFixed(2)} ${unit}${op}`); + } + } + const sorted = rows.slice().sort((a, b) => b.plays - a.plays); + if (sorted.length) L.push(`최다 사용: ${sorted[0].name} / 최소 사용: ${sorted[sorted.length - 1].name}`); + return L.join('\n'); +} + +function main() { + const args = process.argv.slice(2); + let N = 2000, seed = 1; + for (let i = 0; i < args.length; i++) { + if (args[i] === '--seed') seed = parseInt(args[++i], 10); + else if (/^\d+$/.test(args[i])) N = parseInt(args[i], 10); + } + console.log(formatReport(runBatch(N, seed))); +} + +if (process.argv[1] && process.argv[1].endsWith('sim-balance.mjs')) main(); diff --git a/tools/sim-balance.test.mjs b/tools/sim-balance.test.mjs new file mode 100644 index 0000000..303e63f --- /dev/null +++ b/tools/sim-balance.test.mjs @@ -0,0 +1,80 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { + mulberry32, applyDamage, chooseAction, simulateCombat, runBatch, +} from './sim-balance.mjs'; + +test('applyDamage: 방어 우선 차감 후 hp', () => { + assert.deepEqual(applyDamage(80, 0, 10), { hp: 70, block: 0 }); + assert.deepEqual(applyDamage(80, 5, 10), { hp: 75, block: 0 }); + assert.deepEqual(applyDamage(80, 12, 10), { hp: 80, block: 2 }); + assert.deepEqual(applyDamage(3, 0, 10), { hp: 0, block: 0 }); +}); + +test('mulberry32: 동일 시드 동일 수열', () => { + const a = mulberry32(1), b = mulberry32(1); + assert.equal(a(), b()); + assert.equal(a(), b()); +}); + +const CARDS = { + Strike: { name: '타격', cost: 1, kind: 'Attack', damage: 6 }, + Defend: { name: '방어', cost: 1, kind: 'Skill', block: 5 }, + Bash: { name: '강타', cost: 2, kind: 'Attack', damage: 10 }, +}; + +test('chooseAction: 치사 가능하면 공격 선택', () => { + const idx = chooseAction(['Strike', 'Defend'], CARDS, 3, 5, 0, { kind: 'Attack', value: 10 }); + 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 }); + assert.equal(idx, -1); +}); + +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 }, + ], + }, +}; + +test('simulateCombat: 결정적 결과(동일 시드)', () => { + const r1 = simulateCombat(DATA, mulberry32(1)); + const r2 = simulateCombat(DATA, mulberry32(1)); + assert.deepEqual(r1, r2); + assert.equal(typeof r1.win, 'boolean'); + assert.ok(r1.turns >= 1); +}); + +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('runBatch: 집계 필드·승률 범위', () => { + const r = runBatch(100, 1); + assert.equal(r.N, 100); + assert.ok(r.winRate >= 0 && r.winRate <= 1); + assert.ok(r.avgTurns > 0); + assert.ok(r.cardStats.Strike.plays > 0); +}); + +test('runBatch: 동일 시드 동일 결과', () => { + assert.deepEqual(runBatch(100, 7), runBatch(100, 7)); +});