SLOPSHOPPER

vibemud

Claude Code에서 /mud 명령으로 로컬 VibeMUD RPG를 조작하는 한국어 중심 플러그인입니다.

newpanebandcommandtoastprocess
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · vibemud
│ ┃ VibeMUD ✕ › fix the failing auth test and add an audit log call │ ┃ > 모험 캐릭터 │ ┃ 지도 퀘스트 ⏺ Read(src/auth.ts) │ ┃ ─ PLANET 64 ──────────────────────────────── ⎿ Read 6 lines │ ┃ 코딩하는 동안 함께 떠나는 모험 ⏺ Update(src/auth.ts) │ ┃ [ 모험 시작 ] ⎿ Added 2 lines, removed 1 line │ ┃ ─ 연결 / 명령 안내 ───────────────────────── ⏺ Bash(bun test) │ ┃ SyntaxError: JSON Parse error: Unexpected id ⎿ 3 pass, 1 fail │ ┃ "dev" │ ┃ ─ 조작 ───────────────────────────────────── ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ 명령: help │ ┃ Esc: 코딩으로 · /mud: 작업 중에도 조작 ✻ Worked for 42s · done 4:20 PM │ ┃ [ 창 접기 ] │ › /mud │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · VibeMUD
> 모험 캐릭터 장비 지도 퀘스트 설정 ─ PLANET 64 ──────────────────────────────────────────── 코딩하는 동안 함께 떠나는 모험 [ 모험 시작 ] ─ 연결 / 명령 안내 ───────────────────────────────────── SyntaxError: JSON Parse error: Unexpected identifier "dev" ─ 조작 ───────────────────────────────────────────────── 명령: help Esc: 코딩으로 · /mud: 작업 중에도 조작 [ 창 접기 ]
README

VibeMUD

Claude Code 옆에서 함께 진행되는 로컬 방치형 RPG. /mud start로 게임 창을 열면 코딩하는 동안 캐릭터가 사냥하고 성장합니다. 장비·던전·퀘스트는 Claude Code 안의 mod 창에서 조작합니다.

An idle RPG inside a Claude Code mod pane. The game runs locally in Rust and SQLite; see Quick start for source installation.

배포 상태: 이 저장소의 0.2.0은 개발본입니다. 현재 공개 npm 패키지와 섞어 설치하면 mod용 bridge가 맞지 않을 수 있습니다. 지금은 아래와 같이 이 저장소의 CLI와 플러그인을 함께 실행하세요. 새 퍼블릭 저장소를 푸시하는 것과 npm 배포는 별개입니다.

빠른 시작

Claude Code 2.1.287 이상, Node.js 18 이상, Rust toolchain이 필요합니다. 실제 mod 창은 macOS Claude Code 2.1.288에서 확인했습니다.

macOS / Linux:

git clone https://github.com/treestar84/vibemud.git
cd vibemud
cargo build --workspace
export VIBEMUD_BIN_DIR="$PWD/target/debug"
claude --plugin-dir ./claude-marketplace/plugins/vibemud

Windows PowerShell:

git clone https://github.com/treestar84/vibemud.git
Set-Location vibemud
cargo build --workspace
$env:VIBEMUD_BIN_DIR = (Resolve-Path ./target/debug).Path
claude --plugin-dir ./claude-marketplace/plugins/vibemud

Claude Code 입력창에서 /mud start를 실행하세요. 다른 플러그인이 /mud를 이미 사용한다면 /vibemud:mud start를 사용합니다. /mud는 코딩 중에도 바로 실행되는 명령입니다. 긴 이름의 명령은 Claude Code의 예약 방식에 따라 현재 작업이 끝난 뒤 실행될 수 있습니다.

지속 설치가 필요하면 소스에서 빌드한 뒤 scripts/install.sh --for claude --scope local 또는 PowerShell의 scripts/install.ps1 -For claude -Scope local을 사용하고 Claude Code를 다시 시작하세요. 설치 방식과 관계없이 새 CLI와 mod를 같은 버전으로 맞춰야 합니다.

게임 화면과 조작

/mud start       게임 시작 또는 다시 연결
/mud             모험 화면 열기
/mud c           캐릭터, 파티·스킬·상점
/mud i           장비와 소지품
/mud m           사냥터와 던전
/mud q           퀘스트
/mud set         설정과 게임 정지
/mud close       창 접기 · 게임 계속
/mud end         게임 정지

같은 명령을 /vibemud:mud로도 실행할 수 있습니다. 창 안에서는 버튼을 누르거나 hunt start forest-edge, dungeon enter goblin-den, quest claim-all 같은 게임 명령을 입력하세요.

모험으로 돌아가기는 메뉴만 바꿉니다. 창 접기 · 게임 계속는 창을 숨기고 코딩 입력 위에 모험 펼치기 버튼을 남깁니다. Esc는 코딩 입력으로 초점을 돌립니다. 게임을 실제로 멈추려면 설정에서 게임 정지를 확인하거나 /mud end를 실행하세요. end는 같은 저장을 쓰는 다른 Claude 창의 게임도 정지합니다.

상점·장비·던전·퀘스트·파티는 이름과 수치를 같은 열에 맞춰 보여 줍니다. 한글 표시 폭에 따라 긴 이름을 줄여 표시하며, 선택하면 전체 이름과 상세 정보를 볼 수 있습니다. 소지품과 목적지 목록은 페이지로 나뉩니다. 체력·성장 막대, 전투 장면, 등급 색과 선택 표시를 함께 사용합니다.

Claude 작업이 시작되면 작업 시작·완료 신호만 피버에 사용합니다. 작업 내용·토큰 수·소스 파일은 읽지 않습니다. 작업이 끝난 뒤 15초 동안 피버 여운이 이어집니다. 모험 화면은 그동안 실제로 얻은 처치·경험치·골드·아이템을 보여 줍니다. 구매나 판매는 원정 수익으로 계산하지 않으며, 코딩을 완료했다는 이유로 추가 게임 보상을 만들지 않습니다. 화면에 남는 지난 원정 요약은 mod 실행 중에만 유지됩니다.

Claude Code가 창 배치를 결정합니다. 넓은 전체 화면 터미널에서는 옆에, 좁은 화면에서는 입력창 위에 표시될 수 있습니다. 공식 mod 창 설명

저장 데이터와 개인정보

환경기본 게임 데이터 폴더
macOS / Linux~/.vibemud
Windows%LOCALAPPDATA%\VibeMUD (LOCALAPPDATA가 없으면 %USERPROFILE%\.vibemud)

VIBEMUD_HOME에 절대 경로를 주면 다른 데이터 폴더를 사용할 수 있습니다. 프로젝트 파일에는 새 게임 저장을 만들지 않습니다. 기존 프로젝트의 .vibemud 저장이 발견되고 사용자 저장소가 비어 있다면, 창에서 원본을 남긴 채 복사할지 선택할 수 있습니다.

게임 상태와 원정 집계는 사용자 데이터 폴더의 SQLite에 저장됩니다. VibeMUD는 소스 파일, 프롬프트, 대화 기록, 에디터 버퍼, 에이전트 대화 내용을 게임 상태로 사용하지 않습니다. 게임 명령과 결과도 Claude의 대화 문맥에 추가하지 않습니다.

초기화는 확인 후 backups/before-mod-reset-*.db에 백업을 만들고 진행을 지웁니다. 백업을 복원할 때는 모든 게임과 Claude 세션을 종료하고, 현재 데이터 폴더를 따로 보관한 다음, 새 데이터 폴더에 백업을 vibemud.db로 복사하세요. 기존 config.toml도 함께 놓고 VIBEMUD_HOME을 새 폴더로 지정합니다. 실행 중인 DB를 덮어쓰지 마세요.

각 Claude 세션의 게임 연결은 별도로 갱신됩니다. mod가 시작한 게임은 마지막 연결이 사라지면 종료되며, 비정상 종료 시에도 연결 만료 후 약 30초 안에 멈춥니다. 창만 접으면 연결이 유지됩니다.

호환 옵션과 지원 범위

