Files
maplecontest/docs/superpowers/plans/2026-06-09-data-externalization.md
2026-06-09 01:25:25 +09:00

13 KiB

카드/적 데이터 외부화 (TODO D) 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: 카드·적 데이터를 data/cards.json·data/enemies.json로 분리하고, gen-slaydeck.mjs가 읽어 codeblock·UI에 주입한다(데이터만 바꿔 재생성하면 반영).

Architecture: 신규 JSON 2개가 데이터 단일 소스. 생성기는 상단에서 JSON을 로드·검증하고, Lua 직렬화 헬퍼로 self.Cards/self.DrawPile/적 상태를 만들어 StartCombat에 주입한다. DeckHud 카드 미리보기·CombatHud 초기 텍스트도 동일 데이터에서 파생.

Tech Stack: Node.js ESM 생성기, JSON 데이터, MSW Lua codeblock/UI JSON. 검증은 node --check+재생성+sha1 결정성+데이터변경 반영 확인+메이커 Play.


File Structure

  • Create: data/cards.json — 카드 정의(cards) + 시작 덱(starterDeck).
  • Create: data/enemies.json — 적 정의(enemies) + 활성 적(activeEnemy).
  • Modify: tools/gen-slaydeck.mjs — JSON 로드·검증·Lua 직렬화 헬퍼, StartCombat/upsertUi/속성 데이터화.

검증 한계: MSW Lua 단위 테스트 러너 없음 → 자동 검증은 생성기 문법·재생성·결정성·데이터 반영·JSON 유효성. 실제 동작은 메이커 Play(사용자).


Task 1: 데이터 파일 생성

Files:

  • Create: data/cards.json

  • Create: data/enemies.json

  • Step 1: data/cards.json 작성

{
  "cards": {
    "Strike": { "name": "타격", "cost": 1, "kind": "Attack", "damage": 6, "desc": "피해 6" },
    "Defend": { "name": "방어", "cost": 1, "kind": "Skill", "block": 5, "desc": "방어도 5" },
    "Bash": { "name": "강타", "cost": 2, "kind": "Attack", "damage": 10, "desc": "피해 10" }
  },
  "starterDeck": ["Strike", "Strike", "Strike", "Strike", "Strike", "Defend", "Defend", "Defend", "Defend", "Bash"]
}
  • Step 2: data/enemies.json 작성
{
  "enemies": {
    "slime": {
      "name": "슬라임",
      "maxHp": 45,
      "intents": [
        { "kind": "Attack", "value": 10 },
        { "kind": "Attack", "value": 6 },
        { "kind": "Defend", "value": 8 }
      ]
    }
  },
  "activeEnemy": "slime"
}
  • Step 3: JSON 유효성 확인

Run: node -e "JSON.parse(require('fs').readFileSync('data/cards.json','utf8')); JSON.parse(require('fs').readFileSync('data/enemies.json','utf8')); console.log('JSON OK')" Expected: JSON OK

  • Step 4: 커밋
git add data/cards.json data/enemies.json
git commit -m "data(D): 카드/적 데이터 JSON 외부화 파일 추가"

Task 2: 생성기에 JSON 로드·검증·Lua 직렬화 헬퍼 추가

Files:

  • Modify: tools/gen-slaydeck.mjs (상단 import 직후)

  • Step 1: 파일 상단 import { readFileSync, writeFileSync } from 'node:fs'; 바로 다음에 추가

const CARDS = JSON.parse(readFileSync('data/cards.json', 'utf8'));
const ENEMIES = JSON.parse(readFileSync('data/enemies.json', 'utf8'));

// 검증 (fail-fast): 잘못된 데이터면 생성 중단
for (const id of CARDS.starterDeck) {
  if (!CARDS.cards[id]) {
    throw new Error(`[gen-slaydeck] starterDeck에 없는 카드 id 참조: ${id}`);
  }
}
if (!ENEMIES.enemies[ENEMIES.activeEnemy]) {
  throw new Error(`[gen-slaydeck] activeEnemy가 enemies에 없음: ${ENEMIES.activeEnemy}`);
}
const ACTIVE_ENEMY = ENEMIES.enemies[ENEMIES.activeEnemy];

