A Minecraft-inspired HUD for Claude Code: hearts for context, armor for safety nets, XP for verified work, and 60 advancements that persist across sessions

A Minecraft-inspired HUD for Claude Code. The hearts are your context window, the armor is how safe your work is, the XP bar fills only for verified work, and the conversation reads like in-game chat. Each piece of the screen shows something real about the session, so you can read the state of your work the way you read a game screen.
![]()
Not an official Minecraft product. Not approved by or associated with Mojang or Microsoft.
You need Claude Code 2.1.288 or later. In Claude Code, run:
/plugin marketplace add mkpvishnu/claude-mods
/plugin install codecraft@claude-mods
Start a new session and the HUD appears above the prompt. Nothing else is needed. The status line is an optional extra, described below.
The band above the prompt is laid out like the game's: armor over the hearts, food on the right, the XP bar underneath, and the hotbar while a turn runs.
| Element | What it shows |
|---|---|
| Hearts | Context window left. Ten full hearts is a fresh session. When health runs low, a warning suggests /compact. |
| Food | Your five-hour usage limit left. |
| Armor | Safety nets, out of 10 points: tests green since your last edit (4), work committed (3), commits pushed (3). Shown only inside a git repository. |
| Level and XP bar | Experience for verified work: green test runs, turning a red run green, commits made with the tests green. Plain tool calls earn nothing. Resets each session. |
| Hotbar | While a turn runs, one slot per tool used in that turn with its call count. The tool in hand has a white frame. Subagents show as villagers. |
| Boss bar | A shell command that has run for 5 seconds, draining toward its timeout. |
Each kind of tool has its own item in the hotbar:
| Item | Tool |
|---|---|
| Pickaxe | Searching code and finding files |
| Book | Reading files |
| Bricks | Editing and writing files |
| Redstone | Shell commands |
| Compass | Web search and fetch |
| Emerald | Subagents |
| Enchanted book | Skills |
| Chest | MCP tools |
| Paper | The to-do list |
| Stone | Anything else |
The conversation reads like a game server's chat:
<you> joined the game.<your name> beside the player's head, and replies under <Claude> beside Claude's block head.[Claude: ran pytest -q] or [Claude: edited src/app.py]. A failed call turns red and gets a death message.<your name> has made the advancement [Stone Age].There are 60 advancements across five tabs modelled on the game's, earned by real work and saved across sessions. Six are hidden until you earn them.
| Tab | What it covers | Examples |
|---|---|---|
| Story | Building and verifying | Stone Age (edit a file), Diamonds! (commit with the tests green), A Furious Cocktail (tests, checks and build all green) |
| Nether | Subagents and git history | We Need to Go Deeper (send out a subagent), Hidden in the Depths (find a bad commit with git bisect) |
| The End | Pull requests and releases | The End? (open a pull request), Free the End (merge one), The City at the End of the Game (cut a release) |
| Adventure | Testing and exploring | Monster Hunter (turn a red test run green), Bullseye (green on the first run 10 times in a row), Is It a Bird? (search the web) |
| Husbandry | Habits over days | A Seedy Place (write a new test file and see it pass), Homesteader (work 7 days in a row), Old Growth (30 days in a row) |
Run /advancements to open the advancements screen. Each tab is drawn as the game's tree, with every advancement joined by a line to the one it comes after:

