4 Commits

Author SHA1 Message Date
fc03d58ee7 fix(data): Tactician·Adrenaline 잘린 설명 완성 (필드와 일치)
Tactician(전략가) "교활. 을 얻습니다." → gainEnergy:1 반영해
"교활. 에너지를 1 얻습니다." Adrenaline(아드레날린) "를 얻습니다..."
→ "에너지를 1 얻습니다. 카드를 2장 뽑습니다. 소멸." 산출물 재생성 포함.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UUvHKjrt8jqLzDeCsRRGmj
2026-06-29 20:50:04 +09:00
ead73b427e fix(deck): useAllEnergy 카드는 코스트감소를 무시하고 전 에너지 소비 (Lua/JS 동기화)
Malaise(불쾌, xWeakPerEnergy)·Skewer(꼬챙이, xDamagePerEnergy) 같은
useAllEnergy 카드는 X 효과가 소비 에너지에 비례하는데, Lua는 코스트
감소(스킬코스트감소·다음스킬무료·전투코스트감소)를 useAllEnergy에도
적용해 소비 에너지가 full보다 줄고 X도 약해졌다(코스트감소가 카드를
약화시키는 역설). JS는 스킬코스트감소만 건너뛰고 combatReduction은
적용해 양쪽이 미묘하게 달랐다.

정답: useAllEnergy는 "전 에너지 소비"이므로 어떤 코스트감소도 무시.
Lua는 3개 감소 조건에 useAllEnergy 제외 추가, JS는 finalCost를
useAllEnergy면 combatReduction 미적용으로. 양쪽 모두 full 에너지 소비로
일치. 산출물 재생성 포함.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UUvHKjrt8jqLzDeCsRRGmj
2026-06-29 20:48:29 +09:00
d78049182b fix(deck): DealDamageToAllMonsters를 isAttack 매개변수화 (버스트 평면화)
DealDamageToAllMonsters는 AoE 공격(취약 1.5x·attackPoison 적용)과
Outbreak 독 버스트(평면 피해) 두 용도로 공유되는데, 취약을 항상
적용해 버스트가 취약 대상에 과다 피해를 줬다(JS 미러는 버스트를
평면 applyDamage로 처리 — Lua만 발산). 또한 직전 커밋에서 추가한
attackPoison도 버스트에 적용돼, Envenom+Outbreak 동시 활성 시
버스트→attackPoison→독 적용→또 버스트의 재귀 위험이 있었다.

isAttack 매개변수를 추가해 취약·attackPoison을 공격일 때만 적용:
AoE 공격(ResolveCardEffects)은 true, 버스트는 미전달(평면). JS의
dealToTarget(취약+attackPoison) vs 버스트(평면) 분리와 일치.
산출물 재생성 포함.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UUvHKjrt8jqLzDeCsRRGmj
2026-06-29 20:45:03 +09:00
5f615e30e2 fix(balance): Prepared 시뮬에 blockPerDamageDealtThisTurn 실제 방어 적용 + 설명 정확화
Lua는 Prepared(예비)에서 AddCardBlock으로 실제 방어를 부여하는데, JS
시뮬은 blockGained(통계 카운터)만 증가시키고 addBlock을 호출하지 않아
플레이어가 실제 방어를 못 받았다(시뮬이 방어를 과소집계).

JS도 다른 블록 출처처럼 addBlock 경유로 변경(Lua 동기화). 또한
Prepared 데이터는 discard:1 + blockPerDamageDealtThisTurn뿐(draw 없음)
인데 설명이 "1장 뽑고 1장 버립니다"로 부정확해, 실제 동작(1장 버리고
이번 턴 피해만큼 방어)에 맞게 보강. RED-GREEN 테스트 추가. 88개.
산출물 재생성 포함.

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

File diff suppressed because one or more lines are too long

View File

