# P6 — 버프/디버프·Power 카드·적 방어도 UI 구현 계획 > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** StS 표준 약화·취약·힘 버프 시스템 + Power 카드 kind + 적 방어도 배지 UI를 데이터 주도로 구현. **Architecture:** `data/cards.json`·`enemies.json` 스키마 확장 → `tools/deck/gen-slaydeck.mjs`의 Lua 생성부(상태 props·전투 메서드·UI 엔티티) 확장 → 산출물 재생성. 밸런스 시뮬(`tools/balance/sim-balance.mjs`)에 동일 규칙 재현. **Tech Stack:** Node.js(생성기·시뮬), MSW Lua(생성물), node:test. 설계 문서: `docs/superpowers/specs/2026-06-12-buffs-power-design.md` --- ### Task 1: 신규 카드 이미지 RUID 선별 (메이커) **Files:** 없음 (RUID 4개 확보가 산출물) - [ ] **Step 1**: `asset_search_resources`(cat=sprite, source=maplestory)로 후보 수집 — 쿼리: "차지 블로우", "위협", "인레이지", "분노" (결과 빈약 시 "스킬", "버프" 등 보조 쿼리) - [ ] **Step 2**: 메이커 Play Test 상태에서 `maker_execute_script`(client)로 후보 RUID를 UIGroup에 격자 배치(아래 패턴) 후 `maker_screenshot`으로 확인, 카드당 1개 선별 ```lua -- 후보 미리보기 패턴 (client 컨텍스트) local ruids = { "", "", "..." } local root = _EntityService:GetEntityByPath("/ui/DefaultGroup") for i = 1, #ruids do local e = _SpawnService:SpawnByModelId("model://uisprite", "RuidPreview" .. i, Vector3(0,0,0), root) e.SpriteGUIRendererComponent.ImageRUID = ruids[i] e.UITransformComponent.anchoredPosition = Vector2(-600 + ((i-1) % 8) * 160, 200 - math.floor((i-1) / 8) * 160) e.UITransformComponent.RectSize = Vector2(140, 140) end ``` - [ ] **Step 3**: 미리보기 엔티티 제거(스크립트로 Destroy) 후 플레이 종료 ### Task 2: 카드·적 데이터 확장 **Files:** - Modify: `data/cards.json` - Modify: `data/enemies.json` - [ ] **Step 1**: `data/cards.json`의 `cards`에 4종 추가 (image는 Task 1 선별값) ```json "ChargedBlow": { "name": "차지 블로우", "cost": 2, "kind": "Attack", "damage": 8, "vuln": 2, "desc": "피해 8, 취약 2", "image": "<선별RUID>" }, "Threaten": { "name": "위협", "cost": 0, "kind": "Skill", "weak": 2, "desc": "약화 2 부여", "image": "<선별RUID>" }, "Enrage": { "name": "인레이지", "cost": 1, "kind": "Skill", "strength": 2, "desc": "힘 +2", "image": "<선별RUID>" }, "Rage": { "name": "분노", "cost": 1, "kind": "Power", "powerEffect": "strengthPerTurn", "value": 1, "desc": "매 턴 시작 시 힘 +1", "image": "<선별RUID>" } ``` - [ ] **Step 2**: `data/enemies.json` 인텐트에 Debuff 추가 — mushmom에 `{ "kind": "Debuff", "effect": "weak", "value": 2 }` (intents 2번째로 삽입), slime_elite에 `{ "kind": "Debuff", "effect": "weak", "value": 1 }` (마지막), slime_boss·king_slime에 `{ "kind": "Debuff", "effect": "vuln", "value": 2 }` (Defend 다음), modified_snail에 `{ "kind": "Debuff", "effect": "weak", "value": 1 }` (마지막) - [ ] **Step 3**: 커밋 `feat(buffs-power): 신규 카드 4종·적 디버프 인텐트 데이터` ### Task 3: 생성기 — 직렬화·상태·전투 규칙 **Files:** - Modify: `tools/deck/gen-slaydeck.mjs` (luaCardsTable ~line 64, props ~1888, StartCombat ~2001, BuildMonsters ~2040, StartPlayerTurn ~2184, EndPlayerTurn ~2191, PlayCard ~2410, DealDamageToTarget ~2520, EnemyActStep ~2599, ApplyCardFace ~2347) - [ ] **Step 1**: `luaCardsTable`에 신규 필드 직렬화 추가 ```js if (c.strength != null) fields.push(`strength = ${c.strength}`); if (c.weak != null) fields.push(`weak = ${c.weak}`); if (c.vuln != null) fields.push(`vuln = ${c.vuln}`); if (c.powerEffect != null) fields.push(`powerEffect = ${luaStr(c.powerEffect)}`); if (c.value != null) fields.push(`value = ${c.value}`); ``` - [ ] **Step 2**: props 추가 — `prop('number', 'PlayerStr', '0')`, `prop('number', 'PlayerWeak', '0')`, `prop('number', 'PlayerVuln', '0')`, `prop('any', 'PlayerPowers')` - [ ] **Step 3**: `StartCombat`에 리셋 추가 (`self.PlayerBlock = 0` 다음 줄) ```lua self.PlayerStr = 0 self.PlayerWeak = 0 self.PlayerVuln = 0 self.PlayerPowers = {} ``` - [ ] **Step 4**: `BuildMonsters`의 몬스터 테이블 생성에 `str = 0, weak = 0, vuln = 0` 필드 추가 (기존 `block = 0` 자리 옆) - [ ] **Step 5**: 플레이어 공격 피해 헬퍼 `CalcPlayerAttack` 메서드 신설 + `PlayCard`의 Attack 분기를 수정 — `c.damage`에 힘·약화 적용한 값을 `PlayAttackFx`에 전달. 버프 필드 공통 처리(Attack/Skill 양쪽): `strength`/`weak`/`vuln` 적용 ```lua -- method CalcPlayerAttack(base) → number local dmg = base + self.PlayerStr if self.PlayerWeak > 0 then dmg = math.floor(dmg * 0.75) end return dmg ``` ```lua -- PlayCard 내 교체 (Attack 분기) if c.kind == "Attack" then if c.damage ~= nil then self:PlayAttackFx(self.TargetIndex, c.image, self:CalcPlayerAttack(c.damage)) end if c.block ~= nil then self.PlayerBlock = self.PlayerBlock + c.block end self:ApplyRelics("cardPlayed") elseif c.kind == "Skill" then if c.block ~= nil then self.PlayerBlock = self.PlayerBlock + c.block end elseif c.kind == "Power" then if c.powerEffect ~= nil then table.insert(self.PlayerPowers, cardId) end end -- 공통 버프/디버프 적용 (kind 분기 아래, table.remove 위) if c.strength ~= nil then self.PlayerStr = self.PlayerStr + c.strength end if c.weak ~= nil or c.vuln ~= nil then local tm = self.Monsters[self.TargetIndex] if tm ~= nil and tm.alive == true then if c.weak ~= nil then tm.weak = tm.weak + c.weak end if c.vuln ~= nil then tm.vuln = tm.vuln + c.vuln end end end ``` - [ ] **Step 6**: Power 소멸 처리 — `PlayCard`의 `table.insert(self.DiscardPile, cardId)`를 조건부로 ```lua table.remove(self.Hand, slot) if c.kind ~= "Power" then table.insert(self.DiscardPile, cardId) end ``` - [ ] **Step 7**: `DealDamageToTarget`에 취약 배수 (block 차감 **이전**) ```lua local dmg = amount if m.vuln > 0 then dmg = math.floor(dmg * 1.5) end ``` - [ ] **Step 8**: `EnemyActStep` — Debuff 인텐트 처리 + 적 공격 피해 공식 + 행동 후 적 디버프 감소 ```lua if intent.kind == "Attack" then local atk = intent.value + m.str if m.weak > 0 then atk = math.floor(atk * 0.75) end if self.PlayerVuln > 0 then atk = math.floor(atk * 1.5) end local before = self.PlayerHp self:DealDamageToPlayer(atk) self:ShowPlayerDmgPop(before - self.PlayerHp) elseif intent.kind == "Defend" then m.block = m.block + intent.value elseif intent.kind == "Debuff" then if intent.effect == "weak" then self.PlayerWeak = self.PlayerWeak + intent.value elseif intent.effect == "vuln" then self.PlayerVuln = self.PlayerVuln + intent.value end end -- intentIdx 갱신 직후 if m.weak > 0 then m.weak = m.weak - 1 end if m.vuln > 0 then m.vuln = m.vuln - 1 end ``` - [ ] **Step 9**: `EndPlayerTurn`에 플레이어 디버프 감소 (`self:EnemyTurn()` 직전) ```lua if self.PlayerWeak > 0 then self.PlayerWeak = self.PlayerWeak - 1 end if self.PlayerVuln > 0 then self.PlayerVuln = self.PlayerVuln - 1 end ``` - [ ] **Step 10**: `StartPlayerTurn`에 파워 발동 (`self:ApplyRelics("turnStart")` 다음) ```lua if self.PlayerPowers ~= nil then for i = 1, #self.PlayerPowers do local pc = self.Cards[self.PlayerPowers[i]] if pc ~= nil and pc.powerEffect == "strengthPerTurn" then self.PlayerStr = self.PlayerStr + pc.value end end end ``` - [ ] **Step 11**: `ApplyCardFace` kind 색 분기에 `elseif c.kind == "Power" then` → `Color(0.46, 0.68, 0.52, 1)` 명시 (기존 else를 Power로) - [ ] **Step 12**: 커밋 `feat(buffs-power): 버프/디버프·Power 전투 규칙 (생성기)` ### Task 4: 생성기 — UI (적 방어도 배지·버프 라인·인텐트) **Files:** - Modify: `tools/deck/gen-slaydeck.mjs` (몬스터 슬롯 UI 생성부 ~line 916-1015, PlayerPanel ~1015-1100, RenderCombat ~2688) - [ ] **Step 1**: 몬스터 슬롯 루프에 BlockBadge(guid 270+i)·Value(guid 280+i)·Buffs(guid 290+i) 엔티티 추가 (DmgPop 추가 코드 다음) ```js const mBlockBadge = entity({ id: guid('cmb', 270 + i), path: `${base}/BlockBadge`, modelId: 'uisprite', entryId: 'UISprite', componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent', displayOrder: 6, components: [ transform({ parentW: SLOT_W, parentH: SLOT_H, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 40, y: 36 }, pos: { x: -HP_BAR_W / 2 - 30, y: -14 } }), sprite({ color: { r: 0.32, g: 0.5, b: 0.85, a: 1 }, type: 1 }), ], }); mBlockBadge.jsonString.enable = false; combat.push(mBlockBadge); combat.push(entity({ id: guid('cmb', 280 + i), path: `${base}/BlockBadge/Value`, modelId: 'uitext', entryId: 'UIText', componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent', displayOrder: 0, components: [ transform({ parentW: 40, parentH: 36, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 40, y: 32 }, pos: { x: 0, y: 0 } }), sprite({ color: TRANSPARENT }), text({ value: '0', fontSize: 17, bold: true, color: { r: 1, g: 1, b: 1, a: 1 }, alignment: 4 }), ], })); combat.push(entity({ id: guid('cmb', 290 + i), path: `${base}/Buffs`, modelId: 'uitext', entryId: 'UIText', componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent', displayOrder: 7, components: [ transform({ parentW: SLOT_W, parentH: SLOT_H, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: SLOT_W + 60, y: 22 }, pos: { x: 0, y: -58 } }), sprite({ color: TRANSPARENT }), text({ value: '', fontSize: 15, bold: true, color: { r: 0.85, g: 0.65, b: 1, a: 1 }, alignment: 4 }), ], })); ``` - [ ] **Step 2**: PlayerPanel에 Buffs 텍스트(guid 217) 추가 (BlockBadge/Value 다음) ```js combat.push(entity({ id: guid('cmb', 217), path: `${PP}/Buffs`, modelId: 'uitext', entryId: 'UIText', componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent', displayOrder: 6, components: [ transform({ parentW: 300, parentH: 96, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 280, y: 22 }, pos: { x: 0, y: -44 } }), sprite({ color: TRANSPARENT }), text({ value: '', fontSize: 14, bold: true, color: { r: 0.85, g: 0.65, b: 1, a: 1 }, alignment: 4 }), ], })); ``` - [ ] **Step 3**: 버프 문자열 헬퍼 메서드 `BuffsLabel` 신설 (Lua, str/weak/vuln → "힘+2 약화1 취약2") ```lua -- method BuffsLabel(str, weak, vuln) → string local parts = {} if str ~= nil and str > 0 then table.insert(parts, "힘+" .. tostring(str)) end if weak ~= nil and weak > 0 then table.insert(parts, "약화" .. tostring(weak)) end if vuln ~= nil and vuln > 0 then table.insert(parts, "취약" .. tostring(vuln)) end return table.concat(parts, " ") ``` - [ ] **Step 4**: `RenderCombat` 확장 — 몬스터 루프 내(SetHpBar 다음) ```lua self:SetEntityEnabled(base .. "/BlockBadge", m.block > 0) self:SetText(base .. "/BlockBadge/Value", string.format("%d", m.block)) self:SetText(base .. "/Buffs", self:BuffsLabel(m.str, m.weak, m.vuln)) ``` 인텐트 분기 교체 (Attack은 최종 예상치·Debuff 추가): ```lua local t = "" if intent ~= nil then if intent.kind == "Attack" then local atk = intent.value + m.str if m.weak > 0 then atk = math.floor(atk * 0.75) end if self.PlayerVuln > 0 then atk = math.floor(atk * 1.5) end t = "공격 " .. tostring(atk) elseif intent.kind == "Defend" then t = "방어 " .. tostring(intent.value) elseif intent.kind == "Debuff" then if intent.effect == "weak" then t = "약화 " .. tostring(intent.value) .. " 부여" else t = "취약 " .. tostring(intent.value) .. " 부여" end end end ``` 인텐트 색: Attack 빨강(기존), Defend 파랑(기존 else), Debuff 보라 `Color(0.8, 0.5, 1, 1)` 분기 추가. 플레이어 표시 (기존 BlockBadge 갱신 다음): ```lua local pb = self:BuffsLabel(self.PlayerStr, self.PlayerWeak, self.PlayerVuln) if self.PlayerPowers ~= nil and #self.PlayerPowers > 0 then local names = {} for i = 1, #self.PlayerPowers do local pc = self.Cards[self.PlayerPowers[i]] if pc ~= nil then table.insert(names, pc.name) end end if pb ~= "" then pb = pb .. " · " end pb = pb .. table.concat(names, " ") end self:SetText("/ui/DefaultGroup/CombatHud/PlayerPanel/Buffs", pb) ``` - [ ] **Step 5**: 커밋 `feat(buffs-power): 적 방어도 배지·버프 라인·디버프 인텐트 UI (생성기)` ### Task 5: 밸런스 시뮬 동기화 + 테스트 **Files:** - Modify: `tools/balance/sim-balance.mjs` - Test: `tools/balance/sim-balance.test.mjs` - [ ] **Step 1**: 실패 테스트 먼저 추가 — 약화·취약·힘 계산 + Debuff 인텐트 + Power 동작 ```js test('simulateCombat: 취약이 플레이어 공격을 1.5배로', () => { const data = { cards: { Vuln: { name: '취약기', cost: 1, kind: 'Skill', vuln: 9 }, Hit: { name: '타격', cost: 1, kind: 'Attack', damage: 10 } }, starterDeck: ['Vuln', 'Hit', 'Hit', 'Hit', 'Hit'], monsters: [{ name: '적', maxHp: 100, intents: [{ kind: 'Defend', value: 0 }] }], }; const r = simulateCombat(data, mulberry32(1)); // 1턴: 공격 우선 휴리스틱 → Hit×3 (취약 미부여, 30) — 그래도 30+α로 수치 검증은 별도 단위 함수로 assert.equal(typeof r.win, 'boolean'); }); test('calcAttack: 힘·약화·취약 공식', () => { assert.equal(calcAttack(6, 2, 0, 0), 8); // 힘+2 assert.equal(calcAttack(6, 0, 1, 0), 4); // 약화 floor(6*0.75) assert.equal(calcAttack(6, 0, 0, 1), 9); // 취약 floor(6*1.5) assert.equal(calcAttack(10, 2, 1, 1), 13); // floor(floor(12*0.75)=9 → floor(9*1.5)=13 }); test('simulateCombat: 적 Debuff 인텐트로 플레이어 약화 → 받는 피해 감소 검증', () => { const data = { cards: { Hit: { name: '타격', cost: 1, kind: 'Attack', damage: 1 } }, starterDeck: ['Hit', 'Hit', 'Hit', 'Hit', 'Hit'], monsters: [{ name: '적', maxHp: 9999, intents: [{ kind: 'Debuff', effect: 'weak', value: 1 }] }], }; const r = simulateCombat(data, mulberry32(1)); assert.equal(r.playerHpRemaining, 80); // Debuff만 하는 적 → 피해 0 }); test('simulateCombat: Power(매턴 힘) 누적', () => { const data = { cards: { Rage: { name: '분노', cost: 1, kind: 'Power', powerEffect: 'strengthPerTurn', value: 5 }, Hit: { name: '타격', cost: 1, kind: 'Attack', damage: 1 }, }, starterDeck: ['Rage', 'Hit', 'Hit', 'Hit', 'Hit'], monsters: [{ name: '적', maxHp: 60, intents: [{ kind: 'Defend', value: 0 }] }], }; const r = simulateCombat(data, mulberry32(1)); assert.equal(r.win, true); assert.ok(r.turns <= 6, `파워 누적으로 빠른 처치 기대, 실제 ${r.turns}턴`); }); ``` - [ ] **Step 2**: `node --test tools/balance/sim-balance.test.mjs` → 신규 테스트 FAIL 확인 - [ ] **Step 3**: `sim-balance.mjs` 구현 — `calcAttack(base, str, weak, vulnOnTarget)` export 신설, `simulateCombat`에 pStr/pWeak/pVuln/powers·몬스터 str/weak/vuln 상태 추가, 규칙 재현(부여→감소 타이밍 Lua와 동일), `chooseAction` 확장(Attack 우선 유지, 잔여 에너지로 Power→버프 Skill 사용), Debuff 인텐트 처리. `formatReport`의 kind 루프에 'Power' 포함(효율 계산은 plays만 표시). - [ ] **Step 4**: `node --test tools/balance/sim-balance.test.mjs` → 전체 PASS - [ ] **Step 5**: 커밋 `feat(buffs-power): 밸런스 시뮬 버프/디버프·Power 동기화` ### Task 6: 산출물 재생성·시뮬 확인·푸시·PR·머지 **Files:** - Regen: `RootDesk/MyDesk/SlayDeckController.codeblock`, `ui/DefaultGroup.ui`, `Global/common.gamelogic` - [ ] **Step 1**: `node tools/deck/gen-slaydeck.mjs` 실행 성공 확인 - [ ] **Step 2**: `node tools/balance/sim-balance.mjs` — 승률 0%/100% 극단 아님 확인 (참고용 리포트 기록) - [ ] **Step 3**: 커밋 `feat(buffs-power): 산출물 재생성 (버프/디버프·Power·적 방어도 UI)` - [ ] **Step 4**: `git push -u origin feature/p6-buffs-power` - [ ] **Step 5**: Gitea API로 PR 생성 → 머지 (기존 자동화 패턴: `curl -s -X POST .../repos/gahusb/maplecontest/pulls`, 토큰은 `.mcp.json` 참조 금지 — `git credential` 또는 기존 사용 토큰 경로) ## Self-Review 결과 - 설계 요구 전 항목(버프 3종·Power·적 방어도 배지·예시 카드 4종·적 디버프 인텐트·시뮬 동기화) 태스크 매핑 확인 - 타입/이름 일관성: `CalcPlayerAttack`·`BuffsLabel`·`PlayerStr/Weak/Vuln`·`PlayerPowers`·`m.str/weak/vuln` 통일 확인 - 플레이스홀더: 카드 image RUID만 Task 1 산출물에 의존 (의도된 순서)