370 lines
15 KiB
JavaScript
370 lines
15 KiB
JavaScript
// 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;
|
|
}
|
|
|
|
// 공격 피해 공식 — Lua CalcPlayerAttack(힘·약화) + DealDamageToTarget(취약)과 동기화.
|
|
// floor((base + str) * (weak>0 ? 0.75 : 1)) → floor(... * (vulnOnTarget>0 ? 1.5 : 1))
|
|
// 보상 카드 등급 추첨 (Lua OfferReward 미러) — roll ∈ 1..100, normal 70 / unique 25 / legend 5
|
|
export function rarityForRoll(roll) {
|
|
if (roll > 95) return 'legend';
|
|
if (roll > 70) return 'unique';
|
|
return 'normal';
|
|
}
|
|
|
|
export function calcAttack(base, str, weak, vulnOnTarget) {
|
|
let dmg = base + str;
|
|
if (weak > 0) dmg = Math.floor(dmg * 0.75);
|
|
if (vulnOnTarget > 0) dmg = Math.floor(dmg * 1.5);
|
|
if (dmg < 0) dmg = 0;
|
|
return dmg;
|
|
}
|
|
|
|
// 방어 우선 차감 후 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 };
|
|
});
|
|
// 시뮬 기본 덱은 전사 시작 덱 (클래스별 시뮬은 starterDeck 직접 주입으로 가능)
|
|
return { cards: cardsData.cards, starterDeck: cardsData.starterDecks.warrior, monsters };
|
|
}
|
|
|
|
// 주의: 인게임은 플레이어가 카드를 직접 선택한다. 이 chooseAction은 밸런스 추정용 자동 플레이 휴리스틱일 뿐
|
|
// 이며, Lua에 대응 AI가 없다(동기화 대상은 데미지/방어/의도/승패 규칙이지 플레이어 선택이 아님).
|
|
// 손패에서 낼 카드 인덱스(-1=종료). 파워 우선(지속 가치) → 공격 → 스킬.
|
|
export function chooseAction(hand, cards, energy) {
|
|
const entries = hand.map((id, i) => ({ id, i })).filter((x) => cards[x.id] && cards[x.id].cost <= energy && !cards[x.id].unplayable);
|
|
const powers = entries.filter((x) => cards[x.id].kind === 'Power');
|
|
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) / Math.max(cards[x.id].cost, 1);
|
|
const blkEff = (x) => (cards[x.id].block || 0) / Math.max(cards[x.id].cost, 1);
|
|
const bestBy = (list, fn) => list.slice().sort((a, b) => fn(b) - fn(a))[0];
|
|
if (powers.length) return powers[0].i;
|
|
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 = [];
|
|
const exhaust = [];
|
|
let hand = [];
|
|
let pHp = PLAYER_HP, pBlock = 0;
|
|
let pStr = 0, pDex = 0, pThorns = 0, pWeak = 0, pVuln = 0;
|
|
const powers = [];
|
|
const mob = monsters.map((m) => ({
|
|
name: m.name, hp: m.maxHp, maxHp: m.maxHp, block: 0, str: 0, weak: 0, vuln: 0, poison: 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;
|
|
const card = drawPile.pop();
|
|
// 손패 10장 상한 — 초과 드로는 자동 버림 (Lua DrawCards 동기화)
|
|
if (hand.length >= 10) {
|
|
discard.push(card);
|
|
triggerSly(card);
|
|
} else hand.push(card);
|
|
}
|
|
}
|
|
function addCardsToHand(id, n) {
|
|
for (let k = 0; k < n; k++) {
|
|
if (hand.length >= 10) discard.push(id);
|
|
else hand.push(id);
|
|
}
|
|
}
|
|
const aliveList = () => mob.filter((m) => m.alive);
|
|
function resolveCardEffects(id, c, costSpent, recordStats = true) {
|
|
const alive = aliveList();
|
|
let dmg = 0;
|
|
let blockGained = 0;
|
|
if (c.kind === 'Attack') {
|
|
if (alive.length && c.damage) {
|
|
const target = chooseTarget(alive, calcAttack(c.damage || 0, pStr, pWeak, 0));
|
|
if (c.weak) target.weak += c.weak;
|
|
if (c.vuln) target.vuln += c.vuln;
|
|
const hitN = c.hits || 1;
|
|
let totalNv = 0;
|
|
for (let h = 0; h < hitN; h++) totalNv += calcAttack(c.damage || 0, pStr, pWeak, 0);
|
|
dmg = totalNv;
|
|
if (c.aoe === true) {
|
|
for (const m2 of aliveList()) {
|
|
const d2 = m2.vuln > 0 ? Math.floor(totalNv * 1.5) : totalNv;
|
|
const r2 = applyDamage(m2.hp, m2.block, d2);
|
|
m2.hp = r2.hp; m2.block = r2.block;
|
|
if (m2.hp <= 0) m2.alive = false;
|
|
}
|
|
} else {
|
|
dmg = target.vuln > 0 ? Math.floor(totalNv * 1.5) : totalNv;
|
|
if (c.pierce === true) {
|
|
target.hp -= dmg;
|
|
if (target.hp < 0) target.hp = 0;
|
|
} else {
|
|
const r = applyDamage(target.hp, target.block, dmg);
|
|
target.hp = r.hp; target.block = r.block;
|
|
}
|
|
if (target.hp <= 0) target.alive = false;
|
|
}
|
|
}
|
|
if (c.block) { blockGained = Math.max(0, c.block + pDex); pBlock += blockGained; }
|
|
} else if (c.kind === 'Power') {
|
|
if (recordStats) powers.push(id);
|
|
} else {
|
|
if (c.block) { blockGained = Math.max(0, c.block + pDex); pBlock += blockGained; }
|
|
if ((c.weak || c.vuln || c.poison) && alive.length) {
|
|
const target = chooseTarget(alive, 0);
|
|
if (c.weak) target.weak += c.weak;
|
|
if (c.vuln) target.vuln += c.vuln;
|
|
if (c.poison) target.poison += c.poison;
|
|
}
|
|
}
|
|
if (c.strength) pStr += c.strength;
|
|
if (c.dex) pDex += c.dex;
|
|
if (c.thorns) pThorns += c.thorns;
|
|
if (c.selfVuln) pVuln += c.selfVuln;
|
|
if (c.heal) pHp = Math.min(pHp + c.heal, PLAYER_HP);
|
|
if (c.draw) draw(c.draw);
|
|
if (c.addShiv && !c.discard && c.discardAll !== true) addCardsToHand('Shiv', c.addShiv);
|
|
if (recordStats && stats) stats[id] = bump(stats[id], costSpent, dmg, blockGained);
|
|
}
|
|
function triggerSly(id) {
|
|
const c = cards[id];
|
|
if (!c?.sly) return;
|
|
resolveCardEffects(id, c, 0, false);
|
|
}
|
|
function discardHandCard(idx, trigger = true) {
|
|
const [id] = hand.splice(idx, 1);
|
|
if (!id) return;
|
|
discard.push(id);
|
|
if (trigger) triggerSly(id);
|
|
}
|
|
function applyDiscardEffects(c) {
|
|
let discarded = 0;
|
|
if (c.discardAll) {
|
|
while (hand.length) { discardHandCard(hand.length - 1, true); discarded++; }
|
|
} else if (c.discard) {
|
|
const n = Math.min(c.discard, hand.length);
|
|
for (let i = 0; i < n; i++) { discardHandCard(hand.length - 1, true); discarded++; }
|
|
}
|
|
if (c.addShiv && (c.discard || c.discardAll === true)) addCardsToHand('Shiv', c.addShiv);
|
|
if (c.addShivPerDiscard === true) addCardsToHand('Shiv', discarded);
|
|
}
|
|
|
|
while (turns < MAX_TURNS) {
|
|
turns++;
|
|
// 파워 발동 — Lua StartPlayerTurn 동기화 (블록 리셋 후 strength/energy/block 파워)
|
|
pBlock = 0;
|
|
let energyBonus = 0;
|
|
for (const pid of powers) {
|
|
const pc = cards[pid];
|
|
if (!pc) continue;
|
|
if (pc.powerEffect === 'strengthPerTurn') pStr += pc.value;
|
|
else if (pc.powerEffect === 'energyPerTurn') energyBonus += pc.value;
|
|
else if (pc.powerEffect === 'blockPerTurn') pBlock += pc.value;
|
|
if (pc.turnStartShiv) addCardsToHand('Shiv', pc.turnStartShiv);
|
|
}
|
|
let energy = ENERGY + energyBonus; 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;
|
|
resolveCardEffects(id, c, c.cost);
|
|
hand.splice(idx, 1);
|
|
if (c.exhaust === true || String(c.desc || '').includes('소멸.')) exhaust.push(id);
|
|
else if (c.kind !== 'Power') discard.push(id);
|
|
applyDiscardEffects(c);
|
|
if (aliveList().length === 0) return { win: true, turns, playerHpRemaining: pHp };
|
|
}
|
|
// 화상(endTurnDamage) — 손패에 있으면 턴 종료 시 피해 (Lua EndPlayerTurn 동기화)
|
|
let burn = 0;
|
|
for (const hid of hand) { const hc = cards[hid]; if (hc && hc.endTurnDamage) burn += hc.endTurnDamage; }
|
|
if (burn > 0) { pHp -= burn; if (pHp < 0) pHp = 0; }
|
|
const kept = [];
|
|
for (const hid of hand) {
|
|
const hc = cards[hid];
|
|
if (hc?.retain === true) kept.push(hid);
|
|
else discard.push(hid);
|
|
}
|
|
hand = kept;
|
|
if (pHp <= 0) return { win: false, turns, playerHpRemaining: 0 };
|
|
// 플레이어 디버프 감소 — Lua EndPlayerTurn 동기화 (적 행동 전)
|
|
if (pWeak > 0) pWeak--;
|
|
if (pVuln > 0) pVuln--;
|
|
for (const m of mob) {
|
|
if (!m.alive) continue;
|
|
// 독 틱 — 행동 시작 시 (Lua EnemyActStep 동기화). 사망 시 행동 생략
|
|
if (m.poison > 0) {
|
|
m.hp -= m.poison;
|
|
m.poison--;
|
|
if (m.hp <= 0) { m.hp = 0; m.alive = false; continue; }
|
|
}
|
|
m.block = 0; // 매 턴 초기화 (이전 턴 블록 미이월)
|
|
// 정의된 intent 중 랜덤 선택 (Lua EnemyActStep 동기화 — 순차→랜덤)
|
|
const it = m.intents.length ? m.intents[Math.floor(rng() * m.intents.length)] : null;
|
|
if (it) {
|
|
if (it.kind === 'Attack') {
|
|
const atk = calcAttack(it.value, m.str, m.weak, pVuln);
|
|
const beforeHp = pHp;
|
|
const r = applyDamage(pHp, pBlock, atk); pHp = r.hp; pBlock = r.block;
|
|
if (beforeHp > pHp && pThorns > 0) {
|
|
m.hp -= pThorns;
|
|
if (m.hp <= 0) m.alive = false;
|
|
}
|
|
} else if (it.kind === 'Defend') { m.block += it.value; }
|
|
else if (it.kind === 'Debuff') {
|
|
if (it.effect === 'weak') pWeak += it.value;
|
|
else if (it.effect === 'vuln') pVuln += it.value;
|
|
} else if (it.kind === 'AddCard') {
|
|
// StS2식 덱 오염 — 저주 카드를 버린 더미에 추가 (Lua 동기화)
|
|
const cnt = it.count || 1;
|
|
for (let k = 0; k < cnt; k++) discard.push(it.card);
|
|
}
|
|
}
|
|
// 적 디버프 감소 — Lua EnemyActStep 동기화 (자기 행동 후)
|
|
if (m.weak > 0) m.weak--;
|
|
if (m.vuln > 0) m.vuln--;
|
|
if (pHp <= 0) return { win: false, turns, playerHpRemaining: 0 };
|
|
}
|
|
// 독 사망 등 적 페이즈 중 전멸 처리 (Lua FinishEnemyTurn→CheckCombatEnd 동기화)
|
|
if (!mob.some((m) => m.alive)) return { win: true, turns, playerHpRemaining: pHp };
|
|
}
|
|
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', 'Power']) {
|
|
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' : kind === 'Power' ? '(지속)' : '블록/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();
|