balance: 전사 계열 5섹션 성장 곡선 조정

This commit is contained in:
2026-07-04 02:22:17 +09:00
parent 90494232bc
commit 758ebd19f2
6 changed files with 153 additions and 91 deletions

View File

@@ -6,7 +6,10 @@ import {
simulateCombat,
} from './sim-balance.mjs';
const ROGUE_CLASSES = new Set(['rogue', 'thief', 'thiefmaster', 'assassin', 'hermit']);
const AUDITED_CLASSES = new Set([
'rogue', 'thief', 'thiefmaster', 'assassin', 'hermit',
'warrior', 'fighter', 'crusader', 'page', 'knight',
]);
const CONTEXT_DECKS = {
rogue: [
@@ -38,6 +41,27 @@ const CONTEXT_DECKS = {
'Survivor', 'LeadingStrike', 'BladeDance', 'JavelinAcceleration',
'JavelinMastery', 'TripleThrow', 'SpiritJavelin', 'SkilledJavelin',
],
warrior: [
'Strike', 'Strike', 'Strike', 'Strike',
'Defend', 'Defend', 'Defend', 'Defend',
'Bash', 'SlashBlast', 'IronBody', 'WarriorMastery',
],
fighter: [
'Strike', 'Strike', 'Strike', 'Defend', 'Defend', 'Defend',
'Bash', 'SlashBlast', 'ComboAttack', 'Brandish', 'WeaponMastery', 'FlashSlash',
],
crusader: [
'Strike', 'Strike', 'Defend', 'Defend', 'Bash', 'SlashBlast',
'ComboAttack', 'Brandish', 'WeaponMastery', 'BraveSlash', 'ComboSynergy', 'Rush',
],
page: [
'Strike', 'Strike', 'Strike', 'Defend', 'Defend', 'Defend',
'Bash', 'SlashBlast', 'HolyCharge', 'DivineSwing', 'PageOrder', 'PageStance',
],
knight: [
'Strike', 'Strike', 'Defend', 'Defend', 'Bash', 'SlashBlast',
'HolyCharge', 'DivineSwing', 'PageOrder', 'DivineCharge', 'KnightRush', 'Restoration',
],
};
const ENCOUNTER_SCALE = {
@@ -46,6 +70,11 @@ const ENCOUNTER_SCALE = {
assassin: { hp: 2.25, attack: 1.65 },
thiefmaster: { hp: 2.4, attack: 1.5 },
hermit: { hp: 2.6, attack: 1.65 },
warrior: { hp: 1.9, attack: 1.5 },
fighter: { hp: 2.2, attack: 1.6 },
crusader: { hp: 2.6, attack: 1.7 },
page: { hp: 2.2, attack: 1.6 },
knight: { hp: 2.6, attack: 1.7 },
};
const median = (values) => {
@@ -172,7 +201,7 @@ export function auditCardEfficiency({ runs = 300, seed = 20260701 } = {}) {
const rows = [];
for (const [id, card] of Object.entries(cards)) {
if (!ROGUE_CLASSES.has(card.class)) continue;
if (!AUDITED_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);
@@ -204,7 +233,7 @@ function formatPercent(value) {
export function formatEfficiencyReport(report) {
const lines = [];
lines.push(`도적 카드 효율 검증: 카드 ${report.rows.length}장, 카드당 ${report.runs}`);
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)}`);

View File

@@ -6,7 +6,7 @@ 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 PLAYER_MAX_HP = { rogue: 70, warrior: 80 };
const REST_HEAL = 30;
const SECTION_COUNT = 5;
const NORMAL_FIGHTS = 4;
@@ -18,6 +18,8 @@ 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' },
fighter: { root: 'warrior', tier2: 'fighter', tier3: 'crusader', tier2Starter: 'ComboAttack', tier3Starter: 'ComboSynergy' },
page: { root: 'warrior', tier2: 'page', tier3: 'knight', tier2Starter: 'HolyCharge', tier3Starter: 'DivineCharge' },
};
const LINEAGES = {
@@ -26,12 +28,17 @@ const LINEAGES = {
thiefmaster: ['rogue', 'thief', 'thiefmaster'],
assassin: ['rogue', 'assassin'],
hermit: ['rogue', 'assassin', 'hermit'],
warrior: ['warrior'],
fighter: ['warrior', 'fighter'],
crusader: ['warrior', 'fighter', 'crusader'],
page: ['warrior', 'page'],
knight: ['warrior', 'page', 'knight'],
};
const pick = (rng, values) => values[Math.floor(rng() * values.length)];
export function campaignJobAtSection(branch, section) {
if (section <= 1) return 'rogue';
if (section <= 1) return JOBS[branch].root || 'rogue';
if (section === 2) return JOBS[branch].tier2;
return JOBS[branch].tier3;
}
@@ -102,11 +109,22 @@ function branchCardValue(card, branch, deck, id) {
value += card.sly ? 5 : 0;
value += (card.discard || 0) * 2 + (card.drawPerDiscarded || 0) * 4;
value += (card.poisonApplicationBurstDamage || 0) * 1.5;
} else {
} else if (branch === 'assassin') {
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;
} else if (branch === 'fighter') {
value += (card.hits || 1) * 1.5;
value += (card.comboGain || 0) * 5 + (card.comboOnAttack || 0) * 10;
value += (card.damagePerCombo || 0) * 8 + (card.attackDamagePerCombo || 0) * 12;
value += (card.attackPlayedDamage || 0) * 5;
} else if (branch === 'page') {
value += (card.block || 0) * 0.5 + (card.cardPlayedBlock || 0) * 7;
value += (card.holyChargeOnHolyForce || 0) * 12 + (card.damagePerHolyCharge || 0) * 7;
value += (card.blockPerHolyCharge || 0) * 6 + (card.healPerHolyCharge || 0) * 3;
value += (card.damageTakenReduction || 0) * 40;
value += (card.blockOnDamaged || 0) * 3 + (card.strengthOnDamagedOnce || 0) * 5;
}
const copies = deck.filter((cardId) => cardId === id).length;
value -= copies * (card.kind === 'Power' ? 10 : 3);
@@ -203,11 +221,13 @@ export function simulateCampaign(branch, rng, {
minimumRewardValue = 10,
} = {}) {
if (!JOBS[branch]) throw new Error(`지원하지 않는 도적 분기: ${branch}`);
const root = JOBS[branch].root || 'rogue';
const maxHp = PLAYER_MAX_HP[root];
const state = {
hp: PLAYER_MAX_HP,
maxHp: PLAYER_MAX_HP,
deck: cardsData.starterDecks.rogue.slice(),
job: 'rogue',
hp: maxHp,
maxHp,
deck: cardsData.starterDecks[root].slice(),
job: root,
turns: 0,
sectionCleared: 0,
diedAt: '',
@@ -306,7 +326,7 @@ function main() {
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']) {
for (const branch of ['thief', 'assassin', 'fighter', 'page']) {
console.log(formatCampaignReport(runCampaignBatch(branch, runs, seed, { restHeal, sectionHeal, scaleStep, minimumRewardValue })));
}
}

View File

@@ -17,6 +17,16 @@ test('도적 전직 시점: 1섹션 Rogue, 2섹션 2차, 3섹션부터 3차', ()
test('3차 직업은 자기 계보 카드만 사용', () => {
assert.deepEqual(playableClassesForJob('thiefmaster'), ['rogue', 'thief', 'thiefmaster']);
assert.deepEqual(playableClassesForJob('hermit'), ['rogue', 'assassin', 'hermit']);
assert.deepEqual(playableClassesForJob('crusader'), ['warrior', 'fighter', 'crusader']);
assert.deepEqual(playableClassesForJob('knight'), ['warrior', 'page', 'knight']);
});
test('전사 전직 시점: 1섹션 Warrior, 2섹션 2차, 3섹션부터 3차', () => {
assert.equal(campaignJobAtSection('fighter', 1), 'warrior');
assert.equal(campaignJobAtSection('fighter', 2), 'fighter');
assert.equal(campaignJobAtSection('fighter', 3), 'crusader');
assert.equal(campaignJobAtSection('page', 2), 'page');
assert.equal(campaignJobAtSection('page', 5), 'knight');
});
test('섹션 난이도는 3차 이후 더 빠르게 증가', () => {