- calcAttack(힘·약화·취약) 공식 export + 단위 테스트 - 적 Debuff 인텐트·플레이어/적 디버프 감소 타이밍 Lua 동기화 - Power 등록·매턴 발동·소멸 재현, chooseAction 파워 우선 - 신규 테스트 6건 (총 21건 통과) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
203 lines
8.1 KiB
JavaScript
203 lines
8.1 KiB
JavaScript
import { test } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import {
|
|
mulberry32, applyDamage, chooseAction, chooseTarget, simulateCombat, runBatch, calcAttack,
|
|
} from './sim-balance.mjs';
|
|
|
|
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);
|
|
});
|