# 상점/휴식 노드 (TODO E4) Implementation Plan > **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:** 맵에 상점(골드→카드)·휴식(HP 회복) 노드를 추가하고, 진입 시 전투 대신 상점/휴식 UI로 분기. **Architecture:** `data/map.json`에 shop/rest 노드 추가(enemy 없음). SlayDeckController에 상점/휴식 메서드, PickNode 타입 분기, ShopHud/RestHud UI. 모두 `gen-slaydeck.mjs`에서 생성. **Tech Stack:** Node.js ESM 생성기, MSW Lua codeblock/UI. 검증은 node --check+재생성+결정성+메이커 Play. --- ## File Structure - Modify: `data/map.json` — 4행, shop/rest 노드. - Modify: `tools/gen-slaydeck.mjs` — 검증 완화, enemy 조건부 직렬화, 상수, 속성, PickNode 분기, 상점/휴식 메서드, ShopHud/RestHud UI, MapHud y 중앙정렬. 검증: MSW Lua 단위테스트 불가 → 생성기 문법·재생성·결정성·메이커 Play. --- ### Task 1: 데이터 + 검증완화 + enemy 조건부 직렬화 + 상수·속성 **Files:** Modify `data/map.json`, `tools/gen-slaydeck.mjs` - [ ] **Step 1: `data/map.json` 교체** ```json { "start": ["A", "B"], "nodes": { "A": { "type": "combat", "enemy": "slime", "row": 1, "col": -1, "next": ["C", "D"] }, "B": { "type": "combat", "enemy": "slime", "row": 1, "col": 1, "next": ["C", "D"] }, "C": { "type": "rest", "row": 2, "col": -1, "next": ["E", "F"] }, "D": { "type": "shop", "row": 2, "col": 1, "next": ["E", "F"] }, "E": { "type": "elite", "enemy": "slime_elite", "row": 3, "col": -1, "next": ["BOSS"] }, "F": { "type": "combat", "enemy": "slime", "row": 3, "col": 1, "next": ["BOSS"] }, "BOSS": { "type": "boss", "enemy": "slime_boss", "row": 4, "col": 0, "next": [] } } } ``` - [ ] **Step 2: 검증 완화 (enemy 선택적)** — 생성기의 맵 검증 루프를 교체: ```js for (const [id, n] of Object.entries(MAP.nodes)) { if (n.enemy && !ENEMIES.enemies[n.enemy]) throw new Error(`[gen-slaydeck] 노드 ${id}의 enemy 없음: ${n.enemy}`); for (const nx of n.next) { if (!MAP.nodes[nx]) throw new Error(`[gen-slaydeck] 노드 ${id}.next에 없는 노드 id: ${nx}`); } } ``` - [ ] **Step 3: luaMapNodesTable enemy 조건부** — 함수를 교체: ```js function luaMapNodesTable(nodes) { const lines = Object.entries(nodes).map(([id, n]) => { const nx = '{ ' + n.next.map(luaStr).join(', ') + ' }'; const enemyField = n.enemy ? `enemy = ${luaStr(n.enemy)}, ` : ''; return `\t${id} = { type = ${luaStr(n.type)}, ${enemyField}row = ${n.row}, col = ${n.col}, next = ${nx} },`; }); return `self.MapNodes = {\n${lines.join('\n')}\n}`; } ``` - [ ] **Step 4: 상수 추가** — `writeCodeblocks()` 안 `const GOLD_PER_WIN = 15;` 다음에: ```js const CARD_PRICE = 30; const REST_HEAL = 30; ``` - [ ] **Step 5: 상점 상태 속성 추가** — `prop('string', 'CurrentEnemyId', '""'),` 다음에: ```js prop('any', 'ShopChoices'), prop('any', 'ShopBought'), ``` - [ ] **Step 6: JSON·문법 검사** Run: `node -e "JSON.parse(require('fs').readFileSync('data/map.json','utf8')); console.log('JSON OK')" && node --check tools/gen-slaydeck.mjs` Expected: `JSON OK` + 오류 없음 - [ ] **Step 7: 커밋** ```bash git add data/map.json tools/gen-slaydeck.mjs git commit -m "data(E4): 상점/휴식 노드 맵 + enemy 선택적 검증/직렬화 + 상수/속성" ``` --- ### Task 2: PickNode 분기 + 상점/휴식 메서드 **Files:** Modify `tools/gen-slaydeck.mjs` - [ ] **Step 1: PickNode 교체 (타입 분기)** ```js method('PickNode', `if self.RunActive ~= true then return end if self:IsReachable(id) ~= true then return end self.CurrentNodeId = id local hud = _EntityService:GetEntityByPath("/ui/DefaultGroup/MapHud") if hud ~= nil then hud.Enable = false end local node = self.MapNodes[id] if node.type == "shop" then self:ShowShop() elseif node.type == "rest" then self:ShowRest() else self.CurrentEnemyId = node.enemy self:StartCombat() end`, [{ Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'id' }]), ``` - [ ] **Step 2: 상점/휴식 메서드 추가** — PickNode 메서드 다음에 삽입: ```js method('ShowShop', `local pool = {} for cid, _ in pairs(self.Cards) do table.insert(pool, cid) end self.ShopChoices = {} self.ShopBought = { false, false, false } for i = 1, 3 do self.ShopChoices[i] = pool[math.random(1, #pool)] end self:RenderShop() local hud = _EntityService:GetEntityByPath("/ui/DefaultGroup/ShopHud") if hud ~= nil then hud.Enable = true end`), method('RenderShop', `self:SetText("/ui/DefaultGroup/ShopHud/Gold", "골드 " .. string.format("%d", self.Gold)) for i = 1, 3 do local cid = self.ShopChoices[i] local c = self.Cards[cid] local base = "/ui/DefaultGroup/ShopHud/Card" .. tostring(i) if c ~= nil then self:SetText(base .. "/Name", c.name) self:SetText(base .. "/Cost", tostring(c.cost)) self:SetText(base .. "/Desc", c.desc) self:SetText(base .. "/Price", string.format("%d", ${CARD_PRICE}) .. " 골드") local e = _EntityService:GetEntityByPath(base) if e ~= nil and e.SpriteGUIRendererComponent ~= nil then if self.ShopBought[i] == true then e.SpriteGUIRendererComponent.Color = Color(0.2, 0.22, 0.26, 0.6) elseif c.kind == "Attack" then e.SpriteGUIRendererComponent.Color = Color(0.86, 0.42, 0.38, 1) elseif c.kind == "Skill" then e.SpriteGUIRendererComponent.Color = Color(0.42, 0.55, 0.85, 1) else e.SpriteGUIRendererComponent.Color = Color(0.46, 0.68, 0.52, 1) end end end end`), method('BuyCard', `if self.ShopBought == nil or self.ShopBought[slot] == true then return end if self.Gold < ${CARD_PRICE} then return end self.Gold = self.Gold - ${CARD_PRICE} table.insert(self.RunDeck, self.ShopChoices[slot]) self.ShopBought[slot] = true self:RenderShop() self:RenderRun()`, [{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slot' }]), method('ShowRest', `local old = self.PlayerHp self.PlayerHp = self.PlayerHp + ${REST_HEAL} if self.PlayerHp > self.PlayerMaxHp then self.PlayerHp = self.PlayerMaxHp end local healed = self.PlayerHp - old self:SetText("/ui/DefaultGroup/RestHud/Info", "HP " .. string.format("%d", old) .. " → " .. string.format("%d", self.PlayerHp) .. " (+" .. string.format("%d", healed) .. ")") self:RenderCombat() local hud = _EntityService:GetEntityByPath("/ui/DefaultGroup/RestHud") if hud ~= nil then hud.Enable = true end`), method('LeaveNode', `local s = _EntityService:GetEntityByPath("/ui/DefaultGroup/ShopHud") if s ~= nil then s.Enable = false end local r = _EntityService:GetEntityByPath("/ui/DefaultGroup/RestHud") if r ~= nil then r.Enable = false end self:ShowMap()`), ``` - [ ] **Step 3: 문법 검사** Run: `node --check tools/gen-slaydeck.mjs` Expected: 오류 없음 - [ ] **Step 4: 커밋** ```bash git add tools/gen-slaydeck.mjs git commit -m "gen-slaydeck(E4): PickNode 타입 분기·상점(구매)/휴식(회복) 메서드" ``` --- ### Task 3: BindButtons 바인딩 **Files:** Modify `tools/gen-slaydeck.mjs` - [ ] **Step 1: BindButtons 맵 노드 루프 다음에 상점/휴식 바인딩 추가** — BindButtons 코드의 맵 노드 for-loop(`...PickNode(nid)...end\nend`) 다음, 닫는 백틱 직전에 삽입. 맵 노드 루프 끝 부분을 아래로 교체: ```js for i = 1, #mapNodeIds do local nid = mapNodeIds[i] local mn = _EntityService:GetEntityByPath("/ui/DefaultGroup/MapHud/Node_" .. nid) if mn ~= nil and mn.ButtonComponent ~= nil then mn:ConnectEvent(ButtonClickEvent, function() self:PickNode(nid) end) end end for i = 1, 3 do local sc = _EntityService:GetEntityByPath("/ui/DefaultGroup/ShopHud/Card" .. tostring(i)) if sc ~= nil and sc.ButtonComponent ~= nil then sc:ConnectEvent(ButtonClickEvent, function() self:BuyCard(i) end) end end local shopLeave = _EntityService:GetEntityByPath("/ui/DefaultGroup/ShopHud/Leave") if shopLeave ~= nil and shopLeave.ButtonComponent ~= nil then shopLeave:ConnectEvent(ButtonClickEvent, function() self:LeaveNode() end) end local restLeave = _EntityService:GetEntityByPath("/ui/DefaultGroup/RestHud/Leave") if restLeave ~= nil and restLeave.ButtonComponent ~= nil then restLeave:ConnectEvent(ButtonClickEvent, function() self:LeaveNode() end) end`), ``` - [ ] **Step 2: 문법 검사** Run: `node --check tools/gen-slaydeck.mjs` Expected: 오류 없음 - [ ] **Step 3: 커밋** ```bash git add tools/gen-slaydeck.mjs git commit -m "gen-slaydeck(E4): 상점 구매/나가기·휴식 나가기 버튼 바인딩" ``` --- ### Task 4: ShopHud·RestHud UI + MapHud 4행 정렬 **Files:** Modify `tools/gen-slaydeck.mjs` (`guid`, `upsertUi`) - [ ] **Step 1: guid 'shp'·'rst' 분기** — ns 매핑에 추가(map 분기 다음): ```js const ns = prefix === 'hud' ? 0xd0 : prefix === 'dck' ? 0xca : prefix === 'cmb' ? 0xcb : prefix === 'rwd' ? 0xcc : prefix === 'map' ? 0xcd : prefix === 'shp' ? 0xce : prefix === 'rst' ? 0xcf : 0xfe; ``` - [ ] **Step 2: 필터 확장** — upsertUi 필터에 ShopHud·RestHud 추가: ```js ui.ContentProto.Entities = E.filter((e) => !e.path.startsWith('/ui/DefaultGroup/DeckHud') && !e.path.startsWith('/ui/DefaultGroup/CombatHud') && !e.path.startsWith('/ui/DefaultGroup/RewardHud') && !e.path.startsWith('/ui/DefaultGroup/MapHud') && !e.path.startsWith('/ui/DefaultGroup/ShopHud') && !e.path.startsWith('/ui/DefaultGroup/RestHud')); ``` - [ ] **Step 3: MapHud 노드 y 중앙정렬** — upsertUi의 노드 pos 계산을 교체: ```js const pos = { x: node.col * 180, y: (node.row - (MAX_ROW + 1) / 2) * 140 }; ``` - [ ] **Step 4: ShopHud·RestHud 생성** — `ui.ContentProto.Entities.push(...map);` 다음에 삽입: ```js const shop = []; const shopHud = entity({ id: guid('shp', 0), path: '/ui/DefaultGroup/ShopHud', modelId: 'uisprite', entryId: 'UISprite', componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent', displayOrder: 8, components: [ transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 1920, y: 1080 }, pos: { x: 0, y: 0 }, align: ALIGN_CENTER }), sprite({ color: { r: 0.05, g: 0.06, b: 0.09, a: 0.92 }, type: 1, raycast: true }), ], }); shopHud.jsonString.enable = false; shop.push(shopHud); shop.push(entity({ id: guid('shp', 1), path: '/ui/DefaultGroup/ShopHud/Title', modelId: 'uitext', entryId: 'UIText', componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent', displayOrder: 0, components: [ transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 700, y: 60 }, pos: { x: 0, y: 400 } }), sprite({ color: TRANSPARENT }), text({ value: '상점', fontSize: 44, bold: true, color: GOLD, alignment: 4 }), ], })); shop.push(entity({ id: guid('shp', 2), path: '/ui/DefaultGroup/ShopHud/Gold', modelId: 'uitext', entryId: 'UIText', componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent', displayOrder: 1, components: [ transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 300, y: 44 }, pos: { x: 0, y: 330 } }), sprite({ color: TRANSPARENT }), text({ value: '골드 0', fontSize: 28, bold: true, color: { r: 0.98, g: 0.85, b: 0.4, a: 1 }, alignment: 4 }), ], })); let shpN = 3; const shopXs = [-300, 0, 300]; for (let i = 1; i <= 3; i++) { const cardPath = `/ui/DefaultGroup/ShopHud/Card${i}`; shop.push(entity({ id: guid('shp', shpN++), path: cardPath, modelId: 'uisprite', entryId: 'UISprite', componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.ButtonComponent', displayOrder: i, components: [ transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: CARD_W, y: CARD_H }, pos: { x: shopXs[i - 1], y: 20 } }), sprite({ color: ATTACK, type: 1, raycast: true }), button(), ], })); for (const [suffix, cfg] of [ ['Cost', { size: { x: 50, y: 50 }, pos: { x: -60, y: 95 }, value: '1', fontSize: 34, bold: true, color: { r: 1, g: 1, b: 1, a: 1 } }], ['Name', { size: { x: 160, y: 50 }, pos: { x: 0, y: 50 }, value: '카드', fontSize: 26, bold: true, color: { r: 1, g: 1, b: 1, a: 1 } }], ['Desc', { size: { x: 160, y: 60 }, pos: { x: 0, y: -50 }, value: '', fontSize: 20, bold: false, color: { r: 1, g: 1, b: 1, a: 1 } }], ['Price', { size: { x: 160, y: 40 }, pos: { x: 0, y: -105 }, value: '30 골드', fontSize: 22, bold: true, color: { r: 0.98, g: 0.85, b: 0.4, a: 1 } }], ]) { shop.push(entity({ id: guid('shp', shpN++), path: `${cardPath}/${suffix}`, modelId: 'uitext', entryId: 'UIText', componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent', displayOrder: suffix === 'Cost' ? 0 : suffix === 'Name' ? 1 : suffix === 'Desc' ? 2 : 3, components: [ transform({ parentW: CARD_W, parentH: CARD_H, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: cfg.size, pos: cfg.pos }), sprite({ color: TRANSPARENT }), text({ value: cfg.value, fontSize: cfg.fontSize, bold: cfg.bold, color: cfg.color }), ], })); } } shop.push(entity({ id: guid('shp', shpN++), path: '/ui/DefaultGroup/ShopHud/Leave', modelId: 'uibutton', entryId: 'UIButton', componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.ButtonComponent,MOD.Core.TextComponent', displayOrder: 10, components: [ transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 200, y: 60 }, pos: { x: 0, y: -300 } }), sprite({ color: DARK, type: 1, raycast: true }), button(), text({ value: '나가기', fontSize: 26, bold: true, color: GOLD, alignment: 4 }), ], })); ui.ContentProto.Entities.push(...shop); const rest = []; const restHud = entity({ id: guid('rst', 0), path: '/ui/DefaultGroup/RestHud', modelId: 'uisprite', entryId: 'UISprite', componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent', displayOrder: 9, components: [ transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 1920, y: 1080 }, pos: { x: 0, y: 0 }, align: ALIGN_CENTER }), sprite({ color: { r: 0.05, g: 0.08, b: 0.06, a: 0.92 }, type: 1, raycast: true }), ], }); restHud.jsonString.enable = false; rest.push(restHud); rest.push(entity({ id: guid('rst', 1), path: '/ui/DefaultGroup/RestHud/Title', modelId: 'uitext', entryId: 'UIText', componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent', displayOrder: 0, components: [ transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 700, y: 60 }, pos: { x: 0, y: 140 } }), sprite({ color: TRANSPARENT }), text({ value: '휴식', fontSize: 44, bold: true, color: GOLD, alignment: 4 }), ], })); rest.push(entity({ id: guid('rst', 2), path: '/ui/DefaultGroup/RestHud/Info', modelId: 'uitext', entryId: 'UIText', componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent', displayOrder: 1, components: [ transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 600, y: 50 }, pos: { x: 0, y: 30 } }), sprite({ color: TRANSPARENT }), text({ value: 'HP 회복', fontSize: 30, bold: true, color: { r: 1, g: 1, b: 1, a: 1 }, alignment: 4 }), ], })); rest.push(entity({ id: guid('rst', 3), path: '/ui/DefaultGroup/RestHud/Leave', modelId: 'uibutton', entryId: 'UIButton', componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.ButtonComponent,MOD.Core.TextComponent', displayOrder: 2, components: [ transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 200, y: 60 }, pos: { x: 0, y: -120 } }), sprite({ color: DARK, type: 1, raycast: true }), button(), text({ value: '나가기', fontSize: 26, bold: true, color: GOLD, alignment: 4 }), ], })); ui.ContentProto.Entities.push(...rest); ``` - [ ] **Step 5: 문법 검사** Run: `node --check tools/gen-slaydeck.mjs` Expected: 오류 없음 - [ ] **Step 6: 커밋** ```bash git add tools/gen-slaydeck.mjs git commit -m "gen-slaydeck(E4): ShopHud/RestHud UI·MapHud 4행 중앙정렬" ``` --- ### Task 5: 재생성 + 검증 **Files:** 생성물 - [ ] **Step 1: 생성** Run: `node tools/gen-slaydeck.mjs` Expected: `Slay deck UI and combat codeblocks generated.` - [ ] **Step 2: 메서드·UI 확인** Run: `node -e "const j=JSON.parse(require('fs').readFileSync('RootDesk/MyDesk/SlayDeckController.codeblock','utf8')); const n=j.ContentProto.Json.Methods.map(m=>m.Name); console.log(['ShowShop','BuyCard','ShowRest','LeaveNode','RenderShop'].every(x=>n.includes(x))?'METHODS OK':'MISSING'); const u=JSON.parse(require('fs').readFileSync('ui/DefaultGroup.ui','utf8')); const has=p=>u.ContentProto.Entities.some(e=>e.path===p); console.log(has('/ui/DefaultGroup/ShopHud/Card1/Price')&&has('/ui/DefaultGroup/RestHud/Info')&&has('/ui/DefaultGroup/MapHud/Node_D')?'UI OK':'UI MISSING')"` Expected: `METHODS OK` / `UI OK` - [ ] **Step 3: 결정성** Run: `node tools/gen-slaydeck.mjs >/dev/null && sha1sum ui/DefaultGroup.ui RootDesk/MyDesk/SlayDeckController.codeblock > /tmp/a.sha && node tools/gen-slaydeck.mjs >/dev/null && sha1sum ui/DefaultGroup.ui RootDesk/MyDesk/SlayDeckController.codeblock > /tmp/b.sha && diff /tmp/a.sha /tmp/b.sha && echo DETERMINISTIC` Expected: `DETERMINISTIC` - [ ] **Step 4: git status** Run: `git checkout -- Global/common.gamelogic 2>/dev/null; git status --short` Expected: `data/map.json`, `tools/gen-slaydeck.mjs`, `ui/DefaultGroup.ui`, `RootDesk/MyDesk/SlayDeckController.codeblock` (+docs). - [ ] **Step 5: 생성물 커밋** ```bash git add ui/DefaultGroup.ui RootDesk/MyDesk/SlayDeckController.codeblock git commit -m "재생성(E4): 상점/휴식 노드·UI 반영" ``` - [ ] **Step 6: 메이커 Play 수동 검증 (MCP)** reload→Play: 맵(4행, 2행에 휴식C·상점D) → PickNode("D")→상점(카드3·골드) → BuyCard(골드≥30이면 -30·RunDeck+1·비활성; 부족하면 무시) → LeaveNode→맵 / PickNode("C")→휴식(HP+30 클램프)→LeaveNode→맵 / 전투·보스·런 클리어 회귀 확인. MCP는 PickNode/BuyCard/LeaveNode 직접 호출 + 로그. --- ## Self-Review - **Spec coverage:** 맵/검증/직렬화/상수/속성(Task1), PickNode분기·상점·휴식(Task2), 바인딩(Task3), UI·MapHud정렬(Task4), 검증(Task5). 스펙 전 항목 매핑. - **Placeholder scan:** 모든 단계 실제 코드/명령. - **Type consistency:** 메서드 `PickNode/ShowShop/RenderShop/BuyCard/ShowRest/LeaveNode` 정의·호출·바인딩 일치. 속성 `ShopChoices/ShopBought` 정의(Task1)·사용(Task2) 일치. UI 경로 `/ui/DefaultGroup/ShopHud/Card{1..3}/{Name,Cost,Desc,Price}`·`/Gold`·`/Leave`, `/RestHud/{Info,Leave}`가 codeblock(RenderShop/ShowRest/BindButtons)·생성(Task4)에서 동일. 상수 `CARD_PRICE/REST_HEAL` Task1 정의·Task2 사용 일치.