feat(sim): 전투 규칙을 멀티 몬스터로 (타겟 선택·각자 의도·전체 처치 승리)

This commit is contained in:
2026-06-10 00:52:17 +09:00
parent b14b614d94
commit 4ef3d1811d
2 changed files with 87 additions and 51 deletions

View File

@@ -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}`);