import { test } from 'node:test'; import assert from 'node:assert/strict'; import { mulberry32, applyDamage, chooseAction, chooseTarget, simulateCombat, runBatch, calcAttack, rarityForRoll, } from './sim-balance.mjs'; test('rarityForRoll: 70/25/5 경계 (Lua OfferReward 미러)', () => { assert.equal(rarityForRoll(1), 'normal'); assert.equal(rarityForRoll(70), 'normal'); assert.equal(rarityForRoll(71), 'unique'); assert.equal(rarityForRoll(95), 'unique'); assert.equal(rarityForRoll(96), 'legend'); assert.equal(rarityForRoll(100), 'legend'); }); test("simulateCombat: nextTurnBlock grants block on the following turn", () => { const data = { cards: { GuardLater: { name: "예약 방어", cost: 0, kind: "Skill", nextTurnBlock: 4 }, Pass: { name: "대기", cost: 99, kind: "Skill", block: 0 }, }, starterDeck: ["GuardLater", "Pass"], monsters: [{ name: "Dummy", maxHp: 99, intents: [{ kind: "Attack", value: 3 }, { kind: "Attack", value: 3 }] }], }; const r = simulateCombat(data, () => 0.999999); assert.equal(r.win, false); assert.equal(r.draw, true); assert.equal(r.playerHpRemaining, 77); }); test("simulateCombat: nextTurnDraw draws extra cards next turn", () => { const data = { cards: { Setup: { name: "설치", cost: 0, kind: "Skill", nextTurnDraw: 2 }, Hit1: { name: "타격1", cost: 0, kind: "Attack", damage: 3 }, Hit2: { name: "타격2", cost: 0, kind: "Attack", damage: 3 }, Pass1: { name: "대기1", cost: 99, kind: "Skill", block: 0 }, Pass2: { name: "대기2", cost: 99, kind: "Skill", block: 0 }, Pass3: { name: "대기3", cost: 99, kind: "Skill", block: 0 }, Pass4: { name: "대기4", cost: 99, kind: "Skill", block: 0 }, }, starterDeck: ["Hit1", "Hit2", "Pass1", "Pass2", "Pass3", "Pass4", "Setup"], monsters: [{ name: "Dummy", maxHp: 6, intents: [{ kind: "Attack", value: 0 }] }], }; const r = simulateCombat(data, () => 0.999999); assert.equal(r.win, true); assert.equal(r.turns, 2); }); test("simulateCombat: nextTurnKeepBlock preserves current block", () => { const data = { cards: { BlurLater: { name: "흐릿함", cost: 0, kind: "Skill", block: 5, nextTurnKeepBlock: true }, Pass: { name: "대기", cost: 99, kind: "Skill", block: 0 }, }, starterDeck: ["BlurLater", "Pass"], monsters: [{ name: "Dummy", maxHp: 99, intents: [{ kind: "Attack", value: 3 }, { kind: "Attack", value: 3 }] }], }; const r = simulateCombat(data, () => 0.999999); assert.equal(r.win, false); assert.equal(r.draw, true); assert.equal(r.playerHpRemaining, 80); }); test("simulateCombat: nextTurnAttackMultiplier boosts attacks next turn", () => { const data = { cards: { Prep: { name: "그림자 걸음", cost: 0, kind: "Skill", nextTurnAttackMultiplier: 2 }, Hit: { name: "타격", cost: 0, kind: "Attack", damage: 3 }, Pass: { name: "대기", cost: 99, kind: "Skill", block: 0 }, }, starterDeck: ["Prep", "Pass", "Hit"], monsters: [{ name: "Dummy", maxHp: 6, intents: [{ kind: "Attack", value: 0 }] }], }; const r = simulateCombat(data, () => 0.999999); assert.equal(r.win, true); assert.equal(r.turns, 2); }); test("simulateCombat: nextTurnSelectHandCard queues selected copies for next turn", () => { const data = { cards: { Nightmare: { name: "악몽", cost: 0, kind: "Skill", nextTurnCopies: 3, nextTurnSelectHandCard: true }, Hit: { name: "타격", cost: 0, kind: "Attack", damage: 2 }, Pass: { name: "대기", cost: 99, kind: "Skill", block: 0 }, }, starterDeck: ["Pass", "Nightmare", "Hit"], monsters: [{ name: "Dummy", maxHp: 6, intents: [{ kind: "Attack", value: 0 }] }], }; const r = simulateCombat(data, () => 0.999999); assert.equal(r.win, true); assert.equal(r.turns, 4); }); test('applyDamage: 방어 우선 차감 후 hp', () => { assert.deepEqual(applyDamage(80, 0, 10), { hp: 70, block: 0 }); assert.deepEqual(applyDamage(80, 5, 10), { hp: 75, block: 0 }); assert.deepEqual(applyDamage(80, 12, 10), { hp: 80, block: 2 }); assert.deepEqual(applyDamage(3, 0, 10), { hp: 0, block: 0 }); }); test('mulberry32: 동일 시드 동일 수열', () => { const a = mulberry32(1), b = mulberry32(1); assert.equal(a(), b()); assert.equal(a(), b()); }); const CARDS = { Strike: { name: '타격', cost: 1, kind: 'Attack', damage: 6 }, Defend: { name: '방어', cost: 1, kind: 'Skill', block: 5 }, Bash: { name: '강타', cost: 2, kind: 'Attack', damage: 10 }, }; test('chooseAction: 공격을 스킬보다 먼저 선택', () => { const idx = chooseAction(['Defend', 'Strike'], CARDS, 3); assert.equal(idx, 1); // Strike }); test('chooseAction: 공격 없으면 스킬 선택', () => { const idx = chooseAction(['Defend'], CARDS, 3); assert.equal(idx, 0); }); test('chooseAction: 사용 가능 카드 없으면 -1', () => { const idx = chooseAction(['Bash'], CARDS, 1); assert.equal(idx, -1); }); test('chooseTarget: 이번 타격으로 처치 가능한 최소 체력 우선', () => { const mob = [ { hp: 20, block: 0, alive: true }, { hp: 5, block: 0, alive: true }, { hp: 8, block: 0, alive: true }, ]; assert.equal(chooseTarget(mob, 6), mob[1]); // 5<=6 처치 가능, 최소 }); test('chooseTarget: 처치 불가면 유효체력 최소 선택', () => { const mob = [ { hp: 20, block: 0, alive: true }, { hp: 12, block: 5, alive: true }, { hp: 14, block: 0, alive: true }, ]; assert.equal(chooseTarget(mob, 6), mob[2]); // 유효 14 < 17 < 20 }); const DATA = { cards: CARDS, starterDeck: ['Strike', 'Strike', 'Strike', 'Strike', 'Strike', 'Defend', 'Defend', 'Defend', 'Defend', 'Bash'], monsters: [ { name: '주황버섯', maxHp: 16, intents: [{ kind: 'Attack', value: 5 }, { kind: 'Defend', value: 4 }] }, { name: '파란버섯', maxHp: 12, intents: [{ kind: 'Attack', value: 8 }] }, ], }; test('simulateCombat: 결정적 결과(동일 시드)', () => { const r1 = simulateCombat(DATA, mulberry32(1)); const r2 = simulateCombat(DATA, mulberry32(1)); assert.deepEqual(r1, r2); assert.equal(typeof r1.win, 'boolean'); assert.ok(r1.turns >= 1); }); test('simulateCombat: 모든 몬스터 처치 시 승리', () => { let wins = 0; for (let i = 0; i < 50; i++) if (simulateCombat(DATA, mulberry32(i + 1)).win) wins++; assert.ok(wins >= 40, `예상 승리 다수, 실제 ${wins}/50`); }); test('simulateCombat: 강한 다수 적이면 패배 가능', () => { const hard = { cards: CARDS, starterDeck: DATA.starterDeck, monsters: Array.from({ length: 4 }, () => ({ name: '슬라임', maxHp: 60, intents: [{ kind: 'Attack', value: 12 }] })), }; let losses = 0; for (let i = 0; i < 30; i++) if (!simulateCombat(hard, mulberry32(i + 1)).win) losses++; assert.ok(losses >= 1, `강한 적엔 패배가 나와야 함, 실제 패 ${losses}/30`); }); test('simulateCombat: 턴 상한 초과 시 draw 반환', () => { const immortal = { cards: { Defend: { name: '방어', cost: 1, kind: 'Skill', block: 5 } }, starterDeck: Array(10).fill('Defend'), monsters: [{ name: '불사', maxHp: 9999, intents: [{ kind: 'Attack', value: 1 }] }], }; const r = simulateCombat(immortal, mulberry32(1)); assert.equal(r.draw, true); assert.equal(r.win, false); }); test('simulateCombat: 몬스터 없으면 즉시 승리', () => { const r = simulateCombat({ cards: {}, starterDeck: [], monsters: [] }, mulberry32(1)); assert.equal(r.win, true); assert.equal(r.turns, 0); }); test('runBatch: 집계 필드·승률 범위', () => { const r = runBatch(100, 1); assert.equal(r.N, 100); assert.ok(r.winRate >= 0 && r.winRate <= 1); assert.ok(r.avgTurns > 0); assert.ok(r.cardStats.Strike.plays > 0); }); test('runBatch: 동일 시드 동일 결과', () => { assert.deepEqual(runBatch(100, 7), runBatch(100, 7)); }); test('simulateCombat: 복합 카드(공격+방어) 블록이 적 공격을 흡수', () => { const data = { cards: { Combo: { name: '콤보', cost: 1, kind: 'Attack', damage: 1, block: 3 } }, starterDeck: ['Combo', 'Combo', 'Combo', 'Combo', 'Combo'], monsters: [{ name: '적', maxHp: 9999, intents: [{ kind: 'Attack', value: 9 }] }], }; const r = simulateCombat(data, mulberry32(1)); // 매 턴 3장(에너지3) → 블록 9 = 적 공격 9 전부 흡수 → 무피해로 MAX_TURNS 도달(draw), HP 유지. // 블록 미적용이면 매턴 -9로 사망(win=false, draw 아님). 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); }); 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); }); test('simulateCombat: poison — 적 행동 시작 시 틱·1 감소·독 사망 시 승리 처리', () => { const data = { cards: { PB: { name: '포이즌', cost: 3, kind: 'Skill', poison: 4 } }, starterDeck: ['PB', 'PB', 'PB', 'PB', 'PB'], monsters: [{ name: '적', maxHp: 10, intents: [{ kind: 'Defend', value: 0 }] }], }; // T1: 독4 부여 → 틱 4 (hp 6, 독 3). T2: +4 → 7 틱 → hp 0 사망 → 승리 const r = simulateCombat(data, mulberry32(1)); assert.equal(r.win, true); assert.equal(r.turns, 2); }); test('simulateCombat: aoe — 모든 생존 적에게 피해', () => { const data = { cards: { TB: { name: '썬더 볼트', cost: 3, kind: 'Attack', damage: 6, aoe: true } }, starterDeck: ['TB', 'TB', 'TB', 'TB', 'TB'], monsters: [ { name: 'A', maxHp: 6, intents: [{ kind: 'Attack', value: 5 }] }, { name: 'B', maxHp: 6, intents: [{ kind: 'Attack', value: 5 }] }, { name: 'C', maxHp: 6, intents: [{ kind: 'Attack', value: 5 }] }, ], }; const r = simulateCombat(data, mulberry32(1)); assert.equal(r.win, true); assert.equal(r.turns, 1); }); test('simulateCombat: heal — 최대 HP 클램프', () => { const data = { cards: { H: { name: '힐', cost: 1, kind: 'Skill', heal: 10 } }, starterDeck: ['H', 'H', 'H', 'H', 'H'], monsters: [{ name: '적', maxHp: 9999, intents: [{ kind: 'Attack', value: 10 }] }], }; // 매턴: 힐로 80까지 회복(클램프) → 적 10 → 70. MAX_TURNS 도달 시 hp 70 const r = simulateCombat(data, mulberry32(1)); assert.equal(r.draw, true); assert.equal(r.playerHpRemaining, 70); }); test('simulateCombat: draw — 카드 드로로 손패 보충', () => { const data = { cards: { D: { name: '텔레포트류', cost: 0, kind: 'Skill', draw: 1, block: 0 }, Hit: { name: '타격', cost: 1, kind: 'Attack', damage: 1 }, }, starterDeck: ['D', 'D', 'D', 'D', 'D', 'Hit', 'Hit', 'Hit'], monsters: [{ name: '적', maxHp: 4, intents: [{ kind: 'Defend', value: 0 }] }], }; // 드로 덕에 첫 턴 히트 3장 전부 접근 → 늦어도 2턴 내 처치 (시드 무관) for (let s = 1; s <= 10; s++) { const r = simulateCombat(data, mulberry32(s)); assert.equal(r.win, true, `seed ${s}`); assert.ok(r.turns <= 2, `seed ${s}: ${r.turns}턴`); } }); test('chooseAction: unplayable(저주) 카드는 건너뜀', () => { const cards = { Strike: { cost: 1, kind: 'Attack', damage: 6 }, Wound: { cost: 0, kind: 'Status', unplayable: true } }; assert.equal(chooseAction(['Wound', 'Strike'], cards, 3), 1); // Strike 선택 assert.equal(chooseAction(['Wound'], cards, 3), -1); // 낼 카드 없음 }); test('simulateCombat: AddCard intent가 저주를 덱에 추가(오염)', () => { const data = { cards: { Hit: { name: '히트', cost: 1, kind: 'Attack', damage: 1 }, Wound: { name: '상처', cost: 0, kind: 'Status', unplayable: true } }, starterDeck: ['Hit', 'Hit', 'Hit', 'Hit', 'Hit'], monsters: [{ name: '오염자', maxHp: 9999, intents: [{ kind: 'AddCard', card: 'Wound', count: 1 }] }], }; // 적은 공격 안 하고 매 턴 저주만 추가 → 플레이어 무피해(승리 불가, 9999hp) → 무승부, 사망 아님 const r = simulateCombat(data, mulberry32(1)); assert.equal(r.win, false); assert.equal(r.draw, true); }); test('simulateCombat: endTurnDamage(화상)이 턴 종료 시 누적 피해', () => { const data = { cards: { Skip: { name: '대기', cost: 3, kind: 'Skill', block: 0 }, Burn: { name: '화상', cost: 0, kind: 'Status', unplayable: true, endTurnDamage: 2 } }, starterDeck: ['Burn', 'Skip', 'Skip', 'Skip', 'Skip'], monsters: [{ name: '무공격', maxHp: 9999, intents: [{ kind: 'Defend', value: 0 }] }], }; // 적은 방어만(무피해). 손패의 Burn이 매 턴 -2 → 80hp 잠식 → MAX_TURNS 전 사망 → win false(draw 아님) const r = simulateCombat(data, mulberry32(1)); assert.equal(r.win, false); assert.notEqual(r.draw, true); }); test("simulateCombat: sly discarded card resolves for free", () => { const data = { cards: { Toss: { name: "Toss", cost: 1, kind: "Skill", discardAll: true }, SlyHit: { name: "SlyHit", cost: 99, kind: "Attack", damage: 10, sly: true }, Blank: { name: "Blank", cost: 99, kind: "Skill", block: 0 }, }, starterDeck: ["Toss", "SlyHit", "Blank", "Blank", "Blank"], monsters: [{ name: "Dummy", maxHp: 10, intents: [{ kind: "Defend", value: 0 }] }], }; const r = simulateCombat(data, mulberry32(1)); assert.equal(r.win, true); assert.equal(r.turns, 1); }); test("simulateCombat: retain keeps card in hand across turns", () => { const data = { cards: { Boost: { name: "Boost", cost: 3, kind: "Power", powerEffect: "energyPerTurn", value: 98 }, Hold: { name: "Hold", cost: 100, kind: "Attack", damage: 10, retain: true }, Blank: { name: "Blank", cost: 99, kind: "Skill", block: 0 }, }, starterDeck: ["Blank", "Blank", "Blank", "Blank", "Blank", "Boost", "Hold", "Blank", "Blank", "Blank"], monsters: [{ name: "Dummy", maxHp: 10, intents: [{ kind: "Defend", value: 0 }] }], }; const r = simulateCombat(data, () => 0.999999); assert.equal(r.win, true); assert.equal(r.turns, 2); }); test("simulateCombat: exhaust cards do not return through discard reshuffle", () => { const data = { cards: { BurnOut: { name: "BurnOut", cost: 1, kind: "Attack", damage: 10, exhaust: true }, }, starterDeck: ["BurnOut"], monsters: [{ name: "Dummy", maxHp: 12, intents: [{ kind: "Defend", value: 0 }] }], }; const r = simulateCombat(data, mulberry32(1)); assert.equal(r.win, false); assert.equal(r.draw, true); }); test("simulateCombat: dex increases block gained from cards", () => { const data = { cards: { Footwork: { name: "Footwork", cost: 1, kind: "Power", dex: 2 }, Defend: { name: "Defend", cost: 1, kind: "Skill", block: 5 }, }, starterDeck: ["Footwork", "Defend"], monsters: [{ name: "Dummy", maxHp: 99, intents: [{ kind: "Attack", value: 6 }] }], }; const r = simulateCombat(data, () => 0.999999); assert.equal(r.win, false); assert.equal(r.draw, true); assert.equal(r.playerHpRemaining, 80); }); test("simulateCombat: thorns reflects unblocked attack damage", () => { const data = { cards: { Spikes: { name: "Spikes", cost: 1, kind: "Power", thorns: 4 }, }, starterDeck: ["Spikes"], monsters: [{ name: "Dummy", maxHp: 4, intents: [{ kind: "Attack", value: 1 }] }], }; const r = simulateCombat(data, () => 0.999999); assert.equal(r.win, true); assert.equal(r.turns, 1); assert.equal(r.playerHpRemaining, 79); }); test("simulateCombat: addShiv creates shuriken cards in hand", () => { const data = { cards: { MakeShiv: { name: "MakeShiv", cost: 0, kind: "Skill", addShiv: 2 }, Shiv: { name: "표창", cost: 0, kind: "Attack", damage: 4, exhaust: true }, }, starterDeck: ["MakeShiv"], monsters: [{ name: "Dummy", maxHp: 8, intents: [{ kind: "Attack", value: 0 }] }], }; const r = simulateCombat(data, () => 0.999999); assert.equal(r.win, true); assert.equal(r.turns, 1); }); test("simulateCombat: innate cards are drawn into the opening hand first", () => { const data = { cards: { Backstab: { name: "배신", cost: 0, kind: "Attack", damage: 11, innate: true, exhaust: true }, Pass1: { name: "대기1", cost: 99, kind: "Skill", block: 0 }, Pass2: { name: "대기2", cost: 99, kind: "Skill", block: 0 }, Pass3: { name: "대기3", cost: 99, kind: "Skill", block: 0 }, Pass4: { name: "대기4", cost: 99, kind: "Skill", block: 0 }, Pass5: { name: "대기5", cost: 99, kind: "Skill", block: 0 }, }, starterDeck: ["Pass1", "Pass2", "Pass3", "Pass4", "Pass5", "Backstab"], monsters: [{ name: "Dummy", maxHp: 11, intents: [{ kind: "Attack", value: 0 }] }], }; const r = simulateCombat(data, () => 0); assert.equal(r.win, true); assert.equal(r.turns, 1); }); test("simulateCombat: GrandFinale waits until draw pile is empty", () => { const data = { cards: { Finale: { name: "피날레", cost: 0, kind: "Attack", damage: 60, aoe: true, playableWhenDrawPileEmpty: true }, Pass1: { name: "대기1", cost: 99, kind: "Skill", block: 0 }, Pass2: { name: "대기2", cost: 99, kind: "Skill", block: 0 }, Pass3: { name: "대기3", cost: 99, kind: "Skill", block: 0 }, Pass4: { name: "대기4", cost: 99, kind: "Skill", block: 0 }, Pass5: { name: "대기5", cost: 99, kind: "Skill", block: 0 }, }, starterDeck: ["Pass1", "Pass2", "Pass3", "Pass4", "Pass5", "Finale"], monsters: [{ name: "Dummy", maxHp: 60, intents: [{ kind: "Attack", value: 0 }] }], }; const r = simulateCombat(data, () => 0); assert.equal(r.win, false); assert.equal(r.draw, true); }); test("simulateCombat: turnStartDraw and turnStartDiscard powers resolve at turn start", () => { const data = { cards: { Tool: { name: "작업 도구", cost: 0, kind: "Power", turnStartDraw: 1, turnStartDiscard: 1 }, Hit1: { name: "타격1", cost: 0, kind: "Attack", damage: 3 }, Hit2: { name: "타격2", cost: 0, kind: "Attack", damage: 3 }, Pass1: { name: "대기1", cost: 99, kind: "Skill", block: 0 }, Pass2: { name: "대기2", cost: 99, kind: "Skill", block: 0 }, Pass3: { name: "대기3", cost: 99, kind: "Skill", block: 0 }, }, starterDeck: ["Tool", "Pass1", "Pass2", "Pass3", "Hit1", "Hit2"], monsters: [{ name: "Dummy", maxHp: 6, intents: [{ kind: "Attack", value: 0 }] }], }; const r = simulateCombat(data, () => 0); assert.equal(r.win, true); assert.equal(r.turns, 1); }); test("chooseAction: GrandFinale is blocked until draw pile is empty", () => { const cards = { Finale: { name: "피날레", cost: 0, kind: "Attack", damage: 60, playableWhenDrawPileEmpty: true }, Defend: { name: "방어", cost: 1, kind: "Skill", block: 5 }, }; assert.equal(chooseAction(["Finale", "Defend"], cards, 3, { drawPileCount: 1 }), 1); assert.equal(chooseAction(["Finale"], cards, 3, { drawPileCount: 0 }), 0); }); test("simulateCombat: damagePerAttackPlayedThisTurn scales Finisher", () => { const data = { cards: { Hit: { name: "타격", cost: 0, kind: "Attack", damage: 6 }, Finisher: { name: "마무리", cost: 0, kind: "Attack", damage: 0, damagePerAttackPlayedThisTurn: 6 }, }, starterDeck: ["Hit", "Finisher"], monsters: [{ name: "Dummy", maxHp: 12, intents: [{ kind: "Attack", value: 0 }] }], }; const r = simulateCombat(data, () => 0); assert.equal(r.win, true); assert.equal(r.turns, 2); }); test("simulateCombat: damagePerOtherHandCard and damagePerSkillInHand are applied", () => { const data = { cards: { Precise: { name: "정밀", cost: 0, kind: "Attack", damage: 13, damagePerOtherHandCard: -2 }, Flechettes: { name: "프레췌", cost: 0, kind: "Attack", damage: 0, damagePerSkillInHand: 5 }, Skill1: { name: "스킬1", cost: 99, kind: "Skill", block: 0 }, Skill2: { name: "스킬2", cost: 99, kind: "Skill", block: 0 }, Blank: { name: "공백", cost: 99, kind: "Skill", block: 0 }, }, starterDeck: ["Skill1", "Skill2", "Blank", "Precise", "Flechettes"], monsters: [{ name: "Dummy", maxHp: 21, intents: [{ kind: "Attack", value: 0 }] }], }; const r = simulateCombat(data, () => 0); assert.equal(r.win, true); assert.equal(r.turns, 5); }); test("simulateCombat: damagePerDiscardedThisTurn and bonusHitsWhenOtherHandAtLeast work", () => { const data = { cards: { Toss: { name: "버리기", cost: 0, kind: "Skill", discard: 1 }, Memento: { name: "메멘토", cost: 0, kind: "Attack", damage: 9, damagePerDiscardedThisTurn: 4 }, Follow: { name: "완수", cost: 0, kind: "Attack", damage: 7, otherHandAtLeast: 2, bonusHitsWhenOtherHandAtLeast: 1 }, Blank1: { name: "공백1", cost: 99, kind: "Skill", block: 0 }, Blank2: { name: "공백2", cost: 99, kind: "Skill", block: 0 }, }, starterDeck: ["Toss", "Memento", "Follow", "Blank1", "Blank2"], monsters: [{ name: "Dummy", maxHp: 27, intents: [{ kind: "Attack", value: 0 }] }], }; const r = simulateCombat(data, () => 0.999999); assert.equal(r.win, true); assert.equal(r.turns, 2); }); test("simulateCombat: gainEnergy, drawUntilHandSize, and drawPerDiscarded are applied", () => { const data = { cards: { Adrenaline: { name: "Adrenaline", cost: 0, kind: "Skill", gainEnergy: 1, draw: 2, exhaust: true }, Expertise: { name: "Expertise", cost: 1, kind: "Skill", drawUntilHandSize: 6 }, Gamble: { name: "Gamble", cost: 0, kind: "Skill", discardAll: true, drawPerDiscarded: 1, exhaust: true }, Tactician: { name: "Tactician", cost: 99, kind: "Skill", gainEnergy: 1, sly: true }, Hit1: { name: "Hit1", cost: 1, kind: "Attack", damage: 6 }, Hit2: { name: "Hit2", cost: 1, kind: "Attack", damage: 6 }, Hit3: { name: "Hit3", cost: 1, kind: "Attack", damage: 6 }, }, starterDeck: ["Adrenaline", "Expertise", "Gamble", "Tactician", "Hit1", "Hit2", "Hit3"], monsters: [{ name: "Dummy", maxHp: 18, intents: [{ kind: "Attack", value: 0 }] }], }; const r = simulateCombat(data, () => 0); assert.equal(r.win, true); assert.equal(r.turns, 1); }); test("simulateCombat: cardPlayedBlock grants block whenever a card is played", () => { const data = { cards: { After: { name: "Afterimage", cost: 1, kind: "Power", cardPlayedBlock: 1 }, Hit: { name: "Hit", cost: 1, kind: "Attack", damage: 1 }, }, starterDeck: ["After", "Hit", "Hit", "Hit", "Hit"], monsters: [{ name: "Dummy", maxHp: 9999, intents: [{ kind: "Attack", value: 1 }] }], }; const r = simulateCombat(data, () => 0.999999); assert.equal(r.draw, true); assert.equal(r.playerHpRemaining, 80); }); test("simulateCombat: blockGainMultiplier doubles block gain for the turn", () => { const data = { cards: { Shadow: { name: "Shadowmeld", cost: 1, kind: "Skill", block: 5, blockGainMultiplier: 2 }, Shield: { name: "Shield", cost: 1, kind: "Skill", block: 2 }, }, starterDeck: ["Shadow", "Shield"], monsters: [{ name: "Dummy", maxHp: 9999, intents: [{ kind: "Attack", value: 8 }] }], }; const r = simulateCombat(data, () => 0.999999); assert.equal(r.draw, true); assert.equal(r.playerHpRemaining, 80); }); test("simulateCombat: nextSkillCostZero makes the next skill free", () => { const data = { cards: { Pounce: { name: "Pounce", cost: 2, kind: "Attack", damage: 12, nextSkillCostZero: true }, Guard: { name: "Guard", cost: 2, kind: "Skill", block: 8 }, }, starterDeck: ["Pounce", "Guard"], monsters: [{ name: "Dummy", maxHp: 9999, intents: [{ kind: "Attack", value: 8 }] }], }; const r = simulateCombat(data, () => 0.999999); assert.equal(r.draw, true); assert.equal(r.playerHpRemaining, 80); }); test("chooseAction: skillCostReductionThisTurn allows discounted skills", () => { const cards = { Guard: { name: "Guard", cost: 2, kind: "Skill", block: 8 }, }; assert.equal(chooseAction(["Guard"], cards, 1, { skillCostReductionThisTurn: 1 }), 0); assert.equal(chooseAction(["Guard"], cards, 1, {}), -1); }); test("chooseAction: handCostZeroThisTurn lets expensive cards be played", () => { const cards = { Burst: { name: "Burst", cost: 3, kind: "Skill", block: 8 }, }; assert.equal(chooseAction(["Burst"], cards, 0, { handCostZeroThisTurn: true }), 0); assert.equal(chooseAction(["Burst"], cards, 0, {}), -1); }); test("simulateCombat: drawSkillBlock grants block for each drawn skill", () => { const data = { cards: { Escape: { name: "EscapePlan", cost: 0, kind: "Skill", draw: 1, drawSkillBlock: 3, innate: true, exhaust: true }, Filler1: { name: "Filler1", cost: 99, kind: "Skill", block: 0 }, Filler2: { name: "Filler2", cost: 99, kind: "Skill", block: 0 }, Filler3: { name: "Filler3", cost: 99, kind: "Skill", block: 0 }, Filler4: { name: "Filler4", cost: 99, kind: "Skill", block: 0 }, Filler5: { name: "Filler5", cost: 99, kind: "Skill", block: 0 }, }, starterDeck: ["Escape", "Filler1", "Filler2", "Filler3", "Filler4", "Filler5"], monsters: [{ name: "Dummy", maxHp: 9999, intents: [{ kind: "Attack", value: 0 }] }], }; const stats = {}; const r = simulateCombat(data, () => 0.999999, stats); assert.equal(r.draw, true); assert.equal(stats.Escape.block, 3); }); test("simulateCombat: poisonPerTurn powers poison all enemies at turn start", () => { const data = { cards: { Fumes: { name: "NoxiousFumes", cost: 1, kind: "Power", powerEffect: "poisonPerTurn", value: 2 }, }, starterDeck: ["Fumes"], monsters: [ { name: "DummyA", maxHp: 2, intents: [{ kind: "Attack", value: 0 }] }, { name: "DummyB", maxHp: 2, intents: [{ kind: "Attack", value: 0 }] }, ], }; const r = simulateCombat(data, () => 0.999999); assert.equal(r.win, true); }); test("simulateCombat: damagePerTurn powers damage all enemies at turn start", () => { const data = { cards: { Speed: { name: "Speedster", cost: 2, kind: "Power", powerEffect: "damagePerTurn", value: 2 }, }, starterDeck: ["Speed"], monsters: [ { name: "DummyA", maxHp: 2, intents: [{ kind: "Attack", value: 0 }] }, { name: "DummyB", maxHp: 2, intents: [{ kind: "Attack", value: 0 }] }, ], }; const r = simulateCombat(data, () => 0.999999); assert.equal(r.win, true); });