Files
maplecontest/tools/balance/sim-balance.test.mjs
gahusb d0b8fbe091 feat(job): 시뮬 신규 메커니즘 동기화 (hits·pierce·selfVuln·energy/blockPerTurn)
- 다단히트: 타격마다 힘 적용 합산·취약 1회 (Lua 동기화)
- pierce 방어 무시, selfVuln, 파워 루프 확장 (블록 리셋 후)
- 신규 테스트 6건 — 전체 36건 통과 (sim 27 + rogue-map 9)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 13:39:08 +09:00

283 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
});
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);
});