Files
maplecontest/docs/superpowers/plans/2026-06-12-buffs-power.md
gahusb 12f3928ab4 docs(buffs-power): P6 구현 계획
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 01:16:05 +09:00

378 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 = { "<ruid1>", "<ruid2>", "..." }
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 산출물에 의존 (의도된 순서)