@@ -537,7 +537,7 @@
"kind": "Skill", "kind": "Skill",
"class": "bandit", "class": "bandit",
"rarity": "normal", "rarity": "normal",
"desc": "카드를 1장 뽑습니다. 카드를 1장 버립니다.", "desc": "카드를 1장 버리고, 이번 턴에 준 피해만큼 방어를 얻습니다.",
"blockPerDamageDealtThisTurn": 1, "blockPerDamageDealtThisTurn": 1,
"discard": 1, "discard": 1,
"image": "c1e19219745e44c39ae6ac2f77e347d9" "image": "c1e19219745e44c39ae6ac2f77e347d9"
@@ -943,7 +943,7 @@
"kind": "Skill", "kind": "Skill",
"class": "bandit", "class": "bandit",
"rarity": "unique", "rarity": "unique",
"desc": "교활. 얻습니다.", "desc": "교활. 에너지를 1 얻습니다.",
"gainEnergy": 1, "gainEnergy": 1,
"sly": true, "sly": true,
"image": "c1e19219745e44c39ae6ac2f77e347d9" "image": "c1e19219745e44c39ae6ac2f77e347d9"
@@ -1109,7 +1109,7 @@
"kind": "Skill", "kind": "Skill",
"class": "bandit", "class": "bandit",
"rarity": "legend", "rarity": "legend",
"desc": " 얻습니다. 카드를 2장 뽑습니다. 소멸.", "desc": "에너지를 1 얻습니다. 카드를 2장 뽑습니다. 소멸.",
"draw": 2, "draw": 2,
"gainEnergy": 1, "gainEnergy": 1,
"image": "91a2d1c16cb041549adbf1a0d7b1f37f" "image": "91a2d1c16cb041549adbf1a0d7b1f37f"

View File

