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

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 라이선스입니다.
hooks/register.ts 547 lines1import 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}
547hooks/model.ts 147 lines1// 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;
147hooks/view.ts 49 lines1// 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