diff --git a/docs/superpowers/plans/2026-06-12-buffs-power.md b/docs/superpowers/plans/2026-06-12-buffs-power.md new file mode 100644 index 0000000..656c0d7 --- /dev/null +++ b/docs/superpowers/plans/2026-06-12-buffs-power.md @@ -0,0 +1,377 @@ +# 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 산출물에 의존 (의도된 순서)