Files
maplecontest/docs/superpowers/plans/2026-06-09-run-loop-core.md
2026-06-09 02:27:38 +09:00

17 KiB

런 루프 코어 (TODO E1+E2) 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: 단일 전투를 연속 N전투 런으로 확장 — 런 상태(HP/골드/덱) 영속 + 승리 후 카드 1택 보상 + 다음 전투 + N전투 후 "런 클리어".

Architecture: 기존 SlayDeckController(gen-slaydeck.mjs 생성)에 런 상태·보상 메서드 추가. StartRun(영속 초기화·버튼 1회 바인딩) vs StartCombat(전투별 초기화, RunDeck에서 드로) 분리. RewardHud UI 생성.

Tech Stack: Node.js ESM 생성기, MSW Lua codeblock/UI. 검증은 node --check+재생성+결정성+메이커 Play.


File Structure

  • Modify: tools/gen-slaydeck.mjs — 유일 변경 대상.
    • writeCodeblocks: 런 상수, 새 속성, OnBeginPlay/StartRun/StartCombat/BindButtons/CheckCombatEnd/OfferReward/ApplyRewardVisual/PickReward/RenderRun/RenderCombat.
    • upsertUi: CombatHud에 Floor/Gold, RewardHud 그룹 생성, 필터 확장, guid 'rwd' 분기.

MSW Lua 단위 테스트 불가 → 검증은 생성기 문법·재생성·결정성·메이커 Play.


Task 1: 런 상수·속성·StartRun

