feat(buffs-power): 밸런스 시뮬 버프/디버프·Power 동기화

- calcAttack(힘·약화·취약) 공식 export + 단위 테스트
- 적 Debuff 인텐트·플레이어/적 디버프 감소 타이밍 Lua 동기화
- Power 등록·매턴 발동·소멸 재현, chooseAction 파워 우선
- 신규 테스트 6건 (총 21건 통과)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-12 01:32:31 +09:00
parent 00844857ee
commit 0a96a6955a
2 changed files with 128 additions and 13 deletions

View File

@@ -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}`);