이전 외부 창 방식은 전환 안정화 기간 동안 명시적으로 사용할 수 있습니다.

/vibemud:mud legacy start
/vibemud:mud legacy i
/vibemud:mud legacy end

외부 창 검증 대상은 tmux, cmux, macOS Ghostty입니다. Windows의 기본 mod 경로는 Bash·WSL·tmux·wt.exe 없이 Node launcher가 네이티브 .exe를 호출하도록 구현했습니다. Windows 실기, Windows IME, Desktop 앱의 실제 화면은 아직 검증하지 못했습니다. Windows x64 빌드·Rust·bridge 검사와 ARM64 빌드 검사는 수동 실행하는 GitHub Actions에서 확인할 수 있습니다. Codex와 iTerm2는 지원 대상이 아닙니다.

개발과 공개

Rust runtime이 전투와 보상을 처리하고, SQLite 명령 큐가 게임 변경을 직렬화합니다. mod는 읽기 전용 snapshot으로 화면을 그립니다. 같은 요청을 재시도해도 중복 구매·보상이 생기지 않도록 요청 ID를 사용합니다.

Claude Code mod → Node launcher → Rust CLI bridge
                                   ↓
                       SQLite 명령 큐 → Rust runtime
                                   ↓
                          SQLite snapshot → mod 창

주요 검사:

cargo fmt --check
cargo test --workspace
cargo build --workspace
node --test npm/test/bridge.test.js
claude plugin validate ./claude-marketplace/plugins/vibemud --strict
claude plugin test ./claude-marketplace/plugins/vibemud
node npm/scripts/check-release-metadata.js
(cd npm && npm run test:resolve)
(cd npm && npm pack --dry-run --json | node scripts/check-pack-contents.js)

전체 플랫폼·패키지 검증은 릴리스 준비 시에만 GitHub의 Actions → package dry run → Run workflow에서 수동 실행합니다. 일반 푸시와 PR에는 자동 실행되지 않으며, 업로드한 빌드 아티팩트는 3일 뒤 만료됩니다.

기여 방법은 CONTRIBUTING.md, 취약점 제보는 SECURITY.md, CLI 패키징은 npm/README.md를 참고하세요. MIT 라이선스입니다.