Files: Modify tools/gen-slaydeck.mjs

  • Step 1: 런 상수 추가writeCodeblocks() 함수 본문 첫 줄에 삽입:
  const RUN_LENGTH = 3;
  const GOLD_PER_WIN = 15;
  • Step 2: 새 속성 추가 — 속성 배열의 prop('any', 'EnemyName'), 다음에:
    prop('any', 'RunDeck'),
    prop('number', 'Gold', '0'),
    prop('number', 'Floor', '0'),
    prop('number', 'RunLength', String(RUN_LENGTH)),
    prop('any', 'RewardChoices'),
    prop('boolean', 'RunActive', 'false'),
  • Step 3: OnBeginPlay → StartRunmethod('OnBeginPlay', \self:StartCombat()`),` 를:
    method('OnBeginPlay', `self:StartRun()`),
  • Step 4: StartRun 메서드 추가 — OnBeginPlay 다음에 삽입:
    method('StartRun', `self.PlayerMaxHp = 80
self.PlayerHp = self.PlayerMaxHp
self.Gold = 0
self.Floor = 0
self.RunLength = ${RUN_LENGTH}
self.RunDeck = { ${CARDS.starterDeck.map(luaStr).join(', ')} }
self.RunActive = true
self:BindButtons()
self:StartCombat()`),
  • Step 5: 문법 검사

Run: node --check tools/gen-slaydeck.mjs Expected: 오류 없음

  • Step 6: 커밋
git add tools/gen-slaydeck.mjs
git commit -m "gen-slaydeck(E1): 런 상태 속성·StartRun 추가"

Task 2: StartCombat 수정 + BindButtons 수정

Files: Modify tools/gen-slaydeck.mjs

  • Step 1: StartCombat 본문 교체method('StartCombat', \...`)`의 코드를 아래로(HP 보존·Floor++·RunDeck에서 드로·BindButtons 호출 제거):
    method('StartCombat', `self.MaxEnergy = 3
self.Turn = 0
self.Floor = self.Floor + 1
self.PlayerBlock = 0
self.EnemyName = ${luaStr(ACTIVE_ENEMY.name)}
self.EnemyMaxHp = ${ACTIVE_ENEMY.maxHp}
self.EnemyHp = self.EnemyMaxHp
self.EnemyBlock = 0
${luaIntentsTable(ACTIVE_ENEMY.intents)}
self.EnemyIntentIndex = 1
self.CombatOver = false
self.DiscardPile = {}
self.Hand = {}
${luaCardsTable(CARDS.cards)}
self.DrawPile = {}
for i = 1, #self.RunDeck do
	self.DrawPile[i] = self.RunDeck[i]
end
self:Shuffle(self.DrawPile)
self:RenderCombat()
self:StartPlayerTurn()`),
  • Step 2: BindButtons에 보상 버튼 바인딩 추가 — BindButtons 코드 끝(마지막 end 다음)에 추가. 현재 마지막 부분:
for i = 1, 5 do
	local cardEntity = _EntityService:GetEntityByPath("/ui/DefaultGroup/CardHand/Card" .. tostring(i))
	if cardEntity ~= nil and cardEntity.ButtonComponent ~= nil then
		cardEntity:ConnectEvent(ButtonClickEvent, function() self:PlayCard(i) end)
	end
end

뒤에 이어붙이도록 BindButtons 코드를 아래 전체로 교체:

    method('BindButtons', `local endTurn = _EntityService:GetEntityByPath("/ui/DefaultGroup/DeckHud/EndTurnButton")
if endTurn ~= nil and endTurn.ButtonComponent ~= nil then
	if self.EndTurnHandler ~= nil then
		endTurn:DisconnectEvent(ButtonClickEvent, self.EndTurnHandler)
		self.EndTurnHandler = nil
	end
	self.EndTurnHandler = endTurn:ConnectEvent(ButtonClickEvent, function() self:EndPlayerTurn() end)
end
for i = 1, 5 do
	local cardEntity = _EntityService:GetEntityByPath("/ui/DefaultGroup/CardHand/Card" .. tostring(i))
	if cardEntity ~= nil and cardEntity.ButtonComponent ~= nil then
		cardEntity:ConnectEvent(ButtonClickEvent, function() self:PlayCard(i) end)
	end
end
for i = 1, 3 do
	local rc = _EntityService:GetEntityByPath("/ui/DefaultGroup/RewardHud/Reward" .. tostring(i))
	if rc ~= nil and rc.ButtonComponent ~= nil then
		rc:ConnectEvent(ButtonClickEvent, function() self:PickReward(i) end)
	end
end
local skip = _EntityService:GetEntityByPath("/ui/DefaultGroup/RewardHud/Skip")
if skip ~= nil and skip.ButtonComponent ~= nil then
	skip:ConnectEvent(ButtonClickEvent, function() self:PickReward(0) end)
end`),
  • Step 3: 문법 검사

Run: node --check tools/gen-slaydeck.mjs Expected: 오류 없음

  • Step 4: 커밋
git add tools/gen-slaydeck.mjs
git commit -m "gen-slaydeck(E1): StartCombat 런 분리(HP보존·RunDeck드로)·BindButtons 1회+보상버튼"

Task 3: CheckCombatEnd·OfferReward·PickReward·RenderRun

Files: Modify tools/gen-slaydeck.mjs

  • Step 1: CheckCombatEnd 교체 — 보상/런클리어/패배 분기:
    method('CheckCombatEnd', `if self.EnemyHp <= 0 then
	self.CombatOver = true
	self.Gold = self.Gold + ${GOLD_PER_WIN}
	self:RenderRun()
	if self.Floor >= self.RunLength then
		self:ShowResult("런 클리어!")
		self.RunActive = false
	else
		self:OfferReward()
	end
elseif self.PlayerHp <= 0 then
	self.CombatOver = true
	self:ShowResult("패배...")
	self.RunActive = false
end`),
  • Step 2: OfferReward·ApplyRewardVisual·PickReward·RenderRun 추가 — RenderCombat 메서드 다음에 삽입:
    method('OfferReward', `local pool = {}
for id, _ in pairs(self.Cards) do
	table.insert(pool, id)
end
self.RewardChoices = {}
for i = 1, 3 do
	self.RewardChoices[i] = pool[math.random(1, #pool)]
	self:ApplyRewardVisual(i, self.RewardChoices[i])
end
local hud = _EntityService:GetEntityByPath("/ui/DefaultGroup/RewardHud")
if hud ~= nil then
	hud.Enable = true
end`),
    method('ApplyRewardVisual', `local c = self.Cards[cardId]
if c == nil then
	return
end
local base = "/ui/DefaultGroup/RewardHud/Reward" .. tostring(slot)
self:SetText(base .. "/Name", c.name)
self:SetText(base .. "/Cost", tostring(c.cost))
self:SetText(base .. "/Desc", c.desc)
local e = _EntityService:GetEntityByPath(base)
if e ~= nil and e.SpriteGUIRendererComponent ~= nil then
	if 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`, [
      { Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slot' },
      { Type: 'string', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'cardId' },
    ]),
    method('PickReward', `if self.CombatOver ~= true or self.RunActive ~= true then
	return
end
if slot ~= 0 and self.RewardChoices ~= nil then
	local id = self.RewardChoices[slot]
	if id ~= nil then
		table.insert(self.RunDeck, id)
	end
end
local hud = _EntityService:GetEntityByPath("/ui/DefaultGroup/RewardHud")
if hud ~= nil then
	hud.Enable = false
end
self:StartCombat()`, [{ Type: 'number', DefaultValue: null, SyncDirection: 0, Attributes: [], Name: 'slot' }]),
    method('RenderRun', `self:SetText("/ui/DefaultGroup/CombatHud/Floor", "층 " .. string.format("%d", self.Floor) .. "/" .. string.format("%d", self.RunLength))
self:SetText("/ui/DefaultGroup/CombatHud/Gold", "골드 " .. string.format("%d", self.Gold))`),
  • Step 3: RenderCombat 끝에 RenderRun 호출 추가 — RenderCombat 코드의 마지막 줄(...PlayerBlock...) 다음에 \nself:RenderRun() 추가. 즉 RenderCombat 마지막을:
self:SetText("/ui/DefaultGroup/CombatHud/PlayerBlock", "방어 " .. string.format("%d", self.PlayerBlock))
self:RenderRun()

로. (Edit: 기존 마지막 줄 끝에 \nself:RenderRun() 삽입)

  • Step 4: 문법 검사

Run: node --check tools/gen-slaydeck.mjs Expected: 오류 없음

  • Step 5: 커밋
git add tools/gen-slaydeck.mjs
git commit -m "gen-slaydeck(E2): 보상(OfferReward/PickReward)·런 클리어·층/골드 렌더"

Task 4: UI — CombatHud 층/골드 + RewardHud

Files: Modify tools/gen-slaydeck.mjs (upsertUi, guid)

  • Step 1: guid 'rwd' 분기 추가 — guid()의 ns 매핑을:
  const ns = prefix === 'hud' ? 0xd0 : prefix === 'dck' ? 0xca : prefix === 'cmb' ? 0xcb : prefix === 'rwd' ? 0xcc : 0xfe;
  • Step 2: 정리 필터 확장 — upsertUi 시작부 필터를:
  ui.ContentProto.Entities = E.filter((e) => !e.path.startsWith('/ui/DefaultGroup/DeckHud') && !e.path.startsWith('/ui/DefaultGroup/CombatHud') && !e.path.startsWith('/ui/DefaultGroup/RewardHud'));
  • Step 3: CombatHud에 Floor·Gold 텍스트 추가const result = entity({ 선언 직전(즉 result 추가 전)에 삽입:
  for (const [suffix, pos, value, color] of [
    ['Floor', { x: -820, y: 480 }, '층 1/3', GOLD],
    ['Gold', { x: 820, y: 480 }, '골드 0', { r: 0.98, g: 0.85, b: 0.4, a: 1 }],
  ]) {
    combat.push(entity({
      id: guid('cmb', cmbN++),
      path: `/ui/DefaultGroup/CombatHud/${suffix}`,
      modelId: 'uitext',
      entryId: 'UIText',
      componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent',
      displayOrder: 9,
      components: [
        transform({ parentW: 1920, parentH: 1080, anchor: { x: 0.5, y: 0.5 }, pivot: { x: 0.5, y: 0.5 }, size: { x: 240, y: 44 }, pos }),
        sprite({ color: TRANSPARENT }),
        text({ value, fontSize: 26, bold: true, color, alignment: 4 }),
      ],
    }));
  }
  • Step 4: RewardHud 그룹 생성ui.ContentProto.Entities.push(...combat); 직후, JSON.parse(JSON.stringify(ui)); 직전에 삽입:
  const reward = [];
  const rewardHud = entity({
    id: guid('rwd', 0),
    path: '/ui/DefaultGroup/RewardHud',
    modelId: 'uisprite',
    entryId: 'UISprite',
    componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent',
    displayOrder: 6,
    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.04, g: 0.05, b: 0.07, a: 0.86 }, type: 1, raycast: true }),
    ],
  });
  rewardHud.jsonString.enable = false;
  reward.push(rewardHud);
  reward.push(entity({
    id: guid('rwd', 1),
    path: '/ui/DefaultGroup/RewardHud/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: 64 }, pos: { x: 0, y: 300 } }),
      sprite({ color: TRANSPARENT }),
      text({ value: '보상 카드 선택', fontSize: 44, bold: true, color: GOLD, alignment: 4 }),
    ],
  }));
  let rwdN = 2;
  const rewardXs = [-300, 0, 300];
  for (let i = 1; i <= 3; i++) {
    const cardPath = `/ui/DefaultGroup/RewardHud/Reward${i}`;
    reward.push(entity({
      id: guid('rwd', rwdN++),
      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: rewardXs[i - 1], y: 0 } }),
        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 }],
      ['Name', { size: { x: 160, y: 50 }, pos: { x: 0, y: 50 }, value: '카드', fontSize: 26, bold: true }],
      ['Desc', { size: { x: 160, y: 82 }, pos: { x: 0, y: -80 }, value: '', fontSize: 20, bold: false }],
    ]) {
      reward.push(entity({
        id: guid('rwd', rwdN++),
        path: `${cardPath}/${suffix}`,
        modelId: 'uitext',
        entryId: 'UIText',
        componentNames: 'MOD.Core.UITransformComponent,MOD.Core.SpriteGUIRendererComponent,MOD.Core.TextComponent',
        displayOrder: suffix === 'Cost' ? 0 : suffix === 'Name' ? 1 : 2,
        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 }),
        ],
      }));
    }
  }
  reward.push(entity({
    id: guid('rwd', rwdN++),
    path: '/ui/DefaultGroup/RewardHud/Skip',
    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: -260 } }),
      sprite({ color: DARK, type: 1, raycast: true }),
      button(),
      text({ value: '건너뛰기', fontSize: 26, bold: true, color: GOLD, alignment: 4 }),
    ],
  }));
  ui.ContentProto.Entities.push(...reward);
  • Step 5: 문법 검사