1 to 5 switch tabs, n and p move the white marker, and the line above the tree says what the marked advancement asks for. Pointing at an icon with the mouse shows that one instead./inventory prints your level, verification state, armor, edited files and earned advancements as text.
The icons are image sprites at the game's own resolution. They need a terminal that can show images, such as kitty or Ghostty. On other terminals with true colour the mod draws the same things as block art made of text, and on a narrow terminal it falls back to plain symbols. Nothing needs configuring: the mod checks the terminal at the start of each session.
The mod ships a status line (scripts/statusline.sh, needs jq) showing the directory, git branch and changes, model, exact context and usage figures, and the time.
A mod cannot replace your status line by itself, so your own status line script has to hand over to it. Put this at the top of your script, right after it reads its input into $input:
if [ -n "$CODECRAFT_STATUSLINE" ] && [ -x "$CODECRAFT_STATUSLINE" ]; then
printf '%s' "$input" | "$CODECRAFT_STATUSLINE"
exit
fi
The mod sets CODECRAFT_STATUSLINE only in sessions where it is loaded, so your own status line comes back when the mod is off. Without this step everything else still works.
The mod makes no network requests and sends nothing anywhere. It never changes or blocks a tool call; it only watches them to spot test runs, commits and failures. Outside of drawing, it does four things:
git commands in your working directory to find the repository root and whether your work is committed and pushed. This feeds the armor bar.~/.claude/plugins/store/.USER environment variable for the name on your chat lines, and TERM, TERM_PROGRAM and KITTY_WINDOW_ID to tell whether the terminal can show images.CODECRAFT_STATUSLINE environment variable for the session, used by the status line step above.Clone the repository and run Claude Code with the mod loaded from your checkout. It reloads when you save a file.
git clone https://github.com/mkpvishnu/claude-mods
cd claude-mods
claude --plugin-dir ./codecraft
| Task | Command |
|---|---|
| Type check | npx -p typescript@5 tsc -p codecraft |
| Validate the plugin | claude plugin validate --strict ./codecraft |
| Run the tests | cd codecraft && claude plugin test |
| Redraw the sprites | python3 codecraft/tools/sprites.py (needs Pillow) |
The code is in hooks/: register.tsx holds the hooks and the drawing, and lore.ts holds the pure helpers such as the advancement list, levels, chat wording and the advancement tree layout. The sprites in assets/ are generated from the letter art in tools/sprites.py, so edit that file and rerun it to change one.
MIT
hooks/register.tsx 1402 lines1import type { EngineInterface, Register } from 'claude-code'
2import {
3 ADVANCEMENTS,
4 COLOR,
5 MILESTONES,
6 SPLASHES,
7 TABS,
8 bar,
9 commandName,
10 dayOf,
11 daysBetween,
12 deathMessage,
13 gameMode,
14 halves,
15 isBuildCommand,
16 isCheckCommand,
17 isDocFile,
18 isTargetedTest,
19 isTestCommand,
20 isTestFile,
21 languageOf,
22 levelOf,
23 logoRows,
24 pastWord,
25 pick,
26 spinnerWord,
27 streakEnding,
28 toolGlyph,
29 chatAction,
30 HEAD_CLAUDE,
31 HEAD_PLAYER,
32 TAB_GROUND,
33 advancementTree,
34 titleScene,
35 wrapText,
36 slotPicture,
37 hudPicture,
38 itemOf,
39 xpPicture,
40 type Advancement,
41} from './lore.ts'
42
43// What is kept for good, across every session: the advancements earned, and
44// the counters, sets and dates the long ones are earned from
45type Life = {
46 counts: Record<string, number>
47 // Runs that a failure resets, so they are not merged between sessions
48 streaks: Record<string, number>
49 sets: Record<string, string[]>
50 // The days worked in each repository, and the last day each was opened
51 repoDays: Record<string, string[]>
52 repoSeen: Record<string, string>
53 firstDay: string
54}
55const earned = new Set<string>()
56let life: Life = { counts: {}, streaks: {}, sets: {}, repoDays: {}, repoSeen: {}, firstDay: '' }
57let isLifeDirty = false
58
59// Everything else counts one session and is saved under the session's id, so
60// a hot reload or a resume picks it up and a new session starts from zero
61let sessionId = ''
62let xp = 0
63let calls = 0
64let deaths = 0
65// What last verified the work, a green test run or a commit, and the edits
66// made since
67let verified: '' | 'tests' | 'commit' = ''
68let unverifiedEdits = 0
69let editsSinceGreen = 0
70let isRedSinceVerified = false
71// Files edited this session, with their edit counts
72const items = new Map<string, number>()
73// The first prompt's row in the transcript: the title is drawn above it, so
74// it scrolls away with the start of the conversation
75let titleRow = ''
76// The first prompt's text, kept until its turn ends: the row's id changes
77// once the engine stores the message, and the title follows it
78let titleText: string | undefined
79// What this session has done, for How Did We Get Here?
80const facts = new Set<string>()
81// Which of tests, checks and build have passed since the last edit
82const passed = new Set<string>()
83
84// Not saved: these describe the moment
85let today = ''
86// The permission mode, as the last prompt or turn end reported it
87let permissionMode = ''
88let root = ''
89let isClean = false
90let isPushed = false
91let contextPercent: number | undefined
92let hungerPercent: number | undefined
93let hasWarnedHealth = false
94let hasWarnedHunger = false
95let lastTestFailed = false
96let hasFailedCheck = false
97let hasConflict = false
98let hasCompacted = false
99let hasNewTest = false
100// True in a repository last opened 90 or more days ago
101let isRevived = false
102// The test command that last failed with 20 or more failures
103let raid = ''
104const filesSinceTest = new Set<string>()
105let turnAgents = 0
106// True for a moment after a call fails, while the hearts flash
107let isHurt = false
108let isTicking = false
109let splash = ''
110// The name chat lines carry for the person at the prompt
111let player = 'Steve'
112const TITLE = titleScene('CODECRAFT')
113// Where the sprite files are, once the terminal is known to show pictures
114// (kitty and Ghostty do); empty elsewhere, and the cell-grid art is drawn
115let sprites = ''
116let shownTab = 0
117// The advancement the screen's outline is on; '' picks the next one to earn
118let pickedAdvancement = ''
119// Tool calls running now, oldest first; the spinner names the newest
120const active: { id: string; tool: string; label: string; startedAt: number; timeoutMs: number }[] = []
121// Tools used this turn, in first-use order, with their counts
122const hotbar = new Map<string, number>()
123const deathById = new Map<string, string>()
124// How many times in a row each call has failed, and the ones that broke
125const failures = new Map<string, number>()
126const broken = new Set<string>()
127
128type Saved = {
129 id: string
130 // When it was last written, to drop the records of sessions long over
131 at: number
132 xp: number
133 calls: number
134 deaths: number
135 verified: '' | 'tests' | 'commit'
136 unverifiedEdits: number
137 editsSinceGreen: number
138 isRedSinceVerified: boolean
139 items: [string, number][]
140 facts: string[]
141 passed: string[]
142 titleRow?: string
143}
144
145// A failed write costs the saved progress, never the hook that asked for it
146async function keep($: EngineInterface, key: string, value: unknown) {
147 try {
148 await $.store.set(key, value)
149 } catch {
150 $.ui.log('could not save ' + key, { to: 'debug' })
151 }
152}
153
154function union(a: readonly string[] = [], b: readonly string[] = []): string[] {
155 return [...new Set([...a, ...b])]
156}
157
158// Another session may have written since this one read: counters take the
159// larger value and sets are joined, so neither session's progress is lost
160function merged(mine: Life, theirs: Partial<Life> | undefined): Life {
161 if (!theirs) return mine
162 const out: Life = { ...mine, counts: { ...mine.counts }, sets: { ...mine.sets }, repoDays: { ...mine.repoDays }, repoSeen: { ...mine.repoSeen } }
163 for (const [name, value] of Object.entries(theirs.counts ?? {})) out.counts[name] = Math.max(out.counts[name] ?? 0, value)
164 for (const [name, values] of Object.entries(theirs.sets ?? {})) out.sets[name] = union(out.sets[name], values)
165 for (const [name, values] of Object.entries(theirs.repoDays ?? {})) out.repoDays[name] = union(out.repoDays[name], values)
166 for (const [name, day] of Object.entries(theirs.repoSeen ?? {})) {
167 if (day > (out.repoSeen[name] ?? '')) out.repoSeen[name] = day
168 }
169 const firstDays = [mine.firstDay, theirs.firstDay ?? ''].filter(Boolean).sort()
170 out.firstDay = firstDays[0] ?? ''
171 return out
172}
173
174async function save($: EngineInterface) {
175 const now = await clockNow($)
176 const saved: Saved = {
177 id: sessionId,
178 at: now,
179 xp,
180 calls,
181 deaths,
182 verified,
183 unverifiedEdits,
184 editsSinceGreen,
185 isRedSinceVerified,
186 items: [...items],
187 facts: [...facts],
188 passed: [...passed],
189 titleRow,
190 }
191 // Every session with the mod loaded shares one store, so each keeps its own
192 // record under its id; ones untouched for a week are dropped
193 let sessions: Record<string, Saved> = {}
194 try {
195 sessions = ((await $.store.get('sessions')) as Record<string, Saved> | undefined) ?? {}
196 } catch {
197 // Nothing stored yet
198 }
199 for (const [id, other] of Object.entries(sessions)) {
200 if (now - (other.at ?? 0) > 7 * 86400000) delete sessions[id]
201 }
202 if (sessionId) sessions[sessionId] = saved
203 await keep($, 'sessions', sessions)
204 if (!isLifeDirty) return
205 isLifeDirty = false
206 try {
207 life = merged(life, (await $.store.get('lifetime')) as Partial<Life> | undefined)
208 } catch {
209 // Nothing stored to merge with
210 }
211 await keep($, 'lifetime', life)
212}
213
214async function restore($: EngineInterface) {
215 const stored = await $.store.get('advancements')
216 if (Array.isArray(stored)) for (const id of stored) earned.add(String(id))
217 const lifetime = (await $.store.get('lifetime')) as Partial<Life> | undefined
218 life = merged(life, lifetime)
219 life.streaks = { ...lifetime?.streaks, ...life.streaks }
220 const id = await $.session.id()
221 // A new session in the same process (/clear) starts from zero
222 if (sessionId && id !== sessionId) {
223 xp = 0
224 calls = 0
225 deaths = 0
226 verified = ''
227 unverifiedEdits = 0
228 editsSinceGreen = 0
229 isRedSinceVerified = false
230 items.clear()
231 facts.clear()
232 passed.clear()
233 titleRow = ''
234 }
235 sessionId = id
236 const sessions = (await $.store.get('sessions')) as Record<string, Saved> | undefined
237 const saved = sessions?.[sessionId]
238 if (!saved) return
239 xp = saved.xp
240 calls = saved.calls
241 deaths = saved.deaths
242 verified = saved.verified ?? ''
243 unverifiedEdits = saved.unverifiedEdits ?? 0
244 editsSinceGreen = saved.editsSinceGreen ?? 0
245 isRedSinceVerified = saved.isRedSinceVerified ?? false
246 for (const [path, count] of saved.items) items.set(path, count)
247 for (const fact of saved.facts ?? []) facts.add(fact)
248 for (const kind of saved.passed ?? []) passed.add(kind)
249 titleRow = saved.titleRow ?? ''
250}
251
252async function clockNow($: EngineInterface): Promise<number> {
253 try {
254 return await $.clock.now()
255 } catch {
256 return 0
257 }
258}
259
260async function runGit($: EngineInterface, args: string[]) {
261 try {
262 const run = await $.process.run(['git', ...args])
263 return { ok: run.exitCode === 0, out: run.stdout.trim() }
264 } catch {
265 return { ok: false, out: '' }
266 }
267}
268
269// The two safety nets git holds: nothing uncommitted, and nothing unpushed
270async function refreshArmor($: EngineInterface) {
271 if (!root) return
272 const status = await runGit($, ['status', '--porcelain'])
273 const ahead = await runGit($, ['rev-list', '--count', '@{upstream}..HEAD'])
274 isClean = status.ok && status.out === ''
275 isPushed = ahead.ok && ahead.out === '0'
276 $.ui.invalidate('ui.render')
277}
278
279// Armor is what would save the work from a bad edit, out of 10: green tests
280// over every edit (4), everything committed (3), everything pushed (3).
281// Outside a git repository there is no armor row
282function armor(): { points: number; parts: string } | undefined {
283 if (!root) return undefined
284 const isTested = editsSinceGreen === 0
285 const points = (isTested ? 4 : 0) + (isClean ? 3 : 0) + (isPushed ? 3 : 0)
286 const mark = (isOn: boolean, name: string) => (isOn ? '✓ ' : '✗ ') + name
287 return { points, parts: [mark(isTested, 'tests green'), mark(isClean, 'committed'), mark(isPushed, 'pushed')].join(', ') }
288}
289
290function gain($: EngineInterface, amount: number) {
291 const before = levelOf(xp).level
292 xp += amount
293 const after = levelOf(xp).level
294 if (after > before) $.ui.toast('Level up! You are now level ' + after)
295}
296
297async function grant($: EngineInterface, advancement: Advancement) {
298 if (earned.has(advancement.id)) return
299 earned.add(advancement.id)
300 // The game's three toasts, by frame
301 const made = advancement.frame === 'challenge' ? 'Challenge Complete!' : advancement.frame === 'goal' ? 'Goal Reached!' : 'Advancement Made!'
302 $.ui.toast(made + ' ' + advancement.icon + ' ' + advancement.title, { timeoutMs: 6000 })
303 // The line the game writes in chat for each frame
304 const did = advancement.frame === 'challenge' ? ' has completed the challenge [' : advancement.frame === 'goal' ? ' has reached the goal [' : ' has made the advancement ['
305 $.ui.log(player + did + advancement.title + ']' + (advancement.xp ? ' +' + advancement.xp + ' XP' : ''))
306 if (advancement.xp) gain($, advancement.xp)
307 let stored: unknown
308 try {
309 stored = await $.store.get('advancements')
310 } catch {
311 // Nothing stored to join with
312 }
313 if (Array.isArray(stored)) for (const id of stored) earned.add(String(id))
314 await keep($, 'advancements', [...earned])
315}
316
317// Adds to a lifetime counter and grants what its new total earns
318async function tally($: EngineInterface, name: string) {
319 const total = (life.counts[name] ?? 0) + 1
320 life.counts[name] = total
321 isLifeDirty = true
322 for (const [threshold, advancement] of MILESTONES[name] ?? []) {
323 if (total >= threshold) await grant($, advancement)
324 }
325}
326
327// Adds to a lifetime set and returns its size
328function collect(name: string, value: string): number {
329 const values = life.sets[name] ?? (life.sets[name] = [])
330 if (!values.includes(value)) {
331 values.push(value)
332 isLifeDirty = true
333 }
334 return values.length
335}
336
337// A day counts as worked once it has a commit or a green run over new edits
338async function activeDay($: EngineInterface) {
339 if (!today) return
340 const days = collect('days', today)
341 if (root) {
342 const inRepo = life.repoDays[root] ?? (life.repoDays[root] = [])
343 if (!inRepo.includes(today)) inRepo.push(today)
344 isLifeDirty = true
345 if (inRepo.length >= 7) await grant($, ADVANCEMENTS.bestFriendsForever)
346 if (collect('repos', root) >= 10) await grant($, ADVANCEMENTS.adventuringTime)
347 }
348 const streak = streakEnding(life.sets.days ?? [], today)
349 if (streak >= 7) await grant($, ADVANCEMENTS.homesteader)
350 if (streak >= 30) await grant($, ADVANCEMENTS.oldGrowth)
351 if (days >= 100) await grant($, ADVANCEMENTS.passingTheTime)
352 if (life.firstDay && today.slice(5) === life.firstDay.slice(5) && today.slice(0, 4) > life.firstDay.slice(0, 4)) {
353 await grant($, ADVANCEMENTS.birthdaySong)
354 }
355}
356
357function setVerified(kind: 'tests' | 'commit') {
358 verified = kind
359 unverifiedEdits = 0
360 isRedSinceVerified = false
361}
362
363function verifiedLine(): string {
364 if (unverifiedEdits > 0) return '◇ ' + unverifiedEdits + (unverifiedEdits === 1 ? ' unverified edit' : ' unverified edits')
365 if (!verified) return ''
366 return '◆ verified (' + (verified === 'tests' ? 'green tests' : 'a commit') + ')'
367}
368
369// Hidden advancements are left out until they are earned, as in the game
370function shown(): Advancement[] {
371 return (Object.values(ADVANCEMENTS) as Advancement[]).filter(one => !one.isHidden || earned.has(one.id))
372}
373
374function advancementLines(): string[] {
375 const all = Object.values(ADVANCEMENTS) as Advancement[]
376 const lines = ['**Advancements** ' + earned.size + '/' + all.length]
377 for (const tab of TABS) {
378 lines.push('', '**' + tab + '**')
379 for (const advancement of shown().filter(one => one.tab === tab)) {
380 lines.push('- ' + (earned.has(advancement.id) ? '☑ ' : '☐ ') + advancement.icon + ' ' + advancement.title + ': ' + advancement.how)
381 }
382 }
383 return lines
384}
385
386function inventory(): string {
387 const { level, into, cost } = levelOf(xp)
388 const all = Object.values(ADVANCEMENTS) as Advancement[]
389 const titles = all.filter(one => earned.has(one.id)).map(one => one.title)
390 const lines = [
391 '**Level ' + level + '** (' + into + '/' + cost + ' XP to the next) · ' + calls + ' tool calls · ' + deaths + ' deaths',
392 ]
393 if (verifiedLine()) lines.push(verifiedLine())
394 const worn = armor()
395 if (worn) lines.push('Armor ' + worn.points + '/10: ' + worn.parts)
396 lines.push('', '**Items** (files edited this session)')
397 if (items.size === 0) lines.push('- Your inventory is empty')
398 for (const [path, edits] of items) lines.push('- ' + path + ' ×' + edits)
399 lines.push('', '**Advancements** ' + titles.length + '/' + all.length + (titles.length > 0 ? ': ' + titles.join(', ') : ''))
400 lines.push('/advancements opens the screen')
401 return lines.join('\n')
402}
403
404// The message recorded when the call failed, or one worked out from the row
405// itself for a call that failed before the mod loaded
406function deathOf(id: string, tool: string, input: unknown, output: unknown): string {
407 const known = deathById.get(id)
408 if (known) return known
409 const command = (input as { command?: unknown } | undefined)?.command
410 const subject = tool === 'Bash' && typeof command === 'string' ? commandName(command) : ''
411 return deathMessage(tool, subject, typeof output === 'string' ? output : '', id)
412}
413
414function resultText(outcome: { text?: unknown; result?: unknown }): string {
415 if (typeof outcome.text === 'string') return outcome.text
416 if (typeof outcome.result === 'string') return outcome.result
417 try {
418 return JSON.stringify(outcome.result) ?? ''
419 } catch {
420 return ''
421 }
422}
423
424export const register: Register = on => {
425 on('session.start', async ($, e, next) => {
426 // A start with nobody at the prompt, once the person's session is known,
427 // is a loop of its own (a subagent): it is not the session being counted
428 if (sessionId && !e.isInteractive) return next(e)
429 await restore($)
430 const usage = await $.session.usage()
431 contextPercent = usage.context.percent
432 hungerPercent = usage.rateLimits.find(limit => limit.kind === 'five_hour')?.percentUsed
433 const now = await clockNow($)
434 splash = pick(SPLASHES, sessionId)
435 player = (await $.env.get('USER')) || player
436 const terminal = ((await $.env.get('TERM_PROGRAM')) ?? '') + ' ' + ((await $.env.get('TERM')) ?? '')
437 const hasPictures = /ghostty|kitty/i.test(terminal) || Boolean(await $.env.get('KITTY_WINDOW_ID'))
438 sprites = hasPictures && (await $.session.surface()) === 'terminal' ? $.plugin.root + '/assets/' : ''
439 today = dayOf(now)
440 if (!life.firstDay) {
441 life.firstDay = today
442 isLifeDirty = true
443 }
444
445 const top = await runGit($, ['rev-parse', '--show-toplevel'])
446 root = top.ok ? top.out : ''
447 if (root) {
448 const seen = life.repoSeen[root]
449 if (seen && seen < today) {
450 await grant($, ADVANCEMENTS.sweetDreams)
451 isRevived = daysBetween(seen, today) >= 90
452 }
453 life.repoSeen[root] = today
454 isLifeDirty = true
455 await refreshArmor($)
456 }
457
458 await $.command.register({
459 name: 'inventory',
460 description: 'Show your level, armor, the files you edited, and your advancements',
461 immediate: true,
462 })
463 await $.command.register({
464 name: 'advancements',
465 description: 'Open the advancements screen',
466 immediate: true,
467 })
468 // The status line script takes over the line in sessions where this mod
469 // is loaded: the user's own script hands over when it sees the variable
470 await $.env.set('CODECRAFT_STATUSLINE', $.plugin.root + '/scripts/statusline.sh')
471 await save($)
472 // The band may have drawn before the figures above arrived
473 $.ui.invalidate('ui.render')
474 return next(e)
475 })
476
477 on('command.run', { command: 'inventory' }, async () => {
478 return { text: inventory() }
479 })
480
481 on('command.run', { command: 'advancements' }, async $ => {
482 const surface = await $.session.surface()
483 if (surface === 'terminal' || surface === 'desktop') {
484 const opened = await $.ui.open({ id: 'advancements', title: 'Advancements', closeOnEscape: true, rows: 30 })
485 if (opened.isPlaced) return {}
486 }
487 return { text: advancementLines().join('\n') }
488 })
489
490 on('turn.start', async ($, e, next) => {
491 turnAgents = 0
492 hotbar.clear()
493 const now = await clockNow($)
494 if (now) today = dayOf(now)
495 $.ui.invalidate('ui.render')
496 return next(e)
497 })
498
499 on('turn.complete', async ($, e, next) => {
500 titleText = undefined
501 await refreshArmor($)
502 await save($)
503 $.ui.invalidate('ui.render')
504 return next(e)
505 })
506
507 on('session.compact', async ($, e, next) => {
508 const result = await next(e)
509 hasCompacted = true
510 return result
511 })
512
513 on('tool.call', async ($, e, next) => {
514 const id = e.tool_use_id ?? ''
515 const command = e.tool === 'Bash' ? e.command : ''
516 const input = e as { description?: unknown; timeout?: unknown }
517 calls += 1
518 hotbar.set(e.tool, (hotbar.get(e.tool) ?? 0) + 1)
519 active.push({
520 id,
521 tool: e.tool,
522 label: e.tool === 'Agent' && typeof input.description === 'string' ? input.description : command,
523 startedAt: await clockNow($),
524 timeoutMs: typeof input.timeout === 'number' ? input.timeout : 120000,
525 })
526 // A running command redraws the band each second, for its boss bar
527 if (e.tool === 'Bash' && !isTicking) {
528 isTicking = true
529 const tick = () => {
530 if (!active.some(call => call.tool === 'Bash')) {
531 isTicking = false
532 return
533 }
534 $.ui.invalidate('ui.render')
535 $.clock.after(1000, tick)
536 }
537 try {
538 $.clock.after(1000, tick)
539 } catch {
540 isTicking = false
541 }
542 }
543 $.ui.invalidate('ui.render')
544
545 let outcome
546 try {
547 outcome = await next(e)
548 } finally {
549 const at = active.findIndex(call => call.id === id)
550 if (at !== -1) active.splice(at, 1)
551 $.ui.invalidate('ui.render')
552 }
553
554 const isEdit = e.tool === 'Edit' || e.tool === 'Write' || e.tool === 'NotebookEdit'
555 const path = e.tool === 'NotebookEdit' ? e.notebook_path : e.tool === 'Edit' || e.tool === 'Write' ? e.file_path : ''
556 const isTest = isTestCommand(command)
557 const isCheck = !isTest && isCheckCommand(command)
558 // What "the same call" means for durability: the tool and what it ran on
559 const key = e.tool + ':' + (command || path)
560
561 if (outcome.deny !== undefined || outcome.isError) {
562 const text = outcome.deny ?? outcome.text ?? ''
563 deaths += 1
564 deathById.set(id, deathMessage(e.tool, commandName(command), text, id))
565 if (isTest) {
566 lastTestFailed = true
567 isRedSinceVerified = true
568 if (unverifiedEdits > 0) life.streaks.firstTry = 0
569 const failed = /(\d+) failed/.exec(text)
570 if (failed && Number(failed[1]) >= 20) raid = command
571 }
572 if (isCheck) hasFailedCheck = true
573 if (/CONFLICT/.test(text)) hasConflict = true
574 // A refusal or an interrupt is the person's doing, not a failed call
575 if (outcome.deny === undefined && !/interrupt/i.test(text)) life.streaks.cleanCalls = 0
576 const failed = (failures.get(key) ?? 0) + 1
577 failures.set(key, failed)
578 if (failed % 3 === 0) {
579 broken.add(key)
580 const what = command ? '`' + command.slice(0, 60) + '`' : e.tool + ' on ' + path
581 $.ui.toast('Your tool broke! ' + failed + ' failures in a row', { timeoutMs: 8000 })
582 $.ui.log('Your tool broke: ' + what + ' has failed ' + failed + ' times in a row. Time to try another way.')
583 }
584 isHurt = true
585 try {
586 $.clock.after(700, () => {
587 isHurt = false
588 $.ui.invalidate('ui.render')
589 })
590 } catch {
591 isHurt = false
592 }
593 return outcome
594 }
595
596 const text = resultText(outcome)
597 failures.delete(key)
598 if (broken.delete(key)) await grant($, ADVANCEMENTS.zombieDoctor)
599 life.streaks.cleanCalls = (life.streaks.cleanCalls ?? 0) + 1
600 if (life.streaks.cleanCalls >= 100) await grant($, ADVANCEMENTS.sneak100)
601
602 if (isEdit) {
603 items.set(path, (items.get(path) ?? 0) + 1)
604 unverifiedEdits += 1
605 editsSinceGreen += 1
606 passed.clear()
607 filesSinceTest.add(path)
608 await grant($, ADVANCEMENTS.stoneAge)
609 const language = languageOf(path)
610 if (language && collect('languages', language) >= 8) await grant($, ADVANCEMENTS.aBalancedDiet)
611 if (e.tool === 'Write' && isTestFile(path)) hasNewTest = true
612 if (isDocFile(path)) await grant($, ADVANCEMENTS.glowAndBehold)
613 if (/(^|\/)(CLAUDE|AGENTS)\.md$/.test(path)) await grant($, ADVANCEMENTS.countryLode)
614 if (/\.claude\/(agents|skills|commands|hooks)\/|(^|\/)SKILL\.md$/.test(path)) await grant($, ADVANCEMENTS.craftersCrafting)
615 }
616 if (e.tool === 'Agent') {
617 facts.add('agent')
618 turnAgents += 1
619 await grant($, ADVANCEMENTS.deeper)
620 if (turnAgents >= 3) await grant($, ADVANCEMENTS.subspaceBubble)
621 await tally($, 'subagents')
622 }
623 if (e.tool === 'WebSearch' || e.tool === 'WebFetch') {
624 facts.add('web')
625 await grant($, ADVANCEMENTS.isItABird)
626 }
627 if (e.tool === 'Skill') {
628 facts.add('outside')
629 await grant($, ADVANCEMENTS.enchanter)
630 }
631 if (e.tool.startsWith('mcp__')) {
632 facts.add('outside')
633 await grant($, ADVANCEMENTS.underLockAndKey)
634 if (collect('mcpServers', e.tool.split('__')[1] ?? '') >= 5) await grant($, ADVANCEMENTS.revaulting)
635 }
636
637 // Experience comes from verified progress alone: a commit, a pull
638 // request, or a green test run that covers new edits or follows a red one
639 const gitOperation = (outcome.result as { gitOperation?: { commit?: unknown; pr?: { action?: string } } } | undefined)
640 ?.gitOperation
641 if (/CONFLICT/.test(text)) hasConflict = true
642 // A quiet commit prints nothing for the engine to recognise, so the
643 // command itself counts too
644 const isCommit = Boolean(gitOperation?.commit) || /\bgit\b[^|;&]*\bcommit\b/.test(command)
645 if (isCommit) {
646 await grant($, ADVANCEMENTS.ironPick)
647 gain($, 7)
648 if (verified === 'tests' && unverifiedEdits === 0) {
649 facts.add('commit')
650 await grant($, ADVANCEMENTS.diamonds)
651 await tally($, 'verifiedCommits')
652 if ((contextPercent ?? 0) >= 95) await grant($, ADVANCEMENTS.stickySituation)
653 }
654 setVerified('commit')
655 await activeDay($)
656 }
657 if (hasConflict && (isCommit || /\bgit (rebase|merge|cherry-pick) --continue\b/.test(command))) {
658 hasConflict = false
659 await grant($, ADVANCEMENTS.iceBucket)
660 }
661 if (gitOperation?.pr?.action === 'created' || /\bgh pr create\b/.test(command)) {
662 facts.add('pr')
663 await grant($, ADVANCEMENTS.theEnd)
664 gain($, 10)
665 await tally($, 'prs')
666 if (root && collect('prRepos', root) >= 3) await grant($, ADVANCEMENTS.remoteGetaway)
667 }
668 if (command) {
669 if (/\bgit push\b/.test(command)) {
670 facts.add('push')
671 await grant($, ADVANCEMENTS.hotStuff)
672 }
673 if (/\bgh pr merge\b/.test(command)) {
674 await grant($, ADVANCEMENTS.freeTheEnd)
675 await tally($, 'merges')
676 }
677 if (/\b(gh release create|git push\b.*(--tags|\bv\d)|npm publish|cargo publish|twine upload)/.test(command)) {
678 await grant($, ADVANCEMENTS.cityAtTheEnd)
679 }
680 if (/\bgit (checkout -b|switch -c|worktree add\b.* -b)\b/.test(command)) await grant($, ADVANCEMENTS.eyeSpy)
681 if (/\bgit (blame|show|log\b.*( -S| -L|--follow))/.test(command)) await grant($, ADVANCEMENTS.thoseWereTheDays)
682 if (/is the first bad commit/.test(text)) await grant($, ADVANCEMENTS.hiddenInTheDepths)
683 if (/\bgit revert\b/.test(command)) await grant($, ADVANCEMENTS.returnToSender)
684 if (/\bgit rebase\b/.test(command) && !hasConflict) await grant($, ADVANCEMENTS.totalBeelocation)
685 if (isBuildCommand(command)) {
686 passed.add('build')
687 await grant($, ADVANCEMENTS.acquireHardware)
688 }
689 }
690 if (isCheck) {
691 passed.add('check')
692 await grant($, ADVANCEMENTS.suitUp)
693 if (hasFailedCheck) await grant($, ADVANCEMENTS.notToday)
694 hasFailedCheck = false
695 }
696 if (isTest) {
697 passed.add('test')
698 await grant($, ADVANCEMENTS.upgrade)
699 if (isTargetedTest(command)) await grant($, ADVANCEMENTS.takeAim)
700 const passing = /(\d+) passed/.exec(text)
701 if (passing && Number(passing[1]) >= 500) await grant($, ADVANCEMENTS.overOverkill)
702 if (lastTestFailed) {
703 await grant($, ADVANCEMENTS.monsterHunter)
704 gain($, 5)
705 await tally($, 'redToGreen')
706 if (raid === command) await grant($, ADVANCEMENTS.heroOfTheVillage)
707 }
708 if (unverifiedEdits > 0 || lastTestFailed) {
709 gain($, 7)
710 await tally($, 'greens')
711 await activeDay($)
712 if (unverifiedEdits > 0) {
713 if (!isRedSinceVerified) {
714 await grant($, ADVANCEMENTS.sniperDuel)
715 life.streaks.firstTry = (life.streaks.firstTry ?? 0) + 1
716 if (life.streaks.firstTry >= 10) await grant($, ADVANCEMENTS.bullseye)
717 if (filesSinceTest.size >= 5) await grant($, ADVANCEMENTS.arbalistic)
718 }
719 if (hasCompacted) await grant($, ADVANCEMENTS.postmortal)
720 if (hasNewTest) await grant($, ADVANCEMENTS.aSeedyPlace)
721 if (isRevived) await grant($, ADVANCEMENTS.plantingThePast)
722 }
723 }
724 lastTestFailed = false
725 raid = ''
726 hasNewTest = false
727 editsSinceGreen = 0
728 filesSinceTest.clear()
729 setVerified('tests')
730 }
731 if (passed.size === 3) {
732 facts.add('cocktail')
733 await grant($, ADVANCEMENTS.furiousCocktail)
734 }
735 if (['cocktail', 'commit', 'push', 'pr', 'agent', 'web', 'outside'].every(fact => facts.has(fact))) {
736 await grant($, ADVANCEMENTS.howDidWe)
737 }
738 isLifeDirty = true
739 // What the call did to the working tree shows on the armor row
740 if (isEdit || command) await refreshArmor($)
741 return outcome
742 })
743
744 on('session.measure', async ($, e, next) => {
745 contextPercent = e.context.percent
746 const limit = e.rateLimits.find(one => one.kind === 'five_hour')
747 hungerPercent = limit?.percentUsed
748
749 const used = contextPercent ?? 0
750 if (used >= 90) await grant($, ADVANCEMENTS.cavesAndCliffs)
751 if (used >= 80 && !hasWarnedHealth) {
752 hasWarnedHealth = true
753 $.ui.toast('Low health: ' + halves(100 - used) / 2 + ' hearts of context left. /compact to rest.', { timeoutMs: 8000 })
754 }
755 if (used < 60) hasWarnedHealth = false
756
757 const eaten = hungerPercent ?? 0
758 if (eaten >= 80 && !hasWarnedHunger) {
759 hasWarnedHunger = true
760 const resets = limit?.resetsAt ? ' It refills at ' + limit.resetsAt.slice(11, 16) + ' UTC.' : ''
761 $.ui.toast('You are starving: ' + eaten + '% of the five-hour limit is used.' + resets, { timeoutMs: 8000 })
762 }
763 if (eaten < 50) hasWarnedHunger = false
764
765 $.ui.invalidate('ui.render')
766 return next(e)
767 })
768
769 // The spinner names what the newest running tool is doing
770 on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
771 if (e.props.message !== null) return next(e)
772 const tool = active[active.length - 1]?.tool
773 return next({ ...e, props: { ...e.props, word: spinnerWord(tool, e.props.mode, e.props.word) } })
774 })
775
776 on('ui.render', { component: 'TurnDuration' }, async ($, e, next) => {
777 return next({ ...e, props: { ...e.props, word: pastWord(e.props.word) } })
778 })
779
780 // The engine names the permission mode when a prompt is sent and when a
781 // turn ends, so a switch with shift+tab shows from the next prompt on
782 on('classic.SessionStart', async ($, e, next) => {
783 permissionMode = e.permission_mode ?? permissionMode
784 return next(e)
785 })
786 on('classic.UserPromptSubmit', async ($, e, next) => {
787 permissionMode = e.permission_mode ?? permissionMode
788 $.ui.invalidate('ui.render')
789 return next(e)
790 })
791 on('classic.Stop', async ($, e, next) => {
792 permissionMode = e.permission_mode ?? permissionMode
793 $.ui.invalidate('ui.render')
794 return next(e)
795 })
796
797 // The permission mode reads as the game mode it plays like, among the
798 // footer's mode labels: plan mode can look and not touch, bypass has no net
799 on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
800 if (!permissionMode) return next(e)
801 const { Box, Text } = $.ui.resolve(e)
802 const mode = gameMode(permissionMode)
803 return (
804 <Box flexDirection="row" gap={1}>
805 <Text color={mode.color} bold>
806 {mode.name}
807 </Text>
808 {await next(e)}
809 </Box>
810 )
811 })
812
813 // A call through a skill or an MCP server is borrowed power: the dot of
814 // its row is drawn over with the enchantment glint
815 on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
816 // The game reports an operator's command in chat as a gray italic
817 // `[Name: what it did]`; a skill or MCP tool, being enchanted, carries the glint
818 const { Box, Text } = $.ui.resolve(e)
819 const isEnchanted = e.props.tool === 'Skill' || e.props.tool.startsWith('mcp__')
820 const color = e.props.isErrored ? COLOR.red : e.props.isRunning ? COLOR.white : COLOR.gray
821 const tail = e.props.isInterrupted ? ' (interrupted)' : e.props.isRunning ? '…' : ''
822 return (
823 <Box flexDirection="row" gap={1}>
824 {isEnchanted ? <Text color={COLOR.purple}>✦</Text> : null}
825 <Text color={color} italic wrap="truncate">
826 {'[Claude: ' + chatAction(e.props.tool, e.props.input, root) + tail + ']'}
827 </Text>
828 </Box>
829 )
830 })
831
832 // A reply is a chat line from Claude: the name opens the reply and the
833 // blocks after it sit under its text
834 on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
835 const elements = $.ui.resolve(e)
836 const { Box, Text, Markdown } = elements
837 // Only the terminal has the cell grid the heads are painted on
838 const Raster = 'Raster' in elements ? elements.Raster : undefined
839 // A picture of the head where the terminal shows pictures, the cell-grid
840 // one elsewhere
841 const Picture = sprites && 'Image' in elements ? elements.Image : undefined
842 if (!e.props.isFirstOfReply) {
843 return (
844 <Box paddingLeft={(Picture ? 4 : HEAD_CLAUDE.columns) + 1}>
845 <Markdown text={e.props.text} />
846 </Box>
847 )
848 }
849 return (
850 <Box flexDirection="row" gap={1} marginTop={1}>
851 {Picture ? (
852 <Picture source={{ file: sprites + 'head-claude.png', format: 'png' }} columns={4} rows={2} alt=" " />
853 ) : Raster ? (
854 <Raster key="head" {...HEAD_CLAUDE} />
855 ) : null}
856 <Box flexDirection="column" flexGrow={1} flexShrink={1}>
857 <Text color={COLOR.gold} bold>
858 {'<Claude>'}
859 </Text>
860 <Markdown text={e.props.text} />
861 </Box>
862 </Box>
863 )
864 })
865
866 // The title belongs to the start of the conversation: it sits over the
867 // prompt until the first one is sent, then above that prompt's row
868 on('ui.render', { component: 'UserMessage' }, async ($, e, next) => {
869 if (e.props.origin.kind === 'composer') {
870 if (!titleRow) {
871 titleRow = e.requestId
872 titleText = e.props.text
873 $.ui.invalidate('ui.render')
874 } else if (titleText === e.props.text) {
875 titleRow = e.requestId
876 }
877 }
878 const elements = $.ui.resolve(e)
879 const { Box, Text } = elements
880 const Raster = 'Raster' in elements ? elements.Raster : undefined
881 // The person's own prompt is a chat line under their name, beside their
882 // head; a row from anyone else keeps the engine's drawing
883 const row =
884 e.props.origin.kind === 'composer' ? (
885 <Box flexDirection="row" gap={1} marginTop={1}>
886 {sprites && 'Image' in elements ? (
887 <elements.Image source={{ file: sprites + 'head-player.png', format: 'png' }} columns={4} rows={2} alt=" " />
888 ) : Raster ? (
889 <Raster key="head" {...HEAD_PLAYER} />
890 ) : null}
891 <Box flexDirection="column" flexGrow={1} flexShrink={1}>
892 <Text color={COLOR.aqua} bold>
893 {'<' + player + '>'}
894 </Text>
895 <Text color={COLOR.white}>{e.props.text}</Text>
896 </Box>
897 </Box>
898 ) : (
899 await next(e)
900 )
901 if (e.requestId !== titleRow) return row
902 const logo = logoRows('CODECRAFT')
903 return (
904 <Box flexDirection="column">
905 {Raster ? (
906 <Raster key="title" {...TITLE} />
907 ) : (
908 <Box flexDirection="column">
909 <Text color={COLOR.white}>{logo[0]}</Text>
910 <Text color={COLOR.stone}>{logo[1]}</Text>
911 <Text color={COLOR.gray}>{logo[2]}</Text>
912 </Box>
913 )}
914 <Text color={COLOR.yellow} bold italic>
915 {splash}
916 </Text>
917 <Text color={COLOR.yellow}>{player + ' joined the game'}</Text>
918 {row}
919 </Box>
920 )
921 })
922
923 // A failed call gets a death message above the engine's own error text
924 on('ui.render', { component: 'ToolResult', props: { isErrored: true } }, async ($, e, next) => {
925 const { Box, Text } = $.ui.resolve(e)
926 return (
927 <Box flexDirection="column">
928 <Text color={COLOR.red}>☠ {deathOf(e.props.tool_use_id, e.props.tool, undefined, e.props.output)}</Text>
929 {await next(e)}
930 </Box>
931 )
932 })
933
934 // A folded run of calls is drawn as chat lines too, the last four of them,
935 // with each death under its call; ctrl+o still unfolds the engine's rows
936 on('ui.render', { component: 'ToolGroup' }, async ($, e, next) => {
937 if (e.props.isExpanded) return next(e)
938 const { Box, Text } = $.ui.resolve(e)
939 const shown = e.props.calls.slice(-4)
940 const earlier = e.props.calls.length - shown.length
941 const lines = []
942 if (earlier > 0) {
943 lines.push(
944 <Text color={COLOR.darkGray} italic>
945 {'[Claude: ' + earlier + ' earlier ' + (earlier === 1 ? 'action' : 'actions') + ', ctrl+o to see]'}
946 </Text>,
947 )
948 }
949 for (const call of shown) {
950 const isDead = call.isErrored && !call.isInterrupted
951 lines.push(
952 <Text color={call.isErrored ? COLOR.red : call.isRunning ? COLOR.white : COLOR.gray} italic wrap="truncate">
953 {'[Claude: ' + chatAction(call.tool, call.input, root) + (call.isInterrupted ? ' (interrupted)' : call.isRunning ? '…' : '') + ']'}
954 </Text>,
955 )
956 if (isDead) {
957 lines.push(<Text color={COLOR.red}> ☠ {deathOf(call.tool_use_id ?? '', call.tool, call.input, call.output)}</Text>)
958 }
959 }
960 return <Box flexDirection="column">{lines}</Box>
961 })
962
963 // The advancements screen: the game's five tabs, one shown at a time. Where
964 // the terminal shows images the tab is drawn as the game's tree: an icon for
965 // each advancement in a gold frame once earned, a stone one while open and
966 // a dark one while the advancement before it is not earned, lines joining
967 // them, and under the tree what the outlined one asks for. The pointer over
968 // an icon shows that one instead. Elsewhere each advancement is a card
969 on('ui.render', { component: 'Pane', requestId: 'advancements' }, async ($, e) => {
970 const elements = $.ui.resolve(e)
971 const { Box, Text, Button } = elements
972 const all = Object.values(ADVANCEMENTS) as Advancement[]
973 const titles: Record<string, Advancement> = ADVANCEMENTS
974 // 28 cells hold the longest title beside its icon, inside the frame
975 const columns = Math.max(1, Math.min(3, Math.floor((e.props.bodyColumns + 1) / 29)))
976 const cardWidth = Math.floor((e.props.bodyColumns - (columns - 1)) / columns)
977
978 const total = bar(earned.size, all.length, 15)
979 const rows = [
980 <Box flexDirection="row" gap={1}>
981 <Text color={COLOR.xp}>{total.filled}</Text>
982 <Text color={COLOR.darkGray}>{total.empty}</Text>
983 <Text color={COLOR.white} bold>
984 {earned.size}/{all.length}
985 </Text>
986 <Text color={COLOR.gray}>advancements made</Text>
987 </Box>,
988 ]
989
990 const tabs = TABS.map((tab, index) => {
991 const done = all.filter(one => one.tab === tab && earned.has(one.id)).length
992 const label = ' ' + tab + ' ' + done + ' '
993 if (index === shownTab) {
994 return (
995 <Text color={COLOR.black} backgroundColor={COLOR.gold} bold>
996 {label}
997 </Text>
998 )
999 }
1000 return (
1001 <Button
1002 key={'tab-' + index}
1003 hotkey={String(index + 1)}
1004 plain
1005 onPress={() => {
1006 shownTab = index
1007 pickedAdvancement = ''
1008 $.ui.invalidate('ui.render')
1009 }}
1010 >
1011 {tab + ' ' + done}
1012 </Button>
1013 )
1014 })
1015 rows.push(
1016 <Box flexDirection="row" flexWrap="wrap" columnGap={1} marginTop={1}>
1017 {tabs}
1018 </Box>,
1019 )
1020
1021 const tab = TABS[shownTab] ?? 'Story'
1022 const hidden = all.filter(one => one.tab === tab && one.isHidden && !earned.has(one.id)).length
1023 const isLocked = (one: Advancement) => !earned.has(one.id) && one.after !== undefined && !earned.has(one.after)
1024 if (sprites && 'Image' in elements && 'Raster' in elements) {
1025 const { Image, Raster } = elements
1026 const inTab = shown().filter(one => one.tab === tab)
1027 const picked =
1028 inTab.find(one => one.id === pickedAdvancement) ?? inTab.find(one => !earned.has(one.id) && !isLocked(one)) ?? inTab[0]
1029 const tree = advancementTree(tab, inTab, earned, picked?.id ?? '', e.props.bodyColumns)
1030 if (tree && picked) {
1031 // The whole screen sits on the tab's ground, so the text and the gap
1032 // around each icon read the same on a light theme as on a dark one
1033 const backdrop = '#' + TAB_GROUND[tab].toString(16).padStart(6, '0')
1034 const ground = tree.bands.map((band, row) => {
1035 return (
1036 <Box flexDirection="row">
1037 {band.map((piece, index) => {
1038 if ('picture' in piece) return <Raster key={'ground-' + row + '-' + index} {...piece.picture} />
1039 const one = titles[piece.id]!
1040 const state = earned.has(one.id) ? 'earned' : isLocked(one) ? 'locked' : 'open'
1041 return (
1042 <Box key={'node-' + one.id} width={4} height={2} backgroundColor={backdrop} hover={{ scope: 'advancement-' + one.id }}>
1043 <Image source={{ file: sprites + 'adv-' + one.id + '-' + state + '.png', format: 'png' }} columns={4} rows={2} alt={one.icon} />
1044 </Box>
1045 )
1046 })}
1047 </Box>
1048 )
1049 })
1050
1051 // What one advancement asks for, every line as wide as the body so a
1052 // card shown over another hides it. It sits over the tree, where it
1053 // stays in view while a tall tree scrolls
1054 const width = e.props.bodyColumns
1055 const lines = width >= 66 ? 2 : 3
1056 const card = (one: Advancement) => {
1057 const isEarned = earned.has(one.id)
1058 const parent = one.after ? titles[one.after] : undefined
1059 const lit = one.frame === 'challenge' ? COLOR.purple : COLOR.gold
1060 const kind = one.frame === 'task' ? '' : one.frame === 'goal' ? 'Goal' : 'Challenge'
1061 const state = isEarned ? '✔ Made' : isLocked(one) && parent ? 'After ' + parent.title : 'Open'
1062 const facts = [kind, one.xp ? '+' + one.xp + ' XP' : '', state].filter(Boolean).join(' · ')
1063 const how = wrapText(one.how, width).slice(0, lines)
1064 return [
1065 <Box flexDirection="row" width={width}>
1066 <Text color={isEarned ? lit : isLocked(one) ? COLOR.stone : COLOR.white} bold wrap="truncate">
1067 {one.title}
1068 </Text>
1069 <Text color={isEarned ? COLOR.green : COLOR.gray} wrap="truncate">
1070 {(' ' + facts).padEnd(Math.max(0, width - one.title.length))}
1071 </Text>
1072 </Box>,
1073 ...Array.from({ length: lines }, (_, line) => (
1074 <Text color={COLOR.gray} wrap="truncate">
1075 {(how[line] ?? '').padEnd(width)}
1076 </Text>
1077 )),
1078 ]
1079 }
1080 const step = (by: number) => () => {
1081 const at = tree.order.indexOf(picked.id)
1082 pickedAdvancement = tree.order[(at + by + tree.order.length) % tree.order.length] ?? ''
1083 $.ui.invalidate('ui.render')
1084 }
1085 return (
1086 <Box flexDirection="column" width={width} backgroundColor={backdrop}>
1087 <Box flexDirection="row" flexWrap="wrap" columnGap={1}>
1088 {tabs}
1089 </Box>
1090 <Box flexDirection="column" height={lines + 1} marginTop={1}>
1091 {card(picked)}
1092 {tree.order
1093 .filter(id => id !== picked.id)
1094 .map(id => (
1095 <Box
1096 position="absolute"
1097 top={0}
1098 left={0}
1099 flexDirection="column"
1100 backgroundColor={backdrop}
1101 display="none"
1102 hover={{ scope: 'advancement-' + id, display: 'flex' }}
1103 >
1104 {card(titles[id]!)}
1105 </Box>
1106 ))}
1107 </Box>
1108 <Box flexDirection="row" columnGap={1}>
1109 <Button key="previous" hotkey="p" plain onPress={step(-1)}>
1110 Previous
1111 </Button>
1112 <Button key="next" hotkey="n" plain onPress={step(1)}>
1113 Next
1114 </Button>
1115 <Text color={COLOR.gray} wrap="truncate">
1116 {'· ' + earned.size + '/' + all.length + ' made' + (hidden > 0 ? ' · ' + hidden + ' hidden in this tab' : '')}
1117 </Text>
1118 </Box>
1119 {ground}
1120 </Box>
1121 )
1122 }
1123 }
1124
1125 const cards = []
1126 for (const advancement of shown().filter(one => one.tab === tab)) {
1127 const isEarned = earned.has(advancement.id)
1128 const parent = advancement.after ? titles[advancement.after] : undefined
1129 const lit = advancement.frame === 'challenge' ? COLOR.purple : COLOR.gold
1130 const frame = advancement.frame === 'challenge' ? 'double' : advancement.frame === 'goal' ? 'round' : 'single'
1131 cards.push(
1132 <Box flexDirection="column" width={cardWidth} borderStyle={frame} borderColor={isEarned ? lit : COLOR.darkGray}>
1133 <Text color={isEarned ? lit : isLocked(advancement) ? COLOR.darkGray : COLOR.stone} bold={isEarned} wrap="truncate">
1134 {isLocked(advancement) ? '🔒' : advancement.icon} {advancement.title}
1135 </Text>
1136 <Text color={isEarned ? COLOR.white : COLOR.darkGray}>
1137 {isLocked(advancement) && parent ? 'After ' + parent.title : advancement.how}
1138 </Text>
1139 </Box>,
1140 )
1141 }
1142 rows.push(
1143 <Box flexDirection="row" flexWrap="wrap" columnGap={1}>
1144 {cards}
1145 </Box>,
1146 )
1147 if (hidden > 0) {
1148 rows.push(
1149 <Text color={COLOR.darkGray} italic>
1150 {hidden === 1 ? '1 hidden advancement' : hidden + ' hidden advancements'} in this tab
1151 </Text>,
1152 )
1153 }
1154 return <Box flexDirection="column">{rows}</Box>
1155 })
1156
1157 // The HUD, laid out as the game's: armor over the hearts, hearts for the
1158 // context left on the left, drumsticks for the usage limit left on the
1159 // right, the XP bar under both, and while a turn runs the hotbar of tools
1160 // it has used, with a villager for each subagent that is out. Beside it sit
1161 // the two things to act on: a low-health warning and the unverified edits.
1162 // Until the first prompt the title sits above it; a long command's boss
1163 // bar takes that place while it runs
1164 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
1165 if (e.props.hasSurvey) return next(e)
1166 const { Box, Text } = $.ui.resolve(e)
1167 const isWide = e.props.bodyColumns >= 44
1168 const hudWidth = isWide ? 36 : 20
1169
1170 const used = contextPercent ?? 0
1171 const health = halves(100 - used)
1172 const hearts = []
1173 for (let i = 0; i < 10; i++) {
1174 const left = health - i * 2
1175 if (isHurt) hearts.push(<Text color={COLOR.white}>♥</Text>)
1176 else if (left >= 2) hearts.push(<Text color={COLOR.red}>♥</Text>)
1177 else if (left === 1) hearts.push(<Text color={COLOR.darkRed}>♥</Text>)
1178 else hearts.push(<Text color={COLOR.darkGray}>♡</Text>)
1179 }
1180
1181 // Food empties from the left, as the game's right-anchored bar does
1182 const food = []
1183 if (hungerPercent !== undefined && isWide) {
1184 const full = Math.ceil(halves(100 - hungerPercent) / 2)
1185 for (let i = 0; i < 10; i++) {
1186 if (i < 10 - full) food.push(<Text color={COLOR.darkGray}>░░</Text>)
1187 else food.push(<Text>🍗</Text>)
1188 }
1189 }
1190
1191 const worn = armor()
1192 const { level, into, cost } = levelOf(xp)
1193 const label = 'Lv ' + level
1194 const experience = bar(into, cost, hudWidth - label.length - 1)
1195 const verifiedText = verifiedLine()
1196 const verifiedColor = unverifiedEdits >= 5 ? COLOR.gold : unverifiedEdits === 0 ? COLOR.green : COLOR.gray
1197
1198 // The hotbar: painted slots where the terminal has the cell grid and the
1199 // band has four rows left for them, a line of glyphs otherwise
1200 const elements = $.ui.resolve(e)hooks/lore.ts 821 lines1// Words, glyphs and arithmetic for the theme. Nothing here touches the mods API.
2
3export const COLOR = {
4 red: '#FF5555',
5 darkRed: '#AA0000',
6 green: '#55FF55',
7 xp: '#80FF20',
8 yellow: '#FFFF55',
9 gold: '#FFAA00',
10 gray: '#AAAAAA',
11 darkGray: '#555555',
12 white: '#FFFFFF',
13 stone: '#C6C6C6',
14 purple: '#FF55FF',
15 aqua: '#55FFFF',
16 emerald: '#17DD62',
17 slot: '#3A3A3A',
18 black: '#000000',
19}
20
21export const SPLASHES = [
22 'Also try reading the logs!',
23 'Now with more redstone!',
24 '100% pure tool calls!',
25 'Punching trees since turn one!',
26 'Creepers hate passing tests!',
27 'Technically a sandbox game!',
28 'Mine first, craft later!',
29 'May contain flaky tests!',
30 'Never dig straight down!',
31 'Sleep resets the context!',
32]
33
34// A stable pick: the same text always lands on the same entry, so a row keeps
35// its wording across redraws.
36export function pick<T>(list: readonly T[], seed: string): T {
37 let hash = 0
38 for (let i = 0; i < seed.length; i++) hash = (hash * 31 + seed.charCodeAt(i)) >>> 0
39 return list[hash % list.length] as T
40}
41
42const TOOL_WORDS: Record<string, string> = {
43 Grep: 'Mining',
44 Glob: 'Digging',
45 Read: 'Reading book and quill',
46 Edit: 'Crafting',
47 Write: 'Crafting',
48 NotebookEdit: 'Crafting',
49 Bash: 'Wiring redstone',
50 WebSearch: 'Exploring',
51 WebFetch: 'Exploring',
52 Agent: 'Trading with villager',
53 Task: 'Trading with villager',
54 Skill: 'Enchanting',
55 TodoWrite: 'Writing signs',
56 AskUserQuestion: 'Ringing the village bell',
57}
58
59const MODE_WORDS: Record<string, readonly string[]> = {
60 thinking: ['Enchanting', 'Brewing', 'Smelting'],
61 requesting: ['Loading chunks', 'Generating terrain'],
62 responding: ['Writing in book', 'Placing blocks'],
63 'tool-input': ['Gathering materials', 'Sharpening pickaxe'],
64 'tool-use': ['Mining', 'Building'],
65}
66
67export function spinnerWord(tool: string | undefined, mode: string, seed: string): string {
68 let word: string
69 if (tool && tool.startsWith('mcp__')) word = 'Opening chest'
70 else if (tool && TOOL_WORDS[tool]) word = TOOL_WORDS[tool] as string
71 else word = pick(MODE_WORDS[mode] ?? MODE_WORDS['tool-use']!, seed)
72 return '⛏ ' + word
73}
74
75const PAST_WORDS = ['Mined', 'Crafted', 'Smelted', 'Enchanted', 'Brewed', 'Built', 'Farmed']
76
77export function pastWord(seed: string): string {
78 return pick(PAST_WORDS, seed)
79}
80
81const TOOL_GLYPHS: Record<string, string> = {
82 Grep: '⛏',
83 Glob: '⛏',
84 Read: '📖',
85 Edit: '🔨',
86 Write: '🔨',
87 NotebookEdit: '🔨',
88 Bash: '🔴',
89 WebSearch: '🧭',
90 WebFetch: '🧭',
91 Agent: '👤',
92 Task: '👤',
93 Skill: '✨',
94 TodoWrite: '📜',
95}
96
97export function toolGlyph(tool: string): string {
98 if (tool.startsWith('mcp__')) return '📦'
99 return TOOL_GLYPHS[tool] ?? '🧱'
100}
101
102// What a tool call reads as in chat, the way the game reports an operator's
103// command: `ran pytest -q` inside `[Claude: ran pytest -q]`.
104export function chatAction(tool: string, input: unknown, root: string): string {
105 const given = (input ?? {}) as Record<string, unknown>
106 const text = (key: string) => (typeof given[key] === 'string' ? (given[key] as string) : '')
107 const short = (value: string, most = 72) => {
108 const line = (value.split('\n')[0] ?? '').trim()
109 return line.length > most ? line.slice(0, most - 1) + '…' : line
110 }
111 const path = (value: string) => (root && value.startsWith(root + '/') ? value.slice(root.length + 1) : value)
112 if (tool === 'Bash') return 'ran ' + short(text('command'))
113 if (tool === 'Read') return 'read ' + path(text('file_path'))
114 if (tool === 'Edit' || tool === 'NotebookEdit') return 'edited ' + path(text('file_path') || text('notebook_path'))
115 if (tool === 'Write') return 'wrote ' + path(text('file_path'))
116 if (tool === 'Grep') return 'searched for ' + short(text('pattern'), 40)
117 if (tool === 'Glob') return 'looked for ' + short(text('pattern'), 40)
118 if (tool === 'Agent' || tool === 'Task') return 'sent a villager to ' + short(text('description') || 'help', 48)
119 if (tool === 'Skill') return 'used the skill ' + text('skill')
120 if (tool === 'WebFetch') return 'fetched ' + short(text('url'), 60)
121 if (tool === 'WebSearch') return 'searched the web for ' + short(text('query'), 48)
122 if (tool.startsWith('mcp__')) return 'used ' + tool.split('__').slice(1).join(' ')
123 return 'used ' + tool
124}
125
126const DEATHS = [
127 'fell from a high place',
128 'was blown up by Creeper',
129 'tried to swim in lava',
130 'was slain by Zombie',
131 'hit the ground too hard',
132 'fell out of the world',
133 'was shot by Skeleton',
134 'withered away',
135 'was pricked to death',
136 'suffocated in a wall',
137 'was struck by lightning',
138 'walked into a cactus',
139]
140
141// The first word of a shell command that is not an env assignment: `pytest`
142// for `FOO=1 pytest -q tests/`.
143export function commandName(command: string): string {
144 for (const word of command.trim().split(/\s+/)) {
145 if (word === '' || /^[A-Za-z_][A-Za-z0-9_]*=/.test(word)) continue
146 return word.split('/').pop() || word
147 }
148 return ''
149}
150
151export function deathMessage(tool: string, subject: string, text: string, seed: string): string {
152 const who = subject || tool
153 if (/interrupt/i.test(text)) return who + ' left the game'
154 if (/doesn't want to proceed|was rejected|permission.*denied|denied by/i.test(text)) {
155 return who + ' was kicked by an operator'
156 }
157 let detail = ''
158 const failed = /(\d+) failed/.exec(text)
159 const exit = /exit code (\d+)/i.exec(text)
160 if (failed) detail = ' (' + failed[1] + ' failed)'
161 else if (exit) detail = ' (exit ' + exit[1] + ')'
162 else if (/no such file|does not exist|not found/i.test(text)) detail = ' (not found)'
163 else if (/timed? ?out/i.test(text)) detail = ' (timed out)'
164 return who + ' ' + pick(DEATHS, seed) + detail
165}
166
167export function isTestCommand(command: string): boolean {
168 return /\b(pytest|go test|cargo test|npm (run )?test|pnpm (run )?test|yarn test|jest|vitest|tox|just \S*test\S*|make \S*test\S*)\b/.test(
169 command,
170 )
171}
172
173export type Tab = 'Story' | 'Nether' | 'The End' | 'Adventure' | 'Husbandry'
174
175// The game's three frames: a task is a step, a goal a milestone, a challenge
176// one of the hard ones. `after` names the advancement this one hangs under on
177// the screen; it can still be earned first, as in the game. A hidden one is
178// not shown until it is earned. `xp` is paid once, when it is earned.
179export type Advancement = {
180 id: string
181 title: string
182 how: string
183 icon: string
184 tab: Tab
185 frame: 'task' | 'goal' | 'challenge'
186 after?: string
187 isHidden?: boolean
188 xp?: number
189}
190
191export const ADVANCEMENTS = {
192 stoneAge: { id: "stoneAge", title: "Stone Age", how: "Edit a file", icon: "🧱", tab: "Story", frame: "task" },
193 upgrade: { id: "upgrade", title: "Getting an Upgrade", how: "Get a green test run", icon: "🔰", tab: "Story", frame: "task", after: "stoneAge" },
194 acquireHardware: { id: "acquireHardware", title: "Acquire Hardware", how: "Get a build to succeed", icon: "🔩", tab: "Story", frame: "task", after: "upgrade" },
195 suitUp: { id: "suitUp", title: "Suit Up", how: "Pass a linter or a type checker", icon: "🦺", tab: "Story", frame: "task", after: "acquireHardware" },
196 notToday: { id: "notToday", title: "Not Today, Thank You", how: "Clear a problem a linter or type checker caught", icon: "🧿", tab: "Story", frame: "task", after: "suitUp" },
197 furiousCocktail: { id: "furiousCocktail", title: "A Furious Cocktail", how: "Have tests, checks and the build all green since the last edit", icon: "🧪", tab: "Story", frame: "challenge", after: "notToday", xp: 30 },
198 howDidWe: { id: "howDidWe", title: "How Did We Get Here?", how: "In one session: every check green, a verified commit, a push, a pull request, a subagent, a web lookup and a skill or MCP call", icon: "🌀", tab: "Story", frame: "challenge", after: "furiousCocktail", isHidden: true, xp: 100 },
199 ironPick: { id: "ironPick", title: "Isn't It Iron Pick", how: "Make a git commit", icon: "🪓", tab: "Story", frame: "task", after: "acquireHardware" },
200 diamonds: { id: "diamonds", title: "Diamonds!", how: "Commit with the tests green and nothing edited since", icon: "💎", tab: "Story", frame: "task", after: "ironPick" },
201 coverMe: { id: "coverMe", title: "Cover Me with Diamonds", how: "Make 25 verified commits", icon: "👑", tab: "Story", frame: "goal", after: "diamonds", xp: 15 },
202 coverMeInDebris: { id: "coverMeInDebris", title: "Cover Me in Debris", how: "Make 250 verified commits", icon: "🌑", tab: "Story", frame: "challenge", after: "coverMe", xp: 100 },
203 enchanter: { id: "enchanter", title: "Enchanter", how: "Use a skill", icon: "📕", tab: "Story", frame: "task", after: "diamonds" },
204 hotStuff: { id: "hotStuff", title: "Hot Stuff", how: "Push your commits", icon: "🔥", tab: "Story", frame: "task", after: "ironPick" },
205 iceBucket: { id: "iceBucket", title: "Ice Bucket Challenge", how: "Resolve a merge conflict", icon: "🧊", tab: "Story", frame: "task", after: "hotStuff" },
206 eyeSpy: { id: "eyeSpy", title: "Eye Spy", how: "Start a new branch", icon: "👀", tab: "Story", frame: "task", after: "iceBucket" },
207 zombieDoctor: { id: "zombieDoctor", title: "Zombie Doctor", how: "Get a broken tool working again", icon: "💉", tab: "Story", frame: "goal", after: "stoneAge", xp: 15 },
208 deeper: { id: "deeper", title: "We Need to Go Deeper", how: "Send out a subagent", icon: "🌋", tab: "Nether", frame: "task" },
209 subspaceBubble: { id: "subspaceBubble", title: "Subspace Bubble", how: "Send out 3 subagents in one turn", icon: "🫧", tab: "Nether", frame: "goal", after: "deeper" },
210 feelsLikeHome: { id: "feelsLikeHome", title: "Feels Like Home", how: "Send out 100 subagents", icon: "🏠", tab: "Nether", frame: "goal", after: "subspaceBubble", xp: 15 },
211 thoseWereTheDays: { id: "thoseWereTheDays", title: "Those Were the Days", how: "Read the history: git blame, log search or show", icon: "🏚", tab: "Nether", frame: "task", after: "deeper" },
212 hiddenInTheDepths: { id: "hiddenInTheDepths", title: "Hidden in the Depths", how: "Find the first bad commit with git bisect", icon: "🪨", tab: "Nether", frame: "goal", after: "thoseWereTheDays", xp: 15 },
213 returnToSender: { id: "returnToSender", title: "Return to Sender", how: "Revert a commit", icon: "📮", tab: "Nether", frame: "task", after: "thoseWereTheDays" },
214 totalBeelocation: { id: "totalBeelocation", title: "Total Beelocation", how: "Rebase a branch onto a new base", icon: "🐝", tab: "Nether", frame: "task", after: "thoseWereTheDays" },
215 theEnd: { id: "theEnd", title: "The End?", how: "Open a pull request", icon: "🌌", tab: "The End", frame: "task" },
216 freeTheEnd: { id: "freeTheEnd", title: "Free the End", how: "Merge a pull request", icon: "🐉", tab: "The End", frame: "task", after: "theEnd" },
217 nextGeneration: { id: "nextGeneration", title: "The Next Generation", how: "Open 10 pull requests", icon: "🥚", tab: "The End", frame: "goal", after: "freeTheEnd", xp: 15 },
218 theEndAgain: { id: "theEndAgain", title: "The End... Again...", how: "Open 50 pull requests", icon: "🔁", tab: "The End", frame: "goal", after: "nextGeneration", xp: 15 },
219 greatView: { id: "greatView", title: "Great View From Up Here", how: "Merge 10 pull requests", icon: "🔭", tab: "The End", frame: "challenge", after: "freeTheEnd", xp: 30 },
220 remoteGetaway: { id: "remoteGetaway", title: "Remote Getaway", how: "Open pull requests in 3 repositories", icon: "🏝", tab: "The End", frame: "task", after: "freeTheEnd" },
221 cityAtTheEnd: { id: "cityAtTheEnd", title: "The City at the End of the Game", how: "Cut a release", icon: "🏙", tab: "The End", frame: "task", after: "remoteGetaway" },
222 monsterHunter: { id: "monsterHunter", title: "Monster Hunter", how: "Turn a red test run green", icon: "🧟", tab: "Adventure", frame: "task" },
223 takeAim: { id: "takeAim", title: "Take Aim", how: "Run one targeted test", icon: "🎯", tab: "Adventure", frame: "task", after: "monsterHunter" },
224 sniperDuel: { id: "sniperDuel", title: "Sniper Duel", how: "Go green on the first test run after an edit", icon: "🏹", tab: "Adventure", frame: "goal", after: "takeAim", xp: 15 },
225 bullseye: { id: "bullseye", title: "Bullseye", how: "Go green on the first run 10 times in a row", icon: "🟡", tab: "Adventure", frame: "challenge", after: "sniperDuel", xp: 30 },
226 monstersHunted: { id: "monstersHunted", title: "Monsters Hunted", how: "Turn 100 red test runs green", icon: "💀", tab: "Adventure", frame: "challenge", after: "monsterHunter", xp: 50 },
227 overOverkill: { id: "overOverkill", title: "Over-Overkill", how: "Pass 500 or more tests in one run", icon: "🔨", tab: "Adventure", frame: "goal", after: "monsterHunter" },
228 heroOfTheVillage: { id: "heroOfTheVillage", title: "Hero of the Village", how: "Bring a run with 20 or more failures back to green", icon: "🏅", tab: "Adventure", frame: "challenge", after: "monsterHunter", isHidden: true, xp: 30 },
229 arbalistic: { id: "arbalistic", title: "Arbalistic", how: "Edit 5 or more files, then go green on the first run", icon: "🎇", tab: "Adventure", frame: "challenge", after: "sniperDuel", isHidden: true, xp: 30 },
230 postmortal: { id: "postmortal", title: "Postmortal", how: "Go green on your edits after a compaction", icon: "🗿", tab: "Adventure", frame: "goal", after: "monsterHunter", xp: 15 },
231 cavesAndCliffs: { id: "cavesAndCliffs", title: "Caves & Cliffs", how: "Fill 90% of the context window", icon: "🗻", tab: "Adventure", frame: "task" },
232 stickySituation: { id: "stickySituation", title: "Sticky Situation", how: "Make a verified commit with 95% of the context used", icon: "🍯", tab: "Adventure", frame: "task", after: "cavesAndCliffs", isHidden: true },
233 sneak100: { id: "sneak100", title: "Sneak 100", how: "Make 100 tool calls in a row with none failing", icon: "🥷", tab: "Adventure", frame: "goal" },
234 isItABird: { id: "isItABird", title: "Is It a Bird?", how: "Search or fetch from the web", icon: "🔎", tab: "Adventure", frame: "task" },
235 underLockAndKey: { id: "underLockAndKey", title: "Under Lock and Key", how: "Call an MCP server", icon: "🔑", tab: "Adventure", frame: "task" },
236 revaulting: { id: "revaulting", title: "Revaulting", how: "Use 5 different MCP servers", icon: "🔐", tab: "Adventure", frame: "goal", after: "underLockAndKey", xp: 15 },
237 sweetDreams: { id: "sweetDreams", title: "Sweet Dreams", how: "Come back to a repository on a later day", icon: "🛏", tab: "Adventure", frame: "task" },
238 adventuringTime: { id: "adventuringTime", title: "Adventuring Time", how: "Work in 10 different repositories", icon: "🧭", tab: "Adventure", frame: "challenge", after: "sweetDreams", xp: 50 },
239 countryLode: { id: "countryLode", title: "Country Lode, Take Me Home", how: "Edit a CLAUDE.md or AGENTS.md", icon: "🧲", tab: "Adventure", frame: "task" },
240 craftersCrafting: { id: "craftersCrafting", title: "Crafters Crafting Crafters", how: "Edit an agent, skill, command or hook", icon: "🤖", tab: "Adventure", frame: "task", after: "countryLode" },
241 aSeedyPlace: { id: "aSeedyPlace", title: "A Seedy Place", how: "Write a new test file and see it pass", icon: "🌱", tab: "Husbandry", frame: "task" },
242 reapWhatYouSow: { id: "reapWhatYouSow", title: "Reap What You Sow", how: "Verify your edits with a green run 100 times", icon: "🌾", tab: "Husbandry", frame: "goal", after: "aSeedyPlace", xp: 15 },
243 seriousDedication: { id: "seriousDedication", title: "Serious Dedication", how: "Verify your edits with a green run 1,000 times", icon: "🚜", tab: "Husbandry", frame: "challenge", after: "reapWhatYouSow", xp: 100 },
244 aBalancedDiet: { id: "aBalancedDiet", title: "A Balanced Diet", how: "Edit code in 8 different languages", icon: "🍱", tab: "Husbandry", frame: "challenge", after: "aSeedyPlace", xp: 50 },
245 glowAndBehold: { id: "glowAndBehold", title: "Glow and Behold!", how: "Write documentation", icon: "🪧", tab: "Husbandry", frame: "task" },
246 bestFriendsForever: { id: "bestFriendsForever", title: "Best Friends Forever", how: "Work 7 different days in one repository", icon: "🐺", tab: "Husbandry", frame: "goal", xp: 15 },
247 homesteader: { id: "homesteader", title: "Homesteader", how: "Work 7 days in a row", icon: "🏡", tab: "Husbandry", frame: "goal", after: "bestFriendsForever", xp: 15 },
248 oldGrowth: { id: "oldGrowth", title: "Old Growth", how: "Work 30 days in a row", icon: "🌳", tab: "Husbandry", frame: "challenge", after: "homesteader", xp: 50 },
249 passingTheTime: { id: "passingTheTime", title: "Passing the Time", how: "Work 100 days in total", icon: "⏳", tab: "Husbandry", frame: "challenge", after: "bestFriendsForever", xp: 100 },
250 birthdaySong: { id: "birthdaySong", title: "Birthday Song", how: "Work on the anniversary of your first session", icon: "🎂", tab: "Husbandry", frame: "task", isHidden: true },
251 plantingThePast: { id: "plantingThePast", title: "Planting the Past", how: "Go green in a repository you last touched 90 or more days ago", icon: "🏺", tab: "Husbandry", frame: "task", isHidden: true },
252} satisfies Record<string, Advancement>
253
254export const TABS: readonly Tab[] = ['Story', 'Nether', 'The End', 'Adventure', 'Husbandry']
255
256// Lifetime counters and the advancements their totals earn
257export const MILESTONES: Record<string, readonly (readonly [number, Advancement])[]> = {
258 verifiedCommits: [[25, ADVANCEMENTS.coverMe], [250, ADVANCEMENTS.coverMeInDebris]],
259 subagents: [[100, ADVANCEMENTS.feelsLikeHome]],
260 prs: [[10, ADVANCEMENTS.nextGeneration], [50, ADVANCEMENTS.theEndAgain]],
261 merges: [[10, ADVANCEMENTS.greatView]],
262 redToGreen: [[100, ADVANCEMENTS.monstersHunted]],
263 greens: [[100, ADVANCEMENTS.reapWhatYouSow], [1000, ADVANCEMENTS.seriousDedication]],
264}
265
266export function isBuildCommand(command: string): boolean {
267 if (/\btsc\b/.test(command)) return !/--noEmit\b/.test(command)
268 return /\b(cargo build|go build|(npm|pnpm) (run )?build|yarn build|mvn package|gradle\w* build|docker build|make( build| all)?\s*$)/.test(
269 command,
270 )
271}
272
273// A linter or a type checker
274export function isCheckCommand(command: string): boolean {
275 return /\b(eslint|ruff|mypy|pyright|flake8|pylint|clippy|go vet|golangci-lint|tsc\b.*--noEmit)\b/.test(command)
276}
277
278// A test command narrowed to one test or one file
279export function isTargetedTest(command: string): boolean {
280 return isTestCommand(command) && /(::| -k | -run | -t |--filter|\S+[._]test\.\w+|\S*test_\w+\.py|\.spec\.\w+)/.test(command)
281}
282
283export function isTestFile(path: string): boolean {
284 return /(^|\/)(test_[^/]*\.py|[^/]*_test\.\w+|[^/]*\.(test|spec)\.\w+)$|(^|\/)tests?\/[^/]+$/.test(path)
285}
286
287export function isDocFile(path: string): boolean {
288 if (/(^|\/)(CLAUDE|AGENTS|SKILL)\.md$/.test(path)) return false
289 return /(^|\/)README[^/]*$|\.(md|rst)$/i.test(path)
290}
291
292const LANGUAGES: Record<string, string> = {
293 py: 'Python', ts: 'TypeScript', tsx: 'TypeScript', js: 'JavaScript', jsx: 'JavaScript', mjs: 'JavaScript',
294 go: 'Go', rs: 'Rust', java: 'Java', kt: 'Kotlin', rb: 'Ruby', c: 'C', h: 'C', cpp: 'C++', cc: 'C++', hpp: 'C++',
295 cs: 'C#', swift: 'Swift', php: 'PHP', sh: 'Shell', bash: 'Shell', zsh: 'Shell', sql: 'SQL', lua: 'Lua',
296 scala: 'Scala', ex: 'Elixir', exs: 'Elixir', hs: 'Haskell', dart: 'Dart', r: 'R', pl: 'Perl', tf: 'Terraform',
297 html: 'HTML', css: 'CSS', scss: 'CSS',
298}
299
300// The language of a source file by its extension; undefined for data, docs
301// and config
302export function languageOf(path: string): string | undefined {
303 const extension = /\.([A-Za-z0-9]+)$/.exec(path)?.[1]?.toLowerCase()
304 return extension ? LANGUAGES[extension] : undefined
305}
306
307// The game mode a permission mode plays as
308export function gameMode(labels: string): { name: string; color: string } {
309 if (/bypass|dangerous/i.test(labels)) return { name: 'Hardcore', color: COLOR.red }
310 if (/plan/i.test(labels)) return { name: 'Spectator', color: COLOR.gray }
311 if (/accept/i.test(labels)) return { name: 'Creative', color: COLOR.aqua }
312 if (/auto|dontAsk/i.test(labels)) return { name: 'Adventure', color: COLOR.gold }
313 return { name: 'Survival', color: COLOR.green }
314}
315
316// The local calendar day of a clock reading, as 2026-10-03
317export function dayOf(ms: number): string {
318 const local = new Date(ms - new Date(ms).getTimezoneOffset() * 60000)
319 return local.toISOString().slice(0, 10)
320}
321
322export function daysBetween(from: string, to: string): number {
323 return Math.round((Date.parse(to) - Date.parse(from)) / 86400000)
324}
325
326// How many days in a row, ending today, are in the list
327export function streakEnding(days: readonly string[], today: string): number {
328 const have = new Set(days)
329 let streak = 0
330 let at = Date.parse(today)
331 while (have.has(new Date(at).toISOString().slice(0, 10))) {
332 streak += 1
333 at -= 86400000
334 }
335 return streak
336}
337
338// A filled-and-empty bar of `width` cells for `done` out of `total`.
339export function bar(done: number, total: number, width: number): { filled: string; empty: string } {
340 const cells = total <= 0 ? 0 : Math.max(0, Math.min(width, Math.round((done / total) * width)))
341 return { filled: '▰'.repeat(cells), empty: '▱'.repeat(width - cells) }
342}
343
344// Minecraft's own curve: each level costs more than the last.
345function costOfLevel(level: number): number {
346 if (level < 16) return 2 * level + 7
347 if (level < 31) return 5 * level - 38
348 return 9 * level - 158
349}
350
351export function levelOf(xp: number): { level: number; into: number; cost: number } {
352 let level = 0
353 let left = Math.max(0, Math.floor(xp))
354 while (left >= costOfLevel(level)) {
355 left -= costOfLevel(level)
356 level += 1
357 }
358 return { level, into: left, cost: costOfLevel(level) }
359}
360
361// Ten hearts over twenty half-units, as the game counts health.
362export function halves(percentLeft: number): number {
363 return Math.max(0, Math.min(20, Math.ceil(percentLeft / 5)))
364}
365
366const FONT: Record<string, readonly string[]> = {
367 C: ['111', '100', '100', '100', '111'],
368 L: ['100', '100', '100', '100', '111'],
369 A: ['111', '101', '111', '101', '101'],
370 U: ['101', '101', '101', '101', '111'],
371 D: ['110', '101', '101', '101', '110'],
372 O: ['111', '101', '101', '101', '111'],
373 E: ['111', '100', '111', '100', '111'],
374 R: ['111', '101', '110', '101', '101'],
375 F: ['111', '100', '111', '100', '100'],
376 T: ['111', '010', '010', '010', '010'],
377}
378
379// The title in block letters: a five-row font packed into three rows of text
380// with half blocks, two font rows to a text row.
381export function logoRows(word: string): string[] {
382 const rows = ['', '', '']
383 for (const letter of word) {
384 const glyph = FONT[letter]
385 if (!glyph) continue
386 for (let row = 0; row < 3; row++) {
387 for (let column = 0; column < 3; column++) {
388 const top = glyph[row * 2]?.[column] === '1'
389 const bottom = glyph[row * 2 + 1]?.[column] === '1'
390 rows[row] += top && bottom ? '█' : top ? '▀' : bottom ? '▄' : ' '
391 }
392 rows[row] += ' '
393 }
394 }
395 return rows
396}
397
398// A picture for the terminal's cell grid: its size in cells and the packed cells
399export type Picture = { columns: number; rows: number; cells: string }
400
401const CLEAR = 0x01000000
402
403function base64(bytes: Uint8Array): string {
404 const letters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
405 let out = ''
406 for (let at = 0; at < bytes.length; at += 3) {
407 const a = bytes[at] ?? 0
408 const b = bytes[at + 1] ?? 0
409 const c = bytes[at + 2] ?? 0
410 out += letters[a >> 2]! + letters[((a & 3) << 4) | (b >> 4)]!
411 out += at + 1 < bytes.length ? letters[((b & 15) << 2) | (c >> 6)]! : '='
412 out += at + 2 < bytes.length ? letters[c & 63]! : '='
413 }
414 return out
415}
416
417// Packs rows of pixels (a color, or undefined for see-through) into cells: two
418// pixel rows to a text row, drawn with half blocks.
419export type Stamp = { text: string; row: number; column: number; color: number; back: number }
420
421export function paint(pixels: (number | undefined)[][], stamps: Stamp[] = []): Picture {
422 const columns = Math.max(...pixels.map(row => row.length))
423 const rows = Math.ceil(pixels.length / 2)
424 const words = new Uint32Array(columns * rows * 3)
425 for (let row = 0; row < rows; row++) {
426 for (let column = 0; column < columns; column++) {
427 const top = pixels[row * 2]?.[column]
428 const bottom = pixels[row * 2 + 1]?.[column]
429 const at = (row * columns + column) * 3
430 if (top === undefined && bottom === undefined) words.set([0x20, CLEAR, CLEAR], at)
431 else if (bottom === undefined) words.set([0x2580, top!, CLEAR], at)
432 else if (top === undefined) words.set([0x2584, bottom, CLEAR], at)
433 else words.set([0x2580, top, bottom], at)
434 }
435 }
436 // Short labels written over cells, as a stack's count is
437 for (const stamp of stamps) {
438 ;[...stamp.text].forEach((letter, index) => {
439 const column = stamp.column + index
440 if (column < 0 || column >= columns || stamp.row >= rows) return
441 words.set([letter.charCodeAt(0), stamp.color, stamp.back], (stamp.row * columns + column) * 3)
442 })
443 }
444 return { columns, rows, cells: base64(new Uint8Array(words.buffer)) }
445}
446
447function sprite(art: string[], palette: Record<string, number>): Picture {
448 return paint(art.map(row => [...row].map(letter => palette[letter])))
449}
450
451// The player's head, front on, as the game's default skin wears it
452export const HEAD_PLAYER = sprite(['hhhhhh', 'hssssh', 'ssssss', 'wessew', 'ssnnss', 'smmmms'], {
453 h: 0x2f1f0f,
454 s: 0xbb8a66,
455 w: 0xffffff,
456 e: 0x4a3fb5,
457 n: 0x8f5f44,
458 m: 0x5a3622,
459})
460
461// Claude's head: a clay block with two eyes
462export const HEAD_CLAUDE = sprite(['llllll', 'cccccc', 'ckcckc', 'cccccc', 'cckkcc', 'dddddd'], {
463 l: 0xeaa083,
464 c: 0xd97757,
465 d: 0xa9553b,
466 k: 0x2b1a14,
467})
468
469// What sits in a hotbar slot for each kind of tool, six pixels square: a dark
470// outline, a lit side and a shaded one, as the game's item sprites have
471const ITEM_COLORS: Record<string, number> = {
472 d: 0x4aedd9, // diamond
473 D: 0x1f9c8f,
474 i: 0xb5fff5,
475 s: 0x9a6a3a, // stick, wood
476 S: 0x5c3a1c,
477 r: 0xc81e10, // redstone
478 R: 0xff4a36,
479 q: 0x7a0c06,
480 b: 0xa8452f, // brick
481 B: 0x7d2f1f,
482 m: 0x6e5a50, // mortar
483 w: 0xf4f4f4, // paper, pages
484 W: 0xc8c8c8,
485 g: 0xa8a8a8, // iron, stone
486 G: 0x6c6c6c,
487 k: 0x2b2b2b,
488 y: 0xffd83d, // gold
489 p: 0xa85cf0, // enchanted
490 P: 0x5a1f8f,
491 e: 0x17dd62, // emerald
492 E: 0x9cffc0,
493 F: 0x0b8a3c,
494 c: 0x8a5a36, // leather
495 C: 0x4a2d14,
496}
497const ITEMS: Record<string, string[]> = {
498 pickaxe: ['.DDDD.', 'DiddDD', '..SsdD', '.Ss.dD', 'Ss..D.', 'S.....'],
499 book: ['.CCCC.', 'CccccW', 'CcCCcW', 'CccccW', 'CCCCCW', '.wwww.'],
500 bricks: ['bbmbbb', 'BBmBBB', 'mmmmmm', 'bbbbmb', 'BBBBmB', 'mmmmmm'],
501 redstone: ['......', '..R...', '.rRr..', 'qrRRr.', '.qrRRr', '..qqq.'],
502 compass: ['.GGGG.', 'GkkkRG', 'GkkRkG', 'GkwkkG', 'GwkkkG', '.GGGG.'],
503 emerald: ['..FF..', '.FEeF.', 'FEeeeF', 'FeeeeF', '.FeeF.', '..FF..'],
504 enchanted: ['.PPPP.', 'PppppW', 'PpyypW', 'PpyypW', 'PPPPPW', '.wwww.'],
505 chest: ['CCCCCC', 'CssssC', 'CCyyCC', 'CsyysC', 'CssssC', 'CCCCCC'],
506 paper: ['.wwwW.', '.wGGw.', '.wwww.', '.wGGw.', '.wwww.', '.WWWW.'],
507 stone: ['gggGgg', 'gGgggg', 'ggggGg', 'Gggggg', 'ggGggg', 'gggggG'],
508}
509const TOOL_ITEMS: Record<string, string> = {
510 Grep: 'pickaxe',
511 Glob: 'pickaxe',
512 Read: 'book',
513 Edit: 'bricks',
514 Write: 'bricks',
515 NotebookEdit: 'bricks',
516 Bash: 'redstone',
517 WebSearch: 'compass',
518 WebFetch: 'compass',
519 Agent: 'emerald',
520 Task: 'emerald',
521 Skill: 'enchanted',
522 TodoWrite: 'paper',
523}
524
525// The item a tool holds in the hotbar, by its sprite's name
526export function itemOf(tool: string): string {
527 return tool.startsWith('mcp__') ? 'chest' : (TOOL_ITEMS[tool] ?? 'stone')
528}
529
530// The experience bar alone, one row: the level, then a thin notched bar
531export function xpPicture(width: number, level: number, into: number, cost: number): Picture {
532 const label = 'Lv ' + level + ' '
533 const span = Math.max(1, width - label.length)
534 const filled = cost <= 0 ? 0 : Math.max(0, Math.min(span, Math.round((into / cost) * span)))
535 const top: (number | undefined)[] = new Array(width).fill(undefined)
536 const bottom: (number | undefined)[] = new Array(width).fill(undefined)
537 for (let x = 0; x < span; x++) {
538 const isNotch = x % 8 === 7
539 bottom[label.length + x] = x < filled ? (isNotch ? 0x4fa010 : 0x80ff20) : isNotch ? 0x1c1c1c : 0x3a3a3a
540 }
541 return paint([top, bottom], [{ text: label, row: 0, column: 0, color: 0x80ff20, back: CLEAR }])
542}
543
544// One hotbar slot, eight pixels square: the tool's item inside the slot's
545// frame, its use count in the corner, and a white frame on the tool in hand
546export function slotPicture(tool: string, uses: number, isHeld: boolean): Picture {
547 const back = 0x2a2a2a
548 const frame = isHeld ? 0xffffff : 0x7a7a7a
549 const art = ITEMS[tool.startsWith('mcp__') ? 'chest' : (TOOL_ITEMS[tool] ?? 'stone')]!
550 const edge = new Array(8).fill(frame)
551 const pixels = [edge, ...art.map(row => [frame, ...[...row].map(letter => ITEM_COLORS[letter] ?? back), frame]), edge]
552 // The count sits on the frame's corner, so the frame stays whole
553 const count = String(uses)
554 return paint(pixels, [{ text: count, row: 3, column: 8 - count.length, color: isHeld ? 0x000000 : 0xffffff, back: frame }])
555}
556
557// The HUD icons, three pixels wide: a heart and a drumstick are two tall, and
558// an armor point is a single plate over its heart. `1` is the lit pixel, `2` the
559// body, `3` the shaded one; a half heart keeps its left column
560const HEART = ['1.2', '.3.']
561const PLATE = ['122']
562const DRUMSTICK = ['.12', 'b3.']
563const TINTS = {
564 heart: { '1': 0xff8a8a, '2': 0xff1a1a, '3': 0xb00c0c, b: 0, off: 0x4a1c1c },
565 hurt: { '1': 0xffffff, '2': 0xffffff, '3': 0xdddddd, b: 0, off: 0x4a1c1c },
566 plate: { '1': 0xf4f4f4, '2': 0xc4c4c4, '3': 0x8c8c8c, b: 0, off: 0x3a3a3a },
567 food: { '1': 0xe89a4a, '2': 0xc8792d, '3': 0x8f4f1a, b: 0xf0e8d8, off: 0x3a2a1c },
568}
569
570// The game's HUD in one picture: armor over hearts on the left, food on the
571// right, and the experience bar under both with the level at its start.
572// `health` is in half hearts (0 to 20); `armor` and `food` count whole icons
573// (0 to 10) and are left out when undefined.
574export function hudPicture(state: { health: number; isHurt: boolean; armor?: number; food?: number; level: number; into: number; cost: number }): Picture {
575 const width = state.food === undefined ? 39 : 81
576 const pixels: (number | undefined)[][] = []
577 const blank = () => new Array<number | undefined>(width).fill(undefined)
578 const draw = (rows: (number | undefined)[][], art: string[], x: number, tint: Record<string, number>, lit: 'full' | 'half' | 'off') => {
579 art.forEach((line, y) => {
580 ;[...line].forEach((letter, column) => {
581 if (letter === '.') return
582 const isLit = lit === 'full' || (lit === 'half' && column === 0)
583 rows[y]![x + column] = isLit ? tint[letter] : tint.off
584 })
585 })
586 }
587 if (state.armor !== undefined) {
588 const rows = [blank()]
589 for (let i = 0; i < 10; i++) draw(rows, PLATE, i * 4, TINTS.plate, i < state.armor ? 'full' : 'off')
590 pixels.push(...rows, blank())
591 }
592 const rows = [blank(), blank()]
593 for (let i = 0; i < 10; i++) {
594 const left = state.health - i * 2
595 draw(rows, HEART, i * 4, state.isHurt ? TINTS.hurt : TINTS.heart, left >= 2 ? 'full' : left === 1 ? 'half' : 'off')
596 }
597 // Food empties from the left, as the game's right-anchored bar does
598 if (state.food !== undefined) {
599 for (let i = 0; i < 10; i++) draw(rows, DRUMSTICK, 42 + i * 4, TINTS.food, i >= 10 - state.food ? 'full' : 'off')
600 }
601 pixels.push(...rows)
602 // A thin bar, as the game's is; it starts after the level's label, which is stamped on its row
603 const label = 'Lv ' + state.level + ' '
604 const span = width - label.length
605 const filled = state.cost <= 0 ? 0 : Math.max(0, Math.min(span, Math.round((state.into / state.cost) * span)))
606 const top = blank()
607 const bottom = blank()
608 for (let x = 0; x < span; x++) {
609 const isNotch = x % 8 === 7
610 bottom[label.length + x] = x < filled ? (isNotch ? 0x4fa010 : 0x80ff20) : isNotch ? 0x1c1c1c : 0x3a3a3a
611 }
612 pixels.push(top, bottom)
613 return paint(pixels, [{ text: label, row: pixels.length / 2 - 1, column: 0, color: 0x80ff20, back: CLEAR }])
614}
615
616// The title as the game opens on it: stone letters lit from above, standing
617// on a strip of grass and dirt
618export function titleScene(word: string): Picture {
619 const width = word.length * 4 + 1
620 const stone = [0xf2f2f2, 0xd0d0d0, 0xb0b0b0, 0x8e8e8e, 0x6c6c6c]
621 const pixels: (number | undefined)[][] = []
622 for (let row = 0; row < 5; row++) {
623 const line: (number | undefined)[] = new Array(width).fill(undefined)
624 ;[...word].forEach((letter, index) => {
625 const glyph = FONT[letter]
626 for (let column = 0; column < 3; column++) {
627 if (glyph?.[row]?.[column] === '1') line[1 + index * 4 + column] = stone[row]
628 }
629 })
630 pixels.push(line)
631 }
632 pixels.push(new Array(width).fill(undefined))
633 const ground = [
634 [0x7cbd4f, 0x6aa83f],
635 [0x5e9a36, 0x6aa83f],
636 [0x8a5a36, 0x6e4527],
637 [0x7a4e2e, 0x8a5a36],
638 ]
639 for (const [main, speck] of ground) {
640 pixels.push(Array.from({ length: width }, (_, x) => ((x * 7 + pixels.length * 13) % 5 === 0 ? speck : main)))
641 }
642 return paint(pixels)
643}
644
645// The ground each tab's tree is drawn on: stone, netherrack, end stone, grass
646// and farmland, kept dark so the icons stand out. tools/sprites.py fills the
647// corners of each icon with the same color
648export const TAB_GROUND: Record<Tab, number> = {
649 Story: 0x2e2e32,
650 Nether: 0x341416,
651 'The End': 0x32301e,
652 Adventure: 0x1e3020,
653 Husbandry: 0x33261a,
654}
655
656// Breaks text into lines of at most `width` cells, at spaces
657export function wrapText(text: string, width: number): string[] {
658 const lines: string[] = []
659 let line = ''
660 for (const word of text.split(' ')) {
661 if (line && line.length + 1 + word.length > width) {
662 lines.push(line)
663 line = word
664 } else {
665 line = line ? line + ' ' + word : word
666 }
667 }
668 if (line) lines.push(line)
669 return lines
670}
671
672// One piece of a row of the tree: a stretch of ground and lines, or the icon
673// of an advancement
674export type TreePiece = { picture: Picture } | { id: string }
675
676export type Tree = { columns: number; order: string[]; bands: TreePiece[][] }
677
678const NODE_COLUMNS = 4
679const NODE_PITCH = 8
680const ROW_PITCH = 3
681const EDGE = 2
682
683function shade(color: number, by: number): number {
684 const part = (shift: number) => Math.max(0, Math.min(255, ((color >> shift) & 255) + by)) << shift
685 return part(16) | part(8) | part(0)
686}
687
688// Lays a tab's advancements out as the game does: each one to the right of
689// the one it comes after, siblings stacked, lines joining them. Trees with
690// separate roots sit side by side while they fit in `maxColumns`, else in one
691// stack; undefined when even that is too wide. The selected one gets white
692// brackets. `order` is the walk the next and previous keys follow
693export function advancementTree(
694 tab: Tab,
695 visible: Advancement[],
696 earned: ReadonlySet<string>,
697 selected: string,
698 maxColumns: number,
699): Tree | undefined {
700 const mine = visible.filter(one => one.tab === tab)
701 const ids = new Set(mine.map(one => one.id))
702 const roots = mine.filter(one => !one.after || !ids.has(one.after))
703 const childrenOf = (id: string) => mine.filter(one => one.after === id)
704
705 // Each root's tree as a block of its own, in node columns and node rows
706 type Placed = { id: string; x: number; y: number; parent?: Placed }
707 const blocks = roots.map(root => {
708 const nodes: Placed[] = []
709 const place = (one: Advancement, x: number, top: number, parent?: Placed): number => {
710 const node: Placed = { id: one.id, x, y: top, parent }
711 nodes.push(node)
712 let height = 0
713 for (const child of childrenOf(one.id)) height += place(child, x + 1, top + height, node)
714 return Math.max(1, height)
715 }
716 const height = place(root, 0, 0)
717 return { nodes, height, width: Math.max(...nodes.map(node => node.x)) + 1 }
718 })
719 if (blocks.length === 0) return undefined
720
721 const pack = (limit: number) => {
722 const placed: Placed[] = []
723 let left = 0
724 let top = 0
725 let wide = 0
726 let rows = 0
727 for (const block of blocks) {
728 if (top > 0 && top + block.height > limit) {
729 left += wide
730 top = 0
731 wide = 0
732 }
733 for (const node of block.nodes) {
734 node.x += left
735 node.y += top
736 placed.push(node)
737 }
738 top += block.height
739 wide = Math.max(wide, block.width)
740 rows = Math.max(rows, top)
741 }
742 return { placed, rows, columns: (left + wide) * NODE_PITCH - (NODE_PITCH - NODE_COLUMNS) + EDGE * 2 }
743 }
744 // Remember where each node sits inside its block, to pack twice
745 const home = new Map(blocks.flatMap(block => block.nodes.map(node => [node, { x: node.x, y: node.y }] as const)))
746 let packed = pack(Math.max(4, ...blocks.map(block => block.height)))
747 if (packed.columns > maxColumns) {
748 for (const [node, at] of home) Object.assign(node, at)
749 packed = pack(Infinity)
750 }
751 if (packed.columns > maxColumns) return undefined
752
753 // The ground, then the lines, then the outline of the selected one
754 // The ground runs the width of the screen, as the game's window does
755 const { placed } = packed
756 const columns = Math.max(packed.columns, Math.min(maxColumns, 120))
757 const rows = 1 + packed.rows * ROW_PITCH
758 const ground = TAB_GROUND[tab]
759 const pixels: number[][] = Array.from({ length: rows * 2 }, (_, y) =>
760 Array.from({ length: columns }, (_, x) => {
761 const speck = ((x * 73856093) ^ (y * 19349663)) >>> 0
762 return speck % 9 === 0 ? shade(ground, 10) : speck % 7 === 0 ? shade(ground, -8) : ground
763 }),
764 )
765 const fill = (x0: number, x1: number, y0: number, y1: number, color: number) => {
766 for (let y = Math.max(0, y0); y <= y1 && y < pixels.length; y++) {
767 for (let x = Math.max(0, x0); x <= x1 && x < columns; x++) pixels[y]![x] = color
768 }
769 }
770 const leftOf = (node: Placed) => EDGE + node.x * NODE_PITCH
771 const topOf = (node: Placed) => (1 + node.y * ROW_PITCH) * 2
772 const joined = placed.filter(node => node.parent)
773 // Lit lines go on last, so a shared trunk shows the earned path
774 for (const isLit of [false, true]) {
775 for (const node of joined) {
776 if (earned.has(node.id) !== isLit) continue
777 const color = isLit ? 0xffffff : 0x6f6f6f
778 const from = leftOf(node.parent!) + NODE_COLUMNS
779 const out = topOf(node.parent!) + 1
780 const into = topOf(node) + 1
781 fill(from, from + 2, out, out + 1, color)
782 fill(from + 1, from + 2, out, into + 1, color)
783 fill(from + 1, from + 3, into, into + 1, color)
784 }
785 }
786 const picked = placed.find(node => node.id === selected)
787 if (picked) {
788 const x = leftOf(picked)
789 const y = topOf(picked)
790 // A bracket at each corner, clear of where the lines meet the icon
791 const right = x + NODE_COLUMNS
792 for (const [edge, inner] of [
793 [y - 1, y],
794 [y + 4, y + 3],
795 ] as const) {
796 fill(x - 1, x, edge, edge, 0xffffff)
797 fill(right - 1, right, edge, edge, 0xffffff)
798 fill(x - 1, x - 1, Math.min(edge, inner), Math.max(edge, inner), 0xffffff)
799 fill(right, right, Math.min(edge, inner), Math.max(edge, inner), 0xffffff)
800 }
801 }
802
803 // Cut into rows of pieces: icons where the nodes are, ground between
804 const cut = (x0: number, x1: number, row: number, tall: number): TreePiece => ({
805 picture: paint(pixels.slice(row * 2, (row + tall) * 2).map(line => line.slice(x0, x1))),
806 })
807 const bands: TreePiece[][] = [[cut(0, columns, 0, 1)]]
808 for (let y = 0; y < packed.rows; y++) {
809 const row = 1 + y * ROW_PITCH
810 const band: TreePiece[] = []
811 let at = 0
812 for (const node of placed.filter(one => one.y === y).sort((a, b) => a.x - b.x)) {
813 band.push(cut(at, leftOf(node), row, 2), { id: node.id })
814 at = leftOf(node) + NODE_COLUMNS
815 }
816 band.push(cut(at, columns, row, 2))
817 bands.push(band, [cut(0, columns, row + 2, 1)])
818 }
819 return { columns, order: placed.map(node => node.id), bands }
820}
821