Source 3 files
hooks/register.ts 547 lines
1import type { EngineInterface, On, RenderElement } from 'claude-code';
2import { decode, decodeSnapshot, earnedSince, hpBar, parseCommand, RejectedAction, SHOP } from './model';
3import type { Earnings, Intent, Payload, Snapshot, Tab } from './model';
4import { cellWidth, clip, columns, duration, label, number, rarityColor } from './view';
5
6const PANE = 'vibemud';
7let snapshot: Snapshot | null = null;
8let tab: Tab = 'adventure';
9let opened = false;
10let interactive = false;
11let ownsShortCommand = false;
12let attached = false;
13let session = '';
14let activeTurn = '';
15let graceUntil = 0;
16let now = 0;
17let connecting = false;
18let heartbeatError = '';
19let expedition: { turn: string; started: number; generation: string; baseline?: Earnings } | null = null;
20let outcome: { earnings: Earnings; duration: number; aborted: boolean } | null = null;
21const pages: Record<string, number> = {};
22let dataDir = '';
23let migration = '';
24let error = '';
25let pollError = '';
26let notice = '';
27let draft = '';
28let loading = false;
29let mutating = false;
30let sequence = 0;
31let epoch = 0;
32let confirm: Intent | null = null;
33let selection = '';
34let pending: { request: string; generation: string; kind: string; payload: Payload; id?: string } | null = null;
35
36async function bridge($: EngineInterface, args: string[], timeoutMs = 18000): Promise<unknown> {
37  const result = await $.process.run(['node', $.plugin.root + '/scripts/vibemud-mod-launcher.cjs', ...args, '--json'], {
38    timeoutMs, ...(dataDir ? { env: { VIBEMUD_HOME: dataDir } } : {}),
39  });
40  if (!result.stdout.trim()) throw new Error('VibeMUD CLI not available. Install the current native CLI and Node.js 18+.');
41  const data = decode(result.stdout);
42  if (result.exitCode !== 0) throw new Error('VibeMUD native process failed.');
43  return data;
44}
45
46async function initialize($: EngineInterface): Promise<void> {
47  if (!session) session = await $.session.id();
48  const info = await bridge($, ['info']) as { data_dir: string; legacy_data_dir?: string; can_migrate?: boolean };
49  dataDir = info.data_dir;
50  if (info.can_migrate && info.legacy_data_dir) {
51    migration = info.legacy_data_dir; $.ui.invalidate('ui.render'); return;
52  }
53  await bridge($, ['init']);
54  await refresh($);
55}
56
57async function refresh($: EngineInterface): Promise<void> {
58  if (loading || migration) return;
59  loading = true;
60  const currentEpoch = epoch;
61  try {
62    const next = decodeSnapshot(await bridge($, ['snapshot']));
63    if (currentEpoch !== epoch) return;
64    if (snapshot && snapshot.generation !== next.generation) {
65      pending = null; confirm = null; selection = ''; notice = ''; error = '';
66      expedition = null; outcome = null; graceUntil = 0;
67    }
68    snapshot = next;
69    if (next.runtime.status !== 'running') attached = false;
70    if (pending?.id) {
71      const result = await bridge($, ['result', '--id', pending.id]) as { status: string; result?: { message: string }; error?: string };
72      if (currentEpoch !== epoch) return;
73      if (result.status === 'done' || result.status === 'failed') {
74        notice = result.status === 'done' ? (snapshot.settings.language === 'en' ? 'Action complete' : '명령 처리 완료') : result.error || '실패 / Failed';
75        pending = null;
76      }
77    }
78    pollError = '';
79  } catch (e) {
80    pollError = e instanceof Error ? e.message : String(e);
81  } finally {
82    loading = false;
83    $.ui.invalidate('ui.render');
84  }
85}
86
87async function renew($: EngineInterface): Promise<void> {
88  if (!attached || !session) return;
89  try {
90    now = await $.clock.now();
91    const state = await bridge($, ['session', 'renew', '--session-id', session, ...(activeTurn || now < graceUntil ? ['--active'] : [])]) as { status?: string };
92    if (state.status === 'stopped') { attached = false; graceUntil = 0; expedition = null; }
93    heartbeatError = '';
94  } catch (e) {
95    heartbeatError = e instanceof Error ? e.message : String(e);
96  }
97  $.ui.invalidate('ui.render');
98}
99
100async function beginExpedition($: EngineInterface): Promise<void> {
101  if (!attached || !activeTurn || expedition?.turn === activeTurn) return;
102  const run = { turn: activeTurn, started: await $.clock.now(), generation: snapshot?.generation || '', baseline: undefined as Earnings | undefined };
103  expedition = run;
104  try {
105    const fresh = decodeSnapshot(await bridge($, ['snapshot']));
106    if (expedition === run && activeTurn === run.turn && fresh.generation === run.generation) run.baseline = fresh.earnings;
107  } catch { /* Connection status is surfaced by refresh/renew; never invent earnings. */ }
108}
109
110async function show($: EngineInterface, focus: boolean): Promise<void> {
111  const args = { id: PANE, title: 'VibeMUD', columns: 52, rows: 26 };
112  await $.ui.open(focus ? { ...args, focus: true } : args);
113  opened = true;
114  await $.store.set('pane-open', true);
115  $.ui.invalidate('ui.render');
116}
117
118async function submitAction($: EngineInterface): Promise<void> {
119  if (!pending) return;
120  const p = pending;
121  let result: { id: string };
122  try {
123    result = await bridge($, ['action', '--request-id', p.request, '--generation', p.generation, '--kind', p.kind, '--payload-json', JSON.stringify(p.payload)]) as { id: string };
124  } catch (e) {
125    // A structured rejection did not commit. Unknown transport failures retain
126    // the durable id so a retry can never charge/reward the player twice.
127    if (e instanceof RejectedAction) pending = null;
128    throw e;
129  }
130  p.id = result.id;
131  notice = '명령 접수 / Queued';
132  await refresh($);
133}
134
135async function perform($: EngineInterface, intent: Intent, confirmed = false): Promise<void> {
136  // Opening tabs is allowed while a queued mutation is still being processed.
137  if (intent.type === 'tab') {
138    tab = intent.tab; confirm = null;
139    try {
140      await show($, true);
141      if (!snapshot) await initialize($);
142      else await refresh($);
143      if (snapshot?.runtime.status === 'running' && !attached) {
144        attached = true; await renew($); await $.store.set('reconnect', true);
145        await beginExpedition($);
146      }
147    } catch (e) { error = String(e); $.ui.invalidate('ui.render'); }
148    return;
149  }
150  if (intent.type === 'close') { await $.ui.close({ id: PANE }); opened = false; await $.store.set('pane-open', false); return; }
151  if (mutating) { notice = '처리 중입니다 / Working…'; $.ui.invalidate('ui.render'); return; }
152  if (!confirmed && (intent.type === 'stop' || intent.type === 'reset' || (intent.type === 'action' && ['sell_common', 'shop_sell'].includes(intent.kind)))) {
153    confirm = intent; $.ui.invalidate('ui.render'); return;
154  }
155  mutating = true;
156  connecting = intent.type === 'start';
157  error = '';
158  $.ui.invalidate('ui.render');
159  try {
160    if (intent.type === 'migrate' || intent.type === 'use-global') {
161      if (intent.type === 'migrate') await bridge($, ['migrate']);
162      await bridge($, ['init']); migration = ''; await refresh($); return;
163    }
164    if (intent.type === 'legacy') {
165      const r = await $.process.run(['node', $.plugin.root + '/scripts/vibemud-mod-launcher.cjs', '--legacy', ...intent.args], {
166        timeoutMs: 20000, ...(dataDir ? { env: { VIBEMUD_HOME: dataDir } } : {}),
167      });
168      if (r.exitCode !== 0) throw new Error('외부 창을 열지 못했습니다. / Legacy pane unavailable.');
169      if (intent.args[0] === 'start' && session) await bridge($, ['session', 'release', '--session-id', session]);
170      attached = false; graceUntil = 0; expedition = null;
171      await $.store.set('reconnect', false);
172      notice = '외부 창에서 확인하세요. / See the external pane.';
173      if (opened) await $.ui.close({ id: PANE });
174      opened = false;
175      await $.store.set('pane-open', false);
176      return;
177    }
178    if (!snapshot) await initialize($);
179    if (migration) return;
180    if (intent.type === 'start') {
181      await bridge($, ['session', 'ensure', '--session-id', session, ...(activeTurn ? ['--active'] : [])]);
182      attached = true;
183      await $.store.set('reconnect', true);
184      await refresh($);
185      tab = snapshot?.settings.intro_seen ? 'adventure' : 'intro';
186      await show($, false);
187      await beginExpedition($);
188      if (!pending && snapshot) {
189        pending = { request: session + '-' + (++sequence) + '-' + await $.clock.now(), generation: snapshot.generation, kind: 'hunt_start', payload: {} };
190        await submitAction($);
191      }
192    } else if (intent.type === 'stop') {
193      await bridge($, ['session', 'stop', '--session-id', session]);
194      attached = false; pending = null; confirm = null; graceUntil = 0; expedition = null;
195      await $.store.set('reconnect', false);
196      await refresh($);
197      notice = '게임 정지 / Game stopped';
198    } else if (intent.type === 'setting') {
199      await bridge($, ['setting', intent.key, intent.value]);
200      if (intent.key === 'intro_seen') tab = 'adventure';
201      await refresh($);
202    } else if (intent.type === 'reset' && snapshot) {
203      epoch++;
204      await bridge($, ['reset', '--generation', snapshot.generation, '--yes']);
205      attached = false; pending = null; confirm = null; selection = ''; expedition = null; outcome = null; graceUntil = 0; notice = '초기화 완료 / Reset complete';
206      await $.store.set('reconnect', false);
207      await refresh($); tab = 'intro';
208    } else if (intent.type === 'action' && snapshot) {
209      if (pending) throw new Error('이전 명령 처리 후 다시 시도하세요. / Wait for the pending action.');
210      pending = { request: session + '-' + (++sequence) + '-' + await $.clock.now(), generation: snapshot.generation, kind: intent.kind, payload: intent.payload };
211      await submitAction($);
212    }
213  } catch (e) {
214    error = e instanceof Error ? e.message : String(e);
215  } finally {
216    mutating = false; connecting = false; confirm = null;
217    $.ui.invalidate('ui.render');
218  }
219}
220
221async function command($: EngineInterface, input: string): Promise<void> {
222  try {
223    if (!interactive) { $.ui.log('VibeMUD: open /mud in an interactive Claude Code terminal.'); return; }
224    const intent = parseCommand(input);
225    if (intent.type !== 'close' && intent.type !== 'legacy') await show($, intent.type !== 'start');
226    await perform($, intent, intent.type === 'stop');
227  } catch (e) {
228    error = e instanceof Error ? e.message : String(e);
229    $.ui.invalidate('ui.render');
230  }
231}
232
233export function register(on: On) {
234  on('session.start', async ($, e, next) => {
235    interactive = e.isInteractive && (e.surface === 'terminal' || e.surface === 'desktop');
236    session = await $.session.id();
237    // This flag suppresses only duplicate legacy activity heartbeats, never its firewall.
238    await $.env.set('VIBEMUD_MOD_ACTIVE', '1');
239    $.clock.every(1000, async () => { now = await $.clock.now(); if (opened && snapshot) await refresh($); });
240    $.clock.every(5000, async () => { await renew($); });
241    const commands = await $.command.list();
242    const existing = commands.find(c => c.name === 'mud');
243    ownsShortCommand = !existing || existing.plugin === 'vibemud';
244    if (ownsShortCommand) {
245      await $.command.register({ name: 'mud', description: 'VibeMUD 게임 창 / game pane', argumentHint: 'start|c|i|m|q|set|end|legacy', immediate: true });
246    }
247    // Store only interface preferences. A reload reconnects to an existing
248    // runtime but never starts a stopped game without the user's Start action.
249    if (interactive && (await $.store.get('pane-open') || await $.store.get('reconnect'))) {
250      try {
251        await initialize($);
252        attached = snapshot?.runtime.status === 'running';
253        await renew($);
254        if (await $.store.get('pane-open')) await show($, false);
255      } catch (e) { error = String(e); }
256    }
257    return next(e);
258  });
259
260  on('command.run', { command: ['mud', 'vibemud:mud'] }, async ($, e, next) => {
261    if (e.command === 'mud' && !ownsShortCommand) return next(e);
262    await command($, e.args);
263    return {};
264  }).catch(async ($) => { $.ui.toast('VibeMUD 오류: /mud로 다시 열어 주세요.'); return {}; });
265
266  on('turn.start', async ($, e, next) => {
267    activeTurn = e.turnId; graceUntil = 0;
268    // Metadata only: never read e.text, model replies, tool arguments or files.
269    $.clock.after(0, async () => { await renew($); await beginExpedition($); });
270    return next(e);
271  });
272  on('turn.complete', async ($, e, next) => {
273    if (e.turnId === activeTurn && !e.agentId) {
274      activeTurn = '';
275      const run = expedition;
276      now = await $.clock.now();
277      const finishedAt = now;
278      graceUntil = attached && !e.isAborted && e.reason === 'answer' ? now + 15000 : 0;
279      $.clock.after(0, async () => {
280        await renew($);
281        if (!run?.baseline) return;
282        try {
283          const fresh = decodeSnapshot(await bridge($, ['snapshot']));
284          if (expedition === run && fresh.generation === run.generation && fresh.earnings) {
285            outcome = { earnings: earnedSince(run.baseline, fresh.earnings), duration: Math.max(0, finishedAt - run.started), aborted: e.isAborted };
286            expedition = null;
287            $.ui.invalidate('ui.render');
288          }
289        } catch { /* The game keeps its durable counters; no synthetic rewards. */ }
290      });
291      const deadline = graceUntil;
292      if (deadline) $.clock.after(15000, async () => {
293        if (graceUntil === deadline && !activeTurn) { graceUntil = 0; await renew($); }
294      });
295    }
296    return next(e);
297  });
298  on('session.end', async ($, e, next) => {
299    activeTurn = ''; graceUntil = 0; expedition = null;
300    if (attached && session) {
301      // A killed process is also recovered by the 30-second lease expiry.
302      try { await bridge($, ['session', 'detach', '--session-id', session], 700); } catch { /* expiry */ }
303    }
304    attached = false;
305    return next(e);
306  });
307  on('classic.SessionStart', { source: ['clear', 'resume', 'fork'] }, async ($, e, next) => {
308    epoch++; session = await $.session.id(); activeTurn = ''; graceUntil = 0; expedition = null; outcome = null; pending = null; confirm = null;
309    if (snapshot || await $.store.get('reconnect')) {
310      try {
311        await initialize($);
312        attached = snapshot?.runtime.status === 'running';
313        await renew($);
314        if (await $.store.get('pane-open')) await show($, false);
315      } catch (e) { error = String(e); }
316    }
317    return next(e);
318  });
319  on('ui.close', { id: PANE }, async ($, e, next) => {
320    opened = false;
321    if (e.origin.kind !== 'unload') await $.store.set('pane-open', false);
322    return next(e);
323  });
324
325  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
326    const original = await next(e);
327    if (e.props.hasSurvey || opened || !attached || !snapshot || (e.surface !== 'terminal' && e.surface !== 'desktop')) return original;
328    const { Box, Text, Button } = $.ui.resolve(e);
329    const ko = snapshot.settings.language !== 'en';
330    return Box({ flexDirection: 'column', children: [original, Box({ flexDirection: 'row', columnGap: 1, children: [
331      Text({ color: 'cyan', children: [heartbeatError ? 'VibeMUD · 연결 확인 필요' : `VibeMUD · ${label(snapshot.game.player.mode, ko)}${activeTurn ? ' · FEVER' : ''}`] }),
332      Button({ key: 'reopen-game', label: ko ? '모험 펼치기' : 'Open adventure', onPress: async () => { await perform($, { type: 'tab', tab: 'adventure' }); } }),
333    ] })] });
334  });
335
336  on('ui.render', { component: 'Pane' }, async ($, e, next) => {
337    if (e.requestId !== PANE || (e.surface !== 'terminal' && e.surface !== 'desktop')) return next(e);
338    const { Box, Text, Button, Input } = $.ui.resolve(e);
339    const ko = snapshot?.settings.language !== 'en';
340    const word = (a: string, b: string) => ko ? a : b;
341    const width = Math.max(16, Math.floor(e.props.bodyColumns));
342    const narrow = width < 38;
343    const text = (s: string, color?: string) => Text({ ...(color ? { color } : {}), children: [s] });
344    const muted = (s: string) => Text({ dimColor: true, children: [s] });
345    const button = (key: string, title: string, intent: Intent) => Button({ key, label: title, onPress: async () => { await perform($, intent); } });
346    const action = (key: string, title: string, kind: string, payload: Payload = {}) => pending || mutating || !attached
347      ? muted(title) : button(key, title, { type: 'action', kind, payload });
348    const row = (cells: (string | RenderElement)[], sizes: number[], heading = false) => Box({ flexDirection: 'row', columnGap: 1, children: cells.map((cell, i) => Box({
349      width: sizes[i], flexShrink: 0, overflow: 'hidden', justifyContent: i ? 'flex-end' : 'flex-start',
350      children: [typeof cell === 'string' ? Text({ bold: heading, dimColor: heading, wrap: 'truncate-end', children: [cell] }) : cell],
351    })) });
352    const rule = (title: string) => {
353      const head = clip('─ ' + title + ' ', width);
354      return Text({ bold: true, color: 'cyan', children: [head + '─'.repeat(Math.max(0, width - cellWidth(head)))] });
355    };
356    const controls = (children: RenderElement[]) => Box({ flexDirection: 'row', flexWrap: 'wrap', columnGap: 1, children });
357    const nameButton = (key: string, title: string, size: number, press: () => void | Promise<void>) => Button({ key, plain: true, label: clip(title, size), onPress: press });
358    const chooseAction = (key: string, title: string, size: number, kind: string, payload: Payload) => pending || mutating || !attached
359      ? muted(clip(title, size)) : nameButton(key, title, size, async () => { await perform($, { type: 'action', kind, payload }); });
360    const pager = (key: string, count: number, size: number) => {
361      const total = Math.max(1, Math.ceil(count / size));
362      const page = Math.max(0, Math.min(pages[key] || 0, total - 1)); pages[key] = page;
363      const buttons = [muted(`${page + 1}/${total}`)];
364      for (const [step, title] of [[-1, word('이전', 'Prev')], [1, word('다음', 'Next')]] as const) {
365        if (page + step >= 0 && page + step < total) buttons.push(Button({ key: key + '-page-' + step, label: title, onPress: () => { pages[key] = page + step; $.ui.invalidate('ui.render'); } }));
366      }
367      return { start: page * size, element: total > 1 ? controls(buttons) : null };
368    };
369    const body: RenderElement[] = [];
370    const s = snapshot; const p = s?.game.player;
371    if (s && p) {
372      const health = p.max_hp > 0 ? p.hp / p.max_hp : 0;
373      const state = connecting ? word('연결 중…', 'Connecting…') : heartbeatError || pollError ? word('연결 확인 필요', 'Connection lost')
374        : !attached ? word('정지 · 시작 필요', 'Stopped · press Start') : activeTurn ? word('코딩 감지 · 피버 ×1.5', 'Coding · FEVER ×1.5')
375        : now < graceUntil ? word('피버 여운 ', 'Afterglow ') + Math.max(0, Math.ceil((graceUntil - now) / 1000)) + 's'
376        : s.fever ? word('보너스 피버 ×1.5', 'Bonus FEVER ×1.5') : word('연결됨 · 코딩 대기', 'Connected · ready');
377      body.push(Text({ bold: true, children: [clip(`Lv.${p.level} ${p.name} · ${label(p.class_id, ko)}`, width)] }));
378      body.push(text(clip((heartbeatError || pollError ? '! ' : '◆ ') + state, width), heartbeatError || pollError ? 'red' : activeTurn || s.fever ? 'yellow' : 'cyan'));
379      const meters = columns(width, [12]);
380      body.push(row([text(`HP ${hpBar(p.hp, p.max_hp, Math.max(4, meters[0]! - 4))}`, health < .3 ? 'red' : 'green'), `${p.hp}/${p.max_hp}`], meters));
381      body.push(row([text(`XP ${hpBar(p.xp, p.xp_to_next, Math.max(4, meters[0]! - 4))}`, 'cyan'), `${Math.min(100, Math.floor(p.xp / Math.max(1, p.xp_to_next) * 100))}% · ${number(p.gold)}G`], meters));
382    }
383    const tabs: [Tab, string, string][] = [['adventure', '모험', 'Play'], ['character', '캐릭터', 'Hero'], ['inventory', '장비', 'Items'], ['map', '지도', 'Map'], ['quests', '퀘스트', 'Quests'], ['settings', '설정', 'Settings']];
384    const tabColumns = narrow ? 2 : 3;
385    const tabSize = Math.floor((width - tabColumns + 1) / tabColumns);
386    for (let i = 0; i < tabs.length; i += tabColumns) body.push(row(tabs.slice(i, i + tabColumns).map(([t, a, b]) => nameButton('tab-' + t, (tab === t ? '> ' : '  ') + word(a, b), tabSize, async () => { await perform($, { type: 'tab', tab: t }); })), Array(tabColumns).fill(tabSize)));
387    if (migration) {
388      body.push(rule(word('기존 모험 이어하기', 'Continue your adventure')), text(word('기존 저장을 사용자 데이터 폴더로 복사할 수 있습니다. 원본은 보존합니다.', 'Copy the existing save to your user data folder. The original is kept.')),
389        muted(migration), button('migrate', word('기존 게임 복사', 'Copy existing game'), { type: 'migrate' }), button('use-global', word('새 게임', 'New game'), { type: 'use-global' }));
390    } else if (!s || !p) {
391      body.push(rule('PLANET 64'), text(word('코딩하는 동안 함께 떠나는 모험', 'An adventure alongside your coding')),
392        connecting ? text(word('저장 불러오는 중…', 'Loading your save…'), 'yellow') : button('start', word('모험 시작', 'Start adventure'), { type: 'start' }));
393    } else {
394      if (!attached && tab !== 'adventure') body.push(button('start', connecting ? word('연결 중…', 'Connecting…') : word('모험 시작 / 다시 연결', 'Start / reconnect'), { type: 'start' }));
395      if (tab === 'adventure') {
396        const combat = s.game.combat;
397        body.push(rule(word('탐험', 'Adventure')));
398        body.push(text(clip(s.location || p.current_dungeon_id || p.current_area_id || word('마을', 'Town'), width)));
399        body.push(muted(label(p.mode, ko) + ` · MP ${p.mp}/${p.max_mp}`));
400        if (combat.in_combat) {
401          const cols = columns(width, [12]);
402          body.push(row([combat.monster_name || 'Monster', `${combat.monster_hp}/${combat.monster_max_hp}`], cols));
403          body.push(text(hpBar(combat.monster_hp || 0, combat.monster_max_hp || 1, Math.min(width, 24)), 'red'));
404        }
405        // A framed scene keeps the geometry stable as combat phases change.
406        if (s.scene?.length && width >= 30) body.push(Box({ borderStyle: 'round', borderColor: s.fever ? 'yellow' : 'cyan', width, flexDirection: 'column', children: s.scene.map(line => text(clip(line, width - 2), s.fever ? 'yellow' : 'cyan')) }));
407        if (p.current_dungeon_id) {
408          const kills = Number(s.progress.dungeon_kills || 0);
409          body.push(text(word('보스 접근 ', 'Boss approach ') + hpBar(kills, 5, 8) + ` ${Math.min(kills, 5)}/5`, 'magenta'));
410        }
411        if (s.progress.recovery_until) body.push(text(word('회복 중 · 체력이 차면 모험을 재개합니다.', 'Recovering · adventure resumes when ready.'), 'yellow'));
412        body.push(controls([...(attached ? [] : [button('start', connecting ? word('연결 중…', 'Connecting…') : word('모험 시작', 'Start'), { type: 'start' })]),
413          ...(attached ? [action('hunt', word('사냥', 'Hunt'), 'hunt_start')] : []), action('rest', word('휴식', 'Rest'), 'rest'), action('town', word('귀환', 'Town'), 'town')]));
414        const earnings = activeTurn ? (expedition?.baseline && s.earnings && expedition.generation === s.generation ? earnedSince(expedition.baseline, s.earnings) : undefined) : outcome?.earnings;
415        body.push(rule(activeTurn ? word('이번 원정 · 진행 중', 'This expedition · live') : word('지난 원정', 'Last expedition')));
416        if (earnings) {
417          const cols = columns(width, [12]);
418          body.push(row([word('처치 / 획득', 'Kills / loot'), `${earnings.kills} / ${earnings.items}`], cols));
419          body.push(row([`XP +${number(earnings.xp)}`, `+${number(earnings.gold)}G`], cols));
420          if (earnings.last_item) body.push(text('◇ ' + earnings.last_item, 'magenta'));
421          if (!activeTurn && outcome) body.push(muted(duration(outcome.duration) + word(outcome.aborted ? ' · 중단한 작업' : ' · 코딩 중 획득', outcome.aborted ? ' · interrupted' : ' · earned while coding')));
422        } else body.push(muted(word('Claude 작업이 시작되면 원정을 기록합니다.', 'Your next Claude turn begins an expedition.')));
423        body.push(rule(word('최근 소식', 'Recent events')));
424        body.push(...s.game.recent_log.slice(0, 3).reverse().map(line => text(clip(line, width))));
425      } else if (tab === 'character') {
426        body.push(rule(word('능력치', 'Attributes')));
427        const cols = columns(width, [10]);
428        body.push(row([word('능력', 'Attribute'), word('수치', 'Value')], cols, true));
429        for (const [key, value] of [['attack', p.attack], ['defense', p.defense], ['accuracy', p.accuracy], ['evasion', p.evasion], ['speed', p.speed], ['regen', p.regen], ['luck', p.luck]] as const) body.push(row([label(key, ko), number(value)], cols));
430        body.push(row(['MP', `${p.mp}/${p.max_mp}`], cols), row(['XP', `${number(p.xp)}/${number(p.xp_to_next)}`], cols));
431        body.push(controls([button('party', word('파티', 'Party'), { type: 'tab', tab: 'party' }), button('skills', word('스킬', 'Skills'), { type: 'tab', tab: 'skills' }), button('shop', word('상점', 'Shop'), { type: 'tab', tab: 'shop' })]));
432      } else if (tab === 'inventory') {
433        body.push(rule(word('소지품 ', 'Inventory ') + `${s.game.inventory.length}/20`));
434        const cols = columns(width, narrow ? [6] : [6, 5]);
435        body.push(row(narrow ? [word('장비 · 선택하여 상세 보기', 'Item · select for details'), word('등급', 'Rarity')] : [word('장비', 'Item'), word('등급', 'Rarity'), word('강화', 'Boost')], cols, true));
436        const page = pager('inventory', s.game.inventory.length, 6);
437        for (const item of s.game.inventory.slice(page.start, page.start + 6)) {
438          const marker = selection === item.id ? '> ' : item.equipped_slot ? '* ' : item.locked ? '# ' : '  ';
439          const cells = [nameButton('item-' + item.id, marker + item.name, cols[0]!, () => { selection = item.id; $.ui.invalidate('ui.render'); }), text(label(item.rarity, ko), rarityColor(item.rarity))];
440          if (!narrow) cells.push(text('+' + item.enhancement_level));
441          body.push(row(cells, cols));
442        }
443        if (!s.game.inventory.length) body.push(muted(word('아직 소지품이 없습니다.', 'Your bag is empty.')));
444        if (page.element) body.push(page.element);
445        body.push(muted(word('* 착용   # 잠금   > 선택', '* Equipped   # Locked   > Selected')));
446        const item = s.game.inventory.find(i => i.id === selection);
447        if (item) {
448          body.push(rule(word('장비 상세', 'Item details')), Text({ bold: true, color: rarityColor(item.rarity), children: [item.name] }));
449          const detail = columns(width, [12]);
450          for (const pair of [[word('종류', 'Type'), label(item.item_type, ko)], [word('등급 / 강화', 'Rarity / boost'), `${label(item.rarity, ko)} +${item.enhancement_level}`], [word('수량', 'Quantity'), number(item.quantity)]]) body.push(row(pair, detail));
451          for (const [key, value] of [[item.stat1_type, item.stat1_value], [item.stat2_type, item.stat2_value], [item.stat3_type, item.stat3_value]] as const) if (key && value != null) body.push(row([label(key, ko), '+' + value], detail));
452          const equipped = s.game.inventory.find(i => i.equipped_slot === item.item_type);
453          if (equipped && equipped.id !== item.id) body.push(row([word('전투력 비교', 'Power comparison'), `${equipped.power_score || 0} → ${item.power_score || 0}`], detail));
454          const upgrade = s.enhancements?.[item.id];
455          if (upgrade?.cost != null && upgrade.success_rate != null) {
456            body.push(row([word('강화 비용', 'Upgrade cost'), number(upgrade.cost) + 'G'], detail), row([word('성공 / 실패 하락', 'Success / loss'), `${Math.round(upgrade.success_rate * 100)}% / -${upgrade.failure_drop}`], detail));
457          }
458          body.push(controls([action('equip', word('착용', 'Equip'), 'equip', { item_id: item.id }), action('enhance', word('강화', 'Enhance'), 'enhance', { item_id: item.id }), action('lock', word(item.locked ? '잠금 해제' : '잠금', item.locked ? 'Unlock' : 'Lock'), item.locked ? 'item_unlock' : 'item_lock', { item_id: item.id }), action('sell', word('판매', 'Sell'), 'shop_sell', { item_id: item.id })]));
459          if (item.item_type === 'weapon') body.push(action('equip-subweapon', word('보조 무기 착용', 'Equip offhand'), 'equip', { item_id: item.id, equip_slot: 'subweapon' }));
460          if (item.equipped_slot) body.push(action('unequip', word('착용 해제', 'Unequip'), 'unequip', { equip_slot: item.equipped_slot }));
461        }
462        body.push(action('sell-common', word('일반 장비 정리', 'Sell common'), 'sell_common', { rarity: 'Common' }));
463      } else if (tab === 'map') {
464        const cols = columns(width, [6, 6]);
465        for (const [id, title, destinations, kind] of [['areas', word('사냥터', 'Fields'), s.areas, 'hunt_start'], ['dungeons', word('던전', 'Dungeons'), s.dungeons, 'dungeon_enter']] as const) {
466          body.push(rule(title), row([word('목적지', 'Destination'), 'Lv.', word('이동', 'Go')], cols, true));
467          const page = pager(id, destinations.length, 5);
468          for (const place of destinations.slice(page.start, page.start + 5)) body.push(row([
469            nameButton(id + '-detail-' + place.id, (p.current_area_id === place.id || p.current_dungeon_id === place.id ? '* ' : '') + place.name, cols[0]!, () => { selection = place.id; $.ui.invalidate('ui.render'); }),
470            text(String(place.level), place.level > p.level ? 'yellow' : 'green'), chooseAction((id === 'areas' ? 'area-' : 'dungeon-') + place.id, word('입장', 'Enter'), cols[2]!, kind, id === 'areas' ? { area_id: place.id } : { dungeon_id: place.id }),
471          ], cols));
472          if (page.element) body.push(page.element);
473        }
474        const place = [...s.areas, ...s.dungeons].find(a => a.id === selection);
475        if (place) body.push(rule(word('목적지 상세', 'Destination details')), text(place.name), text(word('권장 레벨 ', 'Recommended level ') + place.level));
476        body.push(action('retreat', word('던전 퇴각', 'Retreat'), 'dungeon_retreat'));
477      } else if (tab === 'quests') {
478        body.push(rule(word('일일 퀘스트', 'Daily quests')));
479        const cols = columns(width, [8, 6]);
480        body.push(row([word('목표', 'Goal'), word('진행', 'Progress'), word('상태', 'State')], cols, true));
481        for (const q of s.quests) body.push(row([nameButton('quest-detail-' + q.id, q.title, cols[0]!, () => { selection = q.id; $.ui.invalidate('ui.render'); }), `${q.progress}/${q.target}`, q.status === 'completed' ? chooseAction('quest-' + q.id, word('수령', 'Claim'), cols[2]!, 'quest_claim', { quest_id: q.id }) : label(q.status, ko)], cols));
482        const q = s.quests.find(q => q.id === selection);
483        if (q) body.push(rule(word('퀘스트 상세', 'Quest details')), text(q.title), text(hpBar(q.progress, q.target, Math.min(width, 20)), 'green'), text(`${label(q.reward_kind, ko)} +${q.reward_amount} · FEVER +${q.fever_minutes}m`));
484        body.push(action('claim-all', word('완료 보상 모두 받기', 'Claim all completed'), 'quest_claim_all'));
485      } else if (tab === 'party') {
486        body.push(rule(word('동행하는 파티', 'Active party')));
487        const cols = columns(width, [8]);
488        body.push(row([word('이름', 'Name'), word('역할', 'Role')], cols, true));
489        for (const c of s.game.party) body.push(row([c.name, label(c.role, ko)], cols));
490        body.push(action('recruit', word('동료 모집', 'Recruit'), 'party_recruit'), rule(word('동료 배치 · 슬롯 선택', 'Assign a companion · slot')));
491        const slots = columns(width, [3, 3, 3]);
492        body.push(row([word('동료', 'Companion'), '1', '2', '3'], slots, true));
493        for (const c of s.companions.filter(c => c.unlocked)) body.push(row([nameButton('companion-' + c.id, c.name, slots[0]!, () => { selection = c.id; $.ui.invalidate('ui.render'); }), ...[1, 2, 3].map((slot, i) => chooseAction(`swap-${c.id}-${slot}`, String(slot), slots[i + 1]!, 'party_swap', { slot, companion_id: c.id }))], slots));
494        const c = s.companions.find(c => c.id === selection);
495        if (c) body.push(text(c.name + ' · ' + label(c.role, ko)));
496      } else if (tab === 'skills') {
497        body.push(rule(word('전투 스킬', 'Combat skills')));
498        const cols = columns(width, [8]);
499        body.push(row([word('스킬', 'Skill'), word('행동', 'Action')], cols, true));
500        for (const id of ['slash', 'guard', 'taunt', 'heal', 'firebolt']) body.push(row([label(id, ko), chooseAction('skill-' + id, word('사용', 'Use'), cols[1]!, 'skill_use', { skill_id: id })], cols));
501      } else if (tab === 'shop') {
502        body.push(rule(word('상점', 'Shop')));
503        const cols = columns(width, [7, 6]);
504        body.push(row([word('상품', 'Product'), word('가격', 'Price'), word('행동', 'Action')], cols, true));
505        for (const [id, korean, english, price] of SHOP) body.push(row([nameButton('shop-detail-' + id, word(korean, english), cols[0]!, () => { selection = id; $.ui.invalidate('ui.render'); }), text(number(price) + 'G', p.gold < price ? 'red' : 'yellow'), chooseAction('buy-' + id, word('구매', 'Buy'), cols[2]!, 'shop_buy', { item_id: id })], cols));
506        const item = SHOP.find(i => i[0] === selection);
507        if (item) body.push(rule(word('상품 상세', 'Product details')), text(word(item[1], item[2])), text(`${item[3]}G`));
508      } else if (tab === 'settings') {
509        body.push(rule(word('화면 설정', 'Display')));
510        const cols = columns(width, [Math.min(24, Math.floor(width / 2))]);
511        body.push(row([word('언어', 'Language'), controls([button('language-ko', '한국어', { type: 'setting', key: 'language', value: 'ko' }), button('language-en', 'EN', { type: 'setting', key: 'language', value: 'en' })])], cols));
512        body.push(row([word('전투 연출', 'Animation'), button('animations', s.settings.animations ? 'ON' : 'OFF', { type: 'setting', key: 'animations', value: String(!s.settings.animations) })], cols));
513        body.push(button('intro', word('오프닝 다시보기', 'Replay intro'), { type: 'tab', tab: 'intro' }));
514        body.push(rule(word('게임 관리', 'Game controls')), button('stop', word('게임 정지', 'Stop game'), { type: 'stop' }), button('reset', word('진행 초기화', 'Reset progress'), { type: 'reset' }));
515        body.push(muted(word('창 접기는 사냥을 멈추지 않습니다.', 'Hiding the pane keeps the game running.')), muted(word('외부 창: legacy start', 'External pane: legacy start')));
516      } else if (tab === 'intro') {
517        body.push(rule('PLANET 64 · 2226'), ...(s.intro || []).map(line => text(line)), button('intro-done', word('모험으로', 'Continue'), { type: 'setting', key: 'intro_seen', value: 'true' }));
518      } else if (tab === 'help') {
519        body.push(rule(word('게임 명령', 'Game commands')));
520        const cols = columns(width, [Math.floor(width / 2)]);
521        for (const pair of [['start', word('시작 / 다시 연결', 'Start / reconnect')], ['c · i · m · q', word('캐릭터 · 장비 · 지도 · 퀘스트', 'Hero · Items · Map · Quests')], ['shop · party · skill', word('상점 · 파티 · 스킬', 'Shop · Party · Skills')], ['close / hide', word('창 접기 · 게임 계속', 'Hide · game continues')], ['end', word('게임 정지', 'Stop game')], ['Esc', word('코딩으로 돌아가기', 'Return to coding')]]) body.push(row(pair, cols));
522      }
523    }
524    if (error || pollError || heartbeatError) body.push(rule(word('연결 / 명령 안내', 'Connection / action')), text((error || heartbeatError || pollError).slice(0, 600), 'red'));
525    if (pending || notice || connecting) body.push(muted(clip(connecting ? word('모험에 연결 중…', 'Connecting…') : pending ? word('명령 처리 중…', 'Processing action…') : notice, width)));
526    if (pending && !pending.id && !mutating) body.push(Button({ key: 'retry', label: word('같은 요청 재시도', 'Retry same request'), onPress: async () => {
527      if (mutating) return; mutating = true; error = '';
528      try { await submitAction($); } catch (e) { error = String(e); } finally { mutating = false; $.ui.invalidate('ui.render'); }
529    } }));
530    if (confirm) {
531      const choice = confirm;
532      const question = choice.type === 'stop' ? word('게임을 정지할까요? 진행은 저장됩니다.', 'Stop the game? Progress is saved.') : choice.type === 'reset' ? word('백업 후 모든 진행을 초기화할까요?', 'Back up and reset all progress?')
533        : choice.type === 'action' && choice.kind === 'sell_common' ? word('잠금·착용 장비를 제외하고 판매할까요?', 'Sell spare unlocked equipment?') : word('선택한 아이템을 판매할까요?', 'Sell the selected item?');
534      body.push(Box({ borderStyle: 'round', borderColor: 'yellow', width, flexDirection: 'column', children: [text(question), controls([
535        Button({ key: 'confirm', label: word('확인', 'Confirm'), onPress: async () => { await perform($, choice, true); } }),
536        Button({ key: 'cancel', label: word('취소', 'Cancel'), onPress: () => { confirm = null; $.ui.invalidate('ui.render'); } }),
537      ])] }));
538    }
539    body.push(rule(word('조작', 'Controls')));
540    if (tab !== 'adventure') body.push(button('back-adventure', word('← 모험으로 돌아가기', '← Back to adventure'), { type: 'tab', tab: 'adventure' }));
541    body.push(Input({ key: 'game-command', label: word('명령', 'Command'), value: draft, placeholder: 'help', onInput: value => { draft = value; }, onSubmit: async value => { draft = ''; await command($, value); } }));
542    body.push(muted(word('Esc: 코딩으로 · /mud: 작업 중에도 조작', 'Esc: coding · /mud: works during a turn')));
543    if (tab === 'adventure') body.push(button('close', word(attached ? '창 접기 · 게임 계속' : '창 접기', attached ? 'Hide · game continues' : 'Hide pane'), { type: 'close' }));
544    return Box({ width, flexDirection: 'column', children: body });
545  });
546}
547
hooks/model.ts 147 lines
1// Pure game input/view data. No Claude conversation, filesystem or host APIs.
2export type Tab = 'adventure' | 'character' | 'inventory' | 'map' | 'quests' | 'settings' | 'party' | 'skills' | 'shop' | 'help' | 'intro';
3export type Payload = Record<string, string | number>;
4export type Intent = { type: 'tab'; tab: Tab } | { type: 'action'; kind: string; payload: Payload } |
5  { type: 'start' | 'stop' | 'close' | 'reset' | 'migrate' | 'use-global' } | { type: 'legacy'; args: string[] } |
6  { type: 'setting'; key: string; value: string };
7export interface Item {
8  id: string; item_id: string; item_type: string; name: string; rarity: string; quantity: number; power_score: number | null;
9  equipped_slot: string | null; locked: boolean; enhancement_level: number;
10  stat1_type: string | null; stat1_value: number | null;
11  stat2_type: string | null; stat2_value: number | null;
12  stat3_type: string | null; stat3_value: number | null;
13}
14export interface Player {
15  name: string; class_id: string; level: number; hp: number; max_hp: number; mp: number; max_mp: number;
16  xp: number; xp_to_next: number; gold: number; attack: number; defense: number; accuracy: number;
17  evasion: number; speed: number; regen: number; luck: number; mode: string;
18  current_area_id: string | null; current_dungeon_id: string | null;
19}
20export interface Snapshot {
21  generation: string;
22  scene: string[]; intro: string[]; location: string;
23  runtime: { status: string }; fever: boolean;
24  earnings?: Earnings;
25  game: { player: Player; clock_tick: number; state_version: number; inventory: Item[];
26    party: { id: string; name: string; role: string }[];
27    combat: { in_combat: boolean; monster_name: string | null; monster_hp: number | null; monster_max_hp: number | null; turn_index: number };
28    recent_log: string[] };
29  quests: { id: string; title: string; progress: number; target: number; status: string; reward_kind: string; reward_amount: number; fever_minutes: number }[];
30  areas: { id: string; name: string; level: number }[];
31  dungeons: { id: string; name: string; level: number }[];
32  companions: { id: string; name: string; role: string; unlocked: boolean }[];
33  commands: { id: string; kind: string; status: string; error: string | null; result: { message: string } | null }[];
34  settings: { language: string; intro_seen: boolean; animations: boolean };
35  enhancements: Record<string, { cost: number | null; success_rate: number | null; failure_drop: number }>;
36  progress: { recovery_until: string | null; dungeon_point: string | null; dungeon_kills: string | null };
37}
38
39export interface Earnings { kills: number; xp: number; gold: number; items: number; last_item: string | null }
40export function earnedSince(before: Earnings, after: Earnings): Earnings {
41  return { kills: Math.max(0, after.kills - before.kills), xp: Math.max(0, after.xp - before.xp),
42    gold: Math.max(0, after.gold - before.gold), items: Math.max(0, after.items - before.items),
43    last_item: after.items > before.items ? after.last_item : null };
44}
45
46export function decode(stdout: string): unknown {
47  const envelope = JSON.parse(stdout);
48  if (envelope.protocol_version !== 1) throw new Error('VibeMUD CLI / mod protocol mismatch. Update both together.');
49  if (envelope.ok !== true) {
50    const message = String(envelope.error || 'VibeMUD operation failed');
51    if (envelope.retryable === true) throw new Error(message);
52    throw new RejectedAction(message);
53  }
54  return envelope.data;
55}
56
57export class RejectedAction extends Error {}
58
59export function decodeSnapshot(data: unknown): Snapshot {
60  const s = data as Snapshot;
61  if (!s || typeof s.generation !== 'string' || !s.game?.player || !s.game.combat ||
62    !Array.isArray(s.game.recent_log) || !Array.isArray(s.game.party) || !Array.isArray(s.game.inventory) ||
63    !Array.isArray(s.quests) || !Array.isArray(s.areas) || !Array.isArray(s.dungeons) ||
64    !Array.isArray(s.companions) || !Array.isArray(s.commands) || !s.settings || !s.runtime || !s.progress) {
65    throw new Error('Invalid VibeMUD snapshot. Update the native CLI and mod together.');
66  }
67  return s;
68}
69
70export function parseCommand(input: string): Intent {
71  if (input.length > 1024) throw new Error('명령이 너무 깁니다. / Command too long.');
72  // Tokens are data, never interpreted by a shell. IDs intentionally have no spaces.
73  const parts = input.trim().replace(/^\/(?:vibemud:)?mud(?:\s+|$)/i, '').split(/\s+/).filter(Boolean);
74  const c = (parts.shift() || 'panel').toLowerCase();
75  const a = parts[0]; const b = parts[1];
76  const required = (v: string | undefined) => { if (!v) throw new Error('인수가 필요합니다. help를 확인하세요. / Missing argument.'); return v; };
77  const action = (kind: string, payload: Payload = {}): Intent => ({ type: 'action', kind, payload });
78  const tab = (name: Tab): Intent => ({ type: 'tab', tab: name });
79  if (['start', '시작', 'play', '플레이'].includes(c)) return { type: 'start' };
80  if (['end', 'stop', 's', '종료', '정지', 'pause', '중지'].includes(c)) return { type: 'stop' };
81  if (['close', 'hide', 'x', '닫기', '접기'].includes(c)) return { type: 'close' };
82  if (['back', '뒤로', '모험'].includes(c)) return tab('adventure');
83  if (['panel', 'now', 'status', 'system', '상태', 'log', 'tail', 'queue'].includes(c)) return tab('adventure');
84  if (['c', 'character', '캐릭터', 'stats', 'full-status'].includes(c)) return tab('character');
85  if (['i', 'inventory', 'item', 'items', '장비', '소지품', '가방'].includes(c)) return tab('inventory');
86  if (['m', 'map', 'menu', '지도', '메뉴', '지역', '던전'].includes(c)) return a ? action('hunt_start', { area_id: a }) : tab('map');
87  if (['q', 'quest', 'quests', '퀘스트', '일일퀘스트'].includes(c)) {
88    if (a === 'claim') return action('quest_claim', { quest_id: required(b) });
89    if (a === 'claim-all') return action('quest_claim_all');
90    return tab('quests');
91  }
92  if (['set', 'settings', 'setting', '설정', '환경설정'].includes(c)) {
93    if (a === 'reset' || a === '초기화') return { type: 'reset' };
94    if (a === 'language' || a === 'animations') return { type: 'setting', key: a, value: required(b) };
95    return tab('settings');
96  }
97  if (['reset', '리셋', '초기화', '새게임', 'newgame'].includes(c)) return { type: 'reset' };
98  if (['intro', 'opening', '오프닝', '시나리오', '스토리'].includes(c)) return tab('intro');
99  if (['help', '도움말', '가이드'].includes(c)) return tab('help');
100  if (c === 'legacy') {
101    if (!['start', 'end', 'c', 'i', 'm', 'q', 'set', 'panel', 'help'].includes(a || '') || parts.length !== 1) throw new Error('legacy start|end|c|i|m|q|set|panel|help');
102    return { type: 'legacy', args: parts };
103  }
104  if (c === 'a') return action('hunt_start', a ? { area_id: a } : {});
105  if (c === 'hunt') {
106    if (a === 'stop') return action('hunt_stop');
107    if (a === 'start') return action('hunt_start', b ? { area_id: b } : {});
108    throw new Error('hunt start [area] | hunt stop');
109  }
110  if (c === 'area') return a === 'enter' ? action('area_enter', { area_id: required(b) }) : tab('map');
111  if (c === 'dungeon') {
112    if (a === 'enter') return action('dungeon_enter', { dungeon_id: required(b) });
113    if (a === 'retreat') return action('dungeon_retreat');
114    return tab('map');
115  }
116  if (c === 'rest' || c === '휴식') return action('rest');
117  if (c === 'town' || c === '귀환') return action('town');
118  if (c === 'equip') return action('equip', { item_id: required(a), ...(b ? { equip_slot: b } : {}) });
119  if (c === 'unequip') return action('unequip', { equip_slot: required(a) });
120  if (c === 'enhance' || c === '강화') return action('enhance', { item_id: required(a) });
121  if (c === 'equipment') {
122    if (a === 'lock' || a === 'unlock') return action('item_' + a, { item_id: required(b) });
123    if (a === 'sell-common') return action('sell_common', { rarity: b || 'Common' });
124    if (a === 'empty') return action('sell_common');
125    return tab('inventory');
126  }
127  if (c === 'party') {
128    if (a === 'recruit') return action('party_recruit');
129    if (a === 'swap') return action('party_swap', { slot: Number(required(b)), companion_id: required(parts[2]) });
130    return tab('party');
131  }
132  if (c === 'skill') return a === 'use' ? action('skill_use', { skill_id: required(b) }) : tab('skills');
133  if (c === 'shop') return a === 'buy' || a === 'sell' ? action('shop_' + a, { item_id: required(b) }) : tab('shop');
134  throw new Error('알 수 없는 게임 명령입니다. help를 확인하세요. / Unknown game command.');
135}
136
137export function hpBar(current: number, max: number, width = 12): string {
138  const n = Math.round(Math.max(0, Math.min(1, max > 0 ? current / max : 0)) * width);
139  return '█'.repeat(n) + '░'.repeat(width - n);
140}
141
142export const SHOP = [
143  ['potion-small', '아스클레피오스 물약', 'Potion', 25], ['potion-medium', '히게이아 물약', 'Potion+', 55],
144  ['basic-sword', '아레스 검', 'Sword', 80], ['basic-staff', '헤르메스 지팡이', 'Staff', 80],
145  ['leather-armor', '레오니다스 갑옷', 'Armor', 90], ['repair-kit', '다이달로스 도구', 'Repair kit', 40],
146] as const;
147
hooks/view.ts 49 lines
1// Terminal cell geometry: Korean/CJK occupy two columns, combining marks none.
2// Layout uses Box widths (also on Desktop); this only clips plain button labels.
3function glyphWidth(char: string): number {
4  const n = char.codePointAt(0)!;
5  if (/\p{Mark}/u.test(char) || n === 0x200d || n < 32 || (n >= 0x7f && n < 0xa0)) return 0;
6  return n >= 0x1100 && (n <= 0x115f || n === 0x2329 || n === 0x232a ||
7    (n >= 0x2e80 && n <= 0xa4cf && n !== 0x303f) || (n >= 0xac00 && n <= 0xd7a3) ||
8    (n >= 0xf900 && n <= 0xfaff) || (n >= 0xfe10 && n <= 0xfe6f) ||
9    (n >= 0xff01 && n <= 0xff60) || (n >= 0xffe0 && n <= 0xffe6) ||
10    (n >= 0x1f000 && n <= 0x1faff) || n >= 0x20000) ? 2 : 1;
11}
12export function cellWidth(value: string): number {
13  return Array.from(value.normalize('NFC')).reduce((sum, c) => sum + glyphWidth(c), 0);
14}
15export function clip(value: string, width: number): string {
16  const clean = value.normalize('NFC').replace(/[\x00-\x1f\x7f-\x9f]/g, ' ');
17  if (cellWidth(clean) <= width) return clean;
18  let out = ''; let used = 0;
19  for (const char of clean) {
20    const size = glyphWidth(char);
21    if (used + size > width - 1) break;
22    out += char; used += size;
23  }
24  return width > 0 ? out + '…' : '';
25}
26export function columns(width: number, tails: number[]): number[] {
27  const available = Math.max(tails.length + 1, Math.floor(width) - tails.length);
28  const fixed = tails.map(n => Math.min(n, Math.floor(available / (tails.length + 2))));
29  return [available - fixed.reduce((a, b) => a + b, 0), ...fixed];
30}
31export function number(value: number): string { return Math.floor(value).toLocaleString('en-US'); }
32export function duration(ms: number): string {
33  const seconds = Math.max(0, Math.floor(ms / 1000));
34  return Math.floor(seconds / 60) + ':' + String(seconds % 60).padStart(2, '0');
35}
36const labels: Record<string, string> = {
37  Common: '일반', Uncommon: '고급', Rare: '희귀', Epic: '영웅', Legendary: '전설', Mythic: '신화',
38  warrior: '전사', healer: '치유사', mage: '마법사', tank: '수호자', damage: '공격', support: '지원',
39  idle: '대기', auto_hunt: '자동 사냥', hunting: '사냥', dungeon: '던전 탐험', resting: '휴식', recovery: '회복', town: '마을',
40  attack: '공격', defense: '방어', accuracy: '명중', evasion: '회피', speed: '속도', regen: '재생', luck: '행운',
41  slash: '베기', guard: '방어', taunt: '도발', heal: '치유', firebolt: '화염탄',
42  weapon: '주무기', subweapon: '보조 무기', armor: '갑옷', helmet: '투구', accessory: '장신구', consumable: '소모품',
43  active: '진행 중', completed: '달성', claimed: '수령함', gold: '골드', xp: '경험치',
44};
45export function label(value: string, ko: boolean): string { return ko ? labels[value] || value : value; }
46export function rarityColor(rarity: string): string {
47  return ({ Rare: 'cyan', Epic: 'magenta', Legendary: 'yellow', Mythic: 'red', Uncommon: 'green' } as Record<string, string>)[rarity] || 'white';
48}
49