3 Commits

Author SHA1 Message Date
926733dbef fix(deck): firstCardDamageBonus 게이트 class→kind (영구 미발동 버그)
ChargedBlow(class=warrior, kind=Attack, firstCardDamageBonus=2)의 첫-카드
보너스가 Lua·JS 양쪽에서 `c.class == "Attack"`로 게이트돼 있었다. class는
warrior/bandit 등이라 절대 "Attack"이 아니어서 보너스가 영구 미발동(죽은 코드).
kind가 "Attack"이므로 `c.kind == "Attack"`로 수정(양쪽 미러).

RED-GREEN 회귀 테스트 추가: class=warrior·kind=Attack 카드의 첫 카드
보너스로 7뎀 → 1턴 처치(미수정 시 5뎀 2턴). 테스트 84→85.
산출물 재생성 포함.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UUvHKjrt8jqLzDeCsRRGmj
2026-06-29 18:21:08 +09:00
d7813f9912 fix(deck): drawDamage/drawPoison를 per-play→per-draw로 (카드 설명과 일치)
Speedster("카드를 뽑을 때마다 피해")·CorrosiveWave("뽑을 때마다 중독")의
효과가 Lua에서는 ResolveCardEffects 끝에서 카드를 '낼 때마다' 발동해
카드 설명·JS 미러(sim-balance draw())와 어긋났다.

per-play 블록을 ApplyDrawTrigger() 메서드로 추출하고 DrawCards에서
뽑은 카드마다 호출해 per-draw로 정렬(JS와 동일). JS 미러는 이미
per-draw라 무변경 — 양쪽 일치.

부수: CheckCombatEnd에 self.CombatOver 멱등 가드 추가. per-draw로
호출이 잦아져(턴시작 5드로 등) 전멸 시 보상/골드/유물이 중복
발동할 수 있던 잠재 버그를 차단(공격+drawDamage 카드에서도 위험했음).

밸런스 영향: Speedster(Power)가 매턴 시작 드로에도 발동해 강해짐 —
값 튜닝은 sim으로 후속 조정 가능. 산출물 재생성 포함.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UUvHKjrt8jqLzDeCsRRGmj
2026-06-29 18:17:01 +09:00
e6f351420b fix(deck): BindButtons 1회 바인드 가드로 2회차 런 핸들러 중복 차단
StartRun이 run마다 BindButtons를 호출하는데 앞 7개 핸들러만
disconnect 가드돼 있고 reward/skip/map/shop/monster/relic/potion/job
등 ~30개 ConnectEvent는 미가드라, 2회차+ 런에서 핸들러가 누적된다.
특히 PickReward는 RewardChoices·CombatOver를 클리어하지 않아 중복
핸들러로 두 번 불리면 같은 보상 카드가 RunDeck에 2번 추가된다.

BindLobbyButtons/BindSoulShopButtons와 동일하게 self.ButtonsBound
1회 가드를 추가(런 UI 엔티티는 영속이라 1회 바인드로 충분).
신규 prop ButtonsBound 선언. 산출물 재생성 포함.

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

File diff suppressed because one or more lines are too long

View File

