import { readFileSync } from 'node:fs'; import { mulberry32, rarityForRoll, simulateCombat } from './sim-balance.mjs'; import { ACT_DIFFICULTY_MULTIPLIERS } from '../deck/lib/codeblock.mjs'; const cardsData = JSON.parse(readFileSync('data/cards.json', 'utf8')); const enemiesData = JSON.parse(readFileSync('data/enemies.json', 'utf8')); const relicsData = JSON.parse(readFileSync('data/relics.json', 'utf8')); const PLAYER_MAX_HP = 70; const REST_HEAL = 30; const SECTION_COUNT = 5; const NORMAL_FIGHTS = 4; export const DEFAULT_SECTION_MULTIPLIERS = ACT_DIFFICULTY_MULTIPLIERS; const COMBAT_POOL = ['orange_mushroom', 'green_mushroom', 'pig', 'blue_mushroom', 'red_snail', 'stump']; const ELITE_POOL = ['mushmom', 'modified_snail']; const BOSS_POOL = ['king_slime', 'slime_boss']; const JOBS = { thief: { tier2: 'thief', tier3: 'thiefmaster', tier2Starter: 'DaggerAcceleration', tier3Starter: 'Venom' }, assassin: { tier2: 'assassin', tier3: 'hermit', tier2Starter: 'JavelinAcceleration', tier3Starter: 'SpiritJavelin' }, }; const LINEAGES = { rogue: ['rogue'], thief: ['rogue', 'thief'], thiefmaster: ['rogue', 'thief', 'thiefmaster'], assassin: ['rogue', 'assassin'], hermit: ['rogue', 'assassin', 'hermit'], }; const pick = (rng, values) => values[Math.floor(rng() * values.length)]; export function campaignJobAtSection(branch, section) { if (section <= 1) return 'rogue'; if (section === 2) return JOBS[branch].tier2; return JOBS[branch].tier3; } export function playableClassesForJob(job) { return LINEAGES[job] || [job]; } export function scaleEnemy(enemy, section, rng = () => 0, scaleStep = null) { const multiplier = scaleStep == null ? (DEFAULT_SECTION_MULTIPLIERS[section - 1] || DEFAULT_SECTION_MULTIPLIERS.at(-1)) : 1 + (section - 1) * scaleStep; const offset = enemy.intents.length > 0 ? Math.floor(rng() * enemy.intents.length) : 0; const rotatedIntents = enemy.intents.map((_, index) => enemy.intents[(index + offset) % enemy.intents.length]); return { ...enemy, maxHp: Math.floor(enemy.maxHp * multiplier), intents: rotatedIntents.map((intent) => ({ ...intent, value: intent.kind === 'Debuff' || intent.value == null ? intent.value : Math.floor(intent.value * multiplier), })), }; } function buildEncounter(kind, section, rng, scaleStep) { const ids = []; if (kind === 'normal') { const count = 1 + Math.floor(rng() * 3); for (let i = 0; i < count; i++) ids.push(pick(rng, COMBAT_POOL)); } else if (kind === 'elite') { ids.push(pick(rng, ELITE_POOL)); const extra = Math.floor(rng() * 3); for (let i = 0; i < extra; i++) ids.push(pick(rng, COMBAT_POOL)); } else { ids.push(pick(rng, BOSS_POOL)); } return ids.map((id) => scaleEnemy(enemiesData.enemies[id], section, rng, scaleStep)); } function baseCardValue(card) { const hits = card.hits || 1; const targets = card.aoe ? 1.7 : 1; let value = (card.damage || 0) * hits * targets; value += (card.block || 0) + (card.nextTurnBlock || 0) * 0.7; value += (card.poison || 0) * (card.poisonHits || 1) * (card.affectsAllEnemies ? 2 : 1) * 1.5; value += (card.draw || 0) * 4 + (card.gainEnergy || 0) * 5; value += (card.addShiv || 0) * 4; value += (card.strength || 0) * 6 + (card.dex || 0) * 5; value += (card.weak || 0) * 3 + (card.vuln || 0) * 4; value += (card.intangible || 0) * 12; value += (card.turnStartShiv || 0) * 8 + (card.shivDamageBonus || 0) * 4; value += (card.cardPlayedBlock || 0) * 8 + (card.attackPoison || 0) * 8; value += (card.powerEffect ? 7 : 0) + (card.retain ? 2 : 0) + (card.sly ? 3 : 0); value += (card.damagePerDiscardedThisTurn || 0) * 2; value += (card.damagePerAttackPlayedThisTurn || 0) * 2; value += (card.firstShivDamageBonus || 0) * 2; value -= (card.cost || 0) * 5; if (card.exhaust) value -= 2; return value; } function branchCardValue(card, branch, deck, id) { let value = baseCardValue(card); if (branch === 'thief') { value += (card.poison || 0) * 1.5 + (card.attackPoison || 0) * 8; value += card.sly ? 5 : 0; value += (card.discard || 0) * 2 + (card.drawPerDiscarded || 0) * 4; value += (card.poisonApplicationBurstDamage || 0) * 1.5; } else { value += (card.addShiv || 0) * 3 + (card.turnStartShiv || 0) * 8; value += (card.shivDamageBonus || 0) * 6 + (card.firstShivDamageBonus || 0) * 3; value += card.shivAoe ? 12 : 0; value += card.shivRetain ? 5 : 0; } const copies = deck.filter((cardId) => cardId === id).length; value -= copies * (card.kind === 'Power' ? 10 : 3); return value; } function rewardPool(job) { const classes = new Set(playableClassesForJob(job)); return Object.entries(cardsData.cards) .filter(([, card]) => classes.has(card.class) && card.token !== true && card.unplayable !== true); } function offerReward(job, branch, deck, rng, minimumValue) { const pool = rewardPool(job); const choices = []; for (let i = 0; i < 3; i++) { const rarity = rarityForRoll(1 + Math.floor(rng() * 100)); const bucket = pool.filter(([, card]) => card.rarity === rarity); choices.push(pick(rng, bucket.length > 0 ? bucket : pool)); } choices.sort((a, b) => branchCardValue(b[1], branch, deck, b[0]) - branchCardValue(a[1], branch, deck, a[0])); const [id, card] = choices[0]; if (branchCardValue(card, branch, deck, id) >= minimumValue) deck.push(id); } function relicModifiers(state) { const result = { playerStartBlock: 0, playerStrength: 0, playerThorns: 0, energyBonus: 0, openingDrawBonus: 0, healOnAttack: 0, }; for (const id of state.relics) { const relic = relicsData.relics[id]; if (!relic) continue; if (relic.hook === 'combatStart' && relic.effect === 'block') result.playerStartBlock += relic.value; else if (relic.hook === 'combatStart' && relic.effect === 'strength') result.playerStrength += relic.value; else if (relic.hook === 'turnStart' && relic.effect === 'energy') result.energyBonus += relic.value; else if (relic.hook === 'combatStart' && relic.effect === 'draw') result.openingDrawBonus += relic.value; else if (relic.effect === 'thorns') result.playerThorns += relic.value; else if (relic.effect === 'healOnAttack') result.healOnAttack += relic.value; } return result; } function healFromRelics(state, hook) { for (const id of state.relics) { const relic = relicsData.relics[id]; if (!relic || relic.hook !== hook) continue; if (relic.effect === 'heal') state.hp = Math.min(state.maxHp, state.hp + relic.value); else if (relic.effect === 'healOnWin') state.hp = Math.min(state.maxHp, state.hp + relic.value); else if (relic.effect === 'healIfLow' && state.hp <= state.maxHp * 0.5) state.hp = Math.min(state.maxHp, state.hp + relic.value); } } function acquireRelic(state, rng) { const available = relicsData.relicPool.filter((id) => !state.relics.includes(id)); if (available.length === 0) return; const id = pick(rng, available); state.relics.push(id); const relic = relicsData.relics[id]; if (relic?.effect === 'maxHp') { state.maxHp += relic.value; state.hp += relic.value; } } function fight(state, branch, kind, section, rng, options) { const monsters = buildEncounter(kind, section, rng, options.scaleStep); healFromRelics(state, 'combatStart'); const result = simulateCombat({ cards: cardsData.cards, starterDeck: state.deck, monsters, playerHp: state.hp, playerMaxHp: state.maxHp, smartPlayer: true, ...relicModifiers(state), }, rng); state.hp = result.playerHpRemaining; state.turns += result.turns; if (!result.win) return false; healFromRelics(state, 'combatEnd'); if (kind !== 'boss') offerReward(state.job, branch, state.deck, rng, options.minimumRewardValue); return true; } export function simulateCampaign(branch, rng, { restHeal = REST_HEAL, sectionHeal = 0, scaleStep = null, minimumRewardValue = 10, } = {}) { if (!JOBS[branch]) throw new Error(`지원하지 않는 도적 분기: ${branch}`); const state = { hp: PLAYER_MAX_HP, maxHp: PLAYER_MAX_HP, deck: cardsData.starterDecks.rogue.slice(), job: 'rogue', turns: 0, sectionCleared: 0, diedAt: '', hpAfterSections: [], relics: [relicsData.startingRelic], }; const options = { scaleStep, minimumRewardValue }; for (let section = 1; section <= SECTION_COUNT; section++) { state.job = campaignJobAtSection(branch, section); for (let fightIndex = 1; fightIndex <= NORMAL_FIGHTS; fightIndex++) { if (!fight(state, branch, 'normal', section, rng, options)) { state.diedAt = `${section}-normal`; return state; } } state.hp = Math.min(state.maxHp, state.hp + restHeal); if (!fight(state, branch, 'elite', section, rng, options)) { state.diedAt = `${section}-elite`; return state; } acquireRelic(state, rng); if (!fight(state, branch, 'boss', section, rng, options)) { state.diedAt = `${section}-boss`; return state; } state.sectionCleared = section; state.hpAfterSections.push(state.hp); if (section === 1) state.deck.push(JOBS[branch].tier2Starter); if (section === 2) state.deck.push(JOBS[branch].tier3Starter); if (section >= 3) acquireRelic(state, rng); if (section < SECTION_COUNT) state.hp = Math.min(state.maxHp, state.hp + sectionHeal); } return state; } export function runCampaignBatch(branch, runs = 1000, seed = 20260701, options = {}) { const sectionReached = Array(SECTION_COUNT).fill(0); const sectionClears = Array(SECTION_COUNT).fill(0); const deaths = {}; let fullClears = 0; let totalDeckSize = 0; let totalFinalHp = 0; let totalTurns = 0; for (let i = 0; i < runs; i++) { const rng = mulberry32((seed + Math.imul(i + 1, 0x9e3779b1)) >>> 0); const result = simulateCampaign(branch, rng, options); for (let section = 0; section < SECTION_COUNT; section++) { if (result.sectionCleared >= section) sectionReached[section]++; if (result.sectionCleared >= section + 1) sectionClears[section]++; } if (result.sectionCleared === SECTION_COUNT) { fullClears++; totalFinalHp += result.hp; } if (result.diedAt) deaths[result.diedAt] = (deaths[result.diedAt] || 0) + 1; totalDeckSize += result.deck.length; totalTurns += result.turns; } return { branch, runs, fullClearRate: fullClears / runs, avgFinalHp: fullClears > 0 ? totalFinalHp / fullClears : 0, avgDeckSize: totalDeckSize / runs, avgTurns: totalTurns / runs, sectionConditionalClearRates: sectionClears.map((clears, index) => sectionReached[index] > 0 ? clears / sectionReached[index] : 0), sectionReachRates: sectionReached.map((reached) => reached / runs), deaths, }; } export function formatCampaignReport(result) { const lines = []; lines.push(`${result.branch} 캠페인 ${result.runs}회`); lines.push(` 전체 클리어 ${(result.fullClearRate * 100).toFixed(1)}%, 클리어 HP ${result.avgFinalHp.toFixed(1)}, 평균 덱 ${result.avgDeckSize.toFixed(1)}장`); result.sectionConditionalClearRates.forEach((rate, index) => { lines.push(` 섹션 ${index + 1}: 도달 ${(result.sectionReachRates[index] * 100).toFixed(1)}%, 도달자 클리어 ${(rate * 100).toFixed(1)}%`); }); return lines.join('\n'); } function main() { const args = process.argv.slice(2); let runs = 1000; let seed = 20260701; let restHeal = REST_HEAL; let sectionHeal = 0; let scaleStep = null; let minimumRewardValue = 10; 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); else if (args[i] === '--rest-heal') restHeal = Number.parseInt(args[++i], 10); else if (args[i] === '--section-heal') sectionHeal = Number.parseInt(args[++i], 10); else if (args[i] === '--scale-step') scaleStep = Number.parseFloat(args[++i]); else if (args[i] === '--reward-min') minimumRewardValue = Number.parseFloat(args[++i]); } for (const branch of ['thief', 'assassin']) { console.log(formatCampaignReport(runCampaignBatch(branch, runs, seed, { restHeal, sectionHeal, scaleStep, minimumRewardValue }))); } } if (process.argv[1] && process.argv[1].endsWith('rogue-campaign.mjs')) main();