247 lines
9.0 KiB
JavaScript
247 lines
9.0 KiB
JavaScript
import { readFileSync } from 'node:fs';
|
|
import {
|
|
PLAYER_HP,
|
|
loadData,
|
|
mulberry32,
|
|
simulateCombat,
|
|
} from './sim-balance.mjs';
|
|
|
|
const ROGUE_CLASSES = new Set(['rogue', 'thief', 'thiefmaster', 'assassin', 'hermit']);
|
|
|
|
const CONTEXT_DECKS = {
|
|
rogue: [
|
|
'SilentStrike', 'SilentStrike', 'SilentStrike', 'SilentStrike',
|
|
'SilentDefend', 'SilentDefend', 'SilentDefend', 'SilentDefend',
|
|
'Neutralize', 'Survivor', 'DoubleStab', 'Backflip',
|
|
],
|
|
thief: [
|
|
'SilentStrike', 'SilentStrike', 'SilentStrike',
|
|
'SilentDefend', 'SilentDefend', 'SilentDefend',
|
|
'Neutralize', 'Survivor', 'SavageBlow', 'DaggerAcceleration',
|
|
'DeadlyPoison', 'Acrobatics',
|
|
],
|
|
thiefmaster: [
|
|
'SilentStrike', 'SilentStrike',
|
|
'SilentDefend', 'SilentDefend',
|
|
'Survivor', 'SavageBlow', 'DaggerAcceleration', 'DeadlyPoison',
|
|
'Acrobatics', 'EdgeCarnival', 'PickPocket', 'Venom',
|
|
],
|
|
assassin: [
|
|
'SilentStrike', 'SilentStrike', 'SilentStrike',
|
|
'SilentDefend', 'SilentDefend', 'SilentDefend',
|
|
'Neutralize', 'Survivor', 'LeadingStrike', 'BladeDance',
|
|
'JavelinAcceleration', 'JavelinMastery',
|
|
],
|
|
hermit: [
|
|
'SilentStrike', 'SilentStrike',
|
|
'SilentDefend', 'SilentDefend',
|
|
'Survivor', 'LeadingStrike', 'BladeDance', 'JavelinAcceleration',
|
|
'JavelinMastery', 'TripleThrow', 'SpiritJavelin', 'SkilledJavelin',
|
|
],
|
|
};
|
|
|
|
const ENCOUNTER_SCALE = {
|
|
rogue: { hp: 1.9, attack: 1.5 },
|
|
thief: { hp: 2.2, attack: 1.6 },
|
|
assassin: { hp: 2.25, attack: 1.65 },
|
|
thiefmaster: { hp: 2.4, attack: 1.5 },
|
|
hermit: { hp: 2.6, attack: 1.65 },
|
|
};
|
|
|
|
const median = (values) => {
|
|
if (values.length === 0) return 0;
|
|
const sorted = values.slice().sort((a, b) => a - b);
|
|
const middle = Math.floor(sorted.length / 2);
|
|
return sorted.length % 2 === 1
|
|
? sorted[middle]
|
|
: (sorted[middle - 1] + sorted[middle]) / 2;
|
|
};
|
|
|
|
function validateContextDecks(cards) {
|
|
for (const [classId, deck] of Object.entries(CONTEXT_DECKS)) {
|
|
for (const cardId of deck) {
|
|
if (!cards[cardId]) throw new Error(`${classId} 효율 기준 덱에 없는 카드: ${cardId}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
function outcomeScore(result) {
|
|
if (result.draw) return -60;
|
|
if (!result.win) return -100 - result.turns;
|
|
return 100 + (result.playerHpRemaining / PLAYER_HP) * 30 - result.turns * 2;
|
|
}
|
|
|
|
function scaledEncounter(data, classId) {
|
|
const scale = ENCOUNTER_SCALE[classId];
|
|
return {
|
|
...data,
|
|
monsters: data.monsters.map((monster) => ({
|
|
...monster,
|
|
maxHp: Math.round(monster.maxHp * scale.hp),
|
|
intents: monster.intents.map((intent) => intent.kind === 'Attack'
|
|
? { ...intent, value: Math.round(intent.value * scale.attack) }
|
|
: { ...intent }),
|
|
})),
|
|
};
|
|
}
|
|
|
|
function simulateDeck(baseData, deck, runs, seed, trackedCardId = null) {
|
|
let wins = 0;
|
|
let totalTurns = 0;
|
|
let totalHp = 0;
|
|
let totalScore = 0;
|
|
let totalPlays = 0;
|
|
for (let i = 0; i < runs; i++) {
|
|
const stats = {};
|
|
const rng = mulberry32((seed + Math.imul(i + 1, 0x9e3779b1)) >>> 0);
|
|
const result = simulateCombat({ ...baseData, starterDeck: deck }, rng, stats);
|
|
if (result.win) {
|
|
wins++;
|
|
totalHp += result.playerHpRemaining;
|
|
}
|
|
totalTurns += result.turns;
|
|
totalScore += outcomeScore(result);
|
|
if (trackedCardId && stats[trackedCardId]) totalPlays += stats[trackedCardId].plays;
|
|
}
|
|
return {
|
|
winRate: wins / runs,
|
|
avgTurns: totalTurns / runs,
|
|
avgHpOnWin: wins > 0 ? totalHp / wins : 0,
|
|
score: totalScore / runs,
|
|
avgPlays: totalPlays / runs,
|
|
};
|
|
}
|
|
|
|
function replacementIndex(deck, cards, candidate) {
|
|
const preferredKind = candidate.kind === 'Attack' ? 'Attack' : 'Skill';
|
|
const preferred = deck.findIndex((id) => cards[id]?.kind === preferredKind);
|
|
if (preferred >= 0) return preferred;
|
|
return 0;
|
|
}
|
|
|
|
export function structuralRisks(card) {
|
|
const risks = [];
|
|
const cost = card.cost || 0;
|
|
const exhaust = card.exhaust === true;
|
|
const permanentDex = Math.max(0, (card.dex || 0) - (card.endTurnDexLoss || 0));
|
|
const permanentStats = (card.strength || 0) + permanentDex + (card.thorns || 0);
|
|
const generatedCards = (card.addShiv || 0) + (card.addShivPerDiscard ? 1 : 0);
|
|
|
|
if (cost === 0 && !exhaust && (card.gainEnergy || 0) > 0) {
|
|
risks.push('0코스트 비소멸 카드가 에너지를 생성');
|
|
}
|
|
if (cost === 0 && !exhaust && (card.draw || 0) >= 2 && generatedCards > 0) {
|
|
risks.push('0코스트 비소멸 카드가 2장 이상 드로우하면서 카드를 생성');
|
|
}
|
|
if (card.kind !== 'Power' && !exhaust && permanentStats > 0) {
|
|
risks.push('재사용 가능한 카드가 영구 능력치를 누적');
|
|
}
|
|
if (card.kind === 'Power' && (card.attackDamageVsWeakMultiplier || 0) >= 2 && cost <= 1) {
|
|
risks.push('저비용 지속 효과가 공격 피해를 2배 이상 증폭');
|
|
}
|
|
if ((card.poisonApplicationBurstEvery || 0) > 0) {
|
|
const burstPerApplication = (card.poisonApplicationBurstDamage || 0) / card.poisonApplicationBurstEvery;
|
|
if (burstPerApplication > 3 && cost <= 1) {
|
|
risks.push('저비용 독 누적 폭발 피해가 부여 1회당 3을 초과');
|
|
}
|
|
}
|
|
if (cost === 0 && !exhaust && (card.block || 0) + (card.nextTurnBlock || 0) >= 8) {
|
|
risks.push('0코스트 비소멸 카드의 현재·다음 턴 방어 합계가 8 이상');
|
|
}
|
|
if (cost === 0 && !exhaust && (card.blockPerDamageDealtThisTurn || 0) >= 1) {
|
|
risks.push('0코스트 비소멸 카드가 이번 턴 누적 피해 전부를 방어로 전환');
|
|
}
|
|
if (!exhaust && (card.gainEnergy || 0) > 0 && (card.gainEnergy || 0) >= cost && (card.draw || 0) > 0 && generatedCards > 0) {
|
|
risks.push('에너지 손실 없이 드로우와 카드 생성을 동시에 수행');
|
|
}
|
|
if (!exhaust && (card.skillCostReductionThisTurn || 0) > 0 && (card.gainEnergy || 0) > 0 && (card.gainEnergy || 0) >= cost && (card.draw || 0) > 0) {
|
|
risks.push('에너지 손실 없이 드로우하고 이번 턴 스킬 비용까지 감소');
|
|
}
|
|
return risks;
|
|
}
|
|
|
|
export function auditCardEfficiency({ runs = 300, seed = 20260701 } = {}) {
|
|
const data = loadData();
|
|
const cards = data.cards;
|
|
validateContextDecks(cards);
|
|
|
|
const baselines = {};
|
|
for (const [classId, deck] of Object.entries(CONTEXT_DECKS)) {
|
|
baselines[classId] = simulateDeck(scaledEncounter(data, classId), deck, runs, seed);
|
|
}
|
|
|
|
const rows = [];
|
|
for (const [id, card] of Object.entries(cards)) {
|
|
if (!ROGUE_CLASSES.has(card.class)) continue;
|
|
const deck = CONTEXT_DECKS[card.class].slice();
|
|
deck[replacementIndex(deck, cards, card)] = id;
|
|
const result = simulateDeck(scaledEncounter(data, card.class), deck, runs, seed, id);
|
|
rows.push({
|
|
id,
|
|
name: card.name,
|
|
classId: card.class,
|
|
rarity: card.rarity,
|
|
kind: card.kind,
|
|
cost: card.cost || 0,
|
|
delta: result.score - baselines[card.class].score,
|
|
...result,
|
|
risks: structuralRisks(card),
|
|
});
|
|
}
|
|
|
|
for (const row of rows) {
|
|
const peers = rows.filter((other) => other.classId === row.classId && other.rarity === row.rarity);
|
|
row.peerMedianDelta = median(peers.map((peer) => peer.delta));
|
|
row.peerGap = row.delta - row.peerMedianDelta;
|
|
}
|
|
|
|
return { runs, seed, baselines, rows };
|
|
}
|
|
|
|
function formatPercent(value) {
|
|
return `${(value * 100).toFixed(1)}%`;
|
|
}
|
|
|
|
export function formatEfficiencyReport(report) {
|
|
const lines = [];
|
|
lines.push(`도적 카드 효율 검증: 카드 ${report.rows.length}장, 카드당 ${report.runs}회`);
|
|
lines.push('기준 덱:');
|
|
for (const [classId, baseline] of Object.entries(report.baselines)) {
|
|
lines.push(` ${classId}: 승률 ${formatPercent(baseline.winRate)}, 평균 ${baseline.avgTurns.toFixed(2)}턴, 승리 HP ${baseline.avgHpOnWin.toFixed(1)}`);
|
|
}
|
|
|
|
const risky = report.rows.filter((row) => row.risks.length > 0);
|
|
lines.push('');
|
|
lines.push(`구조적 위험 ${risky.length}장:`);
|
|
for (const row of risky) {
|
|
lines.push(` ${row.name}(${row.id}, ${row.classId}): ${row.risks.join(' / ')}`);
|
|
}
|
|
|
|
lines.push('');
|
|
lines.push('동급 대비 효율 상위:');
|
|
for (const row of report.rows.slice().sort((a, b) => b.peerGap - a.peerGap).slice(0, 10)) {
|
|
lines.push(` ${row.name}(${row.id}): 중앙값 대비 +${row.peerGap.toFixed(1)}, 승률 ${formatPercent(row.winRate)}, 평균 사용 ${row.avgPlays.toFixed(2)}회`);
|
|
}
|
|
lines.push('');
|
|
lines.push('동급 대비 효율 하위:');
|
|
for (const row of report.rows.slice().sort((a, b) => a.peerGap - b.peerGap).slice(0, 10)) {
|
|
lines.push(` ${row.name}(${row.id}): 중앙값 대비 ${row.peerGap.toFixed(1)}, 승률 ${formatPercent(row.winRate)}, 평균 사용 ${row.avgPlays.toFixed(2)}회`);
|
|
}
|
|
return lines.join('\n');
|
|
}
|
|
|
|
function main() {
|
|
const args = process.argv.slice(2);
|
|
let runs = 300;
|
|
let seed = 20260701;
|
|
for (let i = 0; i < args.length; i++) {
|
|
if (args[i] === '--runs') runs = Number.parseInt(args[++i], 10);
|
|
else if (args[i] === '--seed') seed = Number.parseInt(args[++i], 10);
|
|
}
|
|
const report = auditCardEfficiency({ runs, seed });
|
|
console.log(formatEfficiencyReport(report));
|
|
if (report.rows.some((row) => row.risks.length > 0)) process.exitCode = 1;
|
|
}
|
|
|
|
if (process.argv[1] && process.argv[1].endsWith('card-efficiency.mjs')) main();
|