Files
maplecontest/docs/superpowers/plans/2026-06-09-shop-rest.md
gahusb 03b59eeafc docs(E4): 상점/휴식 설계·구현 계획
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 03:35:05 +09:00

20 KiB

상점/휴식 노드 (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 교체
{
  "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 선택적) — 생성기의 맵 검증 루프를 교체:
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 조건부 — 함수를 교체:
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; 다음에:
  const CARD_PRICE = 30;
  const REST_HEAL = 30;
  • Step 5: 상점 상태 속성 추가prop('string', 'CurrentEnemyId', '""'), 다음에:
    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: 커밋
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 교체 (타입 분기)
    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 메서드 다음에 삽입:
    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: 커밋
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) 다음, 닫는 백틱 직전에 삽입. 맵 노드 루프 끝 부분을 아래로 교체:
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: 커밋
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 분기 다음):
  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 추가:
  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 계산을 교체:
    const pos = { x: node.col * 180, y: (node.row - (MAX_ROW + 1) / 2) * 140 };
  • Step 4: ShopHud·RestHud 생성ui.ContentProto.Entities.push(...map); 다음에 삽입:
  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: 커밋
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: 생성물 커밋
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 사용 일치.