data/*.json을 입력으로 전투를 몬테카를로 N회 시뮬 → 승률·턴·OP 카드 리포트. - 전투 엔진 JS 재현(gen-slaydeck Lua 미러): 에너지3·드로우5·방어우선차감·결정적 의도·승패·턴상한 - 플레이어 휴리스틱 정책: 치사 우선 → 적 공격의도 시 방어 → 공격 우선, 에너지 효율순 - 시드 PRNG(mulberry32)로 재현성, OP 탐지(kind별 효율 중앙값 1.5배↑ 플래그) - CLI: node tools/sim-balance.mjs [N] [--seed S] - node:test 단위 테스트 10종(applyDamage·정책·엔진·집계) - 검증: 현 데이터 승률 100%(슬라임 약함 신호), 적 HP 45→300 시 평균턴 5.6→39.6(데이터 반영) - 전투 규칙은 Lua와 중복이라 동기화 주석 명시 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
81 lines
2.8 KiB
JavaScript
81 lines
2.8 KiB
JavaScript
import { test } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import {
|
|
mulberry32, applyDamage, chooseAction, simulateCombat, runBatch,
|
|
} 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(['Strike', 'Defend'], CARDS, 3, 5, 0, { kind: 'Attack', value: 10 });
|
|
assert.equal(idx, 0);
|
|
});
|
|
|
|
test('chooseAction: 치사 불가 + 적 공격 의도면 방어 선택', () => {
|
|
const idx = chooseAction(['Strike', 'Defend'], CARDS, 3, 40, 0, { kind: 'Attack', value: 10 });
|
|
assert.equal(idx, 1);
|
|
});
|
|
|
|
test('chooseAction: 적 방어 의도면 공격 우선', () => {
|
|
const idx = chooseAction(['Defend', 'Strike'], CARDS, 3, 40, 0, { kind: 'Defend', value: 8 });
|
|
assert.equal(idx, 1);
|
|
});
|
|
|
|
test('chooseAction: 사용 가능 카드 없으면 -1', () => {
|
|
const idx = chooseAction(['Bash'], CARDS, 1, 40, 0, { kind: 'Attack', value: 10 });
|
|
assert.equal(idx, -1);
|
|
});
|
|
|
|
const DATA = {
|
|
cards: CARDS,
|
|
starterDeck: ['Strike', 'Strike', 'Strike', 'Strike', 'Strike', 'Defend', 'Defend', 'Defend', 'Defend', 'Bash'],
|
|
enemy: {
|
|
name: '슬라임', maxHp: 45, intents: [
|
|
{ kind: 'Attack', value: 10 }, { kind: 'Attack', value: 6 }, { kind: 'Defend', 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('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));
|
|
});
|