Idle RPG above your prompt: a party of pixel-art heroes fights in an animated image while your coding activity earns them gold and EXP. Needs a terminal with…

Một game idle RPG sống ngay trong Claude Code. Một đội 1–3 anh hùng pixel đánh quái trên dải phía trên ô prompt trong lúc bạn code. Mỗi lần Claude gọi tool, đội nhận vàng và EXP; mỗi lần chạy test xanh, đội nhận thưởng lớn kèm một món đồ. Việc bạn làm (gửi prompt, cho phép quyền, commit, bấm Esc…) buff đội hoặc buff quái. Mọi cửa sổ Claude Code CLI trên một máy cùng xem một trận chung, và tiến trình được lưu lại giữa các session.
Cảnh đánh nhau là một ảnh pixel art chuyển động khoảng 10 khung mỗi giây: phông nền đổi theo ải, anh hùng và quái có đủ động tác đứng, đánh, trúng đòn, gục, cùng mũi tên, đạn phép, số damage và hình cho từng hiệu ứng.
Yêu cầu terminal: cần terminal hiện được ảnh qua kitty graphics (kitty, Ghostty, Orca…) và dải rộng ít nhất 69 cột, cao 8 dòng. Không có fallback: terminal không hiện được ảnh, dải quá nhỏ, hoặc Claude Code Desktop chỉ hiện một dòng báo lỗi thay cho cảnh; game vẫn chạy bình thường và bảng /hero vẫn dùng được.
npm test, pnpm test, vitest, jest, pytest, go test, cargo test, mix test, rspec, …) mà không lỗi: thưởng lớn, chắc chắn rơi một món đồ với tỉ lệ hiếm cao hơn, kèm thông báo.Đội … màu xanh, Quái … màu đỏ).| Action | Phe | Hiệu ứng |
|---|---|---|
| Gửi prompt | Đội | Hô khiến: 30 giây, mỗi bước 20% cơ hội cả đội đánh thêm một lượt |
| Turn trả lời xong | Đội | Tiếp sức: anh hùng còn đứng hồi 15% máu |
| Cho phép quyền trong hộp thoại | Đội | Tin tưởng: 30 giây, +10% chí mạng |
git commit thành công | Đội | Cột mốc: khiên chặn đòn kế tiếp của quái |
/compact thủ công | Đội | Tĩnh tâm: anh hùng còn đứng hồi đầy máu |
| Esc huỷ turn | Quái | Nổi giận: 30 giây, quái +25% sát thương |
| Từ chối quyền trong hộp thoại | Quái | Giáp đá: chặn 2 lượt đánh kế tiếp của cả đội |
| Lệnh Bash lỗi (không tính lệnh bị ngắt) | Quái | Tái sinh: quái hồi 20% máu |
Cùng một hiệu ứng thì không cộng dồn, chỉ làm mới thời hạn. Thời hạn tính theo bước chiến đấu nên dừng đếm khi không có session nào. Không hiệu ứng nào lấy vàng, đồ hay cấp, và hồi máu không hồi sinh anh hùng đã gục. Hai action quyền chỉ tính khi chính bạn trả lời hộp thoại: ở chế độ auto, acceptEdits, bypass hay dontAsk, hoặc khi một rule hay hook quyết định thay, chúng không xảy ra.
/hero: mở bảng anh hùng để xem chỉ số, trang bị, tháo/lắp đồ, nâng cấp đồ đang mặc, bán đồ trong túi và chiêu mộ thêm anh hùng (tối đa 3).Thu gọn dải bằng [-] hoặc ctrl+x ctrl+a.
Trên Claude Code CLI, plugin khởi động một tiến trình nền (daemon) giữ trận chung của máy: nó chạy vòng chiến đấu, nhận action từ mọi cửa sổ và lưu tiến trình. Các cửa sổ chỉ gửi action và vẽ trận nó trả về, nên /hero ở cửa sổ này hiện ngay ở cửa sổ kia, và đóng một cửa sổ không làm dừng trận ở các cửa sổ còn lại.
node hoặc bun trên máy (ưu tiên node). Daemon chạy với quyền của người dùng hiện tại, nghe trên Unix socket trong thư mục riêng quyền 0700, không mở cổng mạng nào.~/.claude/tt/<installId>/: world.json (trận chung), d.sock, daemon.lock, daemon.log. installId được tạo một lần trong store của plugin, nên cập nhật plugin vẫn giữ tiến trình, còn bản --plugin-dir và bản cài từ marketplace vẫn có trận riêng.d.sock và daemon.lock. Cửa sổ kế tiếp tự khởi động lại nó. Trận đứng yên khi không còn cửa sổ nào.world.json đúng một lần (save cũ vẫn được giữ nguyên trong store).world.json không đọc được thì được giữ lại dưới tên world.unreadable-<ts>.json rồi bắt đầu trận mới; world.json do phiên bản mới hơn tạo thì không bao giờ bị ghi đè.Chế độ chạy một mình. Không có node hay bun, trên Claude Code Desktop, hoặc khi daemon không khởi động được, cửa sổ đó tự chạy trận riêng và lưu vào store của plugin như trước, kèm một thông báo; nó không bao giờ ghi vào world.json. Cứ 30 giây nó thử tìm daemon lại. Nếu daemon chưa có trận, trận chơi một mình được chuyển sang daemon; nếu daemon đã có trận, phần chơi một mình kể từ lần chuyển trước bị bỏ.
Cần Claude Code 2.1.292 trở lên (API function hooks đang ở giai đoạn early access).
claude --plugin-dir /đường/dẫn/tới/terminal-tales
Trận chung nằm ở ~/.claude/tt/<installId>/world.json (xem phần trên). Store riêng của plugin dưới thư mục cấu hình Claude Code (~/.claude/plugins/store/) giữ installId và save của chế độ chạy một mình. Store gắn với cách plugin được nạp, nên bản chạy bằng --plugin-dir và bản cài từ marketplace có trận riêng.
Repo này đồng thời là một marketplace (.claude-plugin/marketplace.json). Cài bằng một dòng gõ tại prompt của Claude Code:
/plugin install terminal-tales --marketplace tuntran/terminal-tales
Trả lời y để thêm marketplace, rồi chọn phạm vi (user).
bun scripts/preview.tsx # dải chiến đấu và bảng /hero (ảnh qua kitty graphics)
bun scripts/preview.tsx --watch # dải chiến đấu chuyển động
bun scripts/preview.tsx --html out.html # cùng nội dung dưới dạng trang web
bun scripts/pixel-preview.ts # chỉ cảnh chiến đấu, chuyển động ngay trong terminal
bun scripts/pixel-preview.ts --html out.html # cảnh chiến đấu chuyển động trong trình duyệt
claude plugin validate . # kiểm tra manifest và hooks module như engine sẽ đọc
claude plugin test . # chạy tests/*.test.ts(x) trên engine thật
npx -p typescript tsc -p . # type-check (sau lần nạp đầu, engine đặt types vào .claude-plugin/types/)
bun install # @types/bun cho daemon, pngjs cho script build ảnh
bun run build:assets # đóng gói assets/source/ thành assets/build/*.bin và src/ui/atlas-manifest.ts
bun run check:assets # assets/build/ và manifest phải khớp bản build từ assets/source/
bun test daemon # test daemon (daemon/*.spec.ts)
bun run build:daemon # bundle daemon/ vào dist/ (chạy được bằng node hoặc bun)
bun run check:daemon # dist/ phải khớp đúng bản build từ daemon/; bắt buộc khi sửa daemon/
npx -p typescript tsc -p daemon
bash scripts/acceptance.sh [--sessions] # nghiệm thu trên máy thật; --sessions chạy claude -p
dist/ được commit vì plugin chạy nó trực tiếp bằng node hoặc bun. Đừng sửa tay dist/: sửa daemon/ rồi build lại, và check:daemon sẽ báo nếu hai bên lệch nhau.
assets/build/ cũng được commit: hook module không giải mã được PNG, nên scripts/build-assets.ts giải mã sẵn sprite sheet và phông nền thành bảng màu cộng chỉ số 8 bit, và plugin nạp chúng một lần mỗi session. Đổi ảnh hay cách cắt clip thì sửa assets/source/ hoặc cấu hình trong script, chạy build:assets, rồi để check:assets xác nhận.
Một daemon đang chạy chỉ nhường chỗ cho plugin có version mới hơn hẳn; hai bản build cùng version dùng chung daemon đang có, để chúng không tắt nhau qua lại. Khi sửa daemon/ mà chưa nâng version, hãy dừng daemon cũ (kill pid trong ~/.claude/tt/<installId>/daemon.lock) để session kế tiếp chạy bản mới.
Cấu trúc:
| Đường dẫn | Vai trò |
|---|---|
hooks/register.tsx | Nối game vào Claude Code: nhận diện action, thưởng theo tool call, lệnh /hero, vẽ dải và pane |
src/sync/client.ts | Tìm hoặc khởi động daemon, long-poll trận chung, outbox gửi action chạy nền, chế độ chạy một mình |
daemon/ | Daemon: server trên Unix socket (server.ts), launcher tách tiến trình (launch.ts), giao thức dùng chung (protocol.ts) |
dist/ | Bản bundle của daemon/ mà plugin chạy |
src/game/engine.ts | Luật chơi thuần, tất định theo seed: chiến đấu, rơi đồ, lên cấp, hành động trong /hero, đọc save |
src/game/catalog.ts | Dữ liệu: lớp nhân vật, quái và sprite sheet của từng loại, độ hiếm, tên đồ, hằng số cân bằng |
src/game/test-command.ts | Nhận diện lệnh chạy test, kết quả xanh, git commit, lệnh lỗi và lệnh bị từ chối |
src/ui/ | Dải phía trên prompt (diễn biến và vẽ khung trong animation.ts, nạp ảnh trong atlas.ts, phép vẽ RGBA trong frame-buffer.ts) và bảng /hero |
assets/ | Ảnh nguồn (source/), bản đóng gói plugin nạp (build/) và ghi công |
scripts/ | Đóng gói ảnh; xem trước dải và bảng trong terminal hoặc trình duyệt; nghiệm thu daemon |
types/index.d.ts | Kiểu dữ liệu game và hợp đồng $.state của plugin |
tests/ | Test logic game và test tích hợp qua engine, với một daemon giả trong bộ nhớ |
Lấy cảm hứng từ thể loại idle RPG. Mã nguồn mở theo giấy phép MIT.
Ảnh nhân vật và phông nền đến từ các gói CC0 của LuizMelo và Luis Zuno (ansimuz), và các gói phông nền CC-BY 4.0 của Admurin. Danh sách đầy đủ và giấy phép từng gói nằm trong assets/CREDITS.md; khi phân phối lại plugin, giữ file này.
hooks/register.tsx 266 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { GameAction, HeroClass, Slot } from '../types'
5
6import type { DaemonAction } from '../daemon/protocol'
7import { isFailedRun, isGitCommit, isPassingRun, isRejectedCall, isTestCommand } from '../src/game/test-command'
8import { endSync, startSync, submit, type Host } from '../src/sync/client'
9import { FRAME_MS, type Anim, compose, newAnim, observe, tick } from '../src/ui/animation'
10import { type Atlases, loadAtlases } from '../src/ui/atlas'
11import { band, fitsScene } from '../src/ui/band'
12import { toBase64 } from '../src/ui/base64'
13import { FRAME_HEIGHT, FRAME_WIDTH } from '../src/ui/frame-buffer'
14import { heroPane } from '../src/ui/hero-pane'
15
16export { MIGRATED_KEY, SAVE_KEY, SAVE_MS, STEP_MS, UNREADABLE_KEY } from '../src/sync/client'
17
18const PANE = 'hero'
19
20/** What a toast says when an action buffs the monster, so the user sees why it grew stronger. */
21const MONSTER_TOASTS: Partial<Record<GameAction, string>> = {
22 turnAborted: 'Terminal Tales: Esc! Quái nổi giận.',
23 permissionDenied: 'Terminal Tales: từ chối quyền, quái khoác giáp đá.',
24 bashFailed: 'Terminal Tales: lệnh lỗi, quái tái sinh.',
25}
26
27const game = atom({ plugin: 'terminal-tales', key: 'game' } as const, null)
28const selectedHero = atom({ plugin: 'terminal-tales', key: 'selectedHero' } as const, 0)
29const syncMode = atom({ plugin: 'terminal-tales', key: 'syncMode' } as const, 'starting')
30
31// The timers this copy of the module started; a second session.start in the
32// same copy replaces them rather than doubling the pace.
33let timers: Timer[] = []
34
35// The band's animation, where its picture is mounted and what it last showed
36// there. All only for drawing: a reload starts them over with a walk-in.
37let anim: Anim = newAnim()
38let bandSite: { requestId: string } | null = null
39let shownSource: string | null = null
40/** True while a blit is on its way, so a slow one is not overtaken by a second copy. */
41let isBlitting = false
42
43// The sprite sheets and backgrounds, read once per copy of the module; or why they could not be.
44let atlases: Atlases | null = null
45let atlasError: string | null = null
46
47// Calls the engine's own check put to the mode's decider, by tool_use_id: the
48// dialog asks the user, but auto mode's classifier answers some without one.
49// A classic PermissionRequest no hook beneath answered means the dialog
50// showed, which moves the call to `dialogCalls`; its tool.call result then
51// tells the user's answer.
52const MAX_TRACKED_CALLS = 200
53const askedCalls = new Map<string, string>()
54const dialogCalls = new Set<string>()
55
56function callKey(tool: string, input: unknown): string {
57 return JSON.stringify([tool, input])
58}
59
60/** The engine as the sync client uses it: every call spelled on `$` here, where the module can follow it. */
61function host($: EngineInterface): Host {
62 return {
63 root: $.plugin.root,
64 fetch: (url, init) => $.http.fetch(url, init),
65 run: (argv, init) => $.process.run(argv, init),
66 readFile: path => $.fs.read(path),
67 exists: path => $.fs.exists(path),
68 home: () => $.env.get('HOME'),
69 path: () => $.env.get('PATH'),
70 storeGet: key => $.store.get(key),
71 storeSet: (key, value) => $.store.set(key, value),
72 now: () => $.clock.now(),
73 sleep: ms => $.clock.sleep(ms),
74 every: (ms, fn) => $.clock.every(ms, fn),
75 toast: text => $.ui.toast(text),
76 readGame: () => read($, game),
77 updateGame: async fn => {
78 await update($, game, fn)
79 },
80 setMode: async mode => {
81 await update($, syncMode, () => mode)
82 },
83 }
84}
85
86/** Sends an action the user's activity caused, and says so when it helps the monster. */
87async function perform($: EngineInterface, action: GameAction): Promise<void> {
88 await submit(host($), { kind: action })
89 const toast = MONSTER_TOASTS[action]
90 if (toast !== undefined) $.ui.toast(toast)
91}
92
93function hero($: EngineInterface, op: DaemonAction & { kind: 'hero' }): void {
94 void submit(host($), op)
95}
96
97async function loadArt($: EngineInterface): Promise<void> {
98 if (atlases !== null) return
99 try {
100 atlases = await loadAtlases(path => $.fs.read(path, { as: 'bytes' }), $.plugin.root)
101 atlasError = null
102 } catch (err) {
103 atlasError = `Terminal Tales: không đọc được hình (${err instanceof Error ? err.message : String(err)})`
104 $.ui.toast(atlasError)
105 }
106}
107
108/** One animation frame: follow the game, then swap the mounted picture when it changed. */
109async function animate($: EngineInterface): Promise<void> {
110 const g = await read($, game)
111 if (g === null) return
112 anim = tick(observe(anim, g))
113 const site = bandSite
114 if (site === null || atlases === null || isBlitting) return
115 const rgba = toBase64(compose(g, anim, atlases).rgba)
116 if (rgba === shownSource) return
117 isBlitting = true
118 try {
119 const result = await $.ui.blit({ requestId: site.requestId, key: 'scene', source: { rgba, width: FRAME_WIDTH, height: FRAME_HEIGHT } })
120 // Denied: unmounted, or a terminal drawing the alt in its place. Wait for the next render.
121 if (result.deny !== undefined) bandSite = null
122 else shownSource = rgba
123 } finally {
124 isBlitting = false
125 }
126}
127
128export const register: Register = on => {
129 on('session.start', async ($, e, next) => {
130 await $.command.register({
131 name: 'hero',
132 description: 'Terminal Tales: xem đội hình, trang bị và nâng cấp',
133 })
134 for (const timer of timers) timer.cancel()
135 await loadArt($)
136 timers = [$.clock.every(FRAME_MS, () => void animate($))]
137 startSync(host($))
138 return next(e)
139 })
140
141 on('session.end', async ($, e, next) => {
142 await endSync(host($))
143 return next(e)
144 })
145
146 on('command.run', { command: 'hero' }, async $ => {
147 await $.ui.open({ id: PANE, title: 'Terminal Tales', focus: true, closeOnEscape: true })
148 return { text: 'Đã mở bảng anh hùng Terminal Tales.' }
149 })
150
151 // The game only watches tool calls: every call runs exactly as it would
152 // without the plugin, and the reward follows its result. A failing reward
153 // never refuses the call: the handler hands back what next settled to.
154 on('tool.call', async ($, e, next) => {
155 const ran = await next(e)
156 const id = e.tool_use_id
157 const wasDialog = id !== undefined && dialogCalls.delete(id)
158 if (id !== undefined) askedCalls.delete(id)
159 const text = ran.text ?? ''
160 if (wasDialog) await perform($, isRejectedCall(text, ran.isError === true) ? 'permissionDenied' : 'permissionAllowed')
161 if (ran.deny !== undefined) return ran
162 await submit(host($), { kind: 'toolCall' })
163 if (e.tool === 'Bash') {
164 const isBackgrounded =
165 typeof ran.result === 'object' &&
166 ran.result !== null &&
167 'backgroundTaskId' in ran.result &&
168 ran.result.backgroundTaskId !== undefined
169 if (!isBackgrounded && isTestCommand(e.command) && isPassingRun(text, ran.isError === true)) {
170 await submit(host($), { kind: 'testPass' })
171 $.ui.toast('Terminal Tales: test xanh! Cả đội nhận thưởng lớn và một món đồ.')
172 }
173 if (!isBackgrounded && ran.isError !== true && isGitCommit(e.command)) await perform($, 'commit')
174 if (isFailedRun(text, ran.isError === true)) await perform($, 'bashFailed')
175 }
176 return ran
177 }).catch(($, e, next) => next(e))
178
179 on('tool.check', async ($, e, next) => {
180 const verdict = await next(e)
181 if (verdict.decision === 'ask' && e.tool_use_id !== undefined) {
182 askedCalls.set(e.tool_use_id, callKey(e.tool, e.input))
183 // A call that never reached tool.call (its loop ended) is dropped in time.
184 if (askedCalls.size > MAX_TRACKED_CALLS) askedCalls.delete(askedCalls.keys().next().value as string)
185 }
186 return verdict
187 }).catch(($, e, next) => next(e))
188
189 on('classic.PermissionRequest', async ($, e, next) => {
190 const answer = await next(e)
191 if (answer.decision === undefined) {
192 const key = callKey(e.tool_name, e.tool_input)
193 for (const [id, asked] of askedCalls) {
194 if (asked !== key) continue
195 askedCalls.delete(id)
196 dialogCalls.add(id)
197 if (dialogCalls.size > MAX_TRACKED_CALLS) dialogCalls.delete(dialogCalls.values().next().value as string)
198 break
199 }
200 }
201 return answer
202 }).catch(($, e, next) => next(e))
203
204 // Only the user's own Enter at the prompt rallies the party: not a plugin's
205 // prompt, a notification, a peer session or a schedule.
206 on('prompt.submit', async ($, e, next) => {
207 const entered = await next(e)
208 if (e.origin.kind === 'composer' && entered.drop === undefined) await perform($, 'prompt')
209 return entered
210 }).catch(($, e, next) => next(e))
211
212 on('turn.complete', async ($, e, next) => {
213 const done = await next(e)
214 if (e.agentId === undefined) {
215 if (e.reason === 'answer') await perform($, 'turnDone')
216 else if (e.reason === 'aborted') await perform($, 'turnAborted')
217 }
218 return done
219 }).catch(($, e, next) => next(e))
220
221 on('session.compact', async ($, e, next) => {
222 const compacted = await next(e)
223 if (e.trigger === 'manual' && e.agentId === undefined && compacted.skip === undefined) await perform($, 'compact')
224 return compacted
225 }).catch(($, e, next) => next(e))
226
227 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
228 const g = await read($, game)
229 if (e.props.hasSurvey || g === null) return next(e)
230 const image = e.surface === 'terminal' ? $.ui.resolve(e).Image : undefined
231 const scene = atlases === null || image === undefined ? null : toBase64(compose(g, anim, atlases).rgba)
232 const columns = e.props.bodyColumns
233 const rows = e.props.maxRows
234 const isShown = image !== undefined && scene !== null && fitsScene(columns, rows)
235 bandSite = isShown ? { requestId: e.requestId } : null
236 shownSource = isShown ? scene : null
237 return band({ kit: $.ui.resolve(e), image, scene, error: atlasError, game: g, columns, rows })
238 })
239
240 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
241 const { Box, Text, Button } = $.ui.resolve(e)
242 const g = await read($, game)
243 if (g === null) {
244 return (
245 <Box>
246 <Text dimColor>Đang tải tiến trình…</Text>
247 </Box>
248 )
249 }
250 return heroPane({
251 kit: { Box, Text, Button },
252 game: g,
253 selected: await read($, selectedHero),
254 rows: e.props.scroll.bodyRows,
255 actions: {
256 select: (index: number) => void update($, selectedHero, () => index),
257 equip: (heroId: string, itemId: string) => hero($, { kind: 'hero', op: 'equip', heroId, itemId }),
258 unequip: (heroId: string, slot: Slot) => hero($, { kind: 'hero', op: 'unequip', heroId, slot }),
259 upgrade: (heroId: string, slot: Slot) => hero($, { kind: 'hero', op: 'upgrade', heroId, slot }),
260 sell: (itemId: string) => hero($, { kind: 'hero', op: 'sell', itemId }),
261 recruit: (cls: HeroClass) => hero($, { kind: 'hero', op: 'recruit', cls }),
262 },
263 })
264 })
265}
266daemon/protocol.ts 167 lines1// The daemon's wire protocol and the one way an action changes a game, shared
2// by the server and the plugin's client. It imports no `node:*` module, so
3// the plugin can load it too.
4
5import type { GameAction, GameState, HeroClass, Slot } from '../types'
6
7import { ACTION_EFFECT, CLASSES, SLOT_ORDER } from '../src/game/catalog'
8import {
9 applyAction,
10 equip,
11 recruit,
12 rewardTestPass,
13 rewardToolCall,
14 sell,
15 unequip,
16 upgrade,
17 type ActionResult,
18} from '../src/game/engine'
19
20/** The argument that marks the daemon's process, so `pgrep -f` finds it. */
21export const DAEMON_MARK = 'terminal-tales-daemon'
22
23/** Largest request body the daemon reads; anything longer is refused unread. */
24export const MAX_BODY_BYTES = 64 * 1024
25
26/**
27 * - `empty`: no world yet; the first client seeds one.
28 * - `loaded`: a world the daemon runs and saves.
29 * - `locked`: `world.json` came from a newer version; the daemon never writes it.
30 */
31export type WorldState = 'empty' | 'loaded' | 'locked'
32
33export type HeroOp =
34 | { op: 'equip'; heroId: string; itemId: string }
35 | { op: 'unequip'; heroId: string; slot: Slot }
36 | { op: 'upgrade'; heroId: string; slot: Slot }
37 | { op: 'sell'; itemId: string }
38 | { op: 'recruit'; cls: HeroClass }
39
40/**
41 * One action. `id`, when the client sets one, makes a resend harmless: the
42 * daemon applies each id once, so a send that timed out after it landed
43 * does not pay twice.
44 */
45export type DaemonAction = ({ kind: GameAction | 'toolCall' | 'testPass' } | ({ kind: 'hero' } & HeroOp)) & { id?: string }
46
47export type Hello = { build: string; version: string; pid: number; epoch: string; state: WorldState }
48
49export type SeedReply = { adopted: boolean; state: WorldState }
50
51export type ActionReply = { ok: true; seq: number } | { ok: false; error: string }
52
53export type WorldReply = { epoch: string; seq: number; state: WorldState; game: GameState | null }
54
55export type ShutdownReply = { ok: boolean; reason?: string }
56
57/** The action kinds that need nothing but their name. */
58const PLAIN_KINDS = new Set<string>([...Object.keys(ACTION_EFFECT), 'toolCall', 'testPass'])
59
60const MAX_ID_LENGTH = 64
61
62function isId(value: unknown): value is string {
63 return typeof value === 'string' && value.length > 0 && value.length <= MAX_ID_LENGTH
64}
65
66function isSlot(value: unknown): value is Slot {
67 return typeof value === 'string' && (SLOT_ORDER as readonly string[]).includes(value)
68}
69
70function isClass(value: unknown): value is HeroClass {
71 return typeof value === 'string' && Object.hasOwn(CLASSES, value)
72}
73
74/**
75 * Reads an untrusted `/action` body into an action, or null. Only own,
76 * allow-listed keys are read, so `__proto__` or `constructor` in the JSON
77 * never reach the engine.
78 */
79export function parseAction(raw: unknown): DaemonAction | null {
80 if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) return null
81 const body = raw as Record<string, unknown>
82 const field = (name: string): unknown => (Object.hasOwn(body, name) ? body[name] : undefined)
83 const kind = field('kind')
84 const id = field('id')
85 if (typeof kind !== 'string' || (id !== undefined && !isId(id))) return null
86 const action = parseKind(kind, field)
87 return action === null || id === undefined ? action : { ...action, id }
88}
89
90function parseKind(kind: string, field: (name: string) => unknown): DaemonAction | null {
91 if (PLAIN_KINDS.has(kind)) return { kind: kind as GameAction | 'toolCall' | 'testPass' }
92 if (kind !== 'hero') return null
93 const op = field('op')
94 switch (op) {
95 case 'equip': {
96 const heroId = field('heroId')
97 const itemId = field('itemId')
98 return isId(heroId) && isId(itemId) ? { kind, op, heroId, itemId } : null
99 }
100 case 'unequip':
101 case 'upgrade': {
102 const heroId = field('heroId')
103 const slot = field('slot')
104 return isId(heroId) && isSlot(slot) ? { kind, op, heroId, slot } : null
105 }
106 case 'sell': {
107 const itemId = field('itemId')
108 return isId(itemId) ? { kind, op, itemId } : null
109 }
110 case 'recruit': {
111 const cls = field('cls')
112 return isClass(cls) ? { kind, op, cls } : null
113 }
114 default:
115 return null
116 }
117}
118
119function applyHero(g: GameState, hero: HeroOp): ActionResult {
120 switch (hero.op) {
121 case 'equip':
122 return equip(g, hero.heroId, hero.itemId)
123 case 'unequip':
124 return unequip(g, hero.heroId, hero.slot)
125 case 'upgrade':
126 return upgrade(g, hero.heroId, hero.slot)
127 case 'sell':
128 return sell(g, hero.itemId)
129 case 'recruit':
130 return recruit(g, hero.cls)
131 }
132}
133
134/** Applies one action to a game, the daemon's and a solo session's alike. */
135export function applyDaemonAction(g: GameState, action: DaemonAction): ActionResult {
136 if (action.kind === 'hero') return applyHero(g, action)
137 if (action.kind === 'toolCall') return { state: rewardToolCall(g) }
138 if (action.kind === 'testPass') return { state: rewardTestPass(g) }
139 return { state: applyAction(g, action.kind) }
140}
141
142/** Compares two `x.y.z` versions: negative when `a` is older, 0 when equal. */
143export function compareVersions(a: string, b: string): number {
144 const parts = (v: string) => v.split('.').map(n => Number.parseInt(n, 10) || 0)
145 const pa = parts(a)
146 const pb = parts(b)
147 for (let i = 0; i < Math.max(pa.length, pb.length); i += 1) {
148 const diff = (pa[i] ?? 0) - (pb[i] ?? 0)
149 if (diff !== 0) return diff
150 }
151 return 0
152}
153
154/**
155 * FNV-1a over a text, as 8 hex digits: the build id of a daemon bundle. The
156 * daemon hashes its own file and the plugin the bundle it would launch, so a
157 * changed bundle under the same version is still told apart.
158 */
159export function buildId(text: string): string {
160 let hash = 0x811c9dc5
161 for (let i = 0; i < text.length; i += 1) {
162 hash ^= text.charCodeAt(i)
163 hash = Math.imul(hash, 0x01000193)
164 }
165 return (hash >>> 0).toString(16).padStart(8, '0')
166}
167src/game/test-command.ts 140 lines1// A Bash command counts as a test run when one of its simple commands
2// invokes a known test runner. Text inside quotes or a heredoc body is never a
3// command, so `echo "npm test"` and a commit message that mentions tests do
4// not count.
5
6const PM = '(npm|pnpm|yarn|bun)'
7const PM_SCOPE = '( (-r|--recursive|-w|--workspace-root|--filter[ =]\\S+|-F \\S+|workspace \\S+))*'
8
9const RUNNERS: readonly RegExp[] = [
10 new RegExp(`^${PM}${PM_SCOPE}( run)? (test|t)(\\b|:)`),
11 /^(npx |pnpm (exec |dlx )?|yarn |bunx |bun x )?(vitest|jest|mocha|ava|playwright test|cypress run|turbo( run)? test|nx( run-many -t| run \S+:)? ?test)\b/,
12 /^node --test\b/,
13 /^(python3? -m )?(pytest|py\.test)\b/,
14 /^python3? -m unittest\b/,
15 /^(uv run|poetry run|pipenv run|hatch run) (pytest|py\.test|python3? -m pytest)\b/,
16 /^(tox|nox)\b/,
17 /^go test\b/,
18 /^cargo (test|nextest)\b/,
19 /^(mix|deno|swift|dotnet|flutter|dart|bazel|bazelisk|zig build) test\b/,
20 /^ctest\b/,
21 /^(\.\/)?(gradlew|mvnw|gradle|mvn)( \S+)* test\b/,
22 /^(bundle exec )?(rspec|rake test|rails test)\b/,
23 /^(php artisan test|(vendor\/bin\/)?phpunit|(vendor\/bin\/)?pest)\b/,
24 /^make (test|check)\b/,
25 /^claude plugin test\b/,
26]
27
28// Runs that exit 0 without executing a single test.
29const NO_RUN = /\s(--collect-only|--co|--no-run|--listTests|--list-tests|--list|--help|-h|--version)(\s|=|$)|^go test\b.*\s-run[ =]['"]?(NONE|\^\$)/
30
31// Words that wrap a command without changing what runs.
32const WRAPPER = /^(!|if|then|else|elif|do|while|until|time|exec|command|nice|caffeinate|xvfb-run|env( -\S+)*|timeout( -\S+)* \S+|\w+=\S*)\s+/
33
34/** Splits a shell line into simple commands, leaving quoted text and heredoc bodies out. */
35export function simpleCommands(line: string): string[] {
36 const out: string[] = []
37 let current = ''
38 let quote: string | null = null
39 let heredoc: string | null = null
40 const lines = line.split('\n')
41 for (let n = 0; n < lines.length; n += 1) {
42 const text = lines[n]!
43 if (heredoc !== null) {
44 if (text.trim() === heredoc) heredoc = null
45 continue
46 }
47 for (let i = 0; i < text.length; i += 1) {
48 const ch = text[i]!
49 if (quote !== null) {
50 if (ch === '\\' && quote === '"') i += 1
51 else if (ch === quote) quote = null
52 continue
53 }
54 if (ch === "'" || ch === '"' || ch === '`') {
55 quote = ch
56 current += ' '
57 continue
58 }
59 if (ch === '\\') {
60 i += 1
61 continue
62 }
63 const here = /^<<-?\s*['"]?(\w+)['"]?/.exec(text.slice(i))
64 if (here) {
65 heredoc = here[1]!
66 i += here[0].length - 1
67 continue
68 }
69 if (ch === ';' || ch === '|' || ch === '&' || ch === '(' || ch === ')' || ch === '{' || ch === '}') {
70 out.push(current)
71 current = ''
72 continue
73 }
74 current += ch
75 }
76 if (quote === null) {
77 out.push(current)
78 current = ''
79 }
80 }
81 out.push(current)
82 return out.map(cleanCommand).filter(command => command.length > 0)
83}
84
85function cleanCommand(command: string): string {
86 let rest = command.replace(/\s+/g, ' ').trim()
87 for (let previous = ''; previous !== rest; ) {
88 previous = rest
89 rest = rest.replace(WRAPPER, '')
90 }
91 return rest
92}
93
94export function isTestCommand(command: string): boolean {
95 return simpleCommands(command).some(segment => !NO_RUN.test(segment) && RUNNERS.some(runner => runner.test(segment)))
96}
97
98// Verdict lines runners print. Only summary shapes count, so a passing test
99// whose name mentions "FAIL" or "3 errors" is no failure.
100const FAILURE_MARKERS: readonly RegExp[] = [
101 /^\s*Tests?:?\s.*\b[1-9]\d* fail(ed|ing|ures?)?\b/im, // jest, vitest, playwright, node --test
102 /^\s*Test Files\s.*\b[1-9]\d* failed\b/im, // vitest
103 /^\s*FAIL\s+\S/m, // jest file header
104 /^--- FAIL:|^FAIL\s*$|^FAIL\t/m, // go test
105 /\btest result: FAILED\b/, // cargo
106 /^=+.*\b[1-9]\d* (failed|errors?)\b.*=+\s*$/m, // pytest summary
107 /^not ok\b/m, // TAP
108 /^\s*[1-9]\d* (failing|failures?)\b/m, // mocha, rspec
109 /^FAILED \(/m, // unittest
110 /^\s*# fail [1-9]/m, // node --test
111]
112
113/** True when a test command finished without an error result or a failure verdict. */
114export function isPassingRun(output: string, isError: boolean): boolean {
115 if (isError) return false
116 return !FAILURE_MARKERS.some(marker => marker.test(output))
117}
118
119// Flags that make `git commit` print or check instead of committing.
120const NO_COMMIT = /\s(--dry-run|-h|--help)(\s|=|$)/
121
122/** True when one of the line's simple commands is a real `git commit`. */
123export function isGitCommit(command: string): boolean {
124 return simpleCommands(command).some(segment => /^git( -C \S+| -c \S+)* commit\b/.test(segment) && !NO_COMMIT.test(segment))
125}
126
127/**
128 * True when a Bash command ran and exited non-zero. A run the user
129 * interrupted, a call the user rejected and one a hook blocked are errors too,
130 * but none of them starts with the exit code, so none of them counts.
131 */
132export function isFailedRun(output: string, isError: boolean): boolean {
133 return isError && /^Exit code [1-9]\d*\b/.test(output) && !output.includes('[Request interrupted by user')
134}
135
136/** True when the user answered the permission dialog for a call with No or Esc. */
137export function isRejectedCall(output: string, isError: boolean): boolean {
138 return isError && output.startsWith("The user doesn't want to proceed with this tool use")
139}
140src/sync/client.ts 537 lines1// The plugin's side of the shared fight. It finds or starts the daemon, shows
2// the world it long-polls, and sends every action through an outbox drained
3// in the background, so no hook ever waits on the network. Without a daemon
4// (no runtime, the desktop app, a daemon that keeps failing) the session runs
5// its own game and saves it in `$.store`; it never touches `world.json`.
6
7import type { HttpInit, HttpResponse, ProcessRunInit, ProcessRunResult, Timer } from 'claude-code'
8
9import type { GameState, SyncMode } from '../../types'
10
11import {
12 applyDaemonAction,
13 buildId,
14 compareVersions,
15 type ActionReply,
16 type DaemonAction,
17 type Hello,
18 type SeedReply,
19 type WorldReply,
20} from '../../daemon/protocol'
21import { isNewerSave, newGame, parseSave, step } from '../game/engine'
22
23export const SAVE_KEY = 'save'
24export const UNREADABLE_KEY = 'save-unreadable'
25export const INSTALL_KEY = 'install-id'
26/** True once the save in `SAVE_KEY` was handed to the daemon; a solo save clears it. */
27export const MIGRATED_KEY = 'migrated'
28
29export const STEP_MS = 1500
30export const SAVE_MS = 10_000
31/** How often a solo session looks for the daemon again. */
32export const SOLO_RETRY_MS = 30_000
33
34const HELLO_TIMEOUT_MS = 2000
35const ACTION_TIMEOUT_MS = 2000
36/** Longer than the daemon holds a long-poll (10 s), so a held poll is not cut. */
37const POLL_TIMEOUT_MS = 12_000
38const LAUNCH_WAIT_MS = 3000
39const LAUNCH_TIMEOUT_MS = 5000
40/** A socket path longer than this does not fit `sockaddr_un` everywhere. */
41const MAX_SOCKET_BYTES = 100
42const MAX_OUTBOX = 1000
43
44/** Where node or bun may live when the session's PATH does not reach it. */
45const RUNTIME_PATHS = ['/opt/homebrew/bin/node', '/usr/local/bin/node', '/usr/bin/node', '/opt/homebrew/bin/bun', '/usr/local/bin/bun']
46
47
48/**
49 * What the client needs from the engine. The hooks module builds it over `$`,
50 * which never crosses an import, and a test drives every call it makes.
51 */
52export type Host = {
53 root: string
54 fetch: (url: string, init: HttpInit) => Promise<HttpResponse>
55 run: (argv: readonly string[], init: ProcessRunInit) => Promise<ProcessRunResult>
56 readFile: (path: string) => Promise<string>
57 exists: (path: string) => Promise<boolean>
58 home: () => Promise<string | undefined>
59 path: () => Promise<string | undefined>
60 storeGet: (key: string) => Promise<unknown>
61 storeSet: (key: string, value: unknown) => Promise<void>
62 now: () => Promise<number>
63 sleep: (ms: number) => Promise<void>
64 every: (ms: number, fn: () => void) => Timer
65 toast: (text: string) => void
66 readGame: () => Promise<GameState | null>
67 updateGame: (fn: (g: GameState | null) => GameState | null) => Promise<void>
68 setMode: (mode: SyncMode) => Promise<void>
69}
70
71type Link = { home: string; dataDir: string; socket: string }
72
73type Failure = 'no-home' | 'no-process' | 'no-runtime' | 'failed'
74
75type Ensured = { hello: Hello; link: Link } | { failure: Failure }
76
77// This copy of the module's sync state. A second session.start bumps the
78// generation, and a loop from an older one stops at its next turn.
79let generation = 0
80let mode: SyncMode = 'starting'
81/** The newest world shown: a reply from another epoch, or an older seq, is never shown over it. */
82let cursor = { epoch: '', seq: -1 }
83let outbox: DaemonAction[] = []
84let isFlushing = false
85/** The action whose `/action` request is out now, if any. */
86let inFlight: DaemonAction | null = null
87// Action ids: this module copy's own tag and a counter, unique enough to tell a resend.
88const sender = Array.from({ length: 4 }, () => Math.floor(Math.random() * 0x10000).toString(16).padStart(4, '0')).join('')
89let sent = 0
90let ensuring: Promise<Ensured> | null = null
91let link: Link | null = null
92let runtime: string | null = null
93let soloTimers: Timer[] = []
94let isSoloWritable = true
95const toasted = new Set<string>()
96
97/** True while this session runs its own game (solo or locked), not the daemon's. */
98function isLocal(): boolean {
99 return mode === 'solo' || mode === 'locked'
100}
101
102function toastOnce(host: Host, key: string, text: string): void {
103 if (toasted.has(key)) return
104 toasted.add(key)
105 host.toast(text)
106}
107
108async function setMode(host: Host, next: SyncMode): Promise<void> {
109 mode = next
110 await host.setMode(next)
111}
112
113// ---------- transport ----------
114
115/** One request to the daemon, rejected after `timeoutMs` whatever the host does. */
116async function call<T>(host: Host, where: Link, method: string, path: string, body: unknown, timeoutMs: number): Promise<T> {
117 const request = host.fetch(`http://d${path}`, {
118 method,
119 socketPath: where.socket,
120 ...(body === undefined ? {} : { headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) }),
121 })
122 const timeout = host.sleep(timeoutMs).then((): never => {
123 throw new Error(`timed out: ${method} ${path}`)
124 })
125 const res = await Promise.race([request, timeout])
126 if (res.status !== 200 && res.status !== 400) throw new Error(`status ${res.status}: ${method} ${path}`)
127 return JSON.parse(res.text) as T
128}
129
130async function hello(host: Host, where: Link): Promise<Hello | null> {
131 try {
132 return await call<Hello>(host, where, 'GET', '/hello', undefined, HELLO_TIMEOUT_MS)
133 } catch {
134 return null
135 }
136}
137
138// ---------- where the daemon lives ----------
139
140function utf8Length(text: string): number {
141 let bytes = 0
142 for (const ch of text) {
143 const code = ch.codePointAt(0) ?? 0
144 bytes += code < 0x80 ? 1 : code < 0x800 ? 2 : code < 0x10000 ? 3 : 4
145 }
146 return bytes
147}
148
149function isInstallId(value: unknown): value is string {
150 return typeof value === 'string' && /^[0-9a-f]{8}$/.test(value)
151}
152
153/**
154 * This install's id: made once and kept in `$.store`, which an update of the
155 * plugin keeps and which a `--plugin-dir` copy and a marketplace install do
156 * not share, so neither does their daemon. Two first sessions at once may
157 * each write one; both read back the one that landed.
158 */
159async function installId(host: Host): Promise<string> {
160 const stored = await host.storeGet(INSTALL_KEY)
161 if (isInstallId(stored)) return stored
162 const made = Array.from({ length: 8 }, () => Math.floor(Math.random() * 16).toString(16)).join('')
163 await host.storeSet(INSTALL_KEY, made)
164 await host.sleep(250)
165 const settled = await host.storeGet(INSTALL_KEY)
166 return isInstallId(settled) ? settled : made
167}
168
169async function locate(host: Host): Promise<Link | null> {
170 if (link !== null) return link
171 const home = (await host.home())?.replace(/\/+$/, '')
172 if (home === undefined || !home.startsWith('/') || home.includes('\0') || home.split('/').includes('..')) return null
173 const dataDir = `${home}/.claude/tt/${await installId(host)}`
174 const socket = `${dataDir}/d.sock`
175 if (utf8Length(socket) > MAX_SOCKET_BYTES) return null
176 link = { home, dataDir, socket }
177 return link
178}
179
180let own: { build: string; version: string } | null = null
181
182/** The build and version of the daemon bundle this copy of the plugin ships. */
183async function ownBuild(host: Host): Promise<{ build: string; version: string }> {
184 if (own !== null) return own
185 const bundle = await host.readFile(`${host.root}/dist/server.js`)
186 const manifest = JSON.parse(await host.readFile(`${host.root}/.claude-plugin/plugin.json`)) as { version?: unknown }
187 own = { build: buildId(bundle), version: typeof manifest.version === 'string' ? manifest.version : '0.0.0' }
188 return own
189}
190
191/** node first, then bun: from the session's PATH, else from the usual places. */
192async function findRuntime(host: Host, where: Link): Promise<string | null> {
193 if (runtime !== null) return runtime
194 for (const name of ['node', 'bun']) {
195 try {
196 const found = await host.run(['which', name], { cwd: where.dataDir, timeoutMs: LAUNCH_TIMEOUT_MS })
197 const path = found.stdout.trim()
198 if (found.exitCode === 0 && path.startsWith('/')) return (runtime = path)
199 } catch {
200 // `which` itself is missing: try the usual places.
201 }
202 }
203 for (const path of [...RUNTIME_PATHS, `${where.home}/.bun/bin/bun`]) {
204 if (await host.exists(path)) return (runtime = path)
205 }
206 return null
207}
208
209/**
210 * Starts the daemon detached. Every command runs with the data directory, or
211 * `/` before it exists, as its working directory, never the session's: bun
212 * would run a project's `bunfig.toml` preload.
213 */
214async function launch(host: Host, where: Link): Promise<'ok' | Failure> {
215 try {
216 await host.run(['mkdir', '-p', '-m', '700', where.dataDir], { cwd: '/', timeoutMs: LAUNCH_TIMEOUT_MS })
217 } catch {
218 return 'no-process'
219 }
220 const found = await findRuntime(host, where)
221 if (found === null) return 'no-runtime'
222 try {
223 // `env -i`: the launcher starts from an empty environment, so nothing like
224 // NODE_OPTIONS or BUN_OPTIONS from the session loads code into it.
225 const path = (await host.path()) ?? '/usr/bin:/bin'
226 const argv = ['/usr/bin/env', '-i', `PATH=${path}`, `HOME=${where.home}`, found, `${host.root}/dist/launch.js`, '--detach', where.dataDir]
227 const ran = await host.run(argv, { cwd: where.dataDir, timeoutMs: LAUNCH_TIMEOUT_MS })
228 return ran.exitCode === 0 ? 'ok' : 'failed'
229 } catch {
230 return 'failed'
231 }
232}
233
234async function waitFor(host: Host, where: Link, isUp: boolean): Promise<Hello | null> {
235 for (let waited = 0; waited < LAUNCH_WAIT_MS; waited += 100) {
236 const answer = await hello(host, where)
237 if ((answer !== null) === isUp) return answer
238 await host.sleep(100)
239 }
240 return null
241}
242
243async function doEnsure(host: Host): Promise<Ensured> {
244 const where = await locate(host)
245 if (where === null) return { failure: 'no-home' }
246 let mine: { build: string; version: string }
247 try {
248 mine = await ownBuild(host)
249 } catch {
250 return { failure: 'failed' }
251 }
252 let answer = await hello(host, where)
253 // A daemon from an older version steps down for this one. Any other daemon
254 // is used as it is: two builds of one version must not take turns.
255 if (answer !== null && answer.build !== mine.build && compareVersions(mine.version, answer.version) > 0) {
256 const query = `?build=${encodeURIComponent(mine.build)}&version=${encodeURIComponent(mine.version)}`
257 const reply = await call<{ ok: boolean }>(host, where, 'POST', `/shutdown${query}`, undefined, HELLO_TIMEOUT_MS).catch(() => null)
258 if (reply?.ok === true) {
259 await waitFor(host, where, false)
260 answer = null
261 }
262 }
263 if (answer !== null) return { hello: answer, link: where }
264 for (let attempt = 0; attempt < 2; attempt += 1) {
265 const launched = await launch(host, where)
266 if (launched === 'no-process' || launched === 'no-runtime') return { failure: launched }
267 answer = await waitFor(host, where, true)
268 if (answer !== null) return { hello: answer, link: where }
269 }
270 return { failure: 'failed' }
271}
272
273/** One look for the daemon at a time, however many loops ask. */
274function ensureDaemon(host: Host): Promise<Ensured> {
275 ensuring ??= doEnsure(host).finally(() => {
276 ensuring = null
277 })
278 return ensuring
279}
280
281// ---------- the world ----------
282
283/**
284 * Hands an empty daemon its world: the one this session was showing when
285 * the daemon lost its own, else the save in `$.store` the first time and
286 * after a solo run, else nothing, which makes it start a new game. Of two
287 * sessions seeding at once the daemon adopts one.
288 */
289async function seed(host: Host, where: Link): Promise<void> {
290 const shown = mode === 'daemon' ? parseSave(await host.readGame()) : null
291 const migrated = (await host.storeGet(MIGRATED_KEY)) === true
292 const save = shown ?? (migrated ? null : parseSave(await host.storeGet(SAVE_KEY)))
293 const reply = await call<SeedReply>(host, where, 'POST', '/seed', { game: save }, ACTION_TIMEOUT_MS)
294 if (reply.adopted) await host.storeSet(MIGRATED_KEY, true)
295}
296
297function isWorldReply(value: unknown): value is WorldReply {
298 if (typeof value !== 'object' || value === null) return false
299 const reply = value as Partial<WorldReply>
300 return (
301 typeof reply.epoch === 'string' &&
302 typeof reply.seq === 'number' &&
303 Number.isFinite(reply.seq) &&
304 (reply.state === 'empty' || reply.state === 'loaded' || reply.state === 'locked')
305 )
306}
307
308/**
309 * Shows each new world until the daemon stops answering or a newer loop
310 * takes over; `progress.isPolled` turns true at the first good reply.
311 */
312async function poll(host: Host, gen: number, where: Link, progress: { isPolled: boolean }): Promise<WorldReply['state']> {
313 while (gen === generation) {
314 const query = `/world?since=${cursor.seq}&epoch=${encodeURIComponent(cursor.epoch)}`
315 const reply = await call<unknown>(host, where, 'GET', query, undefined, POLL_TIMEOUT_MS)
316 if (gen !== generation) break
317 if (!isWorldReply(reply)) throw new Error('not a world reply')
318 progress.isPolled = true
319 // A restarted daemon counts from zero again under a new epoch.
320 if (reply.epoch !== cursor.epoch) cursor = { epoch: reply.epoch, seq: -1 }
321 if (reply.seq <= cursor.seq) continue
322 cursor = { epoch: reply.epoch, seq: reply.seq }
323 if (reply.state !== 'loaded') return reply.state
324 const shown = parseSave(reply.game)
325 if (shown !== null) await host.updateGame(() => shown)
326 }
327 return 'loaded'
328}
329
330/**
331 * The session's sync loop: find the daemon, seed it when empty, poll it, and
332 * on failure back off, then play solo after three failures in a row. A
333 * failure is a daemon that cannot be started, or one that answers `/hello`
334 * but never gives a good world.
335 */
336async function run(host: Host, gen: number): Promise<void> {
337 let failures = 0
338 const fail = async (why: Failure): Promise<void> => {
339 failures += 1
340 if (why !== 'failed' || failures >= 3) {
341 await enterSolo(host, why)
342 await host.sleep(SOLO_RETRY_MS)
343 } else {
344 await host.sleep(1000 * failures)
345 }
346 }
347 while (gen === generation) {
348 const ensured = await ensureDaemon(host)
349 if (gen !== generation) return
350 if ('failure' in ensured) {
351 await fail(ensured.failure)
352 continue
353 }
354 const progress = { isPolled: false }
355 try {
356 let state = ensured.hello.state
357 if (state === 'empty') {
358 if (mode === 'solo') await saveSolo(host)
359 await seed(host, ensured.link)
360 state = 'loaded'
361 }
362 if (state === 'locked') {
363 await enterLocked(host)
364 await host.sleep(SOLO_RETRY_MS)
365 continue
366 }
367 await leaveSolo(host)
368 await setMode(host, 'daemon')
369 void flush(host).catch(() => undefined)
370 const ended = await poll(host, gen, ensured.link, progress)
371 if (ended === 'locked') {
372 await enterLocked(host)
373 await host.sleep(SOLO_RETRY_MS)
374 continue
375 }
376 } catch {
377 // The daemon went away mid-poll: look for it again.
378 }
379 if (gen !== generation) return
380 if (progress.isPolled) {
381 failures = 0
382 await host.sleep(250)
383 } else {
384 await fail('failed')
385 }
386 }
387}
388
389// ---------- actions ----------
390
391function heroError(error: string): string {
392 if (error === 'locked') return 'Terminal Tales: save do phiên bản mới hơn tạo, không thể thao tác.'
393 if (error === 'not-ready' || error === 'bad-action') return 'Terminal Tales: trận chưa sẵn sàng, thử lại sau.'
394 return error
395}
396
397/** Sends what the outbox holds, oldest first; a send that fails stays for the next try. */
398async function flush(host: Host): Promise<void> {
399 if (isFlushing) return
400 isFlushing = true
401 try {
402 while (outbox.length > 0 && mode === 'daemon' && link !== null) {
403 const action = outbox[0]!
404 let reply: ActionReply
405 inFlight = action
406 try {
407 reply = await call<ActionReply>(host, link, 'POST', '/action', action, ACTION_TIMEOUT_MS)
408 } catch {
409 // The session went solo while this was out: the drain left it to the
410 // daemon, which did not take it, so the solo game does.
411 if (isLocal()) await applyLocal(host, action)
412 return
413 } finally {
414 inFlight = null
415 }
416 // The outbox may have moved on meanwhile (trimmed, or drained into a solo game).
417 if (outbox[0] === action) outbox.shift()
418 if (!reply.ok && action.kind === 'hero') host.toast(heroError(reply.error))
419 }
420 } finally {
421 isFlushing = false
422 }
423}
424
425async function applyLocal(host: Host, action: DaemonAction): Promise<void> {
426 let error: string | undefined
427 await host.updateGame(g => {
428 if (g === null) return g
429 const result = applyDaemonAction(g, action)
430 error = result.error
431 return result.state
432 })
433 if (error !== undefined && action.kind === 'hero') host.toast(error)
434}
435
436/** Takes an action from a hook or the pane and returns at once: the daemon gets it in the background. */
437export async function submit(host: Host, action: DaemonAction): Promise<void> {
438 if (isLocal()) return applyLocal(host, action)
439 if (outbox.length >= MAX_OUTBOX) outbox.shift()
440 sent += 1
441 outbox.push({ ...action, id: `${sender}-${sent}` })
442 void flush(host).catch(() => undefined)
443}
444
445// ---------- solo ----------
446
447const SOLO_TOASTS: Record<Failure, string> = {
448 'no-home': 'Terminal Tales: không tìm thấy thư mục nhà, phiên này chơi một mình.',
449 'no-process': 'Terminal Tales: nơi này không chạy được tiến trình nền, phiên này chơi một mình.',
450 'no-runtime': 'Terminal Tales: không tìm thấy node hay bun, phiên này chơi một mình.',
451 failed: 'Terminal Tales: không khởi động được tiến trình nền, phiên này chơi một mình.',
452}
453
454function startSoloTimers(host: Host): void {
455 for (const timer of soloTimers) timer.cancel()
456 soloTimers = [
457 host.every(STEP_MS, () => void host.updateGame(g => (g === null ? g : step(g))).catch(() => undefined)),
458 host.every(SAVE_MS, () => void saveSolo(host).catch(() => undefined)),
459 ]
460}
461
462/** Applies what never reached the daemon to the local game; the one on its way there is left to it. */
463async function drainOutbox(host: Host): Promise<void> {
464 const queued = outbox.filter(action => action !== inFlight)
465 outbox = []
466 for (const action of queued) await applyLocal(host, action)
467}
468
469/**
470 * Runs this session's own game: on from the daemon's last world when one was
471 * shown, else from the save in `$.store`.
472 */
473async function enterSolo(host: Host, why: Failure): Promise<void> {
474 if (mode === 'solo') return
475 // Whatever game this session goes on with, the save in the store is only
476 // written over when this version can read it, or after keeping it aside.
477 const raw = await host.storeGet(SAVE_KEY)
478 const stored = parseSave(raw)
479 isSoloWritable = !isNewerSave(raw)
480 if (!isSoloWritable) {
481 toastOnce(host, 'newer', 'Terminal Tales: save do phiên bản mới hơn tạo. Phiên này chơi tạm và không ghi đè save đó.')
482 } else if (raw !== undefined && stored === null) {
483 await host.storeSet(UNREADABLE_KEY, raw)
484 }
485 const loaded = (mode === 'daemon' ? await host.readGame() : null) ?? stored ?? newGame(Math.floor(await host.now()))
486 await host.updateGame(() => loaded)
487 await setMode(host, 'solo')
488 await drainOutbox(host)
489 startSoloTimers(host)
490 toastOnce(host, `solo-${why}`, SOLO_TOASTS[why])
491}
492
493/** The daemon's world comes from a newer version: play a game here that is never written. */
494async function enterLocked(host: Host): Promise<void> {
495 if (mode === 'locked') return
496 isSoloWritable = false
497 const fresh = newGame(Math.floor(await host.now()))
498 await host.updateGame(() => fresh)
499 await setMode(host, 'locked')
500 await drainOutbox(host)
501 startSoloTimers(host)
502 toastOnce(host, 'locked', 'Terminal Tales: save do phiên bản mới hơn tạo. Phiên này chơi tạm và không ghi đè save đó.')
503}
504
505async function leaveSolo(host: Host): Promise<void> {
506 if (mode !== 'solo' && mode !== 'locked') return
507 for (const timer of soloTimers) timer.cancel()
508 soloTimers = []
509}
510
511/** Saves a solo game in `$.store`, and marks it for the daemon to adopt when it next starts empty. */
512async function saveSolo(host: Host): Promise<void> {
513 if (mode !== 'solo' || !isSoloWritable) return
514 const g = await host.readGame()
515 if (g === null) return
516 await host.storeSet(SAVE_KEY, g)
517 await host.storeSet(MIGRATED_KEY, false)
518}
519
520// ---------- lifecycle ----------
521
522/** Starts this session's sync in the background; the caller never waits on it. */
523export function startSync(host: Host): void {
524 generation += 1
525 // A loop cut short by the module unloading has nothing left to do.
526 run(host, generation).catch(() => undefined)
527}
528
529/** At session end: a solo game is saved, and the outbox gets a last, short chance to go out. */
530export async function endSync(host: Host): Promise<void> {
531 if (mode === 'solo') {
532 await saveSolo(host)
533 return
534 }
535 await Promise.race([flush(host), host.sleep(1000)])
536}
537src/ui/animation.ts 443 lines1import type { EffectKind, GameState, HeroClass, Monster, MonsterTier } from '../../types'
2
3import { monsterKind } from '../game/catalog'
4import { heroStats } from '../game/engine'
5import type { Atlases, Clip, Picture } from './atlas'
6import { ATLAS, type AtlasId } from './atlas-manifest'
7import { type Frame, type Tint, backdrop, blit, fill, newFrame, put, text, wash } from './frame-buffer'
8
9// The band's animation runs on its own clock, one frame every FRAME_MS, and
10// follows the game rather than driving it: `observe` turns what changed
11// between two game states into timed events (a volley, a counterattack, a
12// heal, a fall, a death, a walk to the next monster), and `compose` paints
13// one frame of them over the stage's background.
14
15export const FRAME_MS = 100
16const WALK_FRAMES = 8
17const NUMBER_FRAMES = 8
18const HEAL_FRAMES = 8
19/** Frames after a volley starts that its blows land: arrows and orbs fly until then. */
20const IMPACT = 5
21const PROJECTILE_FLIGHT = [2, IMPACT] as const
22/** Frames after a volley starts that the monster answers it. */
23const COUNTER_DELAY = 8
24/** Frames into the monster's attack that it lands on a hero. */
25const COUNTER_IMPACT = 4
26/** A pause between a monster's last death frame and the next one walking in. */
27const AFTER_DEATH = 2
28
29type Snapshot = {
30 seed: number
31 kills: number
32 monster: Monster
33 heroHp: number[]
34 heroMaxHp: number[]
35 /** Each standing hero's attack: what a volley without a crit deals. */
36 heroAtk: number[]
37 charges: Partial<Record<EffectKind, number>>
38 hasRally: boolean
39}
40
41export type DamageNumber = { value: number; at: number; kind: 'hit' | 'crit' | 'blocked' }
42
43export type Anim = {
44 t: number
45 /** Background scroll, moved only while the party walks. */
46 ground: number
47 prev: Snapshot | null
48 volleyAt: number | null
49 /** The current volley had at least one critical hit. */
50 crit: boolean
51 /** The current volley hit stoneskin and did nothing. */
52 blocked: boolean
53 counterAt: number | null
54 /** The hero the counterattack hit; null when the milestone shield took it. */
55 hitHero: number | null
56 /** When each side last healed by more than the party's own regeneration. */
57 healAt: { heroes: number | null; monster: number | null }
58 /** When each hero fell, by party index; null for one standing or fallen before the band saw it. */
59 downAt: (number | null)[]
60 dying: { monster: Monster; at: number } | null
61 enterAt: number
62 numbers: DamageNumber[]
63}
64
65export function newAnim(): Anim {
66 return {
67 t: 0,
68 ground: 0,
69 prev: null,
70 volleyAt: null,
71 crit: false,
72 blocked: false,
73 counterAt: null,
74 hitHero: null,
75 healAt: { heroes: null, monster: null },
76 downAt: [],
77 dying: null,
78 enterAt: 0,
79 numbers: [],
80 }
81}
82
83function snapshot(g: GameState): Snapshot {
84 const charges: Partial<Record<EffectKind, number>> = {}
85 for (const e of g.effects) charges[e.kind] = e.charges
86 return {
87 seed: g.seed,
88 kills: g.stats.kills,
89 monster: g.monster,
90 heroHp: g.heroes.map(h => h.hp),
91 heroMaxHp: g.heroes.map(h => heroStats(h).maxHp),
92 heroAtk: g.heroes.filter(h => h.hp > 0).map(h => heroStats(h).atk),
93 charges,
94 hasRally: g.effects.some(e => e.kind === 'rally'),
95 }
96}
97
98function isWalking(a: Anim): boolean {
99 return a.t >= a.enterAt && a.t < a.enterAt + WALK_FRAMES
100}
101
102function deathFrames(m: Monster): number {
103 return ATLAS[monsterKind(m).asset].clips.death.frames
104}
105
106/**
107 * True when `damage` is more than whole plain volleys deal. The game keeps no
108 * record of crits, but a crit doubles one hero's blow, so it adds an attack
109 * the party's plain sum lacks. This holds while a crit is the only thing that
110 * grows a blow; the rally's second volley, the one other way a step deals
111 * more, is allowed for.
112 */
113export function hasCrit(damage: number, heroAtk: readonly number[], hasRally: boolean): boolean {
114 const plain = heroAtk.reduce((sum, atk) => sum + atk, 0)
115 const near = (v: number) => Math.abs(damage - v) < 0.01
116 return damage > 0 && !near(plain) && !(hasRally && near(plain * 2))
117}
118
119/** HP a hero regains by itself each step: a rise beyond it is a heal. */
120function regeneration(maxHp: number): number {
121 return Math.ceil(maxHp * 0.03)
122}
123
124/** Reads what happened between the last game state seen and this one. */
125export function observe(a: Anim, g: GameState): Anim {
126 const prev = a.prev
127 const next: Anim = {
128 ...a,
129 prev: snapshot(g),
130 numbers: a.numbers.filter(n => a.t < n.at + NUMBER_FRAMES),
131 downAt: g.heroes.map((h, i) => (h.hp > 0 ? null : (a.downAt[i] ?? null))),
132 }
133 if (prev === null) return next
134
135 g.heroes.forEach((h, i) => {
136 if (h.hp <= 0 && (prev.heroHp[i] ?? 0) > 0) next.downAt[i] = a.t
137 })
138 if (g.heroes.some((h, i) => h.hp > (prev.heroHp[i] ?? h.hp) + regeneration(prev.heroMaxHp[i] ?? 0))) {
139 next.healAt = { ...next.healAt, heroes: a.t }
140 }
141
142 if (g.stats.kills > prev.kills) {
143 // The party's blow lands, then the monster plays its whole death before the next walks in.
144 const at = a.t + IMPACT
145 next.volleyAt = a.t
146 next.crit = false
147 next.blocked = false
148 next.counterAt = null
149 next.dying = { monster: { ...prev.monster, hp: 0 }, at }
150 next.numbers = [...next.numbers, { value: prev.monster.hp, at, kind: 'hit' }]
151 next.enterAt = at + deathFrames(prev.monster) + AFTER_DEATH
152 return next
153 }
154
155 if (isWalking(a) || a.t < a.enterAt) return next
156
157 if (g.monster.hp > prev.monster.hp && g.resting === 0) next.healAt = { ...next.healAt, monster: a.t }
158
159 const stepped = g.seed !== prev.seed
160 const isBlocked = stepped && (g.effects.find(e => e.kind === 'stoneskin')?.charges ?? 0) < (prev.charges.stoneskin ?? 0)
161 const damage = prev.monster.hp - g.monster.hp
162 // A free strike from a tool call can land in the same read as a blocked step: the damage wins.
163 if (isBlocked && damage <= 0) {
164 next.volleyAt = a.t
165 next.crit = false
166 next.blocked = true
167 next.numbers = [...next.numbers, { value: 0, at: a.t + IMPACT, kind: 'blocked' }]
168 } else if (stepped && damage > 0) {
169 const crit = hasCrit(damage, prev.heroAtk, prev.hasRally)
170 next.volleyAt = a.t
171 next.crit = crit
172 next.blocked = false
173 next.numbers = [...next.numbers, { value: damage, at: a.t + IMPACT, kind: crit ? 'crit' : 'hit' }]
174 }
175
176 // Only a combat step counterattacks: gear swapped in /hero can lower HP too.
177 if (!stepped) return next
178 const hit = g.heroes.findIndex((h, i) => h.hp < (prev.heroHp[i] ?? h.hp))
179 const isShielded = (g.effects.find(e => e.kind === 'milestone')?.charges ?? 0) < (prev.charges.milestone ?? 0)
180 if (hit >= 0 || isShielded) {
181 next.counterAt = next.volleyAt === a.t ? a.t + COUNTER_DELAY : a.t
182 next.hitHero = hit >= 0 ? hit : null
183 }
184 return next
185}
186
187/** Moves the animation one frame on. */
188export function tick(a: Anim): Anim {
189 return { ...a, t: a.t + 1, ground: isWalking(a) ? a.ground + 1 : a.ground }
190}
191
192/** An animation that has seen this game and has nothing to play: a still frame. */
193export function stillAnim(g: GameState): Anim {
194 return { ...observe(newAnim(), g), t: 100, enterAt: -100 }
195}
196
197// ---------- painting ----------
198
199export const BACKGROUNDS = ['bg-plains', 'bg-forest', 'bg-dead-forest', 'bg-snowy-mountains', 'bg-cave'] as const satisfies readonly AtlasId[]
200
201const HERO_ASSET: Record<HeroClass, AtlasId> = { warrior: 'hero-knight', mage: 'wizard', ranger: 'huntress' }
202
203/** Where each party member's feet are, the first in front. */
204const HERO_X = [140, 90, 40] as const
205const MONSTER_X = 300
206/** Where a monster starts its walk in: just past the right edge. */
207const WALK_FROM_X = 420
208/** Pixels the background slides each walking frame. */
209const SCROLL_STEP = 6
210/** How far above the ground a flying monster hovers. */
211const HOVER = 14
212const HERO_BAR_WIDTH = 24
213const MONSTER_BAR_WIDTH = 36
214
215const WHITE = 0xffffff
216const HIT_RED = 0xff3b3b
217const BAR_EMPTY = 0x3a3f58
218const HERO_BAR = 0x4fbf5a
219const MONSTER_BAR = 0xc23b4f
220const GOLD = 0xffe08a
221const ARCANE = 0x73eff7
222const HEAL_GREEN = 0x9be37a
223const SHIELD_BLUE = 0x8fc1ff
224const ENRAGE_RED = 0xff2a2a
225const BLOCKED_GRAY = 0x9a9a9a
226
227const TIER_OUTLINE: Partial<Record<MonsterTier, number>> = { elite: 0x2fb7c9, rare: 0xd065b0 }
228
229function since(at: number | null, t: number): number | null {
230 return at === null || t < at ? null : t - at
231}
232
233function frameAt(clip: Clip, rel: number, loop: boolean): Picture {
234 const n = clip.frames.length
235 const i = Math.floor((rel * FRAME_MS) / clip.frameMs)
236 return clip.frames[loop ? ((i % n) + n) % n : Math.min(n - 1, i)]!
237}
238
239/** Plays a clip once from `rel`; null once it is over. */
240function playing(clip: Clip, rel: number | null): Picture | null {
241 if (rel === null || rel < 0 || (rel * FRAME_MS) / clip.frameMs >= clip.frames.length) return null
242 return frameAt(clip, rel, false)
243}
244
245function stamp(f: Frame, clip: Clip, picture: Picture, x: number, ground: number, options?: { tint?: Tint; outline?: number }): void {
246 blit(f, picture, Math.round(x) - clip.anchorX, Math.round(ground) - clip.anchorY, options)
247}
248
249function bar(f: Frame, centerX: number, y: number, width: number, ratio: number, color: number): void {
250 const left = Math.round(centerX - width / 2)
251 const filled = Math.round(Math.min(1, Math.max(0, ratio)) * width)
252 fill(f, left, y, width, 2, BAR_EMPTY)
253 fill(f, left, y, filled, 2, color)
254}
255
256function compact(n: number): string {
257 return n >= 10_000 ? `${Math.floor(n / 1000)}k` : String(Math.max(0, Math.round(n)))
258}
259
260/** Green sparks drifting up over a character that just healed. */
261function healSparks(f: Frame, x: number, ground: number, height: number, rel: number): void {
262 for (let k = 0; k < 4; k += 1) {
263 const sx = Math.round(x - 10 + k * 7)
264 const sy = Math.round(ground - 4 - ((rel * 5 + k * 11) % Math.max(12, height)))
265 put(f, sx, sy, HEAL_GREEN)
266 put(f, sx, sy - 1, HEAL_GREEN, 0.6)
267 put(f, sx - 1, sy, HEAL_GREEN, 0.4)
268 put(f, sx + 1, sy, HEAL_GREEN, 0.4)
269 }
270}
271
272/** Paints one frame of the fight, always FRAME_WIDTH × FRAME_HEIGHT, whatever the party's size. */
273export function compose(g: GameState, a: Anim, atlases: Atlases): Frame {
274 const f = newFrame()
275 const t = a.t
276 const walking = isWalking(a)
277 const bgId = BACKGROUNDS[(((g.stage - 1) % BACKGROUNDS.length) + BACKGROUNDS.length) % BACKGROUNDS.length]!
278 const ground = ATLAS[bgId].groundY
279 backdrop(f, atlases[bgId].scene!.frames[0]!, a.ground * SCROLL_STEP)
280
281 const has = (kind: EffectKind) => g.effects.some(e => e.kind === kind)
282 const volley = walking ? null : since(a.volleyAt, t)
283 const counter = walking ? null : since(a.counterAt, t)
284
285 // ---- the monster, behind the numbers and projectiles ----
286 const isDying = a.dying !== null && t < a.enterAt
287 const shown = isDying ? a.dying!.monster : g.monster
288 const kind = monsterKind(shown)
289 const monster = atlases[kind.asset]
290 const lift = kind.asset === 'flying-eye' ? HOVER : 0
291 const monsterTop = ground - lift - monster.idle!.anchorY
292 const outline = TIER_OUTLINE[shown.tier]
293 if (a.dying !== null && isDying) {
294 const rel = t - a.dying.at
295 if (rel < 0) {
296 stamp(f, monster.idle!, frameAt(monster.idle!, t, true), MONSTER_X, ground - lift, { outline })
297 } else {
298 const picture = playing(monster.death!, rel)
299 if (picture !== null) stamp(f, monster.death!, picture, MONSTER_X, ground - lift, { tint: rel === 0 ? { mix: WHITE, amount: 0.8 } : undefined })
300 }
301 } else if (t >= a.enterAt) {
302 let x = MONSTER_X
303 let clip = monster.idle!
304 let picture = frameAt(clip, t, true)
305 let tint: Tint | undefined
306 if (walking) {
307 const p = (t - a.enterAt) / (WALK_FRAMES - 1)
308 x = WALK_FROM_X + (MONSTER_X - WALK_FROM_X) * p
309 clip = monster.run!
310 picture = frameAt(clip, t, true)
311 } else {
312 const attack = playing(monster.attack!, counter)
313 const hurt = volley !== null && !a.blocked ? playing(monster.hit!, volley - IMPACT) : null
314 if (attack !== null) {
315 clip = monster.attack!
316 picture = attack
317 } else if (hurt !== null) {
318 clip = monster.hit!
319 picture = hurt
320 }
321 if (volley === IMPACT && !a.blocked) tint = { mix: WHITE, amount: 0.85 }
322 else if (has('stoneskin')) tint = { gray: 0.85 }
323 else if (has('enrage') && ((t >> 1) & 1) === 0) tint = { mix: ENRAGE_RED, amount: 0.45 }
324 }
325 stamp(f, clip, picture, x, ground - lift, { tint, outline })
326 if (!walking) {
327 bar(f, MONSTER_X, ground + 2, MONSTER_BAR_WIDTH, g.monster.hp / g.monster.maxHp, MONSTER_BAR)
328 const healed = since(a.healAt.monster, t)
329 if (healed !== null && healed < HEAL_FRAMES) healSparks(f, MONSTER_X, ground - lift, monster.idle!.anchorY, healed)
330 }
331 }
332
333 // ---- the party ----
334 const standing = g.resting === 0
335 g.heroes.forEach((hero, i) => {
336 const x = HERO_X[i] ?? HERO_X[HERO_X.length - 1]!
337 const atlas = atlases[HERO_ASSET[hero.cls]]
338 const idle = atlas.idle!
339 const height = idle.anchorY
340 const isDown = hero.hp <= 0 || !standing
341 if (isDown) {
342 const death = atlas.death!
343 const fell = since(a.downAt[i] ?? null, t)
344 const falling = playing(death, fell)
345 if (falling !== null) stamp(f, death, falling, x, ground)
346 else stamp(f, death, death.frames[death.frames.length - 1]!, x, ground, { tint: { gray: 0.7 } })
347 } else {
348 let clip = idle
349 let picture = frameAt(idle, t, true)
350 let tint: Tint | undefined
351 const hurt = a.hitHero === i && counter !== null ? playing(atlas.hit!, counter - COUNTER_IMPACT) : null
352 const swing = playing(atlas.attack!, volley)
353 if (walking) {
354 clip = atlas.run!
355 picture = frameAt(clip, t, true)
356 } else if (hurt !== null) {
357 clip = atlas.hit!
358 picture = hurt
359 if (counter! - COUNTER_IMPACT < 2) tint = { mix: HIT_RED, amount: 0.55 }
360 } else if (swing !== null) {
361 clip = atlas.attack!
362 picture = swing
363 }
364 stamp(f, clip, picture, x, ground, { tint })
365 if (has('trust')) {
366 // A glint at the weapon hand.
367 const hx = x + 12
368 const hy = ground - Math.round(height * 0.55)
369 const glow = ((t >> 1) & 1) === 0 ? 1 : 0.6
370 put(f, hx, hy, WHITE, glow)
371 for (const [dx, dy] of [[-1, 0], [1, 0], [0, -1], [0, 1]] as const) put(f, hx + dx, hy + dy, SHIELD_BLUE, 0.8 * glow)
372 }
373 if (has('rally') && ((t >> 1) & 1) === 0) {
374 // Two short gold streaks over the head.
375 const top = ground - height - 5
376 for (let k = 0; k < 3; k += 1) {
377 put(f, x - 4 + k, top - k, GOLD)
378 put(f, x + 4 - k, top - k, GOLD)
379 }
380 }
381 const healed = since(a.healAt.heroes, t)
382 if (healed !== null && healed < HEAL_FRAMES) healSparks(f, x, ground, height, healed)
383 }
384 bar(f, x, ground + 2, HERO_BAR_WIDTH, hero.hp / heroStats(hero).maxHp, HERO_BAR)
385
386 // Projectiles, from the hand to the monster's middle.
387 if (isDown || volley === null || volley < PROJECTILE_FLIGHT[0] || volley > PROJECTILE_FLIGHT[1]) return
388 const p = (volley - PROJECTILE_FLIGHT[0]) / (PROJECTILE_FLIGHT[1] - PROJECTILE_FLIGHT[0])
389 const fromX = x + 14
390 const fromY = ground - Math.round(height * 0.55)
391 const toY = monsterTop + Math.round(monster.idle!.anchorY * 0.5)
392 const px = fromX + (MONSTER_X - 10 - fromX) * p
393 const py = fromY + (toY - fromY) * p
394 if (hero.cls === 'mage') {
395 const orb = atlases.orb.move!
396 stamp(f, orb, frameAt(orb, t, true), px, py)
397 } else if (hero.cls === 'ranger') {
398 const arrow = atlases.arrow.move!
399 stamp(f, arrow, frameAt(arrow, t, true), px, py)
400 }
401 })
402
403 // ---- the shield of a milestone, in front of the party ----
404 if (has('milestone')) {
405 const cx = (HERO_X[0] + MONSTER_X) / 2 - 50
406 for (let y = ground - 46; y <= ground; y += 1) {
407 const dy = (y - (ground - 23)) / 23
408 const bulge = Math.round(6 * Math.sqrt(Math.max(0, 1 - dy * dy)))
409 fill(f, cx - bulge, y, bulge * 2 + 1, 1, SHIELD_BLUE, 0.22)
410 put(f, cx + bulge, y, WHITE, 0.6)
411 }
412 }
413
414 // ---- zZz over a resting party ----
415 if (!standing) {
416 g.heroes.forEach((_, i) => {
417 const x = HERO_X[i] ?? HERO_X[HERO_X.length - 1]!
418 for (let k = 0; k < 3; k += 1) {
419 const rise = (t + k * 6 + i * 3) % 18
420 text(f, k === 2 ? 'Z' : 'z', x + 6 + k * 4 + Math.floor(rise / 6), ground - 30 - rise, WHITE, 1)
421 }
422 })
423 }
424
425 // ---- numbers, and a flash for a critical volley ----
426 const numberTop = Math.max(2, monsterTop - 10)
427 for (const n of a.numbers) {
428 const rel = t - n.at
429 if (rel < 0 || rel >= NUMBER_FRAMES) continue
430 const top = Math.max(1, numberTop - Math.min(rel, 3) * 2)
431 if (n.kind === 'crit') text(f, compact(n.value), MONSTER_X, top - 4, GOLD, 2)
432 else text(f, compact(n.value), MONSTER_X, top, n.kind === 'blocked' ? BLOCKED_GRAY : WHITE, 1)
433 if (n.kind === 'crit' && rel === 0) wash(f, WHITE, 0.55)
434 }
435
436 return f
437}
438
439/** A still frame of the fight, as the band shows before any animation runs. */
440export function stillScene(g: GameState, atlases: Atlases): Frame {
441 return compose(g, stillAnim(g), atlases)
442}
443src/ui/atlas.ts 66 lines1import { ATLAS, type AtlasClip, type AtlasEntry, type AtlasId } from './atlas-manifest'
2import { fromBase64 } from './base64'
3
4// The sprite sheets and backgrounds the band draws from, decoded once a
5// session from the files scripts/build-assets.ts packs (format in that
6// script): a palette, then one palette index per pixel, 0 transparent.
7
8/** One frame of a clip, RGBA; a transparent pixel is all zeros. */
9export type Picture = { width: number; height: number; rgba: Uint8Array }
10
11export type Clip = {
12 frames: Picture[]
13 width: number
14 height: number
15 /** The point drawn at the character's place: the middle of its feet, or a projectile's center. */
16 anchorX: number
17 anchorY: number
18 frameMs: number
19}
20
21export type Atlas = Record<string, Clip>
22
23export type Atlases = Record<AtlasId, Atlas>
24
25const MAGIC = [0x54, 0x54, 0x41, 0x31] // 'TTA1'
26
27/** Decodes one packed file into its clips; throws on a file that does not match its entry. */
28export function decodeAtlas(bytes: Uint8Array, entry: AtlasEntry): Atlas {
29 if (bytes.length < 6 || MAGIC.some((b, i) => bytes[i] !== b)) throw new Error(`${entry.file}: not a TTA1 file`)
30 const colors = bytes[4]! | (bytes[5]! << 8)
31 const palette = new Uint8Array((colors + 1) * 4)
32 palette.set(bytes.subarray(6, 6 + colors * 4), 4)
33 const start = 6 + colors * 4
34 const atlas: Atlas = {}
35 for (const [name, clip] of Object.entries(entry.clips) as [string, AtlasClip][]) {
36 const size = clip.width * clip.height
37 if (start + clip.offset + clip.frames * size > bytes.length) throw new Error(`${entry.file}: ${name} runs past the end`)
38 const frames = Array.from({ length: clip.frames }, (_, f) => {
39 const rgba = new Uint8Array(size * 4)
40 const from = start + clip.offset + f * size
41 for (let i = 0; i < size; i += 1) {
42 const index = bytes[from + i]!
43 if (index === 0) continue
44 if (index > colors) throw new Error(`${entry.file}: ${name} uses color ${index} of ${colors}`)
45 rgba.set(palette.subarray(index * 4, index * 4 + 4), i * 4)
46 }
47 return { width: clip.width, height: clip.height, rgba }
48 })
49 atlas[name] = { frames, width: clip.width, height: clip.height, anchorX: clip.anchorX, anchorY: clip.anchorY, frameMs: clip.frameMs }
50 }
51 return atlas
52}
53
54/** Reads and decodes every file the manifest names, from the plugin's `assets/build`. */
55export async function loadAtlases(read: (path: string) => Promise<{ base64: string }>, root: string): Promise<Atlases> {
56 const ids = Object.keys(ATLAS) as AtlasId[]
57 const decoded = await Promise.all(
58 ids.map(async id => {
59 const entry: AtlasEntry = ATLAS[id]
60 const { base64 } = await read(`${root}/assets/build/${entry.file}`)
61 return [id, decodeAtlas(fromBase64(base64), entry)] as const
62 }),
63 )
64 return Object.fromEntries(decoded) as Atlases
65}
66src/ui/band.tsx 129 lines1import type { Elements, RenderElement } from 'claude-code'
2
3import type { GameState } from '../../types'
4
5import { KILLS_PER_STAGE, TIERS } from '../game/catalog'
6import { effectsOf, formatNumber, summaryLine } from './art'
7import { FRAME_HEIGHT, FRAME_WIDTH } from './frame-buffer'
8
9export type Kit = Pick<Elements['terminal'], 'Box' | 'Text' | 'Button'>
10/** The terminal's picture element; absent on surfaces that have none, which show an error line. */
11export type ImageKit = Elements['terminal']['Image'] | undefined
12
13/**
14 * The cells the scene's picture fills. FRAME_WIDTH × FRAME_HEIGHT pixels over
15 * 69 × 8 cells keeps a pixel square on a terminal whose cells are twice as
16 * tall as wide.
17 */
18export const SCENE_COLUMNS = 69
19export const SCENE_ROWS = 8
20/** Columns the info column beside the scene needs. */
21const INFO_COLUMNS = 34
22
23/** What the band says where it cannot show a picture: on a surface without one, or as its alt. */
24export const NO_PICTURES = 'Terminal Tales cần terminal hỗ trợ kitty graphics (kitty, Ghostty, Orca…)'
25export const TOO_SMALL = `Terminal Tales cần dải rộng ít nhất ${SCENE_COLUMNS} cột và cao ${SCENE_ROWS} dòng`
26const LOADING = 'Terminal Tales: đang tải hình…'
27
28/** True when the band has room for the whole scene. */
29export function fitsScene(columns: number, rows: number): boolean {
30 return columns >= SCENE_COLUMNS && rows >= SCENE_ROWS
31}
32
33export function band(props: {
34 kit: Kit
35 image: ImageKit
36 /** The scene's current frame, RGBA base64; null while it cannot be painted. */
37 scene: string | null
38 /** Why the scene cannot be painted, when it cannot. */
39 error: string | null
40 game: GameState
41 columns: number
42 rows: number
43}): RenderElement {
44 const { kit, image: Image, scene, error, game, columns, rows } = props
45 const { Box, Text } = kit
46
47 const problem = Image === undefined ? NO_PICTURES : (error ?? (fitsScene(columns, rows) ? null : TOO_SMALL))
48 if (Image === undefined || problem !== null || scene === null) {
49 return (
50 <Box>
51 <Text color="yellow" wrap="truncate-end">
52 {problem ?? LOADING}
53 </Text>
54 </Box>
55 )
56 }
57
58 const picture = (
59 <Image key="scene" source={{ rgba: scene, width: FRAME_WIDTH, height: FRAME_HEIGHT }} columns={SCENE_COLUMNS} rows={SCENE_ROWS} alt={NO_PICTURES} />
60 )
61 if (columns >= SCENE_COLUMNS + INFO_COLUMNS) {
62 return (
63 <Box flexDirection="row">
64 {picture}
65 <Box flexDirection="column" marginLeft={2} flexShrink={1}>
66 {infoLines(kit, game, SCENE_ROWS - 4)}
67 </Box>
68 </Box>
69 )
70 }
71 return (
72 <Box flexDirection="column">
73 {picture}
74 {rows > SCENE_ROWS && (
75 <Text color="yellow" wrap="truncate-end">
76 {summaryLine(game)}
77 </Text>
78 )}
79 </Box>
80 )
81}
82
83/** The lasting effects of both sides, the party's in green and the monster's in red; null when none. */
84function effectsRow(kit: Kit, game: GameState): RenderElement | null {
85 const { Text } = kit
86 const heroes = effectsOf(game, 'heroes')
87 const monster = effectsOf(game, 'monster')
88 if (heroes === null && monster === null) return null
89 return (
90 <Text wrap="truncate-end">
91 {heroes !== null && <Text color="green">{`Đội ${heroes}`}</Text>}
92 {heroes !== null && monster !== null && <Text dimColor>{' | '}</Text>}
93 {monster !== null && <Text color="red">{`Quái ${monster}`}</Text>}
94 </Text>
95 )
96}
97
98function lastLines(log: readonly string[], count: number): readonly string[] {
99 return count > 0 ? log.slice(-count) : []
100}
101
102function infoLines(kit: Kit, game: GameState, logLines = 2): RenderElement[] {
103 const { Text } = kit
104 const effects = effectsRow(kit, game)
105 const monster = game.monster
106 const tier = TIERS[monster.tier]
107 const progress = monster.tier === 'boss' ? 'BOSS!' : `${game.stageKills}/${KILLS_PER_STAGE}`
108 const party = `${game.heroes.map(member => `${member.name} Lv${member.level}`).join(' · ')} · /hero`
109 return [
110 <Text bold color="yellow" wrap="truncate-end">
111 {`Terminal Tales · Ải ${game.stage} (${progress}) · ${formatNumber(game.gold)} vàng`}
112 </Text>,
113 <Text wrap="truncate-end">
114 <Text color={tier.color}>{`${tier.label} ${monster.name}`}</Text>
115 <Text dimColor>{` ${formatNumber(monster.hp)}/${formatNumber(monster.maxHp)} HP`}</Text>
116 </Text>,
117 <Text dimColor wrap="truncate-end">
118 {party}
119 </Text>,
120 // The effects row takes the place of the oldest log line, so the column keeps its height.
121 ...(effects === null ? [] : [effects]),
122 ...lastLines(game.log, effects === null ? logLines : logLines - 1).map(line => (
123 <Text dimColor wrap="truncate-end">
124 {`› ${line}`}
125 </Text>
126 )),
127 ]
128}
129src/ui/base64.ts 46 lines1// Base64 for the band's pictures. The hook environment has the native
2// `Uint8Array.fromBase64` and `toBase64`; a preview script under an older
3// runtime may not, so each falls back to a plain loop.
4
5type Native = {
6 fromBase64?: (text: string) => Uint8Array
7}
8
9const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
10const LOOKUP = new Map(Array.from(B64, (ch, i) => [ch.charCodeAt(0), i]))
11
12export function toBase64(bytes: Uint8Array): string {
13 const native = (bytes as Uint8Array & { toBase64?: () => string }).toBase64
14 if (typeof native === 'function') return native.call(bytes)
15 const parts: string[] = []
16 for (let i = 0; i < bytes.length; i += 3) {
17 const n = (bytes[i]! << 16) | ((bytes[i + 1] ?? 0) << 8) | (bytes[i + 2] ?? 0)
18 parts.push(
19 B64[(n >> 18) & 63]! +
20 B64[(n >> 12) & 63]! +
21 (i + 1 < bytes.length ? B64[(n >> 6) & 63]! : '=') +
22 (i + 2 < bytes.length ? B64[n & 63]! : '='),
23 )
24 }
25 return parts.join('')
26}
27
28export function fromBase64(text: string): Uint8Array {
29 const native = (Uint8Array as unknown as Native).fromBase64
30 if (typeof native === 'function') return native(text)
31 const clean = text.replace(/=+$/, '')
32 const out = new Uint8Array(Math.floor((clean.length * 3) / 4))
33 let at = 0
34 for (let i = 0; i < clean.length; i += 4) {
35 const n =
36 (LOOKUP.get(clean.charCodeAt(i))! << 18) |
37 ((LOOKUP.get(clean.charCodeAt(i + 1)) ?? 0) << 12) |
38 ((LOOKUP.get(clean.charCodeAt(i + 2)) ?? 0) << 6) |
39 (LOOKUP.get(clean.charCodeAt(i + 3)) ?? 0)
40 out[at++] = (n >> 16) & 255
41 if (i + 2 < clean.length) out[at++] = (n >> 8) & 255
42 if (i + 3 < clean.length) out[at++] = n & 255
43 }
44 return out
45}
46src/ui/frame-buffer.ts 135 lines1import type { Picture } from './atlas'
2
3// An RGBA picture the band's scene is painted into, and the few ways it is
4// painted: pictures stamped by their solid pixels, flat fills, tints, and a
5// small pixel font for numbers. Everything clips at the edges.
6
7/** The band's picture, in pixels; a terminal cell shows about 5.6 × 11 of them. */
8export const FRAME_WIDTH = 384
9export const FRAME_HEIGHT = 88
10
11export type Frame = { width: number; height: number; rgba: Uint8Array }
12
13/** How a stamped picture's colors change: mixed toward a color, or turned gray and dimmed. */
14export type Tint = { mix: number; amount: number } | { gray: number }
15
16export function newFrame(width = FRAME_WIDTH, height = FRAME_HEIGHT): Frame {
17 return { width, height, rgba: new Uint8Array(width * height * 4) }
18}
19
20function tinted(r: number, g: number, b: number, tint: Tint | undefined): [number, number, number] {
21 if (tint === undefined) return [r, g, b]
22 if ('gray' in tint) {
23 const v = Math.round((r * 0.3 + g * 0.59 + b * 0.11) * tint.gray)
24 return [v, v, v]
25 }
26 const k = tint.amount
27 return [
28 Math.round(r * (1 - k) + ((tint.mix >> 16) & 255) * k),
29 Math.round(g * (1 - k) + ((tint.mix >> 8) & 255) * k),
30 Math.round(b * (1 - k) + (tint.mix & 255) * k),
31 ]
32}
33
34/** Paints one pixel, `alpha` of the way from what is there. */
35export function put(f: Frame, x: number, y: number, color: number, alpha = 1): void {
36 if (x < 0 || y < 0 || x >= f.width || y >= f.height) return
37 const at = (y * f.width + x) * 4
38 const px = f.rgba
39 px[at] = Math.round(px[at]! * (1 - alpha) + ((color >> 16) & 255) * alpha)
40 px[at + 1] = Math.round(px[at + 1]! * (1 - alpha) + ((color >> 8) & 255) * alpha)
41 px[at + 2] = Math.round(px[at + 2]! * (1 - alpha) + (color & 255) * alpha)
42 px[at + 3] = 255
43}
44
45export function fill(f: Frame, left: number, top: number, width: number, height: number, color: number, alpha = 1): void {
46 for (let y = Math.max(0, top); y < Math.min(f.height, top + height); y += 1) {
47 for (let x = Math.max(0, left); x < Math.min(f.width, left + width); x += 1) put(f, x, y, color, alpha)
48 }
49}
50
51/** Copies a picture of the frame's own size, shifted left by `scroll` and wrapping round. */
52export function backdrop(f: Frame, picture: Picture, scroll: number): void {
53 const shift = ((Math.round(scroll) % picture.width) + picture.width) % picture.width
54 const rowBytes = f.width * 4
55 for (let y = 0; y < Math.min(f.height, picture.height); y += 1) {
56 const src = picture.rgba.subarray(y * picture.width * 4, (y + 1) * picture.width * 4)
57 const head = (picture.width - shift) * 4
58 f.rgba.set(src.subarray(shift * 4, shift * 4 + Math.min(head, rowBytes)), y * rowBytes)
59 if (head < rowBytes) f.rgba.set(src.subarray(0, Math.min(shift * 4, rowBytes - head)), y * rowBytes + head)
60 }
61}
62
63/** Stamps a picture's solid pixels with its top-left at (left, top), ringed by `outline` when given. */
64export function blit(f: Frame, picture: Picture, left: number, top: number, options: { tint?: Tint; outline?: number; alpha?: number } = {}): void {
65 const { width, height, rgba } = picture
66 const alpha = options.alpha ?? 1
67 const solid = (x: number, y: number) => x >= 0 && y >= 0 && x < width && y < height && rgba[(y * width + x) * 4 + 3]! > 0
68 if (options.outline !== undefined) {
69 for (let y = -1; y <= height; y += 1) {
70 for (let x = -1; x <= width; x += 1) {
71 if (!solid(x, y) && (solid(x - 1, y) || solid(x + 1, y) || solid(x, y - 1) || solid(x, y + 1))) {
72 put(f, left + x, top + y, options.outline, alpha)
73 }
74 }
75 }
76 }
77 for (let y = 0; y < height; y += 1) {
78 const fy = top + y
79 if (fy < 0 || fy >= f.height) continue
80 for (let x = 0; x < width; x += 1) {
81 const at = (y * width + x) * 4
82 if (rgba[at + 3] === 0) continue
83 const fx = left + x
84 if (fx < 0 || fx >= f.width) continue
85 const [r, g, b] = tinted(rgba[at]!, rgba[at + 1]!, rgba[at + 2]!, options.tint)
86 put(f, fx, fy, (r << 16) | (g << 8) | b, alpha)
87 }
88 }
89}
90
91/** Washes the whole frame `amount` of the way toward a color. */
92export function wash(f: Frame, color: number, amount: number): void {
93 fill(f, 0, 0, f.width, f.height, color, amount)
94}
95
96/** A 3x5 pixel font for damage numbers: digits and 'k' for thousands. */
97export const DIGITS: Record<string, readonly string[]> = {
98 '0': ['111', '101', '101', '101', '111'],
99 '1': ['010', '110', '010', '010', '111'],
100 '2': ['111', '001', '111', '100', '111'],
101 '3': ['111', '001', '111', '001', '111'],
102 '4': ['101', '101', '111', '001', '001'],
103 '5': ['111', '100', '111', '001', '111'],
104 '6': ['111', '100', '111', '101', '111'],
105 '7': ['111', '001', '010', '010', '010'],
106 '8': ['111', '101', '111', '101', '111'],
107 '9': ['111', '101', '111', '001', '111'],
108 k: ['100', '101', '110', '101', '101'],
109 z: ['111', '001', '010', '100', '111'],
110 Z: ['111', '001', '010', '100', '111'],
111}
112
113const SHADOW = 0x1a1c2c
114
115/** Writes `text` in the pixel font centered on `centerX`, each font pixel `scale` square, with a drop shadow. */
116export function text(f: Frame, value: string, centerX: number, top: number, color: number, scale = 1): void {
117 const advance = 4 * scale
118 let x = centerX - Math.floor((value.length * advance - scale) / 2)
119 for (const ch of value) {
120 const glyph = DIGITS[ch] ?? DIGITS['0']!
121 glyph.forEach((row, gy) =>
122 Array.from(row).forEach((bit, gx) => {
123 if (bit !== '1') return
124 fill(f, x + gx * scale + 1, top + gy * scale + 1, scale, scale, SHADOW)
125 }),
126 )
127 glyph.forEach((row, gy) =>
128 Array.from(row).forEach((bit, gx) => {
129 if (bit === '1') fill(f, x + gx * scale, top + gy * scale, scale, scale, color)
130 }),
131 )
132 x += advance
133 }
134}
135src/ui/hero-pane.tsx 141 lines1import type { RenderElement } from 'claude-code'
2
3import type { GameState, HeroClass, Item, Slot } from '../../types'
4
5import { CLASSES, CLASS_ORDER, MAX_INVENTORY, MAX_PARTY, RARITIES, SLOT_LABEL, SLOT_ORDER } from '../game/catalog'
6import { expToNext, heroStats, itemPower, itemValue, recruitCost, upgradeCost } from '../game/engine'
7import { CLASS_COLOR, bar, formatNumber } from './art'
8import type { Kit } from './band'
9
10export type PaneActions = {
11 select: (index: number) => void
12 equip: (heroId: string, itemId: string) => void
13 unequip: (heroId: string, slot: Slot) => void
14 upgrade: (heroId: string, slot: Slot) => void
15 sell: (itemId: string) => void
16 recruit: (cls: HeroClass) => void
17}
18
19function itemLabel(item: Item): string {
20 return `${item.name}${item.level > 0 ? ` +${item.level}` : ''}`
21}
22
23function statOf(item: Item): string {
24 const power = itemPower(item)
25 if (item.slot === 'weapon') return `+${power} ATK`
26 if (item.slot === 'armor') return `+${power * 4} HP`
27 return `+${Math.round(power * 0.4)} ATK +${Math.min(25, Math.round(power / 2))}% chí mạng`
28}
29
30export function heroPane(props: {
31 kit: Kit
32 game: GameState
33 selected: number
34 rows: number
35 actions: PaneActions
36}): RenderElement {
37 const { kit, game, actions } = props
38 const { Box, Text, Button } = kit
39 const index = Math.min(Math.max(0, props.selected), game.heroes.length - 1)
40 const hero = game.heroes[index]!
41 const stats = heroStats(hero)
42 const cost = recruitCost(game)
43
44 // Rows spent outside the bag list, margins included: header 1, tabs 2,
45 // card 4, gear 4, recruit 2, bag title 2, and 1 for the overflow line.
46 const fixedRows = 14 + (cost === null ? 0 : 2)
47 const bagRoom = Math.max(3, props.rows - fixedRows)
48 const bag = [...game.inventory].sort((a, b) => itemValue(b) - itemValue(a))
49 const shown = bag.slice(0, bagRoom)
50
51 return (
52 <Box flexDirection="column">
53 <Text bold color="yellow">
54 {`${formatNumber(game.gold)} vàng · Ải ${game.stage} · Hạ ${formatNumber(game.stats.kills)} quái · ${game.stats.testPasses} lần test xanh · ${game.stats.toolCalls} tool call`}
55 </Text>
56
57 <Box flexDirection="row" marginTop={1}>
58 {game.heroes.map((member, i) => (
59 <Button
60 key={`sel:${i}`}
61 label={`${member.name} Lv${member.level}`}
62 variant={i === index ? 'primary' : 'secondary'}
63 onPress={() => actions.select(i)}
64 />
65 ))}
66 </Box>
67
68 <Box flexDirection="column" marginTop={1}>
69 <Text>
70 <Text bold color={CLASS_COLOR[hero.cls]}>{hero.name}</Text>
71 <Text>{` · ${CLASSES[hero.cls].label} · Cấp ${hero.level}`}</Text>
72 </Text>
73 <Text dimColor>
74 {`EXP ${bar(hero.exp, expToNext(hero.level), 16)} ${formatNumber(hero.exp)}/${formatNumber(expToNext(hero.level))}`}
75 </Text>
76 <Text>
77 {`HP ${formatNumber(Math.max(0, hero.hp))}/${formatNumber(stats.maxHp)} · ATK ${formatNumber(stats.atk)} · Chí mạng ${Math.round(stats.crit * 100)}%`}
78 </Text>
79 </Box>
80
81 <Box flexDirection="column" marginTop={1}>
82 {SLOT_ORDER.map(slot => {
83 const item = hero.gear[slot]
84 if (!item) {
85 return (
86 <Text dimColor>{`${SLOT_LABEL[slot]}: (trống)`}</Text>
87 )
88 }
89 return (
90 <Box flexDirection="row">
91 <Text>{`${SLOT_LABEL[slot]}: `}</Text>
92 <Text color={RARITIES[item.rarity].color}>{itemLabel(item)}</Text>
93 <Text dimColor>{` ${statOf(item)} `}</Text>
94 <Button
95 key={`upg:${slot}`}
96 label={`Nâng cấp ${formatNumber(upgradeCost(item))}v`}
97 dimColor={game.gold < upgradeCost(item)}
98 onPress={() => actions.upgrade(hero.id, slot)}
99 />
100 <Button key={`off:${slot}`} label="Tháo" dimColor onPress={() => actions.unequip(hero.id, slot)} />
101 </Box>
102 )
103 })}
104 </Box>
105
106 {cost !== null && (
107 <Box flexDirection="row" marginTop={1}>
108 <Text>{`Chiêu mộ (${game.heroes.length}/${MAX_PARTY}, ${formatNumber(cost)}v): `}</Text>
109 {CLASS_ORDER.map(cls => (
110 <Button
111 key={`rec:${cls}`}
112 label={CLASSES[cls].label}
113 dimColor={game.gold < cost}
114 onPress={() => actions.recruit(cls)}
115 />
116 ))}
117 </Box>
118 )}
119
120 <Box flexDirection="column" marginTop={1}>
121 <Text bold>{`Túi đồ ${game.inventory.length}/${MAX_INVENTORY}`}</Text>
122 {shown.length === 0 && <Text dimColor>Chưa có gì. Hạ quái hoặc chạy test xanh để rơi đồ.</Text>}
123 {shown.map(item => (
124 <Box flexDirection="row">
125 <Text color={RARITIES[item.rarity].color}>{`[${RARITIES[item.rarity].label}] ${itemLabel(item)}`}</Text>
126 <Text dimColor>{` ${SLOT_LABEL[item.slot]} ${statOf(item)} `}</Text>
127 <Button key={`equip:${item.id}`} label="Trang bị" onPress={() => actions.equip(hero.id, item.id)} />
128 <Button
129 key={`sell:${item.id}`}
130 label={`Bán ${formatNumber(itemValue(item))}v`}
131 dimColor
132 onPress={() => actions.sell(item.id)}
133 />
134 </Box>
135 ))}
136 {bag.length > shown.length && <Text dimColor>{`… và ${bag.length - shown.length} món nữa`}</Text>}
137 </Box>
138 </Box>
139 )
140}
141src/game/catalog.ts 160 lines1import type { EffectKind, GameAction, HeroClass, Monster, MonsterTier, Rarity, Slot } from '../../types'
2
3export type ClassInfo = {
4 label: string
5 baseHp: number
6 baseAtk: number
7 crit: number
8 hpPerLevel: number
9 atkPerLevel: number
10 names: readonly string[]
11}
12
13export const CLASSES: Record<HeroClass, ClassInfo> = {
14 warrior: {
15 label: 'Kiếm Sĩ',
16 baseHp: 120,
17 baseAtk: 10,
18 crit: 0.05,
19 hpPerLevel: 18,
20 atkPerLevel: 2.2,
21 names: ['Thạch Sanh', 'Gióng', 'Bảo Long', 'Thiết Hổ'],
22 },
23 mage: {
24 label: 'Pháp Sư',
25 baseHp: 70,
26 baseAtk: 16,
27 crit: 0.08,
28 hpPerLevel: 10,
29 atkPerLevel: 3.4,
30 names: ['Mây Tím', 'Lam Nguyệt', 'Huyền Vũ', 'Tinh Sương'],
31 },
32 ranger: {
33 label: 'Xạ Thủ',
34 baseHp: 90,
35 baseAtk: 12,
36 crit: 0.25,
37 hpPerLevel: 13,
38 atkPerLevel: 2.6,
39 names: ['Lạc Phong', 'Hạc Xám', 'Tên Gió', 'Ưng Vàng'],
40 },
41}
42
43export const CLASS_ORDER: readonly HeroClass[] = ['warrior', 'mage', 'ranger']
44
45export type RarityInfo = { label: string; power: number; weight: number; color: string }
46
47export const RARITIES: Record<Rarity, RarityInfo> = {
48 common: { label: 'Thường', power: 1, weight: 60, color: 'gray' },
49 uncommon: { label: 'Khá', power: 1.4, weight: 25, color: 'green' },
50 rare: { label: 'Hiếm', power: 2, weight: 10, color: 'blue' },
51 epic: { label: 'Sử Thi', power: 3, weight: 4, color: 'magenta' },
52 legendary: { label: 'Huyền Thoại', power: 4.5, weight: 1, color: 'yellow' },
53}
54
55export const RARITY_ORDER: readonly Rarity[] = ['common', 'uncommon', 'rare', 'epic', 'legendary']
56
57export const SLOT_LABEL: Record<Slot, string> = {
58 weapon: 'Vũ khí',
59 armor: 'Giáp',
60 trinket: 'Bùa',
61}
62
63export const SLOT_ORDER: readonly Slot[] = ['weapon', 'armor', 'trinket']
64
65export const ITEM_NAMES: Record<Slot, readonly string[]> = {
66 weapon: ['Kiếm Gỉ', 'Gậy Sồi', 'Cung Tre', 'Rìu Đá', 'Dao Găm', 'Thương Sắt'],
67 armor: ['Áo Vải', 'Giáp Da', 'Khiên Gỗ', 'Áo Lưới', 'Mũ Đồng', 'Giáp Vảy'],
68 trinket: ['Nhẫn Đồng', 'Bùa Hạt', 'Ngọc Bích', 'Lông Phượng', 'Vỏ Ốc', 'Chuông Bạc'],
69}
70
71export type TierInfo = {
72 label: string
73 hp: number
74 atk: number
75 reward: number
76 dropChance: number
77 /** Rolls added toward rarer loot: each one keeps the best of extra rolls. */
78 luck: number
79 color: string
80}
81
82export const TIERS: Record<MonsterTier, TierInfo> = {
83 normal: { label: 'Thường', hp: 1, atk: 1, reward: 1, dropChance: 0.12, luck: 0, color: 'white' },
84 elite: { label: 'Tinh Anh', hp: 2.5, atk: 1.4, reward: 3, dropChance: 0.35, luck: 1, color: 'cyan' },
85 rare: { label: 'Hiếm', hp: 4, atk: 1.6, reward: 6, dropChance: 0.7, luck: 2, color: 'magenta' },
86 boss: { label: 'Boss', hp: 8, atk: 2, reward: 12, dropChance: 1, luck: 2, color: 'red' },
87}
88
89/** The sprite sheet a monster is drawn from: an id of the atlas built from assets/source. */
90export type MonsterAsset = 'skeleton' | 'goblin' | 'mushroom' | 'flying-eye' | 'evil-wizard' | 'evil-wizard-2' | 'evil-wizard-3'
91
92export type MonsterKind = { name: string; asset: MonsterAsset }
93
94// A save keeps a monster's index into these lists: append, never reorder.
95export const MONSTERS: readonly MonsterKind[] = [
96 { name: 'Xương Rò Rỉ', asset: 'skeleton' },
97 { name: 'Goblin Lint', asset: 'goblin' },
98 { name: 'Nấm Race', asset: 'mushroom' },
99 { name: 'Mắt Bay Null', asset: 'flying-eye' },
100]
101
102export const BOSSES: readonly MonsterKind[] = [
103 { name: 'Pháp Sư Deadlock', asset: 'evil-wizard' },
104 { name: 'Pháp Sư Merge Conflict', asset: 'evil-wizard-2' },
105 { name: 'Pháp Sư Race Condition', asset: 'evil-wizard-3' },
106]
107
108/**
109 * The kind a monster is drawn as. Its index wraps, so a save from an older,
110 * longer list still draws one of today's kinds.
111 */
112export function monsterKind(m: Pick<Monster, 'sprite' | 'tier'>): MonsterKind {
113 const kinds = m.tier === 'boss' ? BOSSES : MONSTERS
114 const n = kinds.length
115 const i = Number.isFinite(m.sprite) ? ((Math.trunc(m.sprite) % n) + n) % n : 0
116 return kinds[i]!
117}
118
119export const KILLS_PER_STAGE = 10
120export const MAX_PARTY = 3
121export const MAX_INVENTORY = 24
122export const REST_STEPS = 4
123export const LOG_SIZE = 6
124export const RECRUIT_COST: readonly number[] = [0, 150, 900]
125
126export type EffectInfo = {
127 side: 'heroes' | 'monster'
128 label: string
129 icon: string
130 /** Combat steps it lasts; 0 for an instant effect or a shield. */
131 steps: number
132 /** Volleys or hits a shield blocks; 0 for anything else. */
133 charges: number
134 /** Chance, bonus or share of max HP, depending on the kind. */
135 power: number
136 line: string
137}
138
139export const EFFECTS: Record<EffectKind, EffectInfo> = {
140 rally: { side: 'heroes', label: 'Hô khiến', icon: '>>', steps: 20, charges: 0, power: 0.2, line: 'Hô khiến! Cả đội hăng hái.' },
141 secondWind: { side: 'heroes', label: 'Tiếp sức', icon: '+', steps: 0, charges: 0, power: 0.15, line: 'Tiếp sức! Cả đội hồi máu.' },
142 trust: { side: 'heroes', label: 'Tin tưởng', icon: '*', steps: 20, charges: 0, power: 0.1, line: 'Tin tưởng! Đòn chí mạng sắc hơn.' },
143 milestone: { side: 'heroes', label: 'Cột mốc', icon: '[]', steps: 0, charges: 1, power: 0, line: 'Cột mốc! Khiên chặn đòn kế tiếp của quái.' },
144 calm: { side: 'heroes', label: 'Tĩnh tâm', icon: '~', steps: 0, charges: 0, power: 1, line: 'Tĩnh tâm! Cả đội hồi đầy máu.' },
145 enrage: { side: 'monster', label: 'Nổi giận', icon: '!!', steps: 20, charges: 0, power: 0.25, line: 'Quái nổi giận!' },
146 stoneskin: { side: 'monster', label: 'Giáp đá', icon: '##', steps: 0, charges: 2, power: 0, line: 'Quái khoác giáp đá!' },
147 regen: { side: 'monster', label: 'Tái sinh', icon: '^', steps: 0, charges: 0, power: 0.2, line: 'Quái tái sinh!' },
148}
149
150export const ACTION_EFFECT: Record<GameAction, EffectKind> = {
151 prompt: 'rally',
152 turnDone: 'secondWind',
153 permissionAllowed: 'trust',
154 commit: 'milestone',
155 compact: 'calm',
156 turnAborted: 'enrage',
157 permissionDenied: 'stoneskin',
158 bashFailed: 'regen',
159}
160src/game/engine.ts 558 lines1import type { Effect, EffectKind, GameAction, GameState, Hero, HeroClass, Item, Monster, MonsterTier, Rarity, Slot } from '../../types'
2
3import {
4 ACTION_EFFECT,
5 BOSSES,
6 CLASSES,
7 EFFECTS,
8 ITEM_NAMES,
9 KILLS_PER_STAGE,
10 LOG_SIZE,
11 MAX_INVENTORY,
12 MAX_PARTY,
13 MONSTERS,
14 RARITIES,
15 RARITY_ORDER,
16 RECRUIT_COST,
17 REST_STEPS,
18 SLOT_ORDER,
19 TIERS,
20} from './catalog'
21
22// Every function here is pure: it takes a state and returns a new one, and
23// draws its randomness from the seed the state carries, so a game replays
24// exactly under the same seed.
25
26/** One step of mulberry32: the next seed and a float in [0, 1). */
27function nextRandom(seed: number): [number, number] {
28 const s = (seed + 0x6d2b79f5) | 0
29 let t = Math.imul(s ^ (s >>> 15), 1 | s)
30 t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
31 return [s, ((t ^ (t >>> 14)) >>> 0) / 4294967296]
32}
33
34/** A random draw over a mutable copy of the state, advancing its seed. */
35function rand(g: GameState): number {
36 const [seed, value] = nextRandom(g.seed)
37 g.seed = seed
38 return value
39}
40
41function pick<T>(g: GameState, list: readonly T[]): T {
42 return list[Math.floor(rand(g) * list.length)] as T
43}
44
45function clone(g: GameState): GameState {
46 return JSON.parse(JSON.stringify(g)) as GameState
47}
48
49function say(g: GameState, line: string): void {
50 g.log = [...g.log, line].slice(-LOG_SIZE)
51}
52
53function newId(g: GameState, prefix: string): string {
54 g.nextId += 1
55 return `${prefix}${g.nextId}`
56}
57
58// ---------- stats ----------
59
60export function itemPower(item: Item): number {
61 return Math.round((4 + item.stage * 2.5) * RARITIES[item.rarity].power * (1 + 0.15 * item.level))
62}
63
64export function itemValue(item: Item): number {
65 return Math.max(1, Math.round(itemPower(item) * 1.5))
66}
67
68export function upgradeCost(item: Item): number {
69 return itemValue(item) * (item.level + 2)
70}
71
72export type HeroStats = { maxHp: number; atk: number; crit: number }
73
74export function heroStats(hero: Hero): HeroStats {
75 const info = CLASSES[hero.cls]
76 const lv = hero.level - 1
77 let maxHp = info.baseHp + info.hpPerLevel * lv
78 let atk = info.baseAtk + info.atkPerLevel * lv
79 let crit = info.crit
80 const { weapon, armor, trinket } = hero.gear
81 if (weapon) atk += itemPower(weapon)
82 if (armor) maxHp += itemPower(armor) * 4
83 if (trinket) {
84 atk += itemPower(trinket) * 0.4
85 crit += Math.min(0.25, itemPower(trinket) / 200)
86 }
87 return { maxHp: Math.round(maxHp), atk: Math.round(atk), crit: Math.min(0.75, crit) }
88}
89
90export function expToNext(level: number): number {
91 return Math.round(20 * Math.pow(level, 1.5))
92}
93
94// ---------- creation ----------
95
96function makeHero(g: GameState, cls: HeroClass): Hero {
97 const taken = new Set(g.heroes.map(h => h.name))
98 const free = CLASSES[cls].names.filter(n => !taken.has(n))
99 const hero: Hero = {
100 id: newId(g, 'h'),
101 name: pick(g, free.length > 0 ? free : CLASSES[cls].names),
102 cls,
103 level: 1,
104 exp: 0,
105 hp: 0,
106 gear: { weapon: null, armor: null, trinket: null },
107 }
108 hero.hp = heroStats(hero).maxHp
109 return hero
110}
111
112function spawnMonster(g: GameState): Monster {
113 let tier: MonsterTier = 'normal'
114 if (g.stageKills >= KILLS_PER_STAGE) {
115 tier = 'boss'
116 } else {
117 const roll = rand(g)
118 if (roll < 0.04) tier = 'rare'
119 else if (roll < 0.19) tier = 'elite'
120 }
121 const kinds = tier === 'boss' ? BOSSES : MONSTERS
122 const sprite = Math.floor(rand(g) * kinds.length)
123 const kind = kinds[sprite] ?? MONSTERS[0]!
124 const scale = Math.pow(1.22, g.stage - 1)
125 const maxHp = Math.round(30 * scale * TIERS[tier].hp)
126 return {
127 name: kind.name,
128 sprite,
129 tier,
130 hp: maxHp,
131 maxHp,
132 atk: Math.max(1, Math.round(4 * Math.pow(1.17, g.stage - 1) * TIERS[tier].atk)),
133 }
134}
135
136export function newGame(seed: number): GameState {
137 const g: GameState = {
138 version: 1,
139 seed: seed | 0,
140 nextId: 0,
141 gold: 0,
142 stage: 1,
143 stageKills: 0,
144 heroes: [],
145 inventory: [],
146 monster: { name: '', sprite: 0, tier: 'normal', hp: 1, maxHp: 1, atk: 1 },
147 resting: 0,
148 frame: 0,
149 log: [],
150 stats: { kills: 0, toolCalls: 0, testPasses: 0, itemsFound: 0 },
151 effects: [],
152 }
153 g.heroes.push(makeHero(g, 'warrior'))
154 g.monster = spawnMonster(g)
155 say(g, `${g.heroes[0]!.name} lên đường. Gặp ${g.monster.name}!`)
156 return g
157}
158
159// ---------- loot ----------
160
161export function rollRarity(g: GameState, luck: number): Rarity {
162 const total = RARITY_ORDER.reduce((sum, r) => sum + RARITIES[r].weight, 0)
163 let best = 0
164 for (let i = 0; i <= luck; i += 1) {
165 let roll = rand(g) * total
166 let index = 0
167 for (const r of RARITY_ORDER) {
168 roll -= RARITIES[r].weight
169 if (roll < 0) break
170 index += 1
171 }
172 best = Math.max(best, Math.min(index, RARITY_ORDER.length - 1))
173 }
174 return RARITY_ORDER[best]!
175}
176
177function makeItem(g: GameState, luck: number): Item {
178 const slot = pick(g, SLOT_ORDER)
179 return {
180 id: newId(g, 'i'),
181 name: pick(g, ITEM_NAMES[slot]),
182 slot,
183 rarity: rollRarity(g, luck),
184 stage: g.stage,
185 level: 0,
186 }
187}
188
189function gainItem(g: GameState, item: Item): void {
190 g.stats.itemsFound += 1
191 g.inventory = [...g.inventory, item]
192 if (g.inventory.length > MAX_INVENTORY) {
193 // A full bag sells its weakest piece so loot never stops.
194 const weakest = g.inventory.reduce((low, it) => (itemValue(it) < itemValue(low) ? it : low))
195 g.inventory = g.inventory.filter(it => it.id !== weakest.id)
196 g.gold += itemValue(weakest)
197 }
198 say(g, `Nhặt được [${RARITIES[item.rarity].label}] ${item.name}`)
199}
200
201// ---------- progression ----------
202
203function gainExp(g: GameState, amount: number): void {
204 for (const hero of g.heroes) {
205 hero.exp += amount
206 while (hero.exp >= expToNext(hero.level)) {
207 hero.exp -= expToNext(hero.level)
208 hero.level += 1
209 hero.hp = heroStats(hero).maxHp
210 say(g, `${hero.name} lên cấp ${hero.level}!`)
211 }
212 }
213}
214
215function killMonster(g: GameState): void {
216 const m = g.monster
217 const tier = TIERS[m.tier]
218 const scale = Math.pow(1.15, g.stage - 1)
219 const gold = Math.round(5 * scale * tier.reward)
220 const exp = Math.round(6 * scale * tier.reward)
221 g.gold += gold
222 g.stats.kills += 1
223 say(g, `Hạ ${m.name} (+${gold} vàng, +${exp} EXP)`)
224 gainExp(g, exp)
225 if (rand(g) < tier.dropChance) gainItem(g, makeItem(g, tier.luck))
226 if (m.tier === 'boss') {
227 g.stage += 1
228 g.stageKills = 0
229 say(g, `Qua ải! Tiến vào ải ${g.stage}.`)
230 } else {
231 g.stageKills += 1
232 }
233 g.monster = spawnMonster(g)
234}
235
236// ---------- effects ----------
237
238function hasEffect(g: GameState, kind: EffectKind): boolean {
239 return g.effects.some(e => e.kind === kind)
240}
241
242/** Spends one charge of a shield; true when there was one to spend. */
243function useCharge(g: GameState, kind: EffectKind): boolean {
244 const effect = g.effects.find(e => e.kind === kind && e.charges > 0)
245 if (!effect) return false
246 g.effects = g.effects
247 .map(e => (e === effect ? { ...e, charges: e.charges - 1 } : e))
248 .filter(e => e.steps > 0 || e.charges > 0)
249 return true
250}
251
252/** Counts every timed effect down by one step and drops those that ran out. */
253function tickEffects(g: GameState): void {
254 g.effects = g.effects
255 .map(e => (e.steps > 0 ? { ...e, steps: e.steps - 1 } : e))
256 .filter(e => e.steps > 0 || e.charges > 0)
257}
258
259/** A hero's chance to crit in a combat step, with the party's buffs. */
260export function critChance(hero: Hero, g: GameState): number {
261 const { crit } = heroStats(hero)
262 return hasEffect(g, 'trust') ? Math.min(0.75, crit + EFFECTS.trust.power) : crit
263}
264
265// ---------- combat ----------
266
267/**
268 * The party strikes once; true when the monster fell. Only combat steps feel
269 * effects: the free strike of a tool call ignores them, so a burst of calls
270 * never burns a buff before the user can see it.
271 */
272function partyAttacks(g: GameState, withEffects: boolean): boolean {
273 for (const hero of g.heroes) {
274 if (hero.hp <= 0) continue
275 const { atk, crit } = heroStats(hero)
276 const damage = rand(g) < (withEffects ? critChance(hero, g) : crit) ? atk * 2 : atk
277 g.monster.hp = Math.max(0, g.monster.hp - damage)
278 if (g.monster.hp === 0) {
279 killMonster(g)
280 return true
281 }
282 }
283 return false
284}
285
286function stepCombat(state: GameState): GameState {
287 const g = clone(state)
288 g.frame = g.frame === 0 ? 1 : 0
289 if (g.resting > 0) {
290 g.resting -= 1
291 if (g.resting === 0) {
292 for (const hero of g.heroes) hero.hp = heroStats(hero).maxHp
293 say(g, 'Cả đội hồi phục, chiến tiếp!')
294 }
295 return g
296 }
297 // Stoneskin blocks the party's whole turn, rally's extra volley included.
298 if (!useCharge(g, 'stoneskin')) {
299 if (partyAttacks(g, true)) return g
300 if (hasEffect(g, 'rally') && rand(g) < EFFECTS.rally.power && partyAttacks(g, true)) return g
301 }
302 const alive = g.heroes.filter(h => h.hp > 0)
303 if (alive.length > 0) {
304 const target = pick(g, alive)
305 if (!useCharge(g, 'milestone')) {
306 const atk = hasEffect(g, 'enrage') ? Math.round(g.monster.atk * (1 + EFFECTS.enrage.power)) : g.monster.atk
307 target.hp = Math.max(0, target.hp - atk)
308 }
309 }
310 for (const hero of g.heroes) {
311 if (hero.hp > 0) hero.hp = Math.min(heroStats(hero).maxHp, hero.hp + Math.ceil(heroStats(hero).maxHp * 0.03))
312 }
313 if (g.heroes.every(h => h.hp <= 0)) {
314 g.resting = REST_STEPS
315 g.monster.hp = g.monster.maxHp
316 say(g, `Cả đội gục trước ${g.monster.name}... nghỉ một chút.`)
317 }
318 return g
319}
320
321/** One combat step: the party strikes, the monster answers, the frame flips, effects count down. */
322export function step(state: GameState): GameState {
323 const g = stepCombat(state)
324 tickEffects(g)
325 return g
326}
327
328// ---------- coding activity ----------
329
330/** Any finished tool call: a little gold and EXP and one free strike. */
331export function rewardToolCall(state: GameState): GameState {
332 const g = clone(state)
333 g.stats.toolCalls += 1
334 g.gold += 1 + Math.floor(g.stage / 2)
335 gainExp(g, 2 + g.stage)
336 if (g.resting === 0) partyAttacks(g, false)
337 return g
338}
339
340/**
341 * Something the user did: a good action buffs the party, a bad one the
342 * monster. An instant effect applies now; a lasting one replaces any of its
343 * kind at full length, so the same action refreshes rather than stacks.
344 */
345export function applyAction(state: GameState, action: GameAction): GameState {
346 const g = clone(state)
347 const kind = ACTION_EFFECT[action]
348 const info = EFFECTS[kind]
349 if (kind === 'secondWind' || kind === 'calm') {
350 for (const hero of g.heroes) {
351 if (hero.hp <= 0) continue
352 const max = heroStats(hero).maxHp
353 hero.hp = Math.min(max, hero.hp + Math.round(max * info.power))
354 }
355 } else if (kind === 'regen') {
356 g.monster.hp = Math.min(g.monster.maxHp, g.monster.hp + Math.round(g.monster.maxHp * info.power))
357 } else {
358 g.effects = [...g.effects.filter(e => e.kind !== kind), { kind, steps: info.steps, charges: info.charges }]
359 }
360 say(g, info.line)
361 return g
362}
363
364/** A passing test run: a big purse, EXP and a guaranteed lucky drop. */
365export function rewardTestPass(state: GameState): GameState {
366 const g = clone(state)
367 g.stats.testPasses += 1
368 const gold = 20 + 10 * g.stage
369 const exp = 30 + 8 * g.stage
370 g.gold += gold
371 say(g, `Test xanh! +${gold} vàng, +${exp} EXP`)
372 gainExp(g, exp)
373 gainItem(g, makeItem(g, 2))
374 return g
375}
376
377// ---------- /hero actions ----------
378
379export type ActionResult = { state: GameState; error?: string }
380
381export function equip(state: GameState, heroId: string, itemId: string): ActionResult {
382 const g = clone(state)
383 const hero = g.heroes.find(h => h.id === heroId)
384 const item = g.inventory.find(it => it.id === itemId)
385 if (!hero || !item) return { state, error: 'Không tìm thấy anh hùng hoặc vật phẩm.' }
386 const old = hero.gear[item.slot]
387 const before = heroStats(hero).maxHp
388 hero.gear[item.slot] = item
389 g.inventory = g.inventory.filter(it => it.id !== itemId)
390 if (old) g.inventory = [...g.inventory, old]
391 // A fallen hero stays down until the rest; a standing one never drops to 0
392 // from a swap, so a swap alone can neither revive nor wipe the party.
393 hero.hp = hero.hp <= 0 ? 0 : Math.max(1, Math.min(heroStats(hero).maxHp, hero.hp + heroStats(hero).maxHp - before))
394 say(g, `${hero.name} trang bị ${item.name}`)
395 return { state: g }
396}
397
398export function unequip(state: GameState, heroId: string, slot: Slot): ActionResult {
399 const g = clone(state)
400 const hero = g.heroes.find(h => h.id === heroId)
401 const item = hero?.gear[slot]
402 if (!hero || !item) return { state, error: 'Ô trang bị đang trống.' }
403 if (g.inventory.length >= MAX_INVENTORY) return { state, error: 'Túi đồ đã đầy.' }
404 hero.gear[slot] = null
405 g.inventory = [...g.inventory, item]
406 hero.hp = Math.min(hero.hp, heroStats(hero).maxHp)
407 return { state: g }
408}
409
410export function sell(state: GameState, itemId: string): ActionResult {
411 const g = clone(state)
412 const item = g.inventory.find(it => it.id === itemId)
413 if (!item) return { state, error: 'Không tìm thấy vật phẩm.' }
414 g.inventory = g.inventory.filter(it => it.id !== itemId)
415 g.gold += itemValue(item)
416 say(g, `Bán ${item.name} được ${itemValue(item)} vàng`)
417 return { state: g }
418}
419
420export function upgrade(state: GameState, heroId: string, slot: Slot): ActionResult {
421 const g = clone(state)
422 const hero = g.heroes.find(h => h.id === heroId)
423 const item = hero?.gear[slot]
424 if (!hero || !item) return { state, error: 'Ô trang bị đang trống.' }
425 const cost = upgradeCost(item)
426 if (g.gold < cost) return { state, error: `Cần ${cost} vàng để nâng cấp.` }
427 g.gold -= cost
428 item.level += 1
429 say(g, `${item.name} lên +${item.level}`)
430 return { state: g }
431}
432
433export function recruitCost(state: GameState): number | null {
434 return state.heroes.length >= MAX_PARTY ? null : (RECRUIT_COST[state.heroes.length] ?? null)
435}
436
437export function recruit(state: GameState, cls: HeroClass): ActionResult {
438 const cost = recruitCost(state)
439 if (cost === null) return { state, error: `Đội đã đủ ${MAX_PARTY} người.` }
440 if (state.gold < cost) return { state, error: `Cần ${cost} vàng để chiêu mộ.` }
441 const g = clone(state)
442 g.gold -= cost
443 const hero = makeHero(g, cls)
444 g.heroes = [...g.heroes, hero]
445 say(g, `${hero.name} (${CLASSES[cls].label}) gia nhập đội!`)
446 return { state: g }
447}
448
449// ---------- save ----------
450
451const SLOTS_SET = new Set<string>(SLOT_ORDER)
452
453function isItem(value: unknown): value is Item {
454 if (typeof value !== 'object' || value === null) return false
455 const it = value as Partial<Item>
456 return (
457 typeof it.id === 'string' &&
458 typeof it.name === 'string' &&
459 typeof it.slot === 'string' &&
460 SLOTS_SET.has(it.slot) &&
461 typeof it.rarity === 'string' &&
462 Object.hasOwn(RARITIES, it.rarity) &&
463 Number.isFinite(it.stage) &&
464 Number.isFinite(it.level)
465 )
466}
467
468function isHero(value: unknown): value is Hero {
469 if (typeof value !== 'object' || value === null) return false
470 const h = value as Partial<Hero>
471 const gear = h.gear as Record<string, unknown> | undefined
472 return (
473 typeof h.id === 'string' &&
474 typeof h.name === 'string' &&
475 typeof h.cls === 'string' &&
476 Object.hasOwn(CLASSES, h.cls) &&
477 Number.isFinite(h.level) &&
478 Number.isFinite(h.exp) &&
479 Number.isFinite(h.hp) &&
480 typeof gear === 'object' &&
481 gear !== null &&
482 SLOT_ORDER.every(slot => gear[slot] === null || (isItem(gear[slot]) && (gear[slot] as Item).slot === slot))
483 )
484}
485
486function isMonster(value: unknown): value is Monster {
487 if (typeof value !== 'object' || value === null) return false
488 const m = value as Partial<Monster>
489 return (
490 typeof m.name === 'string' &&
491 typeof m.tier === 'string' &&
492 Object.hasOwn(TIERS, m.tier) &&
493 Number.isFinite(m.sprite) &&
494 Number.isFinite(m.hp) &&
495 Number.isFinite(m.maxHp) &&
496 Number.isFinite(m.atk)
497 )
498}
499
500function isEffect(value: unknown): value is Effect {
501 if (typeof value !== 'object' || value === null) return false
502 const e = value as Partial<Effect>
503 return (
504 typeof e.kind === 'string' &&
505 Object.hasOwn(EFFECTS, e.kind) &&
506 typeof e.steps === 'number' &&
507 Number.isFinite(e.steps) &&
508 e.steps >= 0 &&
509 typeof e.charges === 'number' &&
510 Number.isFinite(e.charges) &&
511 e.charges >= 0
512 )
513}
514
515/**
516 * Reads a stored save, or null when it is missing or not one this version
517 * understands. Effects are short-lived, so a bad one is dropped rather than
518 * failing the whole save, and a save from before effects loads with none.
519 */
520export function parseSave(raw: unknown): GameState | null {
521 if (typeof raw !== 'object' || raw === null) return null
522 const g = raw as Partial<GameState>
523 const stats = g.stats as Partial<GameState['stats']> | undefined
524 const isValid =
525 g.version === 1 &&
526 Number.isFinite(g.seed) &&
527 Number.isFinite(g.nextId) &&
528 Number.isFinite(g.gold) &&
529 Number.isFinite(g.stage) &&
530 Number.isFinite(g.stageKills) &&
531 Number.isFinite(g.resting) &&
532 Number.isFinite(g.frame) &&
533 Array.isArray(g.heroes) &&
534 g.heroes.length > 0 &&
535 g.heroes.every(isHero) &&
536 Array.isArray(g.inventory) &&
537 g.inventory.every(isItem) &&
538 isMonster(g.monster) &&
539 Array.isArray(g.log) &&
540 g.log.every(line => typeof line === 'string') &&
541 typeof stats === 'object' &&
542 stats !== null &&
543 Number.isFinite(stats.kills) &&
544 Number.isFinite(stats.toolCalls) &&
545 Number.isFinite(stats.testPasses) &&
546 Number.isFinite(stats.itemsFound)
547 if (!isValid) return null
548 const effects = Array.isArray(g.effects) ? g.effects.filter(isEffect) : []
549 return { ...(raw as GameState), effects }
550}
551
552/** True for a save written by a newer version of the game: never overwrite it. */
553export function isNewerSave(raw: unknown): boolean {
554 if (typeof raw !== 'object' || raw === null) return false
555 const version = (raw as { version?: unknown }).version
556 return typeof version === 'number' && version > 1
557}
558