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();