SLOPSHOPPER

terminal-tales

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…

newpanebandguardcommandtoast
v0.3.0MITupdated 2026-10-07tuntran/terminal-tales
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · terminal-tales
│ ┃ Terminal Tales ✕ › fix the failing auth╭────────────────────────────────────────────╮ │ ┃ 24 vàng · Ải 1 · Hạ 3 quái · 0 lần test xanh │ terminal-tales │ │ ┃ · 9 tool call ⏺ Read(src/auth.ts) │ Terminal Tales: không đọc được hình │ │ ┃ ⎿ Read 6 lines │ (ENOENT: no such file │ │ ┃ [ Thiết Hổ Lv2 ] ⏺ Update(src/auth│ /plugins/terminal-tales/assets/build/arrow.bin) │ │ ┃ ⎿ Added 2 lines, re╰────────────────────────────────────────────╯ │ ┃ Thiết Hổ · Kiếm Sĩ · Cấp 2 ⏺ Bash(bun test) ╭────────────────────────────────────────────╮ │ ┃ EXP ███████░░░░░░░░░ 25/57 ⎿ 3 pass, 1 fail │ terminal-tales │ │ ┃ HP 138/138 · ATK 12 · Chí mạng 5% │ Terminal Tales: không khởi động được tiến │ │ ┃ ● Done. refresh now re│ trình nền, phiên này chơi một mình. │ │ ┃ Vũ khí: (trống) ╰────────────────────────────────────────────╯ │ ┃ Giáp: (trống) ✻ Worked for 42s · done 4:20 PM │ ┃ Bùa: (trống) │ ┃ › /hero │ ┃ Chiêu mộ (1/3, 150v): [ Kiếm Sĩ ][ Pháp Sư ] ⎿ terminal-tales: Đã mở bảng anh hùng Terminal Tales. │ ┃ │ ┃ Túi đồ 0/24 │ ┃ Chưa có gì. Hạ quái hoặc chạy test xanh để │ ┃ rơi đồ. │ Terminal Tales: không đọc được hình (ENOENT: no such file /plugins/terminal-tales/assets/build/arrow.bin) ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
Terminal Tales: không đọc được hình (ENOENT: no such file /plugins/terminal-tales/assets/build/arro…
Pane · Terminal Tales
24 vàng · Ải 1 · Hạ 3 quái · 0 lần test xanh · 9 tool call [ Thiết Hổ Lv2 ] Thiết Hổ · Kiếm Sĩ · Cấp 2 EXP ███████░░░░░░░░░ 25/57 HP 138/138 · ATK 12 · Chí mạng 5% Vũ khí: (trống) Giáp: (trống) Bùa: (trống) Chiêu mộ (1/3, 150v): [ Kiếm Sĩ ][ Pháp Sư ][ Xạ Thủ ] Túi đồ 0/24 Chưa có gì. Hạ quái hoặc chạy test xanh để rơi đồ.
README

Terminal Tales

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.

Cách chơi

  • Dải chiến đấu: tự chạy mỗi 1,5 giây khi session mở. Hạ đủ 10 quái thì Boss xuất hiện; hạ Boss để qua ải, quái mạnh hơn và rơi đồ tốt hơn. Cả đội gục thì nghỉ một lát rồi chiến tiếp, không mất gì.
  • Hoạt động code:
  • Mỗi tool call hoàn tất: một ít vàng, EXP và một đòn đánh miễn phí.
  • Lệnh Bash chạy test (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.
  • Game chỉ quan sát: nó không bao giờ chặn hay sửa tool call, prompt hay quyết định quyền của bạn.
  • Hiệu ứng theo action: mỗi action một hiệu ứng, hiện trên dải chiến đấu (Đội … màu xanh, Quái … màu đỏ).
ActionPheHiệu ứng
Gửi promptĐộiHô 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ĐộiTiếp sức: anh hùng còn đứng hồi 15% máu
Cho phép quyền trong hộp thoạiĐộiTin tưởng: 30 giây, +10% chí mạng
git commit thành côngĐộiCột mốc: khiên chặn đòn kế tiếp của quái
/compact thủ côngĐộiTĩnh tâm: anh hùng còn đứng hồi đầy máu
Esc huỷ turnQuáiNổi giận: 30 giây, quái +25% sát thương
Từ chối quyền trong hộp thoạiQuáiGiá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áiTá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).
  • Lớp nhân vật: Kiếm Sĩ (máu trâu), Pháp Sư (sát thương cao, máu giấy), Xạ Thủ (chí mạng cao).
  • Độ hiếm: quái có Thường, Tinh Anh, Hiếm, Boss; đồ có Thường, Khá, Hiếm, Sử Thi, Huyền Thoại.

Thu gọn dải bằng [-] hoặc ctrl+x ctrl+a.

Nhiều cửa sổ, một trận

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.

  • Cần 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.
  • Dữ liệu nằm ở ~/.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.
  • Daemon tự thoát khoảng 15 giây sau khi cửa sổ cuối cùng đóng, xoá 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.
  • Lần đầu chạy, save cũ trong store được chuyển sang 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ỏ.

Chạy từ mã nguồn

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.

Cài từ GitHub

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).

Xem trước ngoài Claude Code

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

Phát triển

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ẫnVai trò
hooks/register.tsxNố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.tsTì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.tsLuậ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.tsDữ 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.tsNhậ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.tsKiể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ớ

Ghi chú

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.

Source 15 files
hooks/register.tsx 266 lines
1import { 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}
266
daemon/protocol.ts 167 lines
1// 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}
167
src/game/test-command.ts 140 lines
1// 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}
140
src/sync/client.ts 537 lines
1// 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}
537
src/ui/animation.ts 443 lines
1import 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}
443
src/ui/atlas.ts 66 lines
1import { 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}
66
src/ui/band.tsx 129 lines
1import 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}
129
src/ui/base64.ts 46 lines
1// 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}
46
src/ui/frame-buffer.ts 135 lines
1import 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}
135
src/ui/hero-pane.tsx 141 lines
1import 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}
141
src/game/catalog.ts 160 lines
1import 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}
160
src/game/engine.ts 558 lines
1import 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