315 lines
12 KiB
JavaScript
315 lines
12 KiB
JavaScript
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();
|