Run: node --check tools/gen-slaydeck.mjs Expected: 오류 없음

  • Step 6: 커밋
git add tools/gen-slaydeck.mjs
git commit -m "gen-slaydeck(E2): CombatHud 층/골드 + RewardHud(보상 카드 3+건너뛰기) UI"

Task 5: 재생성 + 검증

Files: 생성물 2종

  • 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(['StartRun','OfferReward','PickReward','RenderRun','ApplyRewardVisual'].every(x=>n.includes(x))?'METHODS OK':'MISSING'); const u=JSON.parse(require('fs').readFileSync('ui/DefaultGroup.ui','utf8')); console.log(u.ContentProto.Entities.some(e=>e.path==='/ui/DefaultGroup/RewardHud')&&u.ContentProto.Entities.some(e=>e.path==='/ui/DefaultGroup/CombatHud/Gold')?'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: 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 "재생성(E1+E2): 런 루프·보상 UI 반영"
  • Step 6: 메이커 Play 수동 검증 (사용자/MCP)

reload→Play: 승리 → RewardHud 카드 3장·골드+15·층 표시 → 1택 시 RunDeck+1·다음 전투(HP 유지) → 3전투째 승리 시 "런 클리어!". 패배 시 "패배...". MCP는 PlayCard/EndPlayerTurn/PickReward 직접 호출 + 상태 로그로 검증.


Self-Review

  • Spec coverage: 상수·속성·StartRun(Task1), StartCombat분리·BindButtons1회(Task2), 보상·런클리어·렌더(Task3), 층/골드·RewardHud UI(Task4), 검증(Task5). 스펙 전 항목 매핑.
  • Placeholder scan: 모든 단계 실제 코드/명령.
  • Type consistency: 메서드명 StartRun/StartCombat/BindButtons/CheckCombatEnd/OfferReward/ApplyRewardVisual/PickReward/RenderRun/RenderCombat 정의·호출 일치. UI 경로 /ui/DefaultGroup/RewardHud/Reward{1..3}/{Name,Cost,Desc}·/Skip·/CombatHud/{Floor,Gold}가 codeblock(ApplyRewardVisual/RenderRun/BindButtons)과 생성(Task4) 일치. 속성 RunDeck/Gold/Floor/RunLength/RewardChoices/RunActive 정의(Task1)·사용(Task2·3) 일치.