diff --git a/tools/balance/sim-balance.mjs b/tools/balance/sim-balance.mjs index 209e970..f4fbb8e 100644 --- a/tools/balance/sim-balance.mjs +++ b/tools/balance/sim-balance.mjs @@ -27,6 +27,16 @@ export function shuffle(arr, rng) { return a; } +// 공격 피해 공식 — Lua CalcPlayerAttack(힘·약화) + DealDamageToTarget(취약)과 동기화. +// floor((base + str) * (weak>0 ? 0.75 : 1)) → floor(... * (vulnOnTarget>0 ? 1.5 : 1)) +export function calcAttack(base, str, weak, vulnOnTarget) { + let dmg = base + str; + if (weak > 0) dmg = Math.floor(dmg * 0.75); + if (vulnOnTarget > 0) dmg = Math.floor(dmg * 1.5); + if (dmg < 0) dmg = 0; + return dmg; +} + // 방어 우선 차감 후 hp 적용 → { hp, block } export function applyDamage(hp, block, amount) { let dmg = amount; @@ -54,14 +64,16 @@ export function loadData() { // 주의: 인게임은 플레이어가 카드를 직접 선택한다. 이 chooseAction은 밸런스 추정용 자동 플레이 휴리스틱일 뿐 // 이며, Lua에 대응 AI가 없다(동기화 대상은 데미지/방어/의도/승패 규칙이지 플레이어 선택이 아님). -// 손패에서 낼 카드 인덱스(-1=종료). 공격 우선, 없으면 스킬. +// 손패에서 낼 카드 인덱스(-1=종료). 파워 우선(지속 가치) → 공격 → 스킬. export function chooseAction(hand, cards, energy) { const entries = hand.map((id, i) => ({ id, i })).filter((x) => cards[x.id].cost <= energy); + const powers = entries.filter((x) => cards[x.id].kind === 'Power'); const attacks = entries.filter((x) => cards[x.id].kind === 'Attack'); const skills = entries.filter((x) => cards[x.id].kind === 'Skill'); - const dmgEff = (x) => (cards[x.id].damage || 0) / cards[x.id].cost; - const blkEff = (x) => (cards[x.id].block || 0) / cards[x.id].cost; + const dmgEff = (x) => (cards[x.id].damage || 0) / Math.max(cards[x.id].cost, 1); + const blkEff = (x) => (cards[x.id].block || 0) / Math.max(cards[x.id].cost, 1); const bestBy = (list, fn) => list.slice().sort((a, b) => fn(b) - fn(a))[0]; + if (powers.length) return powers[0].i; if (attacks.length) return bestBy(attacks, dmgEff).i; if (skills.length) return bestBy(skills, blkEff).i; return -1; @@ -90,8 +102,10 @@ export function simulateCombat(data, rng, stats) { let discard = []; let hand = []; let pHp = PLAYER_HP, pBlock = 0; + let pStr = 0, pWeak = 0, pVuln = 0; + const powers = []; const mob = monsters.map((m) => ({ - name: m.name, hp: m.maxHp, maxHp: m.maxHp, block: 0, + name: m.name, hp: m.maxHp, maxHp: m.maxHp, block: 0, str: 0, weak: 0, vuln: 0, intents: m.intents, intentIdx: 0, alive: true, })); let turns = 0; @@ -107,6 +121,11 @@ export function simulateCombat(data, rng, stats) { while (turns < MAX_TURNS) { turns++; + // 파워 발동 — Lua StartPlayerTurn 동기화 (등록된 파워가 매턴 힘 누적) + for (const pid of powers) { + const pc = cards[pid]; + if (pc && pc.powerEffect === 'strengthPerTurn') pStr += pc.value; + } let energy = ENERGY; pBlock = 0; hand = []; draw(HAND_SIZE); while (true) { const alive = aliveList(); @@ -116,29 +135,56 @@ export function simulateCombat(data, rng, stats) { const id = hand[idx], c = cards[id]; energy -= c.cost; if (c.kind === 'Attack') { - const target = chooseTarget(alive, c.damage || 0); - const r = applyDamage(target.hp, target.block, c.damage || 0); + const target = chooseTarget(alive, calcAttack(c.damage || 0, pStr, pWeak, 0)); + // 카드 디버프는 피해보다 먼저 적용 — Lua PlayCard(즉시 부여) + 지연 데미지(0.35s) 동기화 + if (c.weak) target.weak += c.weak; + if (c.vuln) target.vuln += c.vuln; + const dmg = calcAttack(c.damage || 0, pStr, pWeak, target.vuln); + const r = applyDamage(target.hp, target.block, dmg); target.hp = r.hp; target.block = r.block; if (target.hp <= 0) target.alive = false; if (c.block) pBlock += c.block; - if (stats) stats[id] = bump(stats[id], c.cost, c.damage || 0, c.block || 0); + if (c.strength) pStr += c.strength; + if (stats) stats[id] = bump(stats[id], c.cost, dmg, c.block || 0); + } else if (c.kind === 'Power') { + if (c.powerEffect) powers.push(id); + if (stats) stats[id] = bump(stats[id], c.cost, 0, 0); } else { pBlock += c.block || 0; + if (c.strength) pStr += c.strength; + if (c.weak || c.vuln) { + const target = chooseTarget(alive, 0); + if (c.weak) target.weak += c.weak; + if (c.vuln) target.vuln += c.vuln; + } if (stats) stats[id] = bump(stats[id], c.cost, 0, c.block || 0); } - hand.splice(idx, 1); discard.push(id); + hand.splice(idx, 1); + if (c.kind !== 'Power') discard.push(id); // 파워는 소멸 — Lua 동기화 if (aliveList().length === 0) return { win: true, turns, playerHpRemaining: pHp }; } discard.push(...hand); hand = []; + // 플레이어 디버프 감소 — Lua EndPlayerTurn 동기화 (적 행동 전) + if (pWeak > 0) pWeak--; + if (pVuln > 0) pVuln--; for (const m of mob) { if (!m.alive) continue; m.block = 0; // 매 턴 초기화 (이전 턴 블록 미이월) const it = m.intents[m.intentIdx]; if (it) { - if (it.kind === 'Attack') { const r = applyDamage(pHp, pBlock, it.value); pHp = r.hp; pBlock = r.block; } - else if (it.kind === 'Defend') { m.block += it.value; } + if (it.kind === 'Attack') { + const atk = calcAttack(it.value, m.str, m.weak, pVuln); + const r = applyDamage(pHp, pBlock, atk); pHp = r.hp; pBlock = r.block; + } else if (it.kind === 'Defend') { m.block += it.value; } + else if (it.kind === 'Debuff') { + if (it.effect === 'weak') pWeak += it.value; + else if (it.effect === 'vuln') pVuln += it.value; + } } m.intentIdx = (m.intentIdx + 1) % m.intents.length; + // 적 디버프 감소 — Lua EnemyActStep 동기화 (자기 행동 후) + if (m.weak > 0) m.weak--; + if (m.vuln > 0) m.vuln--; if (pHp <= 0) return { win: false, turns, playerHpRemaining: 0 }; } } @@ -188,11 +234,11 @@ export function formatReport(r) { const eff = kind === 'Attack' ? s.damage / s.energy : s.block / s.energy; return { id, name: r.cards[id].name, kind, plays: s.plays, eff }; }); - for (const kind of ['Attack', 'Skill']) { + for (const kind of ['Attack', 'Skill', 'Power']) { const kr = rows.filter((x) => x.kind === kind); if (!kr.length) continue; const med = median(kr.map((x) => x.eff)); - const unit = kind === 'Attack' ? '뎀/E' : '블록/E'; + const unit = kind === 'Attack' ? '뎀/E' : kind === 'Power' ? '(지속)' : '블록/E'; for (const x of kr) { const op = med > 0 && x.eff >= med * 1.5 ? ' ⚠️ OP 의심' : ''; L.push(` ${x.name}(${x.id}): 사용 ${x.plays}, 효율 ${x.eff.toFixed(2)} ${unit}${op}`); diff --git a/tools/balance/sim-balance.test.mjs b/tools/balance/sim-balance.test.mjs index 6401afd..a95fc88 100644 --- a/tools/balance/sim-balance.test.mjs +++ b/tools/balance/sim-balance.test.mjs @@ -1,7 +1,7 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { - mulberry32, applyDamage, chooseAction, chooseTarget, simulateCombat, runBatch, + mulberry32, applyDamage, chooseAction, chooseTarget, simulateCombat, runBatch, calcAttack, } from './sim-balance.mjs'; test('applyDamage: 방어 우선 차감 후 hp', () => { @@ -131,3 +131,72 @@ test('simulateCombat: 복합 카드(공격+방어) 블록이 적 공격을 흡 assert.equal(r.draw, true); assert.equal(r.playerHpRemaining, 80); }); + +test('calcAttack: 힘·약화·취약 공식 (Lua CalcPlayerAttack·DealDamageToTarget 동기화)', () => { + assert.equal(calcAttack(6, 0, 0, 0), 6); // 기본 + assert.equal(calcAttack(6, 2, 0, 0), 8); // 힘+2 + assert.equal(calcAttack(6, 0, 1, 0), 4); // 약화 floor(6*0.75) + assert.equal(calcAttack(6, 0, 0, 1), 9); // 취약 floor(6*1.5) + assert.equal(calcAttack(10, 2, 1, 1), 13); // floor(floor(12*0.75)=9 → floor(9*1.5))=13 +}); + +test('simulateCombat: 적 Debuff 인텐트만 사용 → 플레이어 무피해', () => { + const data = { + cards: { Hit: { name: '타격', cost: 1, kind: 'Attack', damage: 1 } }, + starterDeck: ['Hit', 'Hit', 'Hit', 'Hit', 'Hit'], + monsters: [{ name: '적', maxHp: 9999, intents: [{ kind: 'Debuff', effect: 'weak', value: 1 }] }], + }; + const r = simulateCombat(data, mulberry32(1)); + assert.equal(r.playerHpRemaining, 80); +}); + +test('simulateCombat: 플레이어 약화 시 공격 피해 감소 반영', () => { + // 약화 영구 부여 적: 4피해 카드가 floor(4*0.75)=3으로 감소 + const data = { + cards: { Hit: { name: '타격', cost: 3, kind: 'Attack', damage: 4 } }, + starterDeck: ['Hit', 'Hit', 'Hit', 'Hit', 'Hit'], + monsters: [{ name: '적', maxHp: 10, intents: [{ kind: 'Debuff', effect: 'weak', value: 99 }] }], + }; + const r = simulateCombat(data, mulberry32(1)); + // 턴1: 4 (약화 전), 이후 매턴 3 → 10피해 도달 = 턴3 (4+3+3) + assert.equal(r.win, true); + assert.equal(r.turns, 3); +}); + +test('simulateCombat: 카드 취약 부여가 같은 카드 피해에 선적용 (Lua 동기화)', () => { + const data = { + cards: { CB: { name: '차지', cost: 3, kind: 'Attack', damage: 8, vuln: 2 } }, + starterDeck: ['CB', 'CB', 'CB', 'CB', 'CB'], + monsters: [{ name: '적', maxHp: 12, intents: [{ kind: 'Defend', value: 0 }] }], + }; + const r = simulateCombat(data, mulberry32(1)); + // 취약 선적용이면 floor(8*1.5)=12 → 1턴 처치. 후적용이면 8 → 2턴. + assert.equal(r.turns, 1); +}); + +test('simulateCombat: Power(매턴 힘) 누적', () => { + const data = { + cards: { + Rage: { name: '분노', cost: 1, kind: 'Power', powerEffect: 'strengthPerTurn', value: 5 }, + Hit: { name: '타격', cost: 1, kind: 'Attack', damage: 1 }, + }, + starterDeck: ['Rage', 'Hit', 'Hit', 'Hit', 'Hit'], + monsters: [{ name: '적', maxHp: 60, intents: [{ kind: 'Defend', value: 0 }] }], + }; + const r = simulateCombat(data, mulberry32(1)); + assert.equal(r.win, true); + assert.ok(r.turns <= 6, `파워 누적으로 빠른 처치 기대, 실제 ${r.turns}턴`); +}); + +test('simulateCombat: 적 약화 인텐트 → 적 공격력 감소는 적용 안 됨(적 자신은 약화 안 걸림)', () => { + // 회귀 가드: Debuff 인텐트는 플레이어에게만 적용 + const data = { + cards: { Skip: { name: '대기', cost: 3, kind: 'Skill', block: 0 } }, + starterDeck: ['Skip', 'Skip', 'Skip', 'Skip', 'Skip'], + monsters: [{ name: '적', maxHp: 9999, intents: [{ kind: 'Debuff', effect: 'vuln', value: 1 }, { kind: 'Attack', value: 10 }] }], + }; + const r = simulateCombat(data, mulberry32(1)); + // 턴1: 취약1 부여 → 플레이어 취약. 턴1 종료 시 1 감소 → 0. 턴2: 공격 10 (취약 소멸) → 정확히 10만 피해. + // MAX_TURNS 동안 2턴 주기 공격 → 사망까지 충분 → win=false + assert.equal(r.win, false); +});