3 Commits

Author SHA1 Message Date
222ed92807 fix(balance): firstShivDamageBonus 시뮬을 첫 Shiv에만 적용 (Lua 동기화)
PhantomBlades(환영검: 첫 Shiv +9) 사용 시 Lua는 첫 Shiv 처리 후
ShivFirstDamageBonusUsed를 set(Attack 경로)하는데, JS 시뮬은 이 플래그
set이 else(비-Attack/Skill) 분기에 있어 Shiv(kind=Attack)는 도달 못 함
→ 플래그 영영 false → 모든 Shiv가 +9를 받아 시뮬이 데미지를 과대집계.

Lua가 정답(게임 정상) — 시뮬만 수정: 죽은 else-분기 플래그 set 제거 +
Attack 분기(baseDamage 계산 직후, Lua 순서와 동일)에 추가. RED-GREEN
테스트로 턴당 첫 Shiv만 보너스 검증. 87개.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UUvHKjrt8jqLzDeCsRRGmj
2026-06-29 18:58:10 +09:00
72750f3647 fix(deck): Envenom attackPoison을 광역 공격에도 적용 (Lua 누락)
Envenom(독 바르기: 공격이 막히지 않은 피해를 줄 때마다 중독 1)이
단일타겟(DealDamageToTarget)에는 적용됐지만 광역(DealDamageToAllMonsters)
에는 빠져 있어, Envenom+광역공격이 게임에선 아무 적도 중독 안 됐다
(JS 미러는 양쪽 적용 — Lua가 누락).

DealDamageToAllMonsters의 막히지 않은 피해(dmg>0) 분기에 단일타겟과
동일한 attackPoison 적용을 추가(적별 ApplyPoisonToMonster). JS 미러는
이미 올바라 무변경. 산출물 재생성 포함.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UUvHKjrt8jqLzDeCsRRGmj
2026-06-29 18:53:24 +09:00
1291c52346 fix(balance): 시뮬 enemyStrengthLoss를 음수 힘 허용으로 (Lua 동기화)
PiercingWail(귀를 찢는 비명: 모든 적 힘 -6)에서 Lua는 적 공격을
(value+str-loss, 0클램프)로 줄여 StS처럼 힘이 음수로 작동하는데,
JS 시뮬은 max(0, str-loss)로 힘을 0에서 클램프해 모든 적 str=0일 때
공격이 전혀 안 줄었다(게임 -6, 시뮬 -0). 기존 테스트는 str>=loss
구간만 봐서 못 잡음.

Lua가 정답(게임은 정상) — 시뮬만 수정. calcEnemyAttack의 max(0,...)
제거(음수 힘 허용, 최종 calcAttack이 0클램프) + EnemyActStep을 그
헬퍼로 통일(중복 제거). RED-GREEN 테스트로 loss>str 구간 검증. 86개.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UUvHKjrt8jqLzDeCsRRGmj
2026-06-29 18:50:22 +09:00
4 changed files with 34 additions and 6 deletions

View File

@@ -3666,7 +3666,7 @@
"Name": "amount"
}
],
"Code": "if self.Monsters == nil then\n\treturn false\nend\nlocal killCount = 0\nfor i = 1, #self.Monsters do\n\tlocal m = self.Monsters[i]\n\tif m ~= nil and m.alive == true then\n\t\tlocal dmg = amount\n\t\tif m.vuln > 0 then\n\t\t\tdmg = math.floor(dmg * 1.5)\n\t\tend\n\t\tif m.block > 0 then\n\t\t\tlocal absorbed = math.min(m.block, dmg)\n\t\t\tm.block = m.block - absorbed\n\t\t\tdmg = dmg - absorbed\n\t\tend\n\t\tm.hp = m.hp - dmg\n\t\tif dmg > 0 then\n\t\t\tself.DamageDealtThisTurn = (self.DamageDealtThisTurn or 0) + dmg\n\t\tend\n\t\tself:ShowDmgPop(i, dmg)\n\t\tself:MonsterHitMotion(i)\n\t\tif m.hp <= 0 then\n\t\t\tm.hp = 0\n\t\t\tself:KillMonster(m.slot)\n\t\t\tkillCount = killCount + 1\n\t\tend\n\tend\nend\nif killCount > 0 and self.ActiveKillReward ~= nil and self.ActiveKillReward > 0 then\n\tself.BonusRewardScreens = (self.BonusRewardScreens or 0) + (killCount * self.ActiveKillReward)\nend\nself:RenderCombat()\nself:CheckCombatEnd()\nreturn killCount > 0",
"Code": "if self.Monsters == nil then\n\treturn false\nend\nlocal killCount = 0\nfor i = 1, #self.Monsters do\n\tlocal m = self.Monsters[i]\n\tif m ~= nil and m.alive == true then\n\t\tlocal dmg = amount\n\t\tif m.vuln > 0 then\n\t\t\tdmg = math.floor(dmg * 1.5)\n\t\tend\n\t\tif m.block > 0 then\n\t\t\tlocal absorbed = math.min(m.block, dmg)\n\t\t\tm.block = m.block - absorbed\n\t\t\tdmg = dmg - absorbed\n\t\tend\n\t\tm.hp = m.hp - dmg\n\t\tif dmg > 0 then\n\t\t\tself.DamageDealtThisTurn = (self.DamageDealtThisTurn or 0) + dmg\n\t\t\tlocal poison = self:AddPowerFieldTotal(\"attackPoison\")\n\t\t\tif poison ~= nil and poison > 0 then\n\t\t\t\tself:ApplyPoisonToMonster(m, poison)\n\t\t\tend\n\t\tend\n\t\tself:ShowDmgPop(i, dmg)\n\t\tself:MonsterHitMotion(i)\n\t\tif m.hp <= 0 then\n\t\t\tm.hp = 0\n\t\t\tself:KillMonster(m.slot)\n\t\t\tkillCount = killCount + 1\n\t\tend\n\tend\nend\nif killCount > 0 and self.ActiveKillReward ~= nil and self.ActiveKillReward > 0 then\n\tself.BonusRewardScreens = (self.BonusRewardScreens or 0) + (killCount * self.ActiveKillReward)\nend\nself:RenderCombat()\nself:CheckCombatEnd()\nreturn killCount > 0",
"Scope": 2,
"ExecSpace": 6,
"Attributes": [],