@@ -342,7 +342,7 @@ export function simulateCombat(data, rng, stats) {
if (c.damagePerDiscardedThisTurn) base += turnDiscardedCards * c.damagePerDiscardedThisTurn; if (c.damagePerDiscardedThisTurn) base += turnDiscardedCards * c.damagePerDiscardedThisTurn;
if (c.damagePerSkillInHand) base += countOtherHandSkills(id) * c.damagePerSkillInHand; if (c.damagePerSkillInHand) base += countOtherHandSkills(id) * c.damagePerSkillInHand;
if (c.damagePerCardDrawnThisCombat) base += cardsDrawnThisCombat * c.damagePerCardDrawnThisCombat; if (c.damagePerCardDrawnThisCombat) base += cardsDrawnThisCombat * c.damagePerCardDrawnThisCombat;
if (c.class === 'Attack' && turnCardsPlayedThisTurn === 0 && c.firstCardDamageBonus) base += c.firstCardDamageBonus; if (c.kind === 'Attack' && turnCardsPlayedThisTurn === 0 && c.firstCardDamageBonus) base += c.firstCardDamageBonus;
if (c.class === 'shiv') { if (c.class === 'shiv') {
if (powerFieldTotal('shivDamageBonus') > 0) base += powerFieldTotal('shivDamageBonus'); if (powerFieldTotal('shivDamageBonus') > 0) base += powerFieldTotal('shivDamageBonus');
if (!shivFirstDamageBonusUsed && powerFieldTotal('firstShivDamageBonus') > 0) base += powerFieldTotal('firstShivDamageBonus'); if (!shivFirstDamageBonusUsed && powerFieldTotal('firstShivDamageBonus') > 0) base += powerFieldTotal('firstShivDamageBonus');

View File

@@ -262,6 +262,19 @@ test('simulateCombat: 카드 취약 부여가 같은 카드 피해에 선적용
assert.equal(r.turns, 1); assert.equal(r.turns, 1);
}); });
test('simulateCombat: firstCardDamageBonus가 턴 첫 카드에 적용 (kind===Attack, Lua 동기화)', () => {
// ChargedBlow처럼 class=warrior·kind=Attack인 카드의 첫-카드 보너스.
// 게이트가 class==="Attack"이면 영구 false라 미발동(버그) → 5뎀/2턴.
// kind==="Attack"이면 5+2=7 → 1턴 처치.
const data = {
cards: { CB: { name: '차지블로우', cost: 3, kind: 'Attack', class: 'warrior', damage: 5, firstCardDamageBonus: 2 } },
starterDeck: ['CB', 'CB', 'CB', 'CB', 'CB'],
monsters: [{ name: '적', maxHp: 7, intents: [{ kind: 'Defend', value: 0 }] }],
};
const r = simulateCombat(data, mulberry32(1));
assert.equal(r.turns, 1);
});
test('simulateCombat: Power(매턴 힘) 누적', () => { test('simulateCombat: Power(매턴 힘) 누적', () => {
const data = { const data = {
cards: { cards: {

View File

@@ -682,7 +682,10 @@ self.NextTurnAddCards = {}
self:UpdateDiscardPrompt() self:UpdateDiscardPrompt()
self:RenderHand(false) self:RenderHand(false)
self:RenderPiles()`), self:RenderPiles()`),
method('CheckCombatEnd', `local anyAlive = false method('CheckCombatEnd', `if self.CombatOver == true then
return
end
local anyAlive = false
for i = 1, #self.Monsters do for i = 1, #self.Monsters do
if self.Monsters[i].alive == true then anyAlive = true; break end if self.Monsters[i].alive == true then anyAlive = true; break end
end end

View File

@@ -10,7 +10,11 @@ for i = #list, 2, -1 do
\tlocal j = math.random(1, i) \tlocal j = math.random(1, i)
\tlist[i], list[j] = list[j], list[i] \tlist[i], list[j] = list[j], list[i]
end`, [{ Type: 'any', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'list' }]), end`, [{ Type: 'any', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'list' }]),
method('BindButtons', `local endTurn = _EntityService:GetEntityByPath("/ui/RunUIGroup/DeckHud/EndTurnButton") method('BindButtons', `if self.ButtonsBound == true then
return
end
self.ButtonsBound = true
local endTurn = _EntityService:GetEntityByPath("/ui/RunUIGroup/DeckHud/EndTurnButton")
if endTurn ~= nil and (endTurn.ButtonComponent ~= nil or endTurn:AddComponent("ButtonComponent") ~= nil) then if endTurn ~= nil and (endTurn.ButtonComponent ~= nil or endTurn:AddComponent("ButtonComponent") ~= nil) then
if self.EndTurnHandler ~= nil then if self.EndTurnHandler ~= nil then
endTurn:DisconnectEvent(ButtonClickEvent, self.EndTurnHandler) endTurn:DisconnectEvent(ButtonClickEvent, self.EndTurnHandler)
@@ -471,6 +475,7 @@ for i = 1, amount do
\tlocal cardId = table.remove(self.DrawPile) \tlocal cardId = table.remove(self.DrawPile)
\ttable.insert(drawnCards, cardId) \ttable.insert(drawnCards, cardId)
\tself.CardsDrawnThisCombat = (self.CardsDrawnThisCombat or 0) + 1 \tself.CardsDrawnThisCombat = (self.CardsDrawnThisCombat or 0) + 1
\tself:ApplyDrawTrigger()
\tif #self.Hand >= 10 then \tif #self.Hand >= 10 then
\t\ttable.insert(self.DiscardPile, cardId) \t\ttable.insert(self.DiscardPile, cardId)
\t\tself:TriggerSly(cardId) \t\tself:TriggerSly(cardId)

View File

@@ -3,6 +3,42 @@ import { CARDS, ENEMIES, CLASSES, JOBS, SOUL_UNLOCKS, CARDFRAMES, RARITIES, MAP_
import { UI_FILE, COMMON_FILE, UI_ROOT, GENERATED_UI_SECTIONS, UI_APPEND_ORDER, DISABLED_STOCK_CONTROLS, TRANSPARENT, DARK, GOLD, ATTACK, DEFEND, SKILL, DAMAGE_DIGIT_RUIDS, DAMAGE_POP_MAX_DIGITS, DAMAGE_POP_DIGIT_W, DAMAGE_POP_DIGIT_H, DAMAGE_POP_DIGIT_SPACING, MAX_MONSTERS, HEAD_OFFSET_Y, HP_BAR_W, WHITE, CARD_NAME_TEXT, CARD_DESC_TEXT, cardFaceLayout, CARD_W, CARD_H, CARD_SPACING, CARD_XS, ALIGN_CENTER, ALIGN_BOTTOM_CENTER, guid, transform, sprite, button, text, scrollLayoutGroup, popupLayerFor, uiOrderFor, displayOrderFor, applySortingOverride, entity, uiPath, sectionRoot, isGeneratedUiEntity, appendUiSection } from '../lib/ui-helpers.mjs'; import { UI_FILE, COMMON_FILE, UI_ROOT, GENERATED_UI_SECTIONS, UI_APPEND_ORDER, DISABLED_STOCK_CONTROLS, TRANSPARENT, DARK, GOLD, ATTACK, DEFEND, SKILL, DAMAGE_DIGIT_RUIDS, DAMAGE_POP_MAX_DIGITS, DAMAGE_POP_DIGIT_W, DAMAGE_POP_DIGIT_H, DAMAGE_POP_DIGIT_SPACING, MAX_MONSTERS, HEAD_OFFSET_Y, HP_BAR_W, WHITE, CARD_NAME_TEXT, CARD_DESC_TEXT, cardFaceLayout, CARD_W, CARD_H, CARD_SPACING, CARD_XS, ALIGN_CENTER, ALIGN_BOTTOM_CENTER, guid, transform, sprite, button, text, scrollLayoutGroup, popupLayerFor, uiOrderFor, displayOrderFor, applySortingOverride, entity, uiPath, sectionRoot, isGeneratedUiEntity, appendUiSection } from '../lib/ui-helpers.mjs';
export const handMethods = [ export const handMethods = [
method('ApplyDrawTrigger', `if self.Monsters == nil then
return
end
local drawDamage = self:AddPowerFieldTotal("drawDamage") + (self.DrawDamageThisTurn or 0)
local drawPoison = self:AddPowerFieldTotal("drawPoison") + (self.DrawPoisonThisTurn or 0)
if (drawDamage ~= nil and drawDamage > 0) or (drawPoison ~= nil and drawPoison > 0) then
for mi = 1, #self.Monsters do
local m2 = self.Monsters[mi]
if m2 ~= nil and m2.alive == true then
local dmg = drawDamage or 0
if m2.vuln > 0 then
dmg = math.floor(dmg * 1.5)
end
if m2.block > 0 then
local absorbed = math.min(m2.block, dmg)
m2.block = m2.block - absorbed
dmg = dmg - absorbed
end
if drawPoison ~= nil and drawPoison > 0 then
self:ApplyPoisonToMonster(m2, drawPoison)
end
if dmg > 0 then
m2.hp = m2.hp - dmg
self.DamageDealtThisTurn = (self.DamageDealtThisTurn or 0) + dmg
end
self:ShowDmgPop(mi, dmg)
self:MonsterHitMotion(mi)
if m2.hp <= 0 then
m2.hp = 0
self:KillMonster(m2.slot)
end
end
end
self:RenderCombat()
self:CheckCombatEnd()
end`),
method('GetHandSlotX', `local n = 0 method('GetHandSlotX', `local n = 0
if self.Hand ~= nil then if self.Hand ~= nil then
n = #self.Hand n = #self.Hand
@@ -311,7 +347,7 @@ end
if c.damagePerCardDrawnThisCombat ~= nil then if c.damagePerCardDrawnThisCombat ~= nil then
base2 = base2 + (self.CardsDrawnThisCombat or 0) * c.damagePerCardDrawnThisCombat base2 = base2 + (self.CardsDrawnThisCombat or 0) * c.damagePerCardDrawnThisCombat
end end
if c.class == "Attack" and (self.TurnCardsPlayedThisTurn or 0) == 0 and c.firstCardDamageBonus ~= nil then if c.kind == "Attack" and (self.TurnCardsPlayedThisTurn or 0) == 0 and c.firstCardDamageBonus ~= nil then
base2 = base2 + c.firstCardDamageBonus base2 = base2 + c.firstCardDamageBonus
end end
if c.class == "shiv" then if c.class == "shiv" then
@@ -681,39 +717,6 @@ if c.drawSkillBlock ~= nil and c.drawSkillBlock > 0 then
end end
end end
end end
local drawDamage = self:AddPowerFieldTotal("drawDamage") + (self.DrawDamageThisTurn or 0)
local drawPoison = self:AddPowerFieldTotal("drawPoison") + (self.DrawPoisonThisTurn or 0)
if (drawDamage ~= nil and drawDamage > 0) or (drawPoison ~= nil and drawPoison > 0) then
for mi = 1, #self.Monsters do
local m2 = self.Monsters[mi]
if m2 ~= nil and m2.alive == true then
local dmg = drawDamage or 0
if m2.vuln > 0 then
dmg = math.floor(dmg * 1.5)
end
if m2.block > 0 then
local absorbed = math.min(m2.block, dmg)
m2.block = m2.block - absorbed
dmg = dmg - absorbed
end
if drawPoison ~= nil and drawPoison > 0 then
self:ApplyPoisonToMonster(m2, drawPoison)
end
if dmg > 0 then
m2.hp = m2.hp - dmg
self.DamageDealtThisTurn = (self.DamageDealtThisTurn or 0) + dmg
end
self:ShowDmgPop(mi, dmg)
self:MonsterHitMotion(mi)
if m2.hp <= 0 then
m2.hp = 0
self:KillMonster(m2.slot)
end
end
end
self:RenderCombat()
self:CheckCombatEnd()
end
if c.addShiv ~= nil and c.discard == nil and c.discardAll ~= true then if c.addShiv ~= nil and c.discard == nil and c.discardAll ~= true then
self:AddCardsToHand("Shiv", c.addShiv) self:AddCardsToHand("Shiv", c.addShiv)
end`, [ end`, [

View File

@@ -61,6 +61,7 @@ function writeCodeblocks() {
prop('any', 'AllDeckCloseHandler'), prop('any', 'AllDeckCloseHandler'),
prop('number', 'SoulPoints', '0'), prop('number', 'SoulPoints', '0'),
prop('boolean', 'LobbyBound', 'false'), prop('boolean', 'LobbyBound', 'false'),
prop('boolean', 'ButtonsBound', 'false'),
prop('number', 'LobbyTpTries', '0'), prop('number', 'LobbyTpTries', '0'),
prop('boolean', 'CodexMode', 'false'), prop('boolean', 'CodexMode', 'false'),
prop('any', 'CodexCards'), prop('any', 'CodexCards'),