import { test } from 'node:test'; import assert from 'node:assert/strict'; import { mulberry32, applyDamage, chooseAction, chooseTarget, simulateCombat, runBatch, calcAttack, calcEnemyAttack, 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: firstCardDamageBonus가 턴 첫 카드에 적용 (kind===Attack, Lua 동기화)', () => { // ChargedBlow처럼 class=warrior·kind=Attack인 카드의 첫-카드 보너스. // 게이트가 class==="Attack"이면 영구 false라 미발동(버그) → 5뎀/2턴. // kind==="Attack"이면 5+2=7 → 1턴 처치. const data = { cards: { CB: { name: '차지블로우', cost: 3, kind: 'Attack', class: 'warrior', damage: 5, firstCardDamageBonus: 2 } }, starterDeck: ['CB', 'CB', 'CB', 'CB', 'CB'], monsters: [{ name: '적', maxHp: 7, intents: [{ kind: 'Defend', value: 0 }] }], }; const r = simulateCombat(data, mulberry32(1)); 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("simulateCombat: nextSkillRepeatCount repeats the next skill effect", () => { const shared = { cards: { Burst: { name: "Burst", cost: 1, kind: "Skill", draw: 1, block: 5, nextSkillRepeatCount: 1 }, Guard: { name: "Guard", cost: 2, kind: "Skill", block: 8 }, }, starterDeck: ["Burst", "Guard"], monsters: [{ name: "Dummy", maxHp: 9999, intents: [{ kind: "Attack", value: 15 }] }], }; const withBurst = simulateCombat(shared, () => 0.999999); const withoutBurst = simulateCombat({ ...shared, cards: { Burst: { name: "Burst", cost: 1, kind: "Skill", draw: 1, block: 5 }, Guard: shared.cards.Guard, }, }, () => 0.999999); assert.equal(withBurst.draw, true); assert.equal(withBurst.playerHpRemaining, 80); assert.ok(withBurst.playerHpRemaining > withoutBurst.playerHpRemaining); }); 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("chooseAction: useAllEnergy cards remain playable at zero energy", () => { const cards = { Skewer: { name: "Skewer", cost: 2, kind: "Attack", useAllEnergy: true, xDamagePerEnergy: 8 }, }; assert.equal(chooseAction(["Skewer"], cards, 0, {}), 0); }); test("chooseAction: combatCardCostReduction discounts the same card across combat", () => { const cards = { Sleeve: { name: "UpMySleeve", cost: 2, kind: "Skill" }, }; assert.equal(chooseAction(["Sleeve"], cards, 1, { combatCardCostReduction: { Sleeve: 1 } }), 0); assert.equal(chooseAction(["Sleeve"], cards, 1, {}), -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); }); test("simulateCombat: attackPoison power applies poison on attack damage", () => { const data = { cards: { Venom: { name: "Envenom", cost: 2, kind: "Power", attackPoison: 2 }, Strike: { name: "Strike", cost: 1, kind: "Attack", damage: 1 }, }, starterDeck: ["Venom", "Strike"], monsters: [{ name: "Dummy", maxHp: 3, intents: [{ kind: "Attack", value: 0 }] }], }; const r = simulateCombat(data, () => 0.999999); assert.equal(r.win, true); assert.equal(r.turns, 1); }); test("simulateCombat: skillSlyOnPlay makes later discards of the same skill trigger sly effects", () => { const shared = { cards: { MasterPlanner: { name: "MasterPlanner", cost: 1, kind: "Skill", poison: 1, discardAll: true }, }, starterDeck: ["MasterPlanner", "MasterPlanner"], monsters: [{ name: "Dummy", maxHp: 2, intents: [{ kind: "Attack", value: 0 }] }], }; const withSly = simulateCombat({ ...shared, cards: { MasterPlanner: { name: "MasterPlanner", cost: 1, kind: "Skill", poison: 1, discardAll: true, skillSlyOnPlay: true }, }, }, () => 0.999999); const withoutSly = simulateCombat(shared, () => 0.999999); assert.equal(withSly.win, true); assert.equal(withSly.turns, 1); assert.ok(withoutSly.turns > withSly.turns); }); test("simulateCombat: randomTargetEachHit can spread hits across alive enemies", () => { const shared = { cards: { Ricochet: { name: "Ricochet", cost: 2, kind: "Attack", damage: 3, hits: 4, randomTargetEachHit: true }, }, starterDeck: ["Ricochet"], monsters: [ { name: "DummyA", maxHp: 6, intents: [{ kind: "Attack", value: 0 }] }, { name: "DummyB", maxHp: 6, intents: [{ kind: "Attack", value: 0 }] }, ], }; const makeRng = () => { const seq = [0, 0.999999, 0, 0.999999]; let i = 0; return () => seq[i++ % seq.length]; }; const withRicochet = simulateCombat(shared, makeRng()); const withoutRicochet = simulateCombat({ ...shared, cards: { Ricochet: { name: "Ricochet", cost: 2, kind: "Attack", damage: 3, hits: 4 }, }, }, makeRng()); assert.equal(withRicochet.win, true); assert.equal(withRicochet.turns, 1); assert.equal(withoutRicochet.turns, 2); }); test("calcEnemyAttack: enemyStrengthLossThisTurn reduces enemy attack damage", () => { assert.equal(calcEnemyAttack(10, 6, 0, 0, 6), 10); assert.equal(calcEnemyAttack(10, 6, 0, 0, 0), 16); }); test("calcEnemyAttack: 힘 손실이 base 아래로 공격을 낮춘다 (음수 힘, Lua 동기화)", () => { // 적 str=0, loss=6 → 힘 -6 → 10-6=4. JS가 str을 0에서 클램프하면 10(버그). Lua는 전체에서 차감. assert.equal(calcEnemyAttack(10, 0, 0, 0, 6), 4); assert.equal(calcEnemyAttack(10, 3, 0, 0, 6), 7); assert.equal(calcEnemyAttack(5, 0, 0, 0, 6), 0); // 5-6=-1 → 0 클램프 }); test('simulateCombat: firstShivDamageBonus는 턴당 첫 Shiv에만 적용 (Lua 동기화)', () => { // PhantomBlades(firstShivDamageBonus 9) 활성. 턴당 3 Shiv 사용(에너지3·cost1). // 정답(첫 Shiv만 +9): 턴1 = 10+1+1=12 → 13HP에 1 남김 → 2턴. // 버그(모든 Shiv +9): 턴1 = 10*3=30 → 1턴. const data = { cards: { PhantomBlades: { name: '환영검', cost: 0, kind: 'Power', firstShivDamageBonus: 9 }, Shiv: { name: '시브', cost: 1, kind: 'Attack', class: 'shiv', damage: 1 }, }, starterDeck: ['PhantomBlades', 'Shiv', 'Shiv', 'Shiv', 'Shiv'], monsters: [{ name: '적', maxHp: 13, intents: [{ kind: 'Attack', value: 0 }] }], }; const r = simulateCombat(data, mulberry32(1)); assert.equal(r.turns, 2); }); test('simulateCombat: blockPerDamageDealtThisTurn이 실제 방어를 부여 (Lua 동기화)', () => { // 매턴 Hit(5뎀) → Guard(준 피해만큼 방어 5) → 적 공격 5 상쇄. // 수정(실제 방어): 무한 생존 → 무승부. 버그(방어 미부여): 매턴 5피해 → 사망. const data = { cards: { Hit: { name: '타격', cost: 2, kind: 'Attack', damage: 5 }, Guard: { name: '대비', cost: 1, kind: 'Skill', blockPerDamageDealtThisTurn: 1 }, }, starterDeck: ['Hit', 'Guard'], monsters: [{ name: '적', maxHp: 9999, intents: [{ kind: 'Attack', value: 5 }] }], }; const r = simulateCombat(data, mulberry32(1)); assert.equal(r.draw, true); }); test("simulateCombat: repeatOnKill repeats an attack until no kill occurs", () => { const shared = { cards: { EchoingSlash: { name: "EchoingSlash", cost: 1, kind: "Attack", aoe: true, damage: 10, repeatOnKill: true }, }, starterDeck: ["EchoingSlash"], monsters: [ { name: "DummyA", maxHp: 10, intents: [{ kind: "Attack", value: 0 }] }, { name: "DummyB", maxHp: 20, intents: [{ kind: "Attack", value: 0 }] }, ], }; const withRepeat = simulateCombat(shared, () => 0.999999); const withoutRepeat = simulateCombat({ ...shared, cards: { EchoingSlash: { name: "EchoingSlash", cost: 1, kind: "Attack", aoe: true, damage: 10 }, }, }, () => 0.999999); assert.equal(withRepeat.win, true); assert.equal(withRepeat.turns, 1); assert.equal(withoutRepeat.turns, 2); }); test("simulateCombat: poisonIfTargetPoisoned only applies poison to already poisoned enemies", () => { const shared = { cards: { Bubble: { name: "BubbleBubble", cost: 1, kind: "Skill", poison: 9, poisonIfTargetPoisoned: true }, }, starterDeck: ["Bubble"], monsters: [{ name: "Dummy", maxHp: 2, intents: [{ kind: "Attack", value: 0 }] }], }; const withBubble = simulateCombat(shared, () => 0.999999); const withoutBubble = simulateCombat({ ...shared, cards: { Bubble: { name: "BubbleBubble", cost: 1, kind: "Skill", poison: 9 }, }, }, () => 0.999999); assert.equal(withBubble.draw, true); assert.equal(withBubble.turns, 100); assert.equal(withoutBubble.win, true); assert.equal(withoutBubble.turns, 1); }); test("simulateCombat: turnHandSlyCount marks a skill in hand as sly for the turn", () => { const shared = { cards: { HandTrick: { name: "HandTrick", cost: 0, kind: "Skill", block: 7, turnHandSlyCount: 1 }, Shield: { name: "Shield", cost: 0, kind: "Skill", unplayable: true, block: 7 }, Gamble: { name: "Gamble", cost: 0, kind: "Skill", discardAll: true }, }, starterDeck: ["Gamble", "Shield", "HandTrick"], monsters: [{ name: "Dummy", maxHp: 9999, intents: [{ kind: "Attack", value: 10 }] }], }; const withHandTrick = simulateCombat(shared, () => 0.999999); const withoutHandTrick = simulateCombat({ ...shared, cards: { HandTrick: { name: "HandTrick", cost: 0, kind: "Skill", block: 7 }, Shield: shared.cards.Shield, Gamble: shared.cards.Gamble, }, }, () => 0.999999); assert.equal(withHandTrick.playerHpRemaining, 80); assert.equal(withoutHandTrick.playerHpRemaining, 0); }); test("simulateCombat: extraPoisonTicks adds an extra poison tick at enemy turn start", () => { const shared = { cards: { Accelerant: { name: "Accelerant", cost: 1, kind: "Power", extraPoisonTicks: 1 }, Poison: { name: "Poison", cost: 1, kind: "Skill", poison: 2 }, }, starterDeck: ["Accelerant", "Poison"], monsters: [{ name: "Dummy", maxHp: 3, intents: [{ kind: "Attack", value: 0 }] }], }; const withTick = simulateCombat(shared, () => 0.999999); const withoutTick = simulateCombat({ ...shared, cards: { Accelerant: { name: "Accelerant", cost: 1, kind: "Power" }, Poison: shared.cards.Poison, }, }, () => 0.999999); assert.equal(withTick.win, true); assert.equal(withTick.turns, 1); assert.equal(withoutTick.turns, 2); }); test("simulateCombat: poisonApplicationBurstEvery bursts after every third poison application", () => { const shared = { cards: { Outbreak: { name: "Outbreak", cost: 1, kind: "Power", poisonApplicationBurstEvery: 3, poisonApplicationBurstDamage: 11 }, Poison1: { name: "Poison1", cost: 0, kind: "Skill", poison: 1 }, Poison2: { name: "Poison2", cost: 0, kind: "Skill", poison: 1 }, Poison3: { name: "Poison3", cost: 0, kind: "Skill", poison: 1 }, }, starterDeck: ["Outbreak", "Poison1", "Poison2", "Poison3"], monsters: [ { name: "DummyA", maxHp: 11, intents: [{ kind: "Attack", value: 0 }] }, { name: "DummyB", maxHp: 11, intents: [{ kind: "Attack", value: 0 }] }, ], }; const withBurst = simulateCombat(shared, () => 0.999999); const withoutBurst = simulateCombat({ ...shared, cards: { Outbreak: { name: "Outbreak", cost: 1, kind: "Power" }, Poison1: shared.cards.Poison1, Poison2: shared.cards.Poison2, Poison3: shared.cards.Poison3, }, }, () => 0.999999); assert.equal(withBurst.win, true); assert.equal(withBurst.turns, 1); assert.ok(withoutBurst.turns > withBurst.turns); }); test("simulateCombat: firstCardDamageBonus applies on the first card played this turn", () => { const data = { cards: { Strangle: { name: "Strangle", cost: 1, kind: "Attack", damage: 8, firstCardDamageBonus: 2 }, }, starterDeck: ["Strangle"], monsters: [{ name: "Dummy", maxHp: 10, intents: [{ kind: "Attack", value: 0 }] }], }; const r = simulateCombat(data, () => 0.999999); assert.equal(r.win, true); }); test("simulateCombat: blockPerDamageDealtThisTurn grants block from damage dealt this turn", () => { const data = { cards: { Mirage: { name: "Mirage", cost: 1, kind: "Skill", blockPerDamageDealtThisTurn: 1, block: 0 }, Strike: { name: "Strike", cost: 1, kind: "Attack", damage: 4 }, }, starterDeck: ["Strike", "Mirage"], monsters: [{ name: "Dummy", maxHp: 4, intents: [{ kind: "Attack", value: 0 }] }], }; const r = simulateCombat(data, () => 0.999999); assert.equal(r.win, true); }); test("simulateCombat: cardPlayedRandomDamage hits a random enemy on card play", () => { const data = { cards: { SerpentForm: { name: "SerpentForm", cost: 3, kind: "Power", cardPlayedRandomDamage: 4 }, }, starterDeck: ["SerpentForm"], monsters: [{ name: "Dummy", maxHp: 4, intents: [{ kind: "Attack", value: 0 }] }], }; const r = simulateCombat(data, () => 0.999999); assert.equal(r.win, true); }); test("simulateCombat: rewardOnKill grants an extra reward screen when an attack kills", () => { const data = { cards: { TheHunt: { name: "TheHunt", cost: 1, kind: "Attack", damage: 10, rewardOnKill: 1 }, }, starterDeck: ["TheHunt"], monsters: [{ name: "Dummy", maxHp: 10, intents: [{ kind: "Attack", value: 0 }] }], }; const r = simulateCombat(data, () => 0.999999); assert.equal(r.win, true); assert.equal(r.bonusRewardScreens, 1); }); test("simulateCombat: intangible cards reduce incoming damage and persist across turns", () => { const data = { cards: { Wraith: { name: "WraithForm", cost: 3, kind: "Power", intangible: 2, endTurnDexLoss: 1, innate: true }, Strike: { name: "Strike", cost: 1, kind: "Attack", damage: 1 }, }, starterDeck: ["Wraith", "Strike"], monsters: [{ name: "Dummy", maxHp: 1, intents: [{ kind: "Attack", value: 10 }] }], }; const r = simulateCombat(data, () => 0.999999); assert.equal(r.win, true); }); test("simulateCombat: useAllEnergy skewer consumes all energy for damage", () => { const data = { cards: { Skewer: { name: "Skewer", cost: 2, kind: "Attack", useAllEnergy: true, xDamagePerEnergy: 8 }, }, starterDeck: ["Skewer"], monsters: [{ name: "Dummy", maxHp: 24, intents: [{ kind: "Attack", value: 0 }] }], }; const r = simulateCombat(data, () => 0.999999); assert.equal(r.win, true); }); test("simulateCombat: useAllEnergy malaise scales weak with energy spent", () => { const data = { cards: { Malaise: { name: "Malaise", cost: 2, kind: "Skill", useAllEnergy: true, xWeakPerEnergy: 1 }, Strike: { name: "Strike", cost: 1, kind: "Attack", damage: 1 }, }, starterDeck: ["Malaise", "Strike"], monsters: [{ name: "Dummy", maxHp: 1, intents: [{ kind: "Attack", value: 10 }] }], }; const r = simulateCombat(data, () => 0.999999); assert.equal(r.win, true); }); test("simulateCombat: damagePerCardDrawnThisCombat scales murder", () => { const data = { cards: { Murder: { name: "Murder", cost: 3, kind: "Attack", damage: 1, damagePerCardDrawnThisCombat: 1 }, Filler1: { name: "Filler1", cost: 99, kind: "Skill" }, Filler2: { name: "Filler2", cost: 99, kind: "Skill" }, Filler3: { name: "Filler3", cost: 99, kind: "Skill" }, Filler4: { name: "Filler4", cost: 99, kind: "Skill" }, Filler5: { name: "Filler5", cost: 99, kind: "Skill" }, }, starterDeck: ["Murder", "Filler1", "Filler2", "Filler3", "Filler4", "Filler5"], monsters: [{ name: "Dummy", maxHp: 6, intents: [{ kind: "Attack", value: 0 }] }], }; const stats = {}; const r = simulateCombat(data, () => 0.999999, stats); assert.equal(r.win, true); assert.ok(stats.Murder.damage > 1); }); test("simulateCombat: shiv damage bonuses stack and first Shiv bonus applies once per turn", () => { const data = { cards: { Accuracy: { name: "Accuracy", cost: 1, kind: "Power", shivDamageBonus: 2 }, PhantomBlades: { name: "PhantomBlades", cost: 1, kind: "Power", firstShivDamageBonus: 3 }, Shiv: { name: "Shiv", cost: 0, kind: "Attack", class: "shiv", damage: 1 }, }, starterDeck: ["Accuracy", "PhantomBlades", "Shiv"], 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, 1); }); test("simulateCombat: shivAoe makes Shivs hit all enemies", () => { const data = { cards: { FanOfKnives: { name: "FanOfKnives", cost: 2, kind: "Skill", addShiv: 2, shivAoe: true }, Accuracy: { name: "Accuracy", cost: 1, kind: "Power", shivDamageBonus: 2 }, Shiv: { name: "Shiv", cost: 0, kind: "Attack", class: "shiv", damage: 1 }, Pass: { name: "Pass", cost: 99, kind: "Skill" }, }, starterDeck: ["Accuracy", "FanOfKnives", "Pass"], monsters: [ { name: "A", maxHp: 3, intents: [{ kind: "Attack", value: 0 }] }, { name: "B", maxHp: 3, intents: [{ kind: "Attack", value: 0 }] }, { name: "C", maxHp: 3, intents: [{ kind: "Attack", value: 0 }] }, ], }; const r = simulateCombat(data, () => 0.999999); assert.equal(r.win, true); assert.equal(r.turns, 1); });