View File

@@ -55,7 +55,8 @@ export function calcAttack(base, str, weak, vulnOnTarget) {
}
export function calcEnemyAttack(base, str, weak, vulnOnTarget, strengthLoss = 0) {
return calcAttack(base, Math.max(0, str - strengthLoss), weak, vulnOnTarget);
// Lua EnemyActStep 동기화: 힘 손실은 (value+str) 전체에서 차감(음수 힘 허용), 최종 calcAttack이 0 클램프.
return calcAttack(base, str - strengthLoss, weak, vulnOnTarget);
}
// 방어 우선 차감 후 hp 적용 → { hp, block }
@@ -422,6 +423,9 @@ export function simulateCombat(data, rng, stats) {
const hitN = (c.hits || 1) + bonusHits;
let useAoe = c.aoe === true;
if (c.class === 'shiv' && shivAoeThisCombat === true) useAoe = true;
if (c.class === 'shiv' && !shivFirstDamageBonusUsed && powerFieldTotal('firstShivDamageBonus') > 0) {
shivFirstDamageBonusUsed = true;
}
const perHit = calcAttack(baseDamage || 0, pStr, pWeak, 0) * turnAttackMultiplier;
const dealToTarget = (target, amount) => {
if (!target || !target.alive) return { killed: false, dealt: 0 };
@@ -515,9 +519,6 @@ export function simulateCombat(data, rng, stats) {
}
}
}
if (c.class === 'shiv' && !shivFirstDamageBonusUsed && powerFieldTotal('firstShivDamageBonus') > 0) {
shivFirstDamageBonusUsed = true;
}
}
}
if (c.strength) pStr += c.strength;
@@ -721,7 +722,7 @@ export function simulateCombat(data, rng, stats) {
const it = m.intents.length ? m.intents[Math.floor(rng() * m.intents.length)] : null;
if (it) {
if (it.kind === 'Attack') {
const atk = calcAttack(it.value, Math.max(0, m.str - enemyStrengthLossThisTurn), m.weak, pVuln);
const atk = calcEnemyAttack(it.value, m.str, m.weak, pVuln, enemyStrengthLossThisTurn);
const beforeHp = pHp;
let incoming = atk;
if (pIntangible > 0 && incoming > 1) incoming = 1;

View File

@@ -895,6 +895,29 @@ test("calcEnemyAttack: enemyStrengthLossThisTurn reduces enemy attack damage", (
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: repeatOnKill repeats an attack until no kill occurs", () => {
const shared = {
cards: {

View File

@@ -392,6 +392,10 @@ for i = 1, #self.Monsters do
m.hp = m.hp - dmg
if dmg > 0 then
self.DamageDealtThisTurn = (self.DamageDealtThisTurn or 0) + dmg
local poison = self:AddPowerFieldTotal("attackPoison")
if poison ~= nil and poison > 0 then
self:ApplyPoisonToMonster(m, poison)
end
end
self:ShowDmgPop(i, dmg)
self:MonsterHitMotion(i)