SLOPSHOPPER

codecraft

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

newpanebandspinnerrowsguard
★ 2v0.4.1MITupdated 2026-10-03mkpvishnu/claude-mods/codecraft
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · codecraft
│ ┃ Advancements ✕ ▀▀▀ ▀▀▀ ▀▀▄ ▀▀▀ ▀▀▀ ▀▀▀ ▀▀▀ ▀▀▀╭────────────────────────────────╮ │ ┃ Story 2 2: Nether 0 3: The End 0 4: Advent ▀ ▀ ▀ ▀ ▀ ▀▀▀ ▀ ▀▀▄ ▀▀▀ ▀▀▀│ codecraft │ │ ┃ ▀▀▀ ▀▀▀ ▀▀ ▀▀▀ ▀▀▀ ▀ ▀ ▀ ▀ ▀ │ Advancement Made! 🧱 Stone Age │ │ ┃ Getting an Upgrade Open ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀╰────────────────────────────────╯ │ ┃ Get a green test run ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀╭────────────────────────────────╮ │ ┃ May contain flaky tests! │ codecraft │ │ ┃ dev joined the game │ Advancement Made! 🔥 Hot Stuff │ │ ┃ p: Previous n: Next · 2/60 made · 1 hidden i ╰────────────────────────────────╯ │ ┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▣ <dev> │ ┃ ▀▀▣ 🧱▀▀▀▀▣ 🔰▀▀▀▀▣ 🔩▀▀▀▀▣ 🦺▀▀▀▀▣ 🧿▀▀▀▀▣ fix the failing auth test and add an audit log call │ ┃ ▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀ │ ┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ● codecraft: dev has made the advancement [Stone Age] │ ┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▣ 🪓 ▀▀▀▀▣ 💎▀▀▀▀▣ ● codecraft: dev has made the advancement [Hot Stuff] │ ┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ⏺ Read(src/auth.ts) │ ┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ⎿ Read 6 lines │ ┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▣ [Claude: edited src/auth.ts] │ ┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ⏺ Bash(bun test) │ ┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ⎿ 3 pass, 1 fail │ ┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▣ 🔥▀▀▀▀▣ │ ┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀ Done. I made refresh reject expired claims and added an audit │ ┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ call. │ ┃ ▀▀▀▀▀▀▀▀▀▀▣ 💉▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ │ ┃ ▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ✻ Enchanted for 42s · done 4:20 PM │ ┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ │ › /inventory │ ✻ ⛏ Brewing… ▣ ·▣ ·▣ ·▣ ·▣ ·▣ ·▣ ·▣ ·▣ ·▣ · ✗ tests green, ✗ committed, ✗ pushed ▣ ♥▣ ♥▣ ♥▣ ♥▣ ♥▣ ♥▣ ♡▣ ♡▣ ♡▣ ♡▣ ·▣ ·▣ ·▣ ◆▣ ◆▣ ◆▣ ◆▣ ◆▣ ◆▣ ◆ Lv 0 ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ ◇ 3 unverified edits ☠ 1 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
▣ ·▣ ·▣ ·▣ ·▣ ·▣ ·▣ ·▣ ·▣ ·▣ · ✗ tests green, ✗ committed, ✗ pushed ▣ ♥▣ ♥▣ ♥▣ ♥▣ ♥▣ ♥▣ ♡▣ ♡▣ ♡▣ ♡▣ ·▣ ·▣ ·▣ ◆▣ ◆▣ ◆▣ ◆▣ ◆▣ ◆▣ ◆ Lv 0 ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ ◇ 3 unverified edits ☠ 1
Pane · Advancements
Story 2 2: Nether 0 3: The End 0 4: Adventure 0 5: Husband Getting an Upgrade Open Get a green test run p: Previous n: Next · 2/60 made · 1 hidden in this tab ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▣ 🧱▀▀▀▀▣ 🔰▀▀▀▀▣ 🔩▀▀▀▀▣ 🦺▀▀▀▀▣ 🧿▀▀▀▀▣ 🧪▀▀▀▀▀▀▀▀▀▀ ▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▣ 🪓 ▀▀▀▀▣ 💎▀▀▀▀▣ 👑▀▀▀▀▣ 🌑▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▣ 📕▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▣ 🔥▀▀▀▀▣ 🧊▀▀▀▀▣ 👀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀ ▀▀▀▀ ▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▣ 💉▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
Your message
▀▀▀ ▀▀▀ ▀▀▄ ▀▀▀ ▀▀▀ ▀▀▀ ▀▀▀ ▀▀▀ ▀▀▀ ▀ ▀ ▀ ▀ ▀ ▀▀▀ ▀ ▀▀▄ ▀▀▀ ▀▀▀ ▀ ▀▀▀ ▀▀▀ ▀▀ ▀▀▀ ▀▀▀ ▀ ▀ ▀ ▀ ▀ ▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ May contain flaky tests! dev joined the game ▣ <dev> fix the failing auth test and add an audit log call
Claude's reply
Done. I made refresh reject expired claims and added an audit call.
Tool row
[Claude: edited src/auth.ts]
README

