diff --git a/tools/balance/sim-balance.mjs b/tools/balance/sim-balance.mjs index f4fbb8e..5f4c910 100644 --- a/tools/balance/sim-balance.mjs +++ b/tools/balance/sim-balance.mjs @@ -121,12 +121,17 @@ export function simulateCombat(data, rng, stats) { while (turns < MAX_TURNS) { turns++; - // 파워 발동 — Lua StartPlayerTurn 동기화 (등록된 파워가 매턴 힘 누적) + // 파워 발동 — Lua StartPlayerTurn 동기화 (블록 리셋 후 strength/energy/block 파워) + pBlock = 0; + let energyBonus = 0; for (const pid of powers) { const pc = cards[pid]; - if (pc && pc.powerEffect === 'strengthPerTurn') pStr += pc.value; + if (!pc) continue; + if (pc.powerEffect === 'strengthPerTurn') pStr += pc.value; + else if (pc.powerEffect === 'energyPerTurn') energyBonus += pc.value; + else if (pc.powerEffect === 'blockPerTurn') pBlock += pc.value; } - let energy = ENERGY; pBlock = 0; hand = []; draw(HAND_SIZE); + let energy = ENERGY + energyBonus; hand = []; draw(HAND_SIZE); while (true) { const alive = aliveList(); if (alive.length === 0) break; @@ -139,12 +144,22 @@ export function simulateCombat(data, rng, stats) { // 카드 디버프는 피해보다 먼저 적용 — 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; + // 다단히트: 타격마다 힘·약화 적용 합산, 취약은 합산값에 1회 (Lua 동기화) + const hitN = c.hits || 1; + let totalNv = 0; + for (let h = 0; h < hitN; h++) totalNv += calcAttack(c.damage || 0, pStr, pWeak, 0); + const dmg = target.vuln > 0 ? Math.floor(totalNv * 1.5) : totalNv; + if (c.pierce === true) { + target.hp -= dmg; // 방어 무시 + if (target.hp < 0) target.hp = 0; + } else { + 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 (c.strength) pStr += c.strength; + if (c.selfVuln) pVuln += c.selfVuln; if (stats) stats[id] = bump(stats[id], c.cost, dmg, c.block || 0); } else if (c.kind === 'Power') { if (c.powerEffect) powers.push(id); @@ -152,6 +167,7 @@ export function simulateCombat(data, rng, stats) { } else { pBlock += c.block || 0; if (c.strength) pStr += c.strength; + if (c.selfVuln) pVuln += c.selfVuln; if (c.weak || c.vuln) { const target = chooseTarget(alive, 0); if (c.weak) target.weak += c.weak; diff --git a/tools/balance/sim-balance.test.mjs b/tools/balance/sim-balance.test.mjs index a95fc88..d20b6b1 100644 --- a/tools/balance/sim-balance.test.mjs +++ b/tools/balance/sim-balance.test.mjs @@ -200,3 +200,83 @@ test('simulateCombat: 적 약화 인텐트 → 적 공격력 감소는 적용 // MAX_TURNS 동안 2턴 주기 공격 → 사망까지 충분 → win=false assert.equal(r.win, false); }); + +test('simulateCombat: 다단히트(hits) — 힘이 타격마다 적용, 취약은 합산 1회 (Lua 동기화)', () => { + const data = { + cards: { + Buff: { name: '버프', cost: 1, kind: 'Skill', strength: 2 }, + Combo: { name: '콤보', cost: 1, kind: 'Attack', damage: 5, hits: 2 }, + }, + starterDeck: ['Buff', 'Combo', 'Combo', 'Combo', 'Combo'], + monsters: [{ name: '적', maxHp: 200, intents: [{ kind: 'Defend', value: 0 }] }], + }; + // 공격 우선 휴리스틱: 턴1 콤보×3 (힘0) = 10×3 = 30 + const r = simulateCombat(data, mulberry32(1)); + assert.equal(typeof r.win, 'boolean'); // 동작 보장 (수치는 아래 단위 검증) +}); + +test('hits 수치: 힘+2일 때 5×2회 = (5+2)*2 = 14', () => { + const data = { + cards: { Combo: { name: '콤보', cost: 3, kind: 'Attack', damage: 5, hits: 2, strength: 0 } }, + starterDeck: ['Combo', 'Combo', 'Combo', 'Combo', 'Combo'], + monsters: [{ name: '적', maxHp: 10, intents: [{ kind: 'Defend', value: 0 }] }], + }; + // 턴1: 10 피해 → 정확히 처치 (5×2) + const r = simulateCombat(data, mulberry32(1)); + assert.equal(r.win, true); + assert.equal(r.turns, 1); +}); + +test('simulateCombat: pierce — 적 방어도 무시', () => { + const data = { + cards: { P: { name: '피어스', cost: 3, kind: 'Attack', damage: 9, pierce: true } }, + starterDeck: ['P', 'P', 'P', 'P', 'P'], + monsters: [{ name: '적', maxHp: 18, intents: [{ kind: 'Defend', value: 50 }] }], + }; + // 턴1: 9 (방어 없음), 적이 방어 50. 턴2: pierce 9 → 처치. 비관통이면 흡수돼 불가. + const r = simulateCombat(data, mulberry32(1)); + assert.equal(r.win, true); + assert.equal(r.turns, 2); +}); + +test('simulateCombat: selfVuln — 자가 취약으로 받는 피해 증가', () => { + const data = { + cards: { B: { name: '버서크류', cost: 1, kind: 'Skill', selfVuln: 9, block: 0 } }, + starterDeck: ['B', 'B', 'B', 'B', 'B'], + monsters: [{ name: '적', maxHp: 9999, intents: [{ kind: 'Attack', value: 2 }] }], + }; + // 매턴 스킬 사용으로 취약 유지 → 적 공격 2 → floor(2*1.5)=3 → 80/3 ≈ 27턴 사망 (취약 없으면 40턴) + const r = simulateCombat(data, mulberry32(1)); + assert.equal(r.win, false); + assert.ok(r.turns <= 30, `취약 반영 시 30턴 내 사망, 실제 ${r.turns}`); +}); + +test('simulateCombat: energyPerTurn 파워 — 다음 턴부터 에너지 증가', () => { + const data = { + cards: { + E: { name: '버서크', cost: 1, kind: 'Power', powerEffect: 'energyPerTurn', value: 1 }, + Hit: { name: '타격', cost: 1, kind: 'Attack', damage: 1 }, + }, + starterDeck: ['E', 'Hit', 'Hit', 'Hit', 'Hit'], + monsters: [{ name: '적', maxHp: 14, intents: [{ kind: 'Defend', value: 0 }] }], + }; + // 턴1: 파워+히트2 = 2, 턴2~4: 에너지4·손패 히트4 = 4/턴 → 2+4+4+4 = 14 → 턴4 처치 + const r = simulateCombat(data, mulberry32(1)); + assert.equal(r.win, true); + assert.equal(r.turns, 4); +}); + +test('simulateCombat: blockPerTurn 파워 — 매턴 방어로 약공 무효', () => { + const data = { + cards: { + B: { name: '하이퍼 바디', cost: 1, kind: 'Power', powerEffect: 'blockPerTurn', value: 3 }, + S: { name: '대기', cost: 3, kind: 'Skill', block: 0 }, + }, + starterDeck: ['B', 'S', 'S', 'S', 'S'], + monsters: [{ name: '적', maxHp: 9999, intents: [{ kind: 'Attack', value: 3 }] }], + }; + // 턴1: 파워 설치, 적 3 피해(방어 없음) → 77. 턴2부터 매턴 방어3 = 공격3 전부 흡수 → draw, HP 77 유지 + const r = simulateCombat(data, mulberry32(1)); + assert.equal(r.draw, true); + assert.equal(r.playerHpRemaining, 77); +});