feat(magician): 시뮬 메커니즘 동기화 + 산출물 재생성
- poison 틱(행동 시작·사망 시 행동 생략·전멸 승리 체크)·aoe(개별 취약/방어)·heal 클램프·draw - 테스트 4건 추가 — 전체 40건 통과 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -106,7 +106,7 @@ export function simulateCombat(data, rng, stats) {
|
||||
let pStr = 0, pWeak = 0, pVuln = 0;
|
||||
const powers = [];
|
||||
const mob = monsters.map((m) => ({
|
||||
name: m.name, hp: m.maxHp, maxHp: m.maxHp, block: 0, str: 0, weak: 0, vuln: 0,
|
||||
name: m.name, hp: m.maxHp, maxHp: m.maxHp, block: 0, str: 0, weak: 0, vuln: 0, poison: 0,
|
||||
intents: m.intents, intentIdx: 0, alive: true,
|
||||
}));
|
||||
let turns = 0;
|
||||
@@ -149,18 +149,30 @@ export function simulateCombat(data, rng, stats) {
|
||||
const hitN = c.hits || 1;
|
||||
let totalNv = 0;
|
||||
for (let h = 0; h < hitN; h++) totalNv += calcAttack(c.damage || 0, pStr, pWeak, 0);
|
||||
const dmg = target.vuln > 0 ? Math.floor(totalNv * 1.5) : totalNv;
|
||||
if (c.pierce === true) {
|
||||
target.hp -= dmg; // 방어 무시
|
||||
if (target.hp < 0) target.hp = 0;
|
||||
let dmg = totalNv; // 통계 보고용 (aoe는 1대상 기준)
|
||||
if (c.aoe === true) {
|
||||
// 전체 공격 — 대상마다 취약/방어 개별 적용 (Lua PlayAoeFx 동기화)
|
||||
for (const m2 of aliveList()) {
|
||||
const d2 = m2.vuln > 0 ? Math.floor(totalNv * 1.5) : totalNv;
|
||||
const r2 = applyDamage(m2.hp, m2.block, d2);
|
||||
m2.hp = r2.hp; m2.block = r2.block;
|
||||
if (m2.hp <= 0) m2.alive = false;
|
||||
}
|
||||
} else {
|
||||
const r = applyDamage(target.hp, target.block, dmg);
|
||||
target.hp = r.hp; target.block = r.block;
|
||||
dmg = target.vuln > 0 ? Math.floor(totalNv * 1.5) : totalNv;
|
||||
if (c.pierce === true) {
|
||||
target.hp -= dmg; // 방어 무시
|
||||
if (target.hp < 0) target.hp = 0;
|
||||
} else {
|
||||
const r = applyDamage(target.hp, target.block, dmg);
|
||||
target.hp = r.hp; target.block = r.block;
|
||||
}
|
||||
if (target.hp <= 0) target.alive = false;
|
||||
}
|
||||
if (target.hp <= 0) target.alive = false;
|
||||
if (c.block) pBlock += c.block;
|
||||
if (c.strength) pStr += c.strength;
|
||||
if (c.selfVuln) pVuln += c.selfVuln;
|
||||
if (c.heal) pHp = Math.min(pHp + c.heal, PLAYER_HP);
|
||||
if (stats) stats[id] = bump(stats[id], c.cost, dmg, c.block || 0);
|
||||
} else if (c.kind === 'Power') {
|
||||
if (c.powerEffect) powers.push(id);
|
||||
@@ -169,15 +181,18 @@ export function simulateCombat(data, rng, stats) {
|
||||
pBlock += c.block || 0;
|
||||
if (c.strength) pStr += c.strength;
|
||||
if (c.selfVuln) pVuln += c.selfVuln;
|
||||
if (c.weak || c.vuln) {
|
||||
if (c.heal) pHp = Math.min(pHp + c.heal, PLAYER_HP);
|
||||
if (c.weak || c.vuln || c.poison) {
|
||||
const target = chooseTarget(alive, 0);
|
||||
if (c.weak) target.weak += c.weak;
|
||||
if (c.vuln) target.vuln += c.vuln;
|
||||
if (c.poison) target.poison += c.poison;
|
||||
}
|
||||
if (stats) stats[id] = bump(stats[id], c.cost, 0, c.block || 0);
|
||||
}
|
||||
hand.splice(idx, 1);
|
||||
if (c.kind !== 'Power') discard.push(id); // 파워는 소멸 — Lua 동기화
|
||||
if (c.draw) draw(c.draw);
|
||||
if (aliveList().length === 0) return { win: true, turns, playerHpRemaining: pHp };
|
||||
}
|
||||
discard.push(...hand); hand = [];
|
||||
@@ -186,6 +201,12 @@ export function simulateCombat(data, rng, stats) {
|
||||
if (pVuln > 0) pVuln--;
|
||||
for (const m of mob) {
|
||||
if (!m.alive) continue;
|
||||
// 독 틱 — 행동 시작 시 (Lua EnemyActStep 동기화). 사망 시 행동 생략
|
||||
if (m.poison > 0) {
|
||||
m.hp -= m.poison;
|
||||
m.poison--;
|
||||
if (m.hp <= 0) { m.hp = 0; m.alive = false; continue; }
|
||||
}
|
||||
m.block = 0; // 매 턴 초기화 (이전 턴 블록 미이월)
|
||||
const it = m.intents[m.intentIdx];
|
||||
if (it) {
|
||||
@@ -204,6 +225,8 @@ export function simulateCombat(data, rng, stats) {
|
||||
if (m.vuln > 0) m.vuln--;
|
||||
if (pHp <= 0) return { win: false, turns, playerHpRemaining: 0 };
|
||||
}
|
||||
// 독 사망 등 적 페이즈 중 전멸 처리 (Lua FinishEnemyTurn→CheckCombatEnd 동기화)
|
||||
if (!mob.some((m) => m.alive)) return { win: true, turns, playerHpRemaining: pHp };
|
||||
}
|
||||
return { win: false, turns, playerHpRemaining: pHp, draw: true };
|
||||
}
|
||||
|
||||
@@ -280,3 +280,59 @@ test('simulateCombat: blockPerTurn 파워 — 매턴 방어로 약공 무효', (
|
||||
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}턴`);
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user