CodeCraft

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.

The HUD icons, hotbar items, player heads and advancement icons

Not an official Minecraft product. Not approved by or associated with Mojang or Microsoft.

Install

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 HUD

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.

ElementWhat it shows
HeartsContext window left. Ten full hearts is a fresh session. When health runs low, a warning suggests /compact.
FoodYour five-hour usage limit left.
ArmorSafety 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 barExperience 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.
HotbarWhile 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 barA shell command that has run for 5 seconds, draining toward its timeout.

Each kind of tool has its own item in the hotbar:

ItemTool
PickaxeSearching code and finding files
BookReading files
BricksEditing and writing files
RedstoneShell commands
CompassWeb search and fetch
EmeraldSubagents
Enchanted bookSkills
ChestMCP tools
PaperThe to-do list
StoneAnything else

The chat

The conversation reads like a game server's chat:

  • The CODECRAFT title, in stone letters on a strip of grass, sits above your first prompt, with a splash line and <you> joined the game.
  • Your prompts appear under <your name> beside the player's head, and replies under <Claude> beside Claude's block head.
  • Tool calls are short gray lines such as [Claude: ran pytest -q] or [Claude: edited src/app.py]. A failed call turns red and gets a death message.
  • Skill and MCP tool lines carry an enchantment mark (✦).
  • Advancements are announced the way the game does it: <your name> has made the advancement [Stone Age].
  • The footer names the permission mode as a game mode: Survival (default), Creative (accept edits), Adventure (auto), Spectator (plan) and Hardcore (bypass permissions).

Advancements

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.

TabWhat it coversExamples
StoryBuilding and verifyingStone Age (edit a file), Diamonds! (commit with the tests green), A Furious Cocktail (tests, checks and build all green)
NetherSubagents and git historyWe Need to Go Deeper (send out a subagent), Hidden in the Depths (find a bad commit with git bisect)
The EndPull requests and releasesThe End? (open a pull request), Free the End (merge one), The City at the End of the Game (cut a release)
AdventureTesting and exploringMonster 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)
HusbandryHabits over daysA 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:

The Story and Adventure tabs of the advancements screen

  • Frame colour: gold means earned, stone means open, and dark means locked until the advancement before it is earned.
  • Frame shape: square for a task, rounded for a goal, spiked for a challenge. Challenges also give bonus XP.
  • Moving around: keys 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.

Terminals

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.

Status line

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.

Privacy and what it runs

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:

  • Runs read-only git commands in your working directory to find the repository root and whether your work is committed and pushed. This feeds the armor bar.
  • Saves your levels and advancements in the plugin's own store file under ~/.claude/plugins/store/.
  • Reads the 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.
  • Sets the CODECRAFT_STATUSLINE environment variable for the session, used by the status line step above.

Develop

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
TaskCommand
Type checknpx -p typescript@5 tsc -p codecraft
Validate the pluginclaude plugin validate --strict ./codecraft
Run the testscd codecraft && claude plugin test
Redraw the spritespython3 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.

License

MIT

Source 2 files
hooks/register.tsx 1402 lines
1import 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 lines
1// 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