// AI 전투 밸런스 시뮬레이터 — 오프라인 몬테카를로. // ⚠️ 전투 규칙은 tools/deck/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 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 }; } // 주의: 인게임은 플레이어가 카드를 직접 선택한다. 이 chooseAction은 밸런스 추정용 자동 플레이 휴리스틱일 뿐 // 이며, Lua에 대응 AI가 없다(동기화 대상은 데미지/방어/의도/승패 규칙이지 플레이어 선택이 아님). // 손패에서 낼 카드 인덱스(-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]; 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) || pool.indexOf(a) - pool.indexOf(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; return s; } // 단일 전투 시뮬. stats(선택): {cardId: {plays,energy,damage,block}} 누적. // 반환: { win, turns, playerHpRemaining, draw? } export function simulateCombat(data, rng, stats) { const { cards, starterDeck, monsters } = data; if (monsters.length === 0) return { win: true, turns: 0, playerHpRemaining: PLAYER_HP }; let drawPile = shuffle(starterDeck, rng); let discard = []; let hand = []; let pHp = PLAYER_HP, pBlock = 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) { 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()); } } const aliveList = () => mob.filter((m) => m.alive); while (turns < MAX_TURNS) { turns++; let energy = ENERGY; pBlock = 0; hand = []; draw(HAND_SIZE); while (true) { 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 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 (aliveList().length === 0) return { win: true, turns, playerHpRemaining: pHp }; } discard.push(...hand); hand = []; 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 }; } 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, monsters: data.monsters, seed, }; } export function formatReport(r) { const L = []; 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}`); 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();