// Lua 직렬화 헬퍼
function luaStr(s) {
  return '"' + String(s).replace(/\\/g, '\\\\').replace(/"/g, '\\"') + '"';
}
function luaCardsTable(cards) {
  const lines = Object.entries(cards).map(([id, c]) => {
    const fields = [`name = ${luaStr(c.name)}`, `cost = ${c.cost}`, `desc = ${luaStr(c.desc)}`, `kind = ${luaStr(c.kind)}`];
    if (c.damage != null) fields.push(`damage = ${c.damage}`);
    if (c.block != null) fields.push(`block = ${c.block}`);
    return `\t${id} = { ${fields.join(', ')} },`;
  });
  return `self.Cards = {\n${lines.join('\n')}\n}`;
}
function luaDeckTable(deck) {
  return `self.DrawPile = { ${deck.map(luaStr).join(', ')} }`;
}
function luaIntentsTable(intents) {
  const lines = intents.map((it) => `\t{ kind = ${luaStr(it.kind)}, value = ${it.value} },`);
  return `self.EnemyIntents = {\n${lines.join('\n')}\n}`;
}
function intentText(it) {
  if (it.kind === 'Attack') return `의도: 공격 ${it.value}`;
  if (it.kind === 'Defend') return `의도: 방어 ${it.value}`;
  return '';
}
  • Step 2: 문법 검사

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

  • Step 3: 커밋
git add tools/gen-slaydeck.mjs
git commit -m "gen-slaydeck(D): JSON 로드·검증·Lua 직렬화 헬퍼 추가"

Task 3: StartCombat·EnemyMaxHp 속성을 데이터에서 생성

Files:

  • Modify: tools/gen-slaydeck.mjs (prop('number', 'EnemyMaxHp', ...), method('StartCombat', ...))

  • Step 1: EnemyMaxHp 속성 기본값을 데이터로

prop('number', 'EnemyMaxHp', '45'), 를 아래로 교체:

    prop('number', 'EnemyMaxHp', String(ACTIVE_ENEMY.maxHp)),
  • Step 2: StartCombat 메서드 본문을 데이터 주입형으로 교체

기존 method('StartCombat', \...`)` 호출 전체(아래 "현재" 블록)를 "신규"로 교체.

현재(교체 대상):

self.MaxEnergy = 3
self.Turn = 0
self.PlayerMaxHp = 80
self.PlayerHp = self.PlayerMaxHp
self.PlayerBlock = 0
self.EnemyName = "슬라임"
self.EnemyMaxHp = 45
self.EnemyHp = self.EnemyMaxHp
self.EnemyBlock = 0
self.EnemyIntents = {
	{ kind = "Attack", value = 10 },
	{ kind = "Attack", value = 6 },
	{ kind = "Defend", value = 8 },
}
self.EnemyIntentIndex = 1
self.CombatOver = false
self.DiscardPile = {}
self.Hand = {}
self.Cards = {
	Strike = { name = "타격", cost = 1, desc = "피해 6", kind = "Attack", damage = 6 },
	Defend = { name = "방어", cost = 1, desc = "방어도 5", kind = "Skill", block = 5 },
	Bash = { name = "강타", cost = 2, desc = "피해 10", kind = "Attack", damage = 10 },
}
self.DrawPile = { "Strike", "Strike", "Strike", "Strike", "Strike", "Defend", "Defend", "Defend", "Defend", "Bash" }
self:Shuffle(self.DrawPile)
self:BindButtons()
self:RenderCombat()
self:StartPlayerTurn()

신규 — method('StartCombat', ...)의 코드 인자를 템플릿으로 생성:

    method('StartCombat', `self.MaxEnergy = 3
self.Turn = 0
self.PlayerMaxHp = 80
self.PlayerHp = self.PlayerMaxHp
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)}
${luaDeckTable(CARDS.starterDeck)}
self:Shuffle(self.DrawPile)
self:BindButtons()
self:RenderCombat()
self:StartPlayerTurn()`),
  • Step 3: 문법 검사

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

  • Step 4: 커밋
git add tools/gen-slaydeck.mjs
git commit -m "gen-slaydeck(D): StartCombat·EnemyMaxHp를 데이터에서 생성"

Task 4: DeckHud 카드 미리보기·CombatHud 초기 텍스트를 데이터에서 파생

Files:

  • Modify: tools/gen-slaydeck.mjs (upsertUicards 배열, enemyTexts 초기값)

  • Step 1: upsertUi의 카드 미리보기 배열을 데이터 파생으로 교체

기존:

  const cards = [
    { name: '타격', cost: '1', desc: '피해 6', tint: ATTACK },
    { name: '타격', cost: '1', desc: '피해 6', tint: ATTACK },
    { name: '방어', cost: '1', desc: '방어도 5', tint: DEFEND },
    { name: '방어', cost: '1', desc: '방어도 5', tint: DEFEND },
    { name: '강타', cost: '2', desc: '피해 10', tint: ATTACK },
  ];

교체:

  const cards = CARDS.starterDeck.slice(0, 5).map((id) => {
    const c = CARDS.cards[id];
    return { name: c.name, cost: String(c.cost), desc: c.desc, tint: c.kind === 'Attack' ? ATTACK : DEFEND };
  });
  • Step 2: CombatHud enemyTexts 초기값을 데이터에서 파생

기존:

  const enemyTexts = [
    ['EnemyName', { x: 0, y: 358 }, { x: 360, y: 44 }, '슬라임', 28, true, GOLD],
    ['EnemyHp', { x: 0, y: 316 }, { x: 360, y: 40 }, 'HP 45/45', 24, true, { r: 1, g: 1, b: 1, a: 1 }],
    ['EnemyBlock', { x: 0, y: 280 }, { x: 360, y: 36 }, '방어 0', 20, false, { r: 0.6, g: 0.8, b: 1, a: 1 }],
    ['EnemyIntent', { x: 0, y: 244 }, { x: 360, y: 38 }, '의도: 공격 10', 22, true, { r: 1, g: 0.72, b: 0.5, a: 1 }],
  ];

교체:

  const enemyTexts = [
    ['EnemyName', { x: 0, y: 358 }, { x: 360, y: 44 }, ACTIVE_ENEMY.name, 28, true, GOLD],
    ['EnemyHp', { x: 0, y: 316 }, { x: 360, y: 40 }, `HP ${ACTIVE_ENEMY.maxHp}/${ACTIVE_ENEMY.maxHp}`, 24, true, { r: 1, g: 1, b: 1, a: 1 }],
    ['EnemyBlock', { x: 0, y: 280 }, { x: 360, y: 36 }, '방어 0', 20, false, { r: 0.6, g: 0.8, b: 1, a: 1 }],
    ['EnemyIntent', { x: 0, y: 244 }, { x: 360, y: 38 }, intentText(ACTIVE_ENEMY.intents[0]), 22, true, { r: 1, g: 0.72, b: 0.5, a: 1 }],
  ];
  • Step 3: 문법 검사

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

  • Step 4: 커밋
git add tools/gen-slaydeck.mjs
git commit -m "gen-slaydeck(D): 카드 미리보기·CombatHud 초기 텍스트 데이터 파생"

Task 5: 재생성 + 검증

Files: 생성물 3종 (생성기 실행 결과)

  • Step 1: 생성기 실행

Run: node tools/gen-slaydeck.mjs Expected: Slay deck UI and combat codeblocks generated.

  • Step 2: 생성물이 B와 동치인지 — codeblock에 데이터 값이 반영됐는지 확인

Run: node -e "const j=JSON.parse(require('fs').readFileSync('RootDesk/MyDesk/SlayDeckController.codeblock','utf8')); const sc=j.ContentProto.Json.Methods.find(m=>m.Name==='StartCombat').Code; console.log(/Strike = { name = \"타격\".*damage = 6/.test(sc) && /슬라임/.test(sc) && /value = 10/.test(sc) ? 'DATA INJECTED OK' : 'MISMATCH')" Expected: DATA INJECTED 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: 데이터 변경이 반영되는지 확인 (D의 핵심 검증)

Run: node -e "const fs=require('fs'); const f='data/cards.json'; const o=JSON.parse(fs.readFileSync(f,'utf8')); o.cards.Strike.damage=9; o.cards.Strike.desc='피해 9'; fs.writeFileSync(f, JSON.stringify(o,null,2));" && node tools/gen-slaydeck.mjs >/dev/null && node -e "const j=JSON.parse(require('fs').readFileSync('RootDesk/MyDesk/SlayDeckController.codeblock','utf8')); const sc=j.ContentProto.Json.Methods.find(m=>m.Name==='StartCombat').Code; console.log(/Strike = { name = \"타격\", cost = 1, desc = \"피해 9\", kind = \"Attack\", damage = 9/.test(sc) ? 'CHANGE REFLECTED' : 'NOT REFLECTED')" Expected: CHANGE REFLECTED

  • Step 5: 변경 되돌리고 재생성 (원복)

Run: git checkout -- data/cards.json && node tools/gen-slaydeck.mjs >/dev/null && echo reverted Expected: reverted

  • Step 6: 잘못된 데이터 fail-fast 확인

Run: node -e "const fs=require('fs'); const o=JSON.parse(fs.readFileSync('data/enemies.json','utf8')); o.activeEnemy='nope'; fs.writeFileSync('/tmp/bad-enemies.json', JSON.stringify(o));" && cp data/enemies.json /tmp/enemies.bak && cp /tmp/bad-enemies.json data/enemies.json; node tools/gen-slaydeck.mjs; echo "exit=$?"; cp /tmp/enemies.bak data/enemies.json Expected: 에러 메시지 activeEnemy가 enemies에 없음: nope + exit=1, 이후 원복

  • Step 7: 최종 재생성 + git status 확인

Run: node tools/gen-slaydeck.mjs >/dev/null; git checkout -- Global/common.gamelogic 2>/dev/null; git status --short Expected: data/*.json, tools/gen-slaydeck.mjs, ui/DefaultGroup.ui, RootDesk/MyDesk/SlayDeckController.codeblock만 변경(내용 동일한 common 제외).

  • Step 8: 생성물 커밋
git add ui/DefaultGroup.ui RootDesk/MyDesk/SlayDeckController.codeblock
git commit -m "재생성(D): 데이터 기반 카드/적 주입 반영"
  • Step 9: 메이커 Play 수동 검증 (사용자)

메이커 reload→Play: 기존 B 동작과 동일(데이터 동치라 회귀 없음). 적 슬라임 HP 45·의도 공격10, 카드 3종 효과 정상.


Self-Review

  • Spec coverage: cards.json/enemies.json 생성(Task1), 로드·검증·직렬화(Task2), StartCombat·속성 데이터화(Task3), UI 파생(Task4), 검증·데이터변경 반영(Task5). 스펙 전 항목 매핑됨.
  • Placeholder scan: 모든 단계 실제 코드/명령 포함. "TODO(E)"류 미래 훅은 본 작업 범위 아님.
  • Type consistency: luaStr/luaCardsTable/luaDeckTable/luaIntentsTable/intentText/ACTIVE_ENEMY/CARDS/ENEMIES 명칭이 정의부(Task2)와 사용부(Task3·4)에서 일치. 카드 필드(name/cost/kind/damage/block/desc)가 데이터(Task1)·직렬화(Task2)·검증(Task5)에서 일치.