@@ -565,7 +565,7 @@ export function simulateCombat(data, rng, stats) {
} }
} }
if (c.blockPerDamageDealtThisTurn && c.blockPerDamageDealtThisTurn > 0 && c.kind !== 'Power') { if (c.blockPerDamageDealtThisTurn && c.blockPerDamageDealtThisTurn > 0 && c.kind !== 'Power') {
blockGained += Math.max(0, damageDealtThisTurn * c.blockPerDamageDealtThisTurn); blockGained += addBlock(Math.max(0, damageDealtThisTurn * c.blockPerDamageDealtThisTurn));
} }
if (recordStats && stats) stats[id] = bump(stats[id], costSpent, dmg, blockGained); if (recordStats && stats) stats[id] = bump(stats[id], costSpent, dmg, blockGained);
} }
@@ -659,7 +659,7 @@ export function simulateCombat(data, rng, stats) {
const baseCost = c.cost || 0; const baseCost = c.cost || 0;
const combatReduction = combatCardCostReduction[id] || 0; const combatReduction = combatCardCostReduction[id] || 0;
const cost = handCostZeroThisTurn === true ? 0 : (c.useAllEnergy === true ? energy : (skillFree ? 0 : (c.kind === 'Skill' ? Math.max(0, baseCost - skillCostReductionThisTurn) : baseCost))); const cost = handCostZeroThisTurn === true ? 0 : (c.useAllEnergy === true ? energy : (skillFree ? 0 : (c.kind === 'Skill' ? Math.max(0, baseCost - skillCostReductionThisTurn) : baseCost)));
const finalCost = Math.max(0, cost - combatReduction); const finalCost = c.useAllEnergy === true ? cost : Math.max(0, cost - combatReduction);
energy -= finalCost; energy -= finalCost;
resolveCardEffects(id, c, finalCost); resolveCardEffects(id, c, finalCost);
const playedBlock = powerFieldTotal('cardPlayedBlock'); const playedBlock = powerFieldTotal('cardPlayedBlock');

View File

@@ -918,6 +918,21 @@ test('simulateCombat: firstShivDamageBonus는 턴당 첫 Shiv에만 적용 (Lua
assert.equal(r.turns, 2); assert.equal(r.turns, 2);
}); });
test('simulateCombat: blockPerDamageDealtThisTurn이 실제 방어를 부여 (Lua 동기화)', () => {
// 매턴 Hit(5뎀) → Guard(준 피해만큼 방어 5) → 적 공격 5 상쇄.
// 수정(실제 방어): 무한 생존 → 무승부. 버그(방어 미부여): 매턴 5피해 → 사망.
const data = {
cards: {
Hit: { name: '타격', cost: 2, kind: 'Attack', damage: 5 },
Guard: { name: '대비', cost: 1, kind: 'Skill', blockPerDamageDealtThisTurn: 1 },
},
starterDeck: ['Hit', 'Guard'],
monsters: [{ name: '적', maxHp: 9999, intents: [{ kind: 'Attack', value: 5 }] }],
};
const r = simulateCombat(data, mulberry32(1));
assert.equal(r.draw, true);
});
test("simulateCombat: repeatOnKill repeats an attack until no kill occurs", () => { test("simulateCombat: repeatOnKill repeats an attack until no kill occurs", () => {
const shared = { const shared = {
cards: { cards: {

View File

@@ -52,14 +52,14 @@ if self.HandCostZeroThisTurn == true then
elseif c.useAllEnergy == true then elseif c.useAllEnergy == true then
cost = self.Energy cost = self.Energy
end end
if c.kind == "Skill" and self.NextSkillCostZero == true then if c.kind == "Skill" and c.useAllEnergy ~= true and self.NextSkillCostZero == true then
cost = 0 cost = 0
skillFree = true skillFree = true
end end
if c.kind == "Skill" and self.SkillCostReductionThisTurn ~= nil and self.SkillCostReductionThisTurn > 0 then if c.kind == "Skill" and c.useAllEnergy ~= true and self.SkillCostReductionThisTurn ~= nil and self.SkillCostReductionThisTurn > 0 then
cost = math.max(0, cost - self.SkillCostReductionThisTurn) cost = math.max(0, cost - self.SkillCostReductionThisTurn)
end end
if self.CombatCardCostReduction ~= nil and self.CombatCardCostReduction[cardId] ~= nil then if c.useAllEnergy ~= true and self.CombatCardCostReduction ~= nil and self.CombatCardCostReduction[cardId] ~= nil then
cost = math.max(0, cost - self.CombatCardCostReduction[cardId]) cost = math.max(0, cost - self.CombatCardCostReduction[cardId])
end end
if c.kind == "Skill" and self.NextSkillRepeatCount ~= nil and self.NextSkillRepeatCount > 0 then if c.kind == "Skill" and self.NextSkillRepeatCount ~= nil and self.NextSkillRepeatCount > 0 then
@@ -381,7 +381,7 @@ for i = 1, #self.Monsters do
local m = self.Monsters[i] local m = self.Monsters[i]
if m ~= nil and m.alive == true then if m ~= nil and m.alive == true then
local dmg = amount local dmg = amount
if m.vuln > 0 then if isAttack == true and m.vuln > 0 then
dmg = math.floor(dmg * 1.5) dmg = math.floor(dmg * 1.5)
end end
if m.block > 0 then if m.block > 0 then
@@ -392,9 +392,11 @@ for i = 1, #self.Monsters do
m.hp = m.hp - dmg m.hp = m.hp - dmg
if dmg > 0 then if dmg > 0 then
self.DamageDealtThisTurn = (self.DamageDealtThisTurn or 0) + dmg self.DamageDealtThisTurn = (self.DamageDealtThisTurn or 0) + dmg
local poison = self:AddPowerFieldTotal("attackPoison") if isAttack == true then
if poison ~= nil and poison > 0 then local poison = self:AddPowerFieldTotal("attackPoison")
self:ApplyPoisonToMonster(m, poison) if poison ~= nil and poison > 0 then
self:ApplyPoisonToMonster(m, poison)
end
end end
end end
self:ShowDmgPop(i, dmg) self:ShowDmgPop(i, dmg)
@@ -413,6 +415,7 @@ self:RenderCombat()
self:CheckCombatEnd() self:CheckCombatEnd()
return killCount > 0`, [ return killCount > 0`, [
{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'amount' }, { Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'amount' },
{ Type: 'boolean', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'isAttack' },
], 0, 'boolean'), ], 0, 'boolean'),
method('PlayAttackFx', `local m = self.Monsters[targetIndex] method('PlayAttackFx', `local m = self.Monsters[targetIndex]
if m == nil or m.alive ~= true or m.entity == nil or not isvalid(m.entity) then if m == nil or m.alive ~= true or m.entity == nil or not isvalid(m.entity) then

View File

@@ -542,7 +542,7 @@ if c.kind == "Attack" then
local function resolveAttackRound() local function resolveAttackRound()
local roundKilled = false local roundKilled = false
if useAoe == true then if useAoe == true then
local killed = self:DealDamageToAllMonsters(total) local killed = self:DealDamageToAllMonsters(total, true)
if killed == true then roundKilled = true end if killed == true then roundKilled = true end
elseif c.randomTargetEachHit == true then elseif c.randomTargetEachHit == true then
for h = 1, hitN do for h = 1, hitN do