Merge pull request 'AI 전투 밸런스 시뮬레이터 (TODO F)' (#12) from feature/balance-simulator into main
Reviewed-on: #12
This commit was merged in pull request #12.
This commit is contained in:
475
docs/superpowers/plans/2026-06-09-balance-simulator.md
Normal file
475
docs/superpowers/plans/2026-06-09-balance-simulator.md
Normal file
@@ -0,0 +1,475 @@
|
||||
# AI 전투 시뮬레이터 (TODO F) Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** `data/*.json`을 입력으로 전투를 몬테카를로 N회 시뮬레이션해 승률·턴·OP 카드 리포트를 출력하는 오프라인 CLI `tools/sim-balance.mjs`.
|
||||
|
||||
**Architecture:** 순수 함수(PRNG·applyDamage·chooseAction·simulateCombat·runBatch)로 분리해 `node:test`로 단위 테스트. CLI main은 직접 실행 시에만 동작. 전투 규칙은 gen-slaydeck.mjs의 Lua를 JS로 미러, 데이터는 D의 JSON 공유.
|
||||
|
||||
**Tech Stack:** Node.js ESM, `node:test`+`node:assert`. 검증은 단위 테스트 + CLI 실행 + 결정성 + 데이터 반영.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
- Create: `tools/sim-balance.mjs` — 시뮬레이터(엔진·정책·집계·리포트·CLI). 순수 함수 export.
|
||||
- Create: `tools/sim-balance.test.mjs` — 단위 테스트(node:test).
|
||||
|
||||
전투 규칙은 `tools/gen-slaydeck.mjs` Lua와 중복 → 파일 상단 동기화 주석.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: PRNG·applyDamage·loadData (기반 순수 함수)
|
||||
|
||||
**Files:**
|
||||
- Create: `tools/sim-balance.mjs`
|
||||
- Create: `tools/sim-balance.test.mjs`
|
||||
|
||||
- [ ] **Step 1: 테스트 작성 `tools/sim-balance.test.mjs`**
|
||||
|
||||
```js
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mulberry32, applyDamage } 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());
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 테스트 실패 확인**
|
||||
|
||||
Run: `node --test tools/sim-balance.test.mjs`
|
||||
Expected: FAIL (`Cannot find module './sim-balance.mjs'` 또는 export 없음)
|
||||
|
||||
- [ ] **Step 3: `tools/sim-balance.mjs` 작성(기반부)**
|
||||
|
||||
```js
|
||||
// AI 전투 밸런스 시뮬레이터 — 오프라인 몬테카를로.
|
||||
// ⚠️ 전투 규칙은 tools/gen-slaydeck.mjs 의 Lua(SlayDeckController)와 동기화 유지할 것.
|
||||
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 };
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 테스트 통과 확인**
|
||||
|
||||
Run: `node --test tools/sim-balance.test.mjs`
|
||||
Expected: PASS (2 tests)
|
||||
|
||||
- [ ] **Step 5: 커밋**
|
||||
|
||||
```bash
|
||||
git add tools/sim-balance.mjs tools/sim-balance.test.mjs
|
||||
git commit -m "sim-balance(F): PRNG·applyDamage·loadData 기반 함수 + 테스트"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: chooseAction 정책 (휴리스틱 A)
|
||||
|
||||
**Files:**
|
||||
- Modify: `tools/sim-balance.mjs`, `tools/sim-balance.test.mjs`
|
||||
|
||||
- [ ] **Step 1: 테스트 추가 (test.mjs 하단)**
|
||||
|
||||
```js
|
||||
import { chooseAction } from './sim-balance.mjs';
|
||||
|
||||
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: 치사 가능하면 공격 선택', () => {
|
||||
// 적 hp 5, block 0, 손패 Strike(6) → 공격(인덱스 0)
|
||||
const idx = chooseAction(['Strike', 'Defend'], CARDS, 3, 5, 0, { kind: 'Attack', value: 10 });
|
||||
assert.equal(idx, 0);
|
||||
});
|
||||
|
||||
test('chooseAction: 치사 불가 + 적 공격 의도면 방어 선택', () => {
|
||||
// 적 hp 40(이번 턴 못 죽임), 의도 공격 → Defend(인덱스 1)
|
||||
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);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 테스트 실패 확인**
|
||||
|
||||
Run: `node --test tools/sim-balance.test.mjs`
|
||||
Expected: FAIL (`chooseAction is not a function`)
|
||||
|
||||
- [ ] **Step 3: 구현 추가 (sim-balance.mjs)**
|
||||
|
||||
```js
|
||||
// 손패에서 다음에 낼 카드의 인덱스 반환(-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;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 테스트 통과 확인**
|
||||
|
||||
Run: `node --test tools/sim-balance.test.mjs`
|
||||
Expected: PASS (6 tests)
|
||||
|
||||
- [ ] **Step 5: 커밋**
|
||||
|
||||
```bash
|
||||
git add tools/sim-balance.mjs tools/sim-balance.test.mjs
|
||||
git commit -m "sim-balance(F): 플레이어 휴리스틱 정책 chooseAction + 테스트"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: simulateCombat 엔진
|
||||
|
||||
**Files:**
|
||||
- Modify: `tools/sim-balance.mjs`, `tools/sim-balance.test.mjs`
|
||||
|
||||
- [ ] **Step 1: 테스트 추가**
|
||||
|
||||
```js
|
||||
import { simulateCombat, mulberry32 as m32 } from './sim-balance.mjs';
|
||||
|
||||
const DATA = {
|
||||
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 },
|
||||
},
|
||||
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, m32(1));
|
||||
const r2 = simulateCombat(DATA, m32(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, m32(i + 1)).win) wins++;
|
||||
assert.ok(wins >= 40, `예상 승리 다수, 실제 ${wins}/50`);
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 테스트 실패 확인**
|
||||
|
||||
Run: `node --test tools/sim-balance.test.mjs`
|
||||
Expected: FAIL (`simulateCombat is not a function`)
|
||||
|
||||
- [ ] **Step 3: 구현 추가 (sim-balance.mjs)**
|
||||
|
||||
```js
|
||||
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 };
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 테스트 통과 확인**
|
||||
|
||||
Run: `node --test tools/sim-balance.test.mjs`
|
||||
Expected: PASS (8 tests)
|
||||
|
||||
- [ ] **Step 5: 커밋**
|
||||
|
||||
```bash
|
||||
git add tools/sim-balance.mjs tools/sim-balance.test.mjs
|
||||
git commit -m "sim-balance(F): 단일 전투 시뮬 엔진 simulateCombat + 테스트"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: runBatch·리포트·OP 탐지·CLI
|
||||
|
||||
**Files:**
|
||||
- Modify: `tools/sim-balance.mjs`, `tools/sim-balance.test.mjs`
|
||||
|
||||
- [ ] **Step 1: 테스트 추가**
|
||||
|
||||
```js
|
||||
import { runBatch } from './sim-balance.mjs';
|
||||
|
||||
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));
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 테스트 실패 확인**
|
||||
|
||||
Run: `node --test tools/sim-balance.test.mjs`
|
||||
Expected: FAIL (`runBatch is not a function`)
|
||||
|
||||
- [ ] **Step 3: 구현 추가 (sim-balance.mjs)**
|
||||
|
||||
```js
|
||||
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('카드별:');
|
||||
// 효율 계산 + kind별 중앙값으로 OP 플래그
|
||||
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));
|
||||
for (const x of kr) {
|
||||
const op = med > 0 && x.eff >= med * 1.5 ? ' ⚠️ OP 의심' : '';
|
||||
const unit = kind === 'Attack' ? '뎀/E' : '블록/E';
|
||||
L.push(` ${x.name}(${id2(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 id2(id) { return id; }
|
||||
|
||||
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();
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 테스트 통과 확인**
|
||||
|
||||
Run: `node --test tools/sim-balance.test.mjs`
|
||||
Expected: PASS (10 tests)
|
||||
|
||||
- [ ] **Step 5: 커밋**
|
||||
|
||||
```bash
|
||||
git add tools/sim-balance.mjs tools/sim-balance.test.mjs
|
||||
git commit -m "sim-balance(F): runBatch·리포트·OP 탐지·CLI + 테스트"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: 검증 (CLI 실행·결정성·데이터 반영)
|
||||
|
||||
**Files:** 없음(실행 검증)
|
||||
|
||||
- [ ] **Step 1: 전체 테스트**
|
||||
|
||||
Run: `node --test tools/sim-balance.test.mjs`
|
||||
Expected: PASS (10 tests, 0 fail)
|
||||
|
||||
- [ ] **Step 2: CLI 실행 (기본)**
|
||||
|
||||
Run: `node tools/sim-balance.mjs 2000`
|
||||
Expected: 승률·평균턴·승리시 잔여HP·카드별 효율 리포트 출력.
|
||||
|
||||
- [ ] **Step 3: 결정성 (동일 시드 동일 출력)**
|
||||
|
||||
Run: `node tools/sim-balance.mjs 500 --seed 3 > /tmp/r1.txt && node tools/sim-balance.mjs 500 --seed 3 > /tmp/r2.txt && diff /tmp/r1.txt /tmp/r2.txt && echo DETERMINISTIC`
|
||||
Expected: `DETERMINISTIC`
|
||||
|
||||
- [ ] **Step 4: 데이터 반영 (강타 데미지↑ → 승률·턴 변동)**
|
||||
|
||||
Run: `node tools/sim-balance.mjs 1000 --seed 1 | grep 승률` (기준값 기록) → `data/cards.json`에서 Bash.damage 10→20으로 임시 변경 → `node tools/sim-balance.mjs 1000 --seed 1 | grep 승률`(변동 확인) → `git checkout -- data/cards.json`(원복).
|
||||
Expected: 두 승률/턴 수치가 다름(데이터 반영). 원복 후 기준 복귀.
|
||||
|
||||
- [ ] **Step 5: 최종 커밋(있다면 없음 — 검증 전용)**
|
||||
|
||||
검증 전용 태스크. 변경 없음. `git status`로 `data/cards.json` 원복 확인.
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
- **Spec coverage:** PRNG·applyDamage·loadData(Task1), 정책(Task2), 엔진(Task3), 집계·리포트·OP·CLI(Task4), 검증·데이터반영(Task5). 스펙 전 항목 매핑.
|
||||
- **Placeholder scan:** 모든 단계 실제 코드/명령. 동기화 주석은 의도된 문서.
|
||||
- **Type consistency:** `mulberry32/shuffle/applyDamage/loadData/chooseAction/simulateCombat/runBatch/formatReport` 시그니처가 정의(Task1·2·3·4)와 사용(테스트·CLI)에서 일치. `cardStats` 형태 `{plays,energy,damage,block}`가 `bump`·`runBatch`·`formatReport`에서 일치. 카드 필드 `kind/damage/block/cost`가 데이터·정책·엔진에서 일치.
|
||||
@@ -0,0 +1,68 @@
|
||||
# AI 전투 시뮬레이터 (TODO 항목 F) — 설계
|
||||
|
||||
> 작성: 2026-06-09 / 상태: 승인됨 / 근거: TODO.md 항목 F + gen-slaydeck.mjs 전투 규칙 + D 데이터.
|
||||
> 선행: D(데이터 외부화) 완료.
|
||||
|
||||
## 문제
|
||||
|
||||
박재오 강점(백엔드·AI 자동화) 활용처로 기획됐으나 코드 없음. 카드/적 밸런싱을 손으로
|
||||
검증해야 한다. 데이터 기반 자동 밸런스 검증 도구가 필요하다.
|
||||
|
||||
## 목표
|
||||
|
||||
`data/cards.json`·`data/enemies.json`를 입력으로, 전투를 몬테카를로로 N회 자동 시뮬레이션해
|
||||
승률·평균 턴·OP 카드 탐지 리포트를 출력하는 오프라인 Node CLI(`tools/sim-balance.mjs`).
|
||||
|
||||
## 설계
|
||||
|
||||
### 구조
|
||||
`tools/sim-balance.mjs` 단일 파일, 섹션 분리:
|
||||
1. **데이터 로드**: `data/cards.json`·`data/enemies.json`(D와 동일 소스). `activeEnemy` 사용.
|
||||
2. **시드 PRNG**: mulberry32(시드 고정 → 재현 가능, 데이터 바꾸면 결과 변동).
|
||||
3. **전투 엔진**(Lua 규칙 미러): 아래 규칙을 JS로 재현.
|
||||
4. **플레이어 정책**(휴리스틱 A).
|
||||
5. **집계·리포트**.
|
||||
6. **CLI 파싱·출력**.
|
||||
|
||||
### 전투 규칙 (gen-slaydeck.mjs Lua와 동일)
|
||||
- 시작: 플레이어 `hp=PLAYER_HP(상수 80)`, `block=0`; 적 `hp=maxHp`, `block=0`, `intentIdx=0`(0-base).
|
||||
덱 = `starterDeck` 셔플(PRNG).
|
||||
- 플레이어 턴 시작: `energy=3`, `block=0`, 5장 드로우(덱 소진 시 버림 더미 셔플해 재활용).
|
||||
- 플레이어 행동: 정책이 카드 선택 → 사용 시 `energy -= cost`, `Attack`→적에 `damage`(적 block 우선 차감),
|
||||
`Skill`→플레이어 `block += block`. 사용 카드는 버림. 더 둘 수 없으면 턴 종료.
|
||||
- 적 턴: 적 `block=0` → 현재 의도 실행(`Attack`→플레이어에 피해(플레이어 block 우선 차감),
|
||||
`Defend`→적 `block += value`) → `intentIdx=(intentIdx+1)%len`.
|
||||
- 승패: 적 hp≤0 승리, 플레이어 hp≤0 패배. 턴 상한 `MAX_TURNS=100`(초과 시 무승부로 집계, 경고).
|
||||
|
||||
### 플레이어 정책 (휴리스틱 A)
|
||||
매 플레이어 행동 루프:
|
||||
1. **치사 판단**: 손패의 Attack 카드들로 이번 턴 낼 수 있는 최대 데미지(에너지 한도 내) ≥
|
||||
`적 hp + 적 block` 이면 → 그 Attack들을 사용(킬).
|
||||
2. 아니면 **적 의도가 Attack**이면 → 손패 Defend(Skill+block) 카드를 사용(에너지 닿는 한),
|
||||
이후 잔여 에너지로 Attack 사용.
|
||||
3. 아니면(적 Defend 의도) → Attack 우선 사용.
|
||||
4. 사용 가능한 카드(에너지≥cost)가 없으면 턴 종료.
|
||||
- 동률 선택은 에너지 효율(뎀/E 또는 블록/E) 높은 카드 우선.
|
||||
|
||||
### 리포트 지표
|
||||
- 전체: 승률(%), 평균·중앙값 턴 수, 승리 시 평균 잔여 HP, 패배율, (무승부 시 경고).
|
||||
- 카드별: 사용 횟수, 누적 데미지/방어, **에너지당 효율**(Attack=총뎀/총E, Skill=총블록/총E).
|
||||
- **OP 탐지**: 같은 kind 내 효율이 그 kind 중앙값의 ≥1.5배인 카드를 ⚠️로 플래그. 최다/최소 사용 카드 표기.
|
||||
|
||||
### CLI
|
||||
`node tools/sim-balance.mjs [N] [--seed S]` — 기본 `N=2000`, `seed=1`. 표 형식 출력.
|
||||
|
||||
### 동기화 위험
|
||||
JS 전투 규칙은 Lua(`gen-slaydeck.mjs`)와 **중복**이다(공유 불가). 데이터(JSON)는 공유.
|
||||
파일 상단에 "전투 규칙 변경 시 gen-slaydeck.mjs Lua와 동기화" 주석 명시.
|
||||
|
||||
## 검증 (TDD + CLI)
|
||||
- 전투 엔진/정책 핵심을 순수 함수로 분리해 단위 테스트(Node 내장 `node:test`):
|
||||
데미지 방어차감, 치사 판단, 적 의도 사이클, 승/패 종료.
|
||||
- `node tools/sim-balance.mjs` → 승률·턴·카드 통계 출력.
|
||||
- `data/cards.json`에서 강타 damage↑ → 승률·강타 효율 상승(데이터 반영).
|
||||
- 동일 시드 2회 → 동일 출력(결정성).
|
||||
|
||||
## 범위 밖 (금지)
|
||||
- 상태이상·드로우·복합효과, 다중 적, 로그라이크 메타. 메이커 런타임 연동.
|
||||
- 새 카드/적 추가(현 데이터로 검증). 정책 고도화(MCTS 등).
|
||||
199
tools/sim-balance.mjs
Normal file
199
tools/sim-balance.mjs
Normal file
@@ -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();
|
||||
80
tools/sim-balance.test.mjs
Normal file
80
tools/sim-balance.test.mjs
Normal file
@@ -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));
|
||||
});
|
||||
Reference in New Issue
Block a user