A Minecraft-inspired world for Claude Code: tool calls become blocks and XP, the sky follows your context, the weather follows your plan limits

A Minecraft-inspired world for Claude Code, built as a Claude Code mod. Claude's tool calls turn into blocks and XP. The sky follows your context window, the weather follows your plan limits, and Claude mines beside your prompt while it works.
<img src="docs/previews/day.svg" width="100%" alt="The Desktop band at midday: Claude mining a stone block on the left, the HUD in the middle with hearts, hunger, a level-13 XP bar and a nine-slot hotbar, a cold furnace and a sign on the right">
Not affiliated with Mojang or Microsoft. Free and open source (MIT).
Needs Claude Code 2.1.287 or later (claude --version), the release that added mods.
claude plugin marketplace add DanielPodolsky/tokencraft
claude plugin install tokencraft@tokencraft
Then start a new session, or run /reload-plugins in an open one. From inside a session it's one command: /plugin install tokencraft --marketplace DanielPodolsky/tokencraft.
Update with claude plugin update tokencraft@tokencraft; remove with claude plugin uninstall tokencraft@tokencraft.
Everything in the band is a live reading of your session. Hover any part on Desktop for its exact numbers.
| You see | It means |
|---|---|
| ❤ Hearts | how much of your 5-hour plan limit is left |
| 🍗 Hunger | how much context is left (compacting is eating) |
| the hotbar | this session's blocks; the last one earned is framed, each pickup floats up as +1 |
| the XP bar and level | lifetime XP, on Minecraft's own XP curve (level 30 = 1,395 XP) |
| Claude swinging a pickaxe | a turn is running; idle, Claude stands by |
| the pickaxe's color | your tier: wood → stone (Lv 5) → iron (Lv 10) → diamond (Lv 20) → netherite (Lv 30) |
| the sky: sunrise → noon → sunset → night | how full the context window is |
a creeper saying ssss… /compact | night has fallen (context about 85% full): compact soon |
| rain · thunderstorm | 5-hour limit past 70% · past 90% |
| snow, the ground turning white | 7-day limit past 70% |
| a lit, smoking furnace | a /smelt focus timer is running |
| the sign | level, lifetime blocks, today's XP |
LEVEL 14!, orbs flying into the bar | a level-up |
| a "Milestone reached!" card | a milestone (first green test run, first commit, level 10…) |
<img src="docs/previews/sunset.svg" width="100%" alt="Sunset with rain: context 76% used, 5-hour limit 78% used, hearts and hunger running low"> <img src="docs/previews/night.svg" width="100%" alt="Night: context 94% used, stars and a moon, a torch lit beside Claude, and a creeper whose speech bubble says ssss… /compact"> <img src="docs/previews/storm.svg" width="100%" alt="A thunderstorm with lightning flashes: 5-hour limit 95% used, one heart left"> <img src="docs/previews/snow.svg" width="100%" alt="Snow on the ground and trees: 7-day limit 82% used; the furnace lit for a /smelt called write the tests, 16 minutes left">
Sunset and rain · night and the creeper · a thunderstorm · snow with the furnace lit. Each picture is hooks/scene.js's own output (node tools/render_previews.mjs).
| Claude does | You get |
|---|---|
| reads or searches a file (Read, Grep, Glob) | 🪨 stone · 1 XP |
| edits or writes a file | 🪵 planks · 2 XP |
| runs a shell command | 🧨 TNT · 1 XP |
| runs tests that pass | 💎 diamond · 10 XP |
| runs tests that fail | 💥 a creeper, no XP |
| commits | 💚 emerald · 7 XP |
| uses an MCP tool | 🔴 redstone · 1 XP |
| starts a subagent | 🥚 spawn egg · 3 XP |
| fetches or searches the web | 🟣 ender pearl · 1 XP |
| uses a skill | 📕 enchanted book · 2 XP |
| finishes a turn | 3 XP |
Each answer ends with a loot line, +2 🪨 stone · +1 💎 diamond · +17 XP · ⬆ Level 2. The spinner keeps its own words and adds what the turn has mined so far: Reading… ⛏ 4. A level-up chimes (with a toast in the terminal, and anywhere for a new pickaxe tier); a milestone slides a card into the Desktop band, or toasts in the terminal; a finished /smelt toasts with its own chime. /hud sound off silences the chimes. /clear is a respawn: the hotbar empties, XP stays.
In the terminal the HUD is two lines of colored text above the prompt; the animated world is the Desktop app's.
They run at once, even while Claude is working, and never start a Claude turn.
| Command | What it does |
|---|---|
/f3 | The debug screen: branch, context left, plan limits, level, this session's blocks, the furnace |
/smelt 25 <task> | A focus timer: the furnace burns, the arrow fills, finishing pays 1 XP a minute. /smelt stop puts it out with no XP. Default 25 min, max 180 |
/weather | clear · rain · storm · snow for this session; auto (the default) follows your plan limits |
/hud | on · off · sound on · sound off; with no argument it toggles the HUD |
One option, in /config (or /plugin configure tokencraft@tokencraft):
| Option | Default | What it does |
|---|---|---|
px_per_column | 8.35 | How wide one column of the Desktop band is, in pixels. Raise it if the world stops short of the band's right edge, lower it if it's cut off |
Desktop reports the band's width in columns of its code font; the scene is drawn at columns × px_per_column pixels. /f3's Band line shows both numbers to tune by.
A mod runs inside Claude Code with your permissions, so check before you trust one. claude plugin validate .claude-plugin/plugin.json lists every call this one makes. It:
.git/HEAD for /f3's branch, and, in a repo that keeps a MaiOS sprint file (maios/planning/sprint.local.md), the next sprint item — /f3 only$.store): lifetime XP and blocks, milestones, prefs, the running furnace. Every session on your machine shares it, so XP from parallel sessions adds upsounds/*.wav, synthesized by tools/make_sounds.py)It makes no network call, starts no process, never calls a model, and never approves or blocks a tool call: it only watches them finish.
Claude Code 2.1.287 on macOS: the Desktop app's Code tab and the terminal. Per the mods docs, the VS Code chat panel and claude -p run the hooks (XP still counts) but draw nothing. Mod APIs may change between releases.
git clone https://github.com/DanielPodolsky/tokencraft
claude --plugin-dir ./tokencraft
To load a clone in every session, the Desktop app included, add it to ~/.claude/settings.json (and don't also install it from the marketplace, or it loads twice):
{ "env": { "CLAUDE_CODE_PLUGIN_DIRS": "~/path/to/tokencraft" } }
A watched folder reloads on save in the terminal; on Desktop run /reload-plugins. For a release, bump version in plugin.json: installs keep their copy until it changes.
claude plugin validate --strict .claude-plugin/plugin.json
claude plugin test
26 tests cover the XP curve, the tool → block map, the loot line, level-ups and milestones, the creeper on a red test run, the HUD on Desktop (SVG) and in the terminal (text), the spinner, /smelt, /f3, /hud, /weather, the px_per_column option, a claude -p run staying clean, and the scene: the SVG size limit, the sky, the weather, the pickaxe swinging only while working, loops keeping phase across redraws, one-shot effects dropping out once played, and escaped labels. Claude Code writes the editor types tsconfig.json extends (.claude-plugin/types/, gitignored) each time it loads the clone.
| File | Job |
|---|---|
.claude-plugin/plugin.json | the manifest, with the px_per_column option |
.claude-plugin/marketplace.json | lets claude plugin marketplace add find the plugin |
hooks/register.js | every hook and every mods-API call |
hooks/blocks.js | pure logic and the terminal HUD; no mods API, so it's testable on its own |
hooks/scene.js | the Desktop world: pixel art, sky, weather and every animation, as one SVG |
tests/ | the hooks, commands and HUD (tokencraft.test.ts), the scene (scene.test.ts) |
tools/render_previews.mjs | renders docs/previews/*.svg from scene.js |
tools/make_sounds.py | synthesizes sounds/*.wav |
MIT. Tokencraft is a fan-made, Minecraft-inspired project: its pixel art and sounds are original, and it uses no Minecraft code, textures, fonts or audio. Minecraft is a trademark of Mojang Synergies AB. Not affiliated with or endorsed by Mojang, Microsoft or Anthropic.
hooks/register.js 493 lines1// Tokencraft — a Minecraft HUD for Claude Code.
2//
3// Claude's tool calls turn into blocks and XP. The band above the prompt draws
4// hearts (5-hour plan limit left), hunger (context left), the XP bar with your
5// level, and a nine-slot hotbar of what Claude mined this session — on Desktop
6// inside an animated Minecraft world (scene.js), in the terminal as colored text.
7// Every answer gets a loot line. /f3 prints where you are; /smelt runs a focus timer.
8//
9// Where state lives:
10// module variables this session's hotbar and this turn's loot (lost on reload)
11// $.store lifetime XP and blocks, milestones, prefs, the furnace —
12// shared by every session on this Mac
13// What it reaches: it reads .git/HEAD (and a MaiOS sprint file, where a repo has one)
14// for /f3, and plays two sound files it ships. It starts no process and makes no
15// network call.
16
17import {
18 BLOCKS,
19 TURN_XP,
20 blockFor,
21 branchOf,
22 clockOf,
23 emptyCounts,
24 gitdirOf,
25 dayOf,
26 hudRuns,
27 levelOf,
28 lootLine,
29 newAdvancements,
30 parseSmelt,
31 pickaxeOf,
32 sprintItemOf,
33 totalOf,
34 vitalsOf,
35 HOTBAR,
36} from './blocks.js'
37import { sceneSvg } from './scene.js'
38
39const SOUND_LEVEL_UP = 'sounds/levelup.wav'
40const SOUND_SMELTED = 'sounds/smelted.wav'
41
42// Desktop reports the band's width in cells of its code font. Measured from a
43// screenshot, a 749 px band reported 89 cells: 8.4 px a cell. The frame the scene is
44// drawn in gets exactly that size, one scene unit per pixel; without a size the app
45// falls back to a 300×150 frame. A hair under 8.4 so the frame never overflows.
46// A different font or zoom has a different cell: the `px_per_column` option (in
47// /config) re-tunes it, and /f3's Band line shows the cells and pixels to tune by.
48const PX_PER_COLUMN = 8.35
49let pxPerColumn = PX_PER_COLUMN
50
51// This session and this turn
52let session = emptyCounts()
53let turnLoot = emptyCounts()
54let turnCreepers = 0
55let turnXp = 0
56let turnLevelUp = 0
57let selected = null
58let usage = null
59let ticks = 0
60// False in a `claude -p` run or a script: nothing draws, so no loot line, toast or sound
61let isWatched = true
62// What the scene animates: pickups, the last level-up, milestone and red test run
63let recent = []
64let levelUpAt = 0
65let advancement = null
66let creeperAt = 0
67let smeltDone = null
68// /weather's choice for this session; 'auto' follows the plan limits
69let weather = 'auto'
70// The last band drawn, for /f3 and to skip toasts the scene already shows
71let lastBand = null
72let lastMinuteShown = null
73
74// Lifetime figures: what $.store held at the last flush, plus what is still pending
75let storedXp = 0
76let storedLifetime = {}
77let pendingXp = 0
78let pendingBlocks = {}
79let unlocked = []
80let prefs = { hud: true, sound: true }
81let smelt = null
82let todayStored = 0
83
84const xpNow = () => storedXp + pendingXp
85const drawsScene = () => lastBand !== null && lastBand.surface !== 'terminal'
86
87function lifetimeNow() {
88 const all = { ...storedLifetime }
89 for (const [id, n] of Object.entries(pendingBlocks)) all[id] = (all[id] ?? 0) + n
90 return all
91}
92
93function resetTurn() {
94 turnLoot = emptyCounts()
95 turnCreepers = 0
96 turnXp = 0
97 turnLevelUp = 0
98}
99
100// What the HUD draws, from the state above
101function hudView(now) {
102 const vitals = vitalsOf(usage)
103 const lvl = levelOf(xpNow())
104 return { hearts: vitals.hearts, hunger: vitals.hunger, level: lvl.level, progress: lvl.progress, counts: session, selected, smelt, now }
105}
106
107// What the Desktop scene draws: the HUD, plus the world's own state
108function sceneView(now, W, isWorking) {
109 const vitals = vitalsOf(usage)
110 return {
111 ...hudView(now),
112 ...levelOf(xpNow()),
113 W,
114 isWorking,
115 contextUsed: vitals.contextUsed,
116 fiveHourUsed: vitals.fiveHourUsed,
117 sevenDayUsed: vitals.sevenDayUsed,
118 weather,
119 recent,
120 levelUpAt,
121 advancement,
122 creeperAt,
123 smeltDone,
124 lifetimeTotal: totalOf(lifetimeNow()),
125 todayXp: todayStored + pendingXp,
126 }
127}
128
129function hudAlt(view) {
130 return `Level ${view.level}, hearts ${view.hearts / 2} of 10, hunger ${view.hunger / 2} of 10, ${totalOf(view.counts)} blocks this session`
131}
132
133// Read the lifetime figures back from $.store
134async function load($) {
135 const today = (await $.store.get('today')) ?? {}
136 todayStored = today.date === dayOf(await $.clock.now()) ? Number(today.xp ?? 0) : 0
137 storedXp = Number((await $.store.get('xp')) ?? 0)
138 storedLifetime = { ...((await $.store.get('lifetime')) ?? {}) }
139 unlocked = [...((await $.store.get('advancements')) ?? [])]
140 prefs = { hud: true, sound: true, ...((await $.store.get('prefs')) ?? {}) }
141 smelt = (await $.store.get('smelt')) ?? null
142}
143
144// Add what this session earned to what $.store holds now, so two sessions earning
145// at once both count; a write landing between our get and set is the one loss
146async function flush($) {
147 if (pendingXp === 0 && Object.keys(pendingBlocks).length === 0) return
148 const xpDelta = pendingXp
149 const blockDelta = pendingBlocks
150 pendingXp = 0
151 pendingBlocks = {}
152 const xp = Number((await $.store.get('xp')) ?? 0) + xpDelta
153 const saved = { ...((await $.store.get('lifetime')) ?? {}) }
154 for (const [id, n] of Object.entries(blockDelta)) saved[id] = (saved[id] ?? 0) + n
155 storedXp = xp
156 storedLifetime = saved
157 await $.store.set('xp', xp)
158 await $.store.set('lifetime', saved)
159 // Today's XP starts over at local midnight
160 const date = dayOf(await $.clock.now())
161 const today = (await $.store.get('today')) ?? {}
162 todayStored = (today.date === date ? Number(today.xp ?? 0) : 0) + xpDelta
163 await $.store.set('today', { date, xp: todayStored })
164}
165
166// Count one block (or none, for XP alone), then any level-up and milestone
167function earn($, block, xp, now) {
168 const before = levelOf(xpNow()).level
169 if (block) {
170 turnLoot[block] += 1
171 session[block] += 1
172 selected = block
173 pendingBlocks[block] = (pendingBlocks[block] ?? 0) + 1
174 recent = [...recent.filter((r) => now - r.at < 2000), { id: block, at: now }]
175 }
176 pendingXp += xp
177 turnXp += xp
178 const after = levelOf(xpNow()).level
179 if (after > before) levelUp($, after, now)
180 celebrate($, after, now)
181 $.ui.invalidate('ui.render')
182}
183
184function levelUp($, level, now) {
185 turnLevelUp = level
186 levelUpAt = now
187 const tier = pickaxeOf(level)
188 const isNewTier = tier !== pickaxeOf(level - 1)
189 if (!isWatched) return
190 // The scene plays its own level-up; a toast there would say it twice
191 if (drawsScene() && !isNewTier) {
192 if (prefs.sound) $.audio.play({ asset: SOUND_LEVEL_UP }).catch(() => {})
193 return
194 }
195 $.ui.toast('⬆ Level ' + level + (isNewTier ? ' · ' + tier + ' pickaxe unlocked!' : ''), { timeoutMs: 5000 })
196 // Not awaited: a tool result never waits on a sound
197 if (prefs.sound) $.audio.play({ asset: SOUND_LEVEL_UP }).catch(() => {})
198}
199
200function celebrate($, level, now) {
201 const fresh = newAdvancements({ lifetime: lifetimeNow(), level }, unlocked)
202 if (fresh.length === 0) return
203 unlocked = [...unlocked, ...fresh.map((a) => a.id)]
204 advancement = { title: fresh[0].title, icon: fresh[0].icon, at: now }
205 if (isWatched && !drawsScene()) for (const a of fresh) $.ui.toast('🏆 Milestone reached: ' + a.title, { timeoutMs: 6000 })
206 $.store.set('advancements', unlocked).catch(() => {})
207}
208
209// Another session may have lit or finished the furnace; the store is the truth
210async function syncFurnace($) {
211 const saved = (await $.store.get('smelt')) ?? null
212 if ((saved?.id ?? null) === (smelt?.id ?? null)) return
213 smelt = saved
214 $.ui.invalidate('ui.render')
215}
216
217async function finishSmelt($, now) {
218 const done = smelt
219 smelt = null
220 $.ui.invalidate('ui.render')
221 // Only a session that still finds this furnace in the store pays, so two open
222 // sessions don't both pay for one smelt
223 const saved = await $.store.get('smelt')
224 if (!saved || saved.id !== done.id) return
225 await $.store.delete('smelt')
226 pendingBlocks.smelted = (pendingBlocks.smelted ?? 0) + 1
227 smeltDone = { at: now, minutes: done.minutes }
228 earn($, null, done.minutes, now)
229 $.ui.toast('🔥 Smelted "' + done.label + '"! +' + done.minutes + ' XP. Take five.', { timeoutMs: 10000 })
230 if (prefs.sound && isWatched) $.audio.play({ asset: SOUND_SMELTED }).catch(() => {})
231 await flush($)
232}
233
234// Runs once a second: the furnace's clock, cross-session sync, and a flush.
235// A store hiccup is skipped; the next tick tries again
236async function tick($) {
237 ticks += 1
238 try {
239 if (ticks % 5 === 0) await syncFurnace($)
240 if (ticks % 30 === 0) await flush($)
241 if (!smelt) return
242 const now = await $.clock.now()
243 if (now >= smelt.endsAt) return await finishSmelt($, now)
244 // The arrow animates itself: redraw when the minutes shown change, and each
245 // second of the last minute
246 const left = smelt.endsAt - now
247 const shown = left > 60_000 ? Math.ceil(left / 60_000) : left
248 if (shown !== lastMinuteShown) {
249 lastMinuteShown = shown
250 $.ui.invalidate('ui.render')
251 }
252 } catch {}
253}
254
255// Whether any app draws this session; empty in a `claude -p` run
256async function watchCheck($) {
257 isWatched = (await $.session.surfaces()).length > 0
258}
259
260async function registerCommands($) {
261 const commands = [
262 { name: 'f3', description: 'Tokencraft debug screen: branch, sprint item, context, level', immediate: true },
263 { name: 'smelt', description: 'Focus timer: /smelt 25 <task> · /smelt stop', argumentHint: '[minutes] [task] | stop', immediate: true },
264 { name: 'hud', description: 'Tokencraft HUD: on · off · sound on · sound off', argumentHint: '[on|off|sound on|sound off]', immediate: true },
265 { name: 'weather', description: 'Tokencraft weather: clear · rain · storm · snow · auto (follows your plan limits)', argumentHint: '[clear|rain|storm|snow|auto]', immediate: true },
266 ]
267 for (const spec of commands) {
268 // A taken name throws; log it and keep registering the rest
269 try {
270 await $.command.register(spec)
271 } catch (err) {
272 $.ui.log('/' + spec.name + ' not added: ' + String(err?.message ?? err))
273 }
274 }
275}
276
277// The branch: .git/HEAD in a checkout, or through the gitdir file in a worktree
278async function readBranch($) {
279 try {
280 return branchOf(await $.fs.read('.git/HEAD'))
281 } catch {}
282 try {
283 const gitdir = gitdirOf(await $.fs.read('.git'))
284 if (gitdir) return branchOf(await $.fs.read(gitdir + '/HEAD'))
285 } catch {}
286 return null
287}
288
289// The first open sprint item from MaiOS's local board mirror, in this checkout or the main one
290async function readSprint($) {
291 const roots = [await $.session.root()]
292 const repo = await $.session.repo()
293 if (repo?.root && !roots.includes(repo.root)) roots.push(repo.root)
294 for (const root of roots) {
295 try {
296 return { item: sprintItemOf(await $.fs.read(root + '/maios/planning/sprint.local.md')) }
297 } catch {}
298 }
299 return null
300}
301
302const k = (tokens) => Math.round(tokens / 1000) + 'k'
303
304export function register(on, options) {
305 const tuned = Number(options?.px_per_column)
306 pxPerColumn = tuned >= 4 && tuned <= 20 ? tuned : PX_PER_COLUMN
307 on('session.start', async ($, e, next) => {
308 await load($)
309 await watchCheck($)
310 try {
311 usage = await $.session.usage()
312 } catch {}
313 $.clock.every(1000, () => tick($))
314 await registerCommands($)
315 return next(e)
316 })
317
318 // Every finished tool call, a subagent's too, may drop a block
319 on('tool.call', async ($, e, next) => {
320 const result = await next(e)
321 const block = blockFor(e.tool, e, result)
322 if (!block) return result
323 const now = await $.clock.now()
324 if (block === 'creeper') {
325 turnCreepers += 1
326 creeperAt = now
327 $.ui.invalidate('ui.render')
328 } else {
329 earn($, block, BLOCKS[block].xp, now)
330 }
331 return result
332 })
333
334 on('turn.start', async ($, e, next) => {
335 resetTurn()
336 await watchCheck($)
337 return next(e)
338 })
339
340 // The loot line under the answer, then a flush to the store
341 on('turn.complete', async ($, e, next) => {
342 const result = await next(e)
343 if (e.agentId) return result
344 if (!e.isAborted) earn($, null, TURN_XP, await $.clock.now())
345 const line = lootLine(turnLoot, turnCreepers, turnXp, turnLevelUp)
346 try {
347 await flush($)
348 } catch {}
349 // A script's answer stays exactly what Claude wrote
350 if (!line || !isWatched) return result
351 return { ...result, text: result?.text ? result.text + '\n' + line : line }
352 })
353
354 // Fresh context and plan-limit figures after each turn: hunger and hearts
355 on('session.measure', async ($, e, next) => {
356 usage = e
357 $.ui.invalidate('ui.render')
358 return next(e)
359 })
360
361 // /clear is a respawn: the hotbar empties, XP and lifetime stay
362 on('classic.SessionStart', { source: 'clear' }, async ($, e, next) => {
363 session = emptyCounts()
364 selected = null
365 resetTurn()
366 $.ui.toast('☠ You respawned. Hotbar cleared, XP kept.')
367 $.ui.invalidate('ui.render')
368 return next(e)
369 })
370
371 on('session.end', async ($, e, next) => {
372 try {
373 await flush($)
374 } catch {}
375 return next(e)
376 })
377
378 // The HUD: pixel art where the app draws SVG (Desktop), colored text in the terminal
379 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
380 if (!prefs.hud || e.props.hasSurvey) return next(e)
381 const now = await $.clock.now()
382 const { Box, Text, Svg } = $.ui.resolve(e)
383 const columns = e.props.bodyColumns || e.viewport?.columns || 120
384 // Every app but the terminal draws SVG; branch on the app, not on the constructor
385 if (e.surface !== 'terminal') {
386 const W = Math.max(480, Math.min(3000, Math.round(columns * pxPerColumn)))
387 lastBand = { surface: e.surface, columns, width: W }
388 const view = sceneView(now, W, e.props.isWorking)
389 // isInteractive runs the animations and tooltips, in a frame of exactly this size
390 const scene = sceneSvg(view)
391 return Box({
392 width: '100%',
393 flexDirection: 'row',
394 justifyContent: 'center',
395 children: [Svg({ source: scene.source, alt: hudAlt(view), isInteractive: true, width: scene.width, height: scene.height })],
396 })
397 }
398 lastBand = { surface: 'terminal', columns }
399 const view = hudView(now)
400 const row = (runs) =>
401 Box({ flexDirection: 'row', justifyContent: 'center', children: runs.map(([text, color, isBold]) => Text({ color, bold: isBold, wrap: 'truncate', children: [text] })) })
402 return Box({ flexDirection: 'column', children: hudRuns(view).map(row) })
403 })
404
405 // The spinner keeps your words and adds what this turn has mined so far
406 on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
407 const mined = totalOf(turnLoot)
408 if (mined === 0) return next(e)
409 return next({ ...e, props: { ...e.props, suffix: e.props.suffix + ' ⛏ ' + mined } })
410 })
411
412 on('command.run', { command: 'f3' }, async ($) => {
413 try {
414 usage = await $.session.usage()
415 } catch {}
416 const vitals = vitalsOf(usage)
417 const lvl = levelOf(xpNow())
418 const branch = await readBranch($)
419 const sprint = await readSprint($)
420 const version = (await $.session.version()).version
421 const now = await $.clock.now()
422 const life = lifetimeNow()
423
424 // Only a repo with a MaiOS sprint file gets a Sprint line
425 const sprintText = !sprint
426 ? null
427 : !sprint.item
428 ? 'every sprint item is done'
429 : (sprint.item.isActive ? 'working on it: ' : 'next up: ') + sprint.item.name + ' (' + sprint.item.key + ')'
430 const contextText =
431 typeof vitals.contextUsed === 'number'
432 ? vitals.contextUsed + '% used' + (vitals.contextWindow && vitals.contextTokens !== undefined ? ' · ' + k(vitals.contextWindow - vitals.contextTokens) + ' of ' + k(vitals.contextWindow) + ' left' : '')
433 : 'no reading yet'
434 const limitsText =
435 vitals.fiveHourUsed === undefined ? 'no plan-limit reading yet' : '5h ' + vitals.fiveHourUsed + '% · 7d ' + (vitals.sevenDayUsed ?? '?') + '%'
436 const owned = HOTBAR.filter((id) => session[id] > 0)
437
438 const lines = [
439 'Tokencraft F3 · Claude Code ' + version,
440 'Branch ' + (branch ?? 'not a git checkout'),
441 ...(sprintText ? ['Sprint ' + sprintText] : []),
442 'Context ' + contextText + ' · hunger ' + vitals.hunger / 2 + '/10',
443 'Limits ' + limitsText + ' · hearts ' + vitals.hearts / 2 + '/10',
444 'Level ' + lvl.level + ' · ' + lvl.into + '/' + lvl.need + ' XP to ' + (lvl.level + 1) + ' · ' + pickaxeOf(lvl.level) + ' pickaxe',
445 'Session ' + (owned.length ? owned.map((id) => BLOCKS[id].emoji + ' ' + session[id]).join(' · ') : 'nothing mined yet'),
446 'Lifetime ' + totalOf(life) + ' blocks · ' + unlocked.length + ' milestones',
447 'Furnace ' + (smelt ? '🔥 ' + clockOf(smelt.endsAt - now) + ' left · ' + smelt.label : 'cold · /smelt 25 <task>'),
448 'Today +' + (todayStored + pendingXp) + ' XP',
449 ]
450 if (lastBand) lines.push('Band ' + lastBand.surface + ' · ' + lastBand.columns + ' columns' + (lastBand.width ? ' × ' + pxPerColumn + ' → ' + lastBand.width + ' px scene' : ''))
451 return { text: lines.join('\n') }
452 })
453
454 on('command.run', { command: 'smelt' }, async ($, e) => {
455 const ask = parseSmelt(e.args)
456 const now = await $.clock.now()
457 if (ask.isStop) {
458 if (!smelt) return { text: 'The furnace is cold. Light it with /smelt 25 <task>.' }
459 const label = smelt.label
460 smelt = null
461 await $.store.delete('smelt')
462 $.ui.invalidate('ui.render')
463 return { text: '🧯 Furnace out on "' + label + '". An unfinished smelt pays no XP.' }
464 }
465 if (smelt) return { text: '🔥 Already smelting "' + smelt.label + '", ' + clockOf(smelt.endsAt - now) + ' left. /smelt stop first.' }
466 smelt = { id: String(now), startedAt: now, endsAt: now + ask.minutes * 60_000, minutes: ask.minutes, label: ask.label }
467 await $.store.set('smelt', smelt)
468 $.ui.invalidate('ui.render')
469 return { text: '🔥 Furnace lit: ' + ask.minutes + ' min on "' + ask.label + '". Finishing pays +' + ask.minutes + ' XP. /smelt stop puts it out.' }
470 })
471
472 on('command.run', { command: 'weather' }, async ($, e) => {
473 const ask = e.args.trim().toLowerCase()
474 const choices = ['clear', 'rain', 'storm', 'snow', 'auto']
475 if (!choices.includes(ask)) {
476 return { text: 'Weather is ' + weather + '. Pick one: ' + choices.join(' · ') + '. auto = rain past 70% of the 5-hour limit, storm past 90%, snow past 70% of the 7-day limit.' }
477 }
478 weather = ask
479 $.ui.invalidate('ui.render')
480 return { text: ask === 'auto' ? '🌦 Weather follows your plan limits again.' : '🌦 Weather set to ' + ask + ' for this session. /weather auto puts it back.' }
481 })
482
483 on('command.run', { command: 'hud' }, async ($, e) => {
484 const arg = e.args.trim().toLowerCase()
485 if (arg === 'sound on' || arg === 'sound off') prefs = { ...prefs, sound: arg === 'sound on' }
486 else if (arg === 'on' || arg === 'off') prefs = { ...prefs, hud: arg === 'on' }
487 else prefs = { ...prefs, hud: !prefs.hud }
488 await $.store.set('prefs', prefs)
489 $.ui.invalidate('ui.render')
490 return { text: 'HUD ' + (prefs.hud ? 'on' : 'off') + ' · sounds ' + (prefs.sound ? 'on' : 'off') }
491 })
492}
493hooks/blocks.js 212 lines1// Tokencraft — the pure half of the mod: which block a tool call earns, the XP
2// curve, and the HUD drawings. Nothing here touches the mods API; register.js
3// owns every `$` call, so `claude plugin validate` lists all of them in one place.
4
5// The nine hotbar slots, left to right, like Minecraft's nine
6export const HOTBAR = ['stone', 'planks', 'tnt', 'diamond', 'emerald', 'redstone', 'egg', 'pearl', 'book']
7
8// What each block is called, how it shows in text, and the XP it pays
9export const BLOCKS = {
10 stone: { name: 'stone', emoji: '🪨', xp: 1 },
11 planks: { name: 'planks', emoji: '🪵', xp: 2 },
12 tnt: { name: 'TNT', emoji: '🧨', xp: 1 },
13 diamond: { name: 'diamond', emoji: '💎', xp: 10 },
14 emerald: { name: 'emerald', emoji: '💚', xp: 7 },
15 redstone: { name: 'redstone', emoji: '🔴', xp: 1 },
16 egg: { name: 'spawn egg', emoji: '🥚', xp: 3 },
17 pearl: { name: 'ender pearl', emoji: '🟣', xp: 1 },
18 book: { name: 'enchanted book', emoji: '📕', xp: 2 },
19}
20
21// XP for a finished turn, on top of what its tool calls earned
22export const TURN_XP = 3
23
24// A shell command that runs a test suite: green pays a diamond, red spawns a creeper
25const TEST_RUN = /\b(pytest|vitest|jest|mocha|rspec|phpunit|go test|cargo test|bun test|deno test|claude plugin test|(npm|pnpm|yarn)( run)? test)\b/
26const COMMIT = /\bgit\s+commit\b/
27
28// Which block a finished tool call earns: a block id, 'creeper' for a red test
29// run, or null for nothing (refused calls and tools with no block earn nothing)
30export function blockFor(tool, input, result) {
31 const isRefused = !result || result.deny !== undefined
32 if (isRefused) return null
33 const isFailed = result.isError === true
34 if (tool === 'Bash' || tool === 'PowerShell') {
35 const command = String(input?.command ?? '')
36 if (TEST_RUN.test(command)) return isFailed ? 'creeper' : 'diamond'
37 if (isFailed) return null
38 return COMMIT.test(command) ? 'emerald' : 'tnt'
39 }
40 if (isFailed) return null
41 if (['Read', 'Grep', 'Glob', 'LS'].includes(tool)) return 'stone'
42 if (['Edit', 'Write', 'MultiEdit', 'NotebookEdit'].includes(tool)) return 'planks'
43 if (tool === 'Agent' || tool === 'Task') return 'egg'
44 if (tool === 'WebFetch' || tool === 'WebSearch') return 'pearl'
45 if (tool === 'Skill') return 'book'
46 if (tool.startsWith('mcp__')) return 'redstone'
47 return null
48}
49
50export function emptyCounts() {
51 return Object.fromEntries(HOTBAR.map((id) => [id, 0]))
52}
53
54export function totalOf(counts) {
55 return HOTBAR.reduce((sum, id) => sum + (counts[id] ?? 0), 0)
56}
57
58// XP from level L to L+1 — Minecraft Java's own curve
59export function xpToNext(level) {
60 if (level < 16) return 2 * level + 7
61 if (level < 31) return 5 * level - 38
62 return 9 * level - 158
63}
64
65// A lifetime XP total as a level, the XP into it, and how full the bar is
66export function levelOf(totalXp) {
67 let level = 0
68 let left = Math.max(0, Math.floor(totalXp))
69 while (left >= xpToNext(level)) {
70 left -= xpToNext(level)
71 level += 1
72 }
73 const need = xpToNext(level)
74 return { level, into: left, need, progress: left / need }
75}
76
77// The pickaxe tier a level unlocks, shown on level-up and in /f3
78export function pickaxeOf(level) {
79 if (level >= 30) return 'Netherite'
80 if (level >= 20) return 'Diamond'
81 if (level >= 10) return 'Iron'
82 if (level >= 5) return 'Stone'
83 return 'Wooden'
84}
85
86// Milestones: one toast each, the first time its condition holds. Titles are our
87// own; ids stay as they were, so saved progress keeps counting
88export const ADVANCEMENTS = [
89 { id: 'stone-age', title: 'Rockhound', icon: 'stone', hint: 'mine 100 stone', when: (s) => s.lifetime.stone >= 100 },
90 { id: 'diamonds', title: 'First Diamond', icon: 'diamond', hint: 'a green test run', when: (s) => s.lifetime.diamond >= 1 },
91 { id: 'first-trade', title: 'Committed', icon: 'emerald', hint: 'a commit', when: (s) => s.lifetime.emerald >= 1 },
92 { id: 'builder', title: 'Master Builder', icon: 'planks', hint: 'place 100 planks', when: (s) => s.lifetime.planks >= 100 },
93 { id: 'hot-stuff', title: 'Forged in Focus', icon: 'furnace', hint: 'finish a /smelt', when: (s) => (s.lifetime.smelted ?? 0) >= 1 },
94 { id: 'iron-tools', title: 'Iron Will', icon: 'book', hint: 'reach level 10', when: (s) => s.level >= 10 },
95]
96
97export function newAdvancements(state, unlocked) {
98 return ADVANCEMENTS.filter((a) => !unlocked.includes(a.id) && a.when(state))
99}
100
101// Ten icons of two halves each, from a percent of something left
102export function halvesOf(percentLeft) {
103 if (typeof percentLeft !== 'number' || Number.isNaN(percentLeft)) return 20
104 return Math.max(0, Math.min(20, Math.round(percentLeft / 5)))
105}
106
107// Hearts are the 5-hour plan limit left; hunger is the context window left
108export function vitalsOf(usage) {
109 const fiveHour = usage?.rateLimits?.find((r) => r.kind === 'five_hour')
110 const sevenDay = usage?.rateLimits?.find((r) => r.kind === 'seven_day')
111 const contextUsed = usage?.context?.percent
112 return {
113 hearts: fiveHour ? halvesOf(100 - fiveHour.percentUsed) : 20,
114 hunger: typeof contextUsed === 'number' ? halvesOf(100 - contextUsed) : 20,
115 fiveHourUsed: fiveHour?.percentUsed,
116 sevenDayUsed: sevenDay?.percentUsed,
117 contextUsed,
118 contextTokens: usage?.context?.tokens,
119 contextWindow: usage?.context?.window,
120 }
121}
122
123// The first open item of the sprint, read the way MaiOS's board.sh `next` reads it
124export function sprintItemOf(markdown) {
125 let isInSprint = false
126 for (const line of String(markdown).split('\n')) {
127 if (/^## Sprint/.test(line)) {
128 isInSprint = true
129 continue
130 }
131 if (/^## /.test(line)) {
132 isInSprint = false
133 continue
134 }
135 if (!isInSprint || !/^- \[[ ~]\] /.test(line)) continue
136 const rest = line.slice(6)
137 const cut = rest.indexOf(' — ')
138 if (cut < 0) return null
139 const parts = rest.slice(cut + 3).split(' · ')
140 const spec = parts.slice(1).find((p) => p.startsWith('spec: '))
141 return { isActive: line[3] === '~', key: rest.slice(0, cut), name: parts[0], spec: spec ? spec.slice(6) : '' }
142 }
143 return null
144}
145
146// `.git` is a directory in a checkout and a `gitdir:` file in a worktree
147export function gitdirOf(dotGitFile) {
148 const match = /^gitdir: (.+)$/m.exec(String(dotGitFile))
149 return match ? match[1].trim() : null
150}
151
152export function branchOf(head) {
153 const match = /^ref: refs\/heads\/(.+)$/m.exec(String(head))
154 return match ? match[1].trim() : String(head).trim().slice(0, 8) + ' (detached)'
155}
156
157// The local calendar day, for today's XP
158export function dayOf(ms) {
159 return new Date(ms).toLocaleDateString('en-CA')
160}
161
162export function clockOf(ms) {
163 const seconds = Math.max(0, Math.ceil(ms / 1000))
164 return Math.floor(seconds / 60) + ':' + String(seconds % 60).padStart(2, '0')
165}
166
167// What /smelt was typed with: minutes and a label, or stop
168export function parseSmelt(args) {
169 const text = String(args ?? '').trim()
170 if (/^(stop|off|cancel)$/i.test(text)) return { isStop: true }
171 const match = /^(\d+)\s*(.*)$/.exec(text)
172 const minutes = match ? Number(match[1]) : 25
173 const label = (match ? match[2] : text).trim() || 'focus'
174 return { isStop: false, minutes: Math.max(1, Math.min(180, minutes)), label }
175}
176
177// The line under an answer: what the turn's tool calls dropped
178export function lootLine(loot, creepers, xp, levelUp) {
179 const parts = HOTBAR.filter((id) => loot[id] > 0).map((id) => '+' + loot[id] + ' ' + BLOCKS[id].emoji + ' ' + BLOCKS[id].name)
180 if (creepers > 0) parts.push('💥 ' + creepers + (creepers === 1 ? ' creeper' : ' creepers'))
181 if (parts.length === 0 && !levelUp) return ''
182 let line = parts.join(' · ') + (xp > 0 ? ' · +' + xp + ' XP' : '')
183 if (levelUp) line += (line ? ' · ' : '') + '⬆ Level ' + levelUp
184 return line
185}
186
187// The same HUD for the terminal, as colored text runs: [text, color, isBold]
188export function hudRuns({ hearts, hunger, level, progress, counts, smelt, now }) {
189 const icons = (halves, full, half, empty) =>
190 Array.from({ length: 10 }, (_, i) => (halves - i * 2 >= 2 ? full : halves - i * 2 === 1 ? half : empty)).join('')
191 const lit = Math.round(20 * Math.max(0, Math.min(1, progress)))
192 const top = [
193 [icons(hearts, '♥', '♥', '♡'), 'red', false],
194 [' Lv ' + level + ' ', 'green', true],
195 ['━'.repeat(lit), 'green', false],
196 ['─'.repeat(20 - lit), 'gray', false],
197 [' ' + icons(hunger, '●', '◐', '○'), 'yellow', false],
198 ]
199 const owned = HOTBAR.filter((id) => (counts[id] ?? 0) > 0)
200 const bottom = owned.length
201 ? [[owned.map((id) => BLOCKS[id].emoji + ' ' + counts[id]).join(' '), 'white', false]]
202 : [['hotbar empty: Claude mines as it works', 'gray', false]]
203 if (smelt) {
204 const done = Math.max(0, Math.min(1, (now - smelt.startedAt) / (smelt.endsAt - smelt.startedAt)))
205 const arrow = Math.round(10 * done)
206 const left = smelt.endsAt - now
207 const time = left > 60_000 ? Math.ceil(left / 60_000) + 'm' : clockOf(left)
208 bottom.push([' 🔥 ' + smelt.label + ' ', 'yellow', false], ['▰'.repeat(arrow) + '▱'.repeat(10 - arrow) + ' ' + time, 'yellow', true])
209 }
210 return [top, bottom]
211}
212hooks/scene.js 456 lines1// Tokencraft — the Desktop band: a Minecraft world drawn as one animated SVG,
2// with the HUD centered over it the way the game draws it.
3//
4// The sky is the context window: a fresh context is sunrise, half full is noon,
5// 80% is sunset, past 90% it's night, and a creeper shows up asking for /compact.
6// The weather is the plan limits: rain past 70% of the 5-hour limit, a thunderstorm
7// past 90%, and snow — the ground turning white — past 70% of the 7-day limit.
8// /weather overrides it for the session.
9// Claude mines beside the HUD while it works; the furnace burns while /smelt runs;
10// the sign keeps your level, lifetime blocks and today's XP.
11//
12// Every redraw rebuilds the SVG, so each looping animation starts at a phase taken
13// from the wall clock (a negative `begin`) and one-shot effects start at the moment
14// they happened: a redraw never makes anything jump or replay.
15
16import { BLOCKS, HOTBAR, clockOf, pickaxeOf } from './blocks.js'
17
18export const SCENE_H = 140
19const HUD_W = 364
20const GROUND = 116
21const PX = 2.9 // one miner pixel, in scene units
22
23const esc = (t) => String(t).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c])
24const r1 = (v) => Math.round(v * 10) / 10
25const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v))
26
27// A looping animation's begin, so its phase follows the clock across redraws
28const phase = (now, seconds) => `-${((now % (seconds * 1000)) / 1000).toFixed(2)}s`
29// A one-shot animation's begin, counted from the moment it happened
30const since = (now, at) => `-${(Math.max(0, now - at) / 1000).toFixed(2)}s`
31
32// Seeded random, so trees, flowers and stars stay put from one redraw to the next
33function rng(seed) {
34 let s = seed >>> 0
35 return () => (s = (s * 1664525 + 1013904223) >>> 0) / 2 ** 32
36}
37
38// Pixel art as one path per color: a run of same-colored pixels is one segment
39function art(rows, colors) {
40 const runs = {}
41 rows.forEach((row, y) => {
42 let x = 0
43 while (x < row.length) {
44 let n = 1
45 while (x + n < row.length && row[x + n] === row[x]) n += 1
46 const color = colors[row[x]]
47 if (color) (runs[color] ??= []).push(`M${x} ${y}h${n}v1h-${n}z`)
48 x += n
49 }
50 })
51 return Object.entries(runs)
52 .map(([color, d]) => `<path fill="${color}" d="${d.join('')}"/>`)
53 .join('')
54}
55
56// --- Textures (8×8 unless noted; '.' is see-through) -----------------------
57
58const TEX = {
59 stone: [['aabaacaa', 'abbcaaba', 'caaabcaa', 'aacaaaab', 'baabcaac', 'aacaabaa', 'abaacaba', 'caabaaac'], { a: '#8b8b8b', b: '#6e6e6e', c: '#a8a8a8' }],
60 planks: [['abaaaaab', 'aaaaabaa', 'cccccccc', 'aaabaaaa', 'aaaaaaba', 'cccccccc', 'aabaaaaa', 'aaaaabaa'], { a: '#b8945f', b: '#9f7d4b', c: '#6f5230' }],
61 tnt: [['rrsrrsrr', 'rsrrsrrs', 'wwwwwwww', 'wkwkkwkw', 'wwwwwwww', 'rrsrrsrr', 'rsrrsrrs', 'rrrrrrrr'], { r: '#db3b26', s: '#a8261a', w: '#f2f2f2', k: '#262626' }],
62 diamond: [['........', '..oooo..', '.ocwcco.', 'ocwccbbo', '.occbbo.', '..obbo..', '...oo...', '........'], { o: '#0e5a55', c: '#4be8df', w: '#eafffd', b: '#1fa59f' }],
63 emerald: [['...oo...', '..ollo..', '.olggdo.', 'olgggddo', '.oggddo.', '..oddo..', '...oo...', '........'], { o: '#07431e', l: '#a7f6c4', g: '#17c45a', d: '#0b7a35' }],
64 redstone: [['........', '..r..r..', '.rRr.rr.', '..rrRr..', '.r.rr.r.', '..rR.rr.', '.rr..r..', '........'], { r: '#9e1b10', R: '#ff3b2b' }],
65 egg: [['...ee...', '..eeee..', '.eebeee.', '.eeeebe.', '.ebeeee.', '.eeeeee.', '..eebe..', '...ee...'], { e: '#bd8e6b', b: '#5e3f2b' }],
66 pearl: [['..oooo..', '.otttto.', 'otTTttto', 'otTLTtto', 'ottTttto', 'otttttto', '.otttto.', '..oooo..'], { o: '#0b3b36', t: '#127b6d', T: '#2fbfa8', L: '#c4f7ec' }],
67 book: [['.bbbbbb.', 'bppppppb', 'bpPppppb', 'bppPpppb', 'bpppPppb', 'bppppPpb', 'bppppppb', '.bwwwwb.'], { b: '#5a2a13', p: '#7b3fa0', P: '#d29bff', w: '#ece4d2' }],
68 furnace: [['gggggggg', 'gddddddg', 'gdkkkkdg', 'gdkffkdg', 'gdkfFkdg', 'gddddddg', 'gGgGgGgg', 'gggggggg'], { g: '#7d7d7d', d: '#585858', k: '#1d1d1d', f: '#ff8a00', F: '#ffd23a', G: '#666666' }],
69 furnaceCold: [['gggggggg', 'gddddddg', 'gdkkkkdg', 'gdkkkkdg', 'gdkkkkdg', 'gddddddg', 'gGgGgGgg', 'gggggggg'], { g: '#7d7d7d', d: '#585858', k: '#1d1d1d', G: '#666666' }],
70 grass: [['GgGggGgG', 'gdgGdggd', 'dDdgdDdd', 'ddDddddD', 'dddDdddd', 'dDdddDdd', 'ddddDddd', 'dddddddD'], { g: '#5fa83b', G: '#4a8f2c', d: '#8b5a35', D: '#6e4527' }],
71 sun: [['yyyyyyyy', 'yYYYYYYy', 'yYwwwwYy', 'yYwwwwYy', 'yYwwwwYy', 'yYwwwwYy', 'yYYYYYYy', 'yyyyyyyy'], { y: '#ffd93b', Y: '#ffea7a', w: '#fffbe0' }],
72 moon: [['mmmmmmmm', 'mMmmmmmm', 'mmmmMMmm', 'mmmmMMmm', 'mMMmmmmm', 'mMMmmmMm', 'mmmmmmmm', 'mmmmmmmm'], { m: '#f1f0d6', M: '#c9c7a8' }],
73 // 8×16: head, body, feet
74 creeper: [
75 ['gGggggGg', 'ggGggggg', 'gkkggkkg', 'gkkggkkg', 'gggkkggg', 'ggkkkkgg', 'ggkkkkgg', 'ggkggkgg', '.gGgggg.', '.ggggGg.', '.gggggg.', '.gGgggg.', '.ggggGg.', '.gggggg.', 'gggggggg', 'gg.gg.gg'],
76 { g: '#5cb85c', G: '#3e8e3e', k: '#10200f' },
77 ],
78 // 12×17: Claude the miner, coral shirt, without the right arm (that one swings)
79 miner: [
80 ['..hhhhhhhh..', '..hhhhhhhh..', '..hssssssh..', '..swpsswps..', '..ssssssss..', '..sssmmsss..', '..ssssssss..', 'aatttttttt..', 'aatttttttt..', 'aatttttttt..', 'aatttttttt..', 'sstttttttt..', '..llllllll..', '..llllllll..', '..lll..lll..', '..lll..lll..', '..ggg..ggg..'],
81 { h: '#3b2a1d', s: '#e0ac8a', w: '#ffffff', p: '#2d3a8c', m: '#b07a5c', a: '#c96442', t: '#d97757', l: '#3b4a7a', g: '#4a4a4a' },
82 ],
83}
84
85const HEART = ['.kk.kk..', 'kffkffk.', 'kfffffk.', 'kfffffk.', '.kfffk..', '..kfk...', '...k....', '........']
86const DRUMSTICK = ['......kk', '.....kwk', '..kkkkk.', '.kffffk.', 'kfffffk.', 'kffffk..', 'kfffk...', '.kkk....']
87
88// A bar icon with 2, 1 or 0 lit halves: 'f' lit, 'e' empty
89function halves(rows, lit) {
90 return rows.map((row) => [...row].map((ch, col) => (ch === 'f' ? (lit === 2 || (lit === 1 && col < 4) ? 'f' : 'e') : ch)).join(''))
91}
92
93// The pickaxe tier shows on the miner's tool
94const PICK_COLOR = { Wooden: '#a07a46', Stone: '#9a9a9a', Iron: '#e2e2e2', Diamond: '#4be8df', Netherite: '#5a4b4b' }
95
96// The sky's colors, top and horizon, as the context window fills
97const SKY = [
98 [0.0, '#f39a63', '#ffe0b0'],
99 [0.15, '#79c0ff', '#d2ecff'],
100 [0.5, '#4a9fff', '#a6d6ff'],
101 [0.72, '#ff9e5e', '#ffd49a'],
102 [0.84, '#3e2f70', '#e9735b'],
103 [0.93, '#070b24', '#1a2552'],
104]
105
106function mix(a, b, t) {
107 const pa = [1, 3, 5].map((i) => parseInt(a.slice(i, i + 2), 16))
108 const pb = [1, 3, 5].map((i) => parseInt(b.slice(i, i + 2), 16))
109 return '#' + pa.map((v, i) => Math.round(v + (pb[i] - v) * t).toString(16).padStart(2, '0')).join('')
110}
111
112export function skyAt(p) {
113 if (p <= SKY[0][0]) return [SKY[0][1], SKY[0][2]]
114 for (let i = 1; i < SKY.length; i++) {
115 if (p <= SKY[i][0]) {
116 const t = (p - SKY[i - 1][0]) / (SKY[i][0] - SKY[i - 1][0])
117 return [mix(SKY[i - 1][1], SKY[i][1], t), mix(SKY[i - 1][2], SKY[i][2], t)]
118 }
119 }
120 const last = SKY[SKY.length - 1]
121 return [last[1], last[2]]
122}
123
124// What each slot stands for, in its tooltip
125const HOW = {
126 stone: 'file reads and searches',
127 planks: 'edits and writes',
128 tnt: 'shell commands',
129 diamond: 'green test runs',
130 emerald: 'commits',
131 redstone: 'MCP tool calls',
132 egg: 'subagents',
133 pearl: 'web fetches and searches',
134 book: 'skills',
135}
136
137function tree(x, scale = 1, isSnowy = false) {
138 const s = scale
139 const snow = isSnowy
140 ? `<rect x="-14" y="-72" width="28" height="4" fill="#f4f8ff"/><rect x="-20" y="-62" width="40" height="3" fill="#f4f8ff"/><rect x="-24" y="-34" width="10" height="2" fill="#f4f8ff"/>`
141 : ''
142 return (
143 `<g transform="translate(${r1(x)},${GROUND}) scale(${s})">` +
144 `<rect x="-4" y="-34" width="8" height="34" fill="#6b4f2a"/><rect x="-1" y="-34" width="2" height="34" fill="#5a4022"/>` +
145 `<rect x="-20" y="-60" width="40" height="28" fill="#3f8f2f"/><rect x="-14" y="-70" width="28" height="12" fill="#3f8f2f"/>` +
146 `<rect x="-16" y="-56" width="6" height="6" fill="#2f6f23"/><rect x="6" y="-48" width="6" height="6" fill="#2f6f23"/>` +
147 `<rect x="-6" y="-66" width="6" height="6" fill="#4caf3a"/><rect x="10" y="-60" width="6" height="6" fill="#4caf3a"/>` +
148 `<rect x="-20" y="-40" width="8" height="6" fill="#2f6f23"/>${snow}</g>`
149 )
150}
151
152function cloud(now, y, w, seconds, offset, W, night, isGrey) {
153 const fill = night > 0.5 ? '#9aa3c0' : isGrey ? '#d9dfe7' : '#ffffff'
154 return (
155 `<g opacity="${night > 0.5 ? 0.35 : 0.9}"><animateTransform attributeName="transform" type="translate" from="${-w - 20} ${r1(y)}" to="${W + 20} ${r1(y)}" dur="${seconds}s" begin="${phase(now + offset, seconds)}" repeatCount="indefinite"/>` +
156 `<rect width="${w}" height="8" fill="${fill}"/><rect x="${r1(w * 0.18)}" y="-6" width="${r1(w * 0.46)}" height="6" fill="${fill}"/>` +
157 `<rect x="${r1(w * 0.1)}" y="8" width="${r1(w * 0.7)}" height="3" fill="${fill}" opacity="0.8"/></g>`
158 )
159}
160
161// The whole band. `v` is the view register.js builds; W is the scene's width in units.
162export function sceneSvg(v) {
163 const { W, now } = v
164 const H = SCENE_H
165 const p = typeof v.contextUsed === 'number' ? clamp(v.contextUsed / 100, 0, 1) : 0.35
166 const [skyTop, skyLow] = skyAt(p)
167 const night = clamp((p - 0.8) / 0.13, 0, 1)
168 const hudX = Math.round(W / 2 - HUD_W / 2)
169 const hudY = H - 82 - 6
170 const leftEnd = hudX - 14
171 const rightStart = hudX + HUD_W + 14
172 const rz = W - rightStart
173 const tier = pickaxeOf(v.level)
174 const limit = typeof v.fiveHourUsed === 'number' ? v.fiveHourUsed : 0
175 const weekly = typeof v.sevenDayUsed === 'number' ? v.sevenDayUsed : 0
176 const weather = v.weather && v.weather !== 'auto' ? v.weather : limit >= 90 ? 'storm' : weekly >= 70 ? 'snow' : limit >= 70 ? 'rain' : 'clear'
177 const isStorm = weather === 'storm'
178 const isRaining = weather === 'rain' || isStorm
179 const isSnowing = weather === 'snow'
180 // One seeded stream per layer, so a layer drawn or skipped never moves another
181 const starRand = rng(1)
182 const cloudRand = rng(2)
183 const floraRand = rng(3)
184 const rainRand = rng(4)
185 const snowRand = rng(5)
186
187 // --- defs ----------------------------------------------------------------
188 let defs =
189 `<linearGradient id="sky" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="${skyTop}"/><stop offset="1" stop-color="${skyLow}"/></linearGradient>` +
190 `<radialGradient id="glow"><stop offset="0" stop-color="#ffcf6a" stop-opacity="0.55"/><stop offset="1" stop-color="#ffcf6a" stop-opacity="0"/></radialGradient>` +
191 `<clipPath id="frame"><rect width="${W}" height="${H}"/></clipPath>` +
192 `<pattern id="grass" width="24" height="24" patternUnits="userSpaceOnUse"><g transform="scale(3)">${art(...TEX.grass)}</g></pattern>`
193 for (const id of [...HOTBAR, 'furnace', 'furnaceCold', 'sun', 'moon', 'creeper', 'miner']) defs += `<g id="t-${id}">${art(...TEX[id])}</g>`
194 for (const lit of [0, 1, 2]) {
195 defs += `<g id="h${lit}">${art(halves(HEART, lit), { k: '#1a1a1a', f: '#e8262c', e: '#3c3c3c' })}</g>`
196 defs += `<g id="d${lit}">${art(halves(DRUMSTICK, lit), { k: '#1a1a1a', f: '#c8742b', e: '#3c3c3c', w: lit ? '#ededed' : '#4a4a4a' })}</g>`
197 }
198
199 // --- sky: gradient, stars, sun or moon, clouds -----------------------------
200 let sky = `<rect width="${W}" height="${H}" fill="url(#sky)"/>`
201 if (night > 0) {
202 for (let i = 0; i < Math.round(W / 25); i++) {
203 const x = r1(starRand() * W)
204 const y = r1(starRand() * (GROUND - 34))
205 const size = starRand() > 0.8 ? 2.5 : 1.5
206 const a = r1(night * (0.5 + starRand() * 0.5))
207 const seconds = r1(2 + starRand() * 3)
208 sky += `<rect x="${x}" y="${y}" width="${size}" height="${size}" fill="#fff" opacity="${a}"><animate attributeName="opacity" values="${a};${r1(a * 0.2)};${a}" dur="${seconds}s" begin="${phase(now + i * 997, seconds)}" repeatCount="indefinite"/></rect>`
209 }
210 }
211 if (p < 0.88) {
212 const t = Math.min(p / 0.85, 1)
213 const x = r1(W * (0.04 + 0.92 * t) - 16)
214 const y = r1(46 - 36 * Math.sin(Math.PI * t))
215 sky += `<g transform="translate(${x},${y})"><title>Context ${Math.round(p * 100)}% used: the sun sets as it fills</title><rect x="-6" y="-6" width="44" height="44" fill="#fff6c0" opacity="0.25"/><g transform="scale(4)"><use href="#t-sun"/></g></g>`
216 }
217 if (p >= 0.84) {
218 const t = clamp((p - 0.84) / 0.16, 0, 1)
219 const x = r1(W * (0.04 + 0.92 * t) - 14)
220 const y = r1(46 - 36 * Math.sin(Math.PI * t))
221 sky += `<g transform="translate(${x},${y}) scale(3.5)"><title>Context ${Math.round(p * 100)}% used: night. /compact brings the sunrise</title><use href="#t-moon"/></g>`
222 }
223 for (let i = 0; i < Math.max(5, Math.round(W / 190)); i++) {
224 const seconds = Math.round(150 + cloudRand() * 90)
225 sky += cloud(now, 6 + cloudRand() * 30, Math.round(40 + cloudRand() * 50), seconds, cloudRand() * seconds * 1000, W, night || (isRaining ? 0.6 : 0), isSnowing)
226 }
227 if (isRaining) sky += `<rect width="${W}" height="${H}" fill="#3a4250" opacity="${isStorm ? 0.45 : 0.25}"/>`
228 if (isSnowing) sky += `<rect width="${W}" height="${H}" fill="#c9d6e3" opacity="0.32"/>`
229
230 // --- world: ground, trees, flowers, Claude, furnace, sign, creeper --------
231 let world = `<rect x="0" y="${GROUND}" width="${W}" height="${H - GROUND}" fill="url(#grass)"/>`
232 // Snow settles on the grass in a white layer, a little uneven
233 if (isSnowing) {
234 world += `<g><title>Snow: 7-day limit ${weekly}% used</title><rect x="0" y="${GROUND - 1}" width="${W}" height="5" fill="#f4f8ff"/>`
235 for (let x = 0; x < W; x += 18 + Math.round(snowRand() * 30)) world += `<rect x="${x}" y="${GROUND - 3}" width="${8 + Math.round(snowRand() * 14)}" height="2" fill="#f4f8ff"/>`
236 world += `<rect x="0" y="${GROUND + 4}" width="${W}" height="2" fill="#dfe8f2" opacity="0.8"/></g>`
237 }
238 // Trees and flowers fill the room either side of the HUD, more of them on a wide band
239 if (leftEnd > 60) world += tree(leftEnd * 0.12, 0.9, isSnowing)
240 if (leftEnd > 420) world += tree(leftEnd * 0.84, 0.8, isSnowing)
241 if (rz > 150) world += tree(rightStart + rz * 0.93, 1, isSnowing)
242 if (rz > 460) world += tree(rightStart + rz * 0.36, 0.85, isSnowing)
243 for (let i = 0; i < Math.round(W / 70); i++) {
244 const isLeft = i % 2 === 0
245 const zoneStart = isLeft ? 0 : rightStart
246 const zoneW = isLeft ? leftEnd : rz
247 const x = r1(zoneStart + floraRand() * zoneW)
248 const color = ['#e8262c', '#ffd93b', '#ffffff', '#c86bff'][Math.floor(floraRand() * 4)]
249 const isFlower = floraRand() > 0.5
250 if (zoneW < 40) continue
251 world += isFlower ? `<rect x="${x}" y="${GROUND - 6}" width="1.5" height="6" fill="#3e8e3e"/><rect x="${r1(x - 1)}" y="${GROUND - 8}" width="3.5" height="3" fill="${color}"/>` : `<path fill="#4caf3a" d="M${x} ${GROUND}l1 -5l1 5zM${r1(x + 2)} ${GROUND}l1.5 -7l1.5 7z"/>`
252 }
253
254 // Claude, mining when a turn runs, standing by when it doesn't
255 const mx = Math.max(18, Math.round(leftEnd * 0.46))
256 const minerTop = GROUND - 17 * PX
257 const bx = Math.round(mx + 13 * PX)
258 const working = v.isWorking === true
259 const swing = working
260 ? `<animateTransform attributeName="transform" type="rotate" values="-60 10.5 7.5;22 10.5 7.5;-60 10.5 7.5" keyTimes="0;0.4;1" dur="0.6s" begin="${phase(now, 0.6)}" repeatCount="indefinite"/>`
261 : ''
262 const bob = working ? '' : `<animateTransform attributeName="transform" type="translate" values="0 0;0 -0.8;0 0" dur="2.6s" begin="${phase(now, 2.6)}" repeatCount="indefinite"/>`
263 world +=
264 `<g transform="translate(${mx},${r1(minerTop)})"><title>Claude${working ? ': mining' : ': waiting for you'} · ${tier} pickaxe</title><g>${bob}<g transform="scale(${PX})">` +
265 `<use href="#t-miner"/><g transform="rotate(${working ? 0 : -12} 10.5 7.5)"><g>${swing}` +
266 `<rect x="10" y="7" width="2" height="4" fill="#c96442"/><rect x="10" y="11" width="2" height="1" fill="#e0ac8a"/>` +
267 `<rect x="10.5" y="4.5" width="1" height="7" fill="#7a5230"/><path fill="${PICK_COLOR[tier]}" d="M8 3.6h7v1.2h-7zM7.2 4.6h1.4v1.2h-1.4zM14.4 4.6h1.4v1.2h-1.4z"/>` +
268 `</g></g></g></g>` +
269 `<g transform="translate(${r1(5 * PX)},-12)"><rect x="-17" y="-1" width="34" height="10" fill="#000" opacity="0.45"/><text x="0" y="6.5" text-anchor="middle" font-size="7" fill="#fff">Claude</text></g></g>`
270 // The block Claude works on: cracks and chips fly while it swings
271 world += `<g transform="translate(${bx},${GROUND - 24}) scale(3)"><use href="#t-stone"/></g>`
272 if (working) {
273 world += `<path d="M${bx + 4} ${GROUND - 20}l6 6l-2 5l7 6M${bx + 20} ${GROUND - 22}l-5 7l3 4" stroke="#1d1d1d" stroke-width="1.2" fill="none" opacity="0"><animate attributeName="opacity" values="0;0.9;0" keyTimes="0;0.45;1" dur="0.6s" begin="${phase(now, 0.6)}" repeatCount="indefinite"/></path>`
274 for (const [dx, dy] of [[-10, -14], [-4, -20], [6, -16], [-12, -6]]) {
275 world += `<rect x="${bx + 2}" y="${GROUND - 18}" width="2.5" height="2.5" fill="#9a9a9a" opacity="0"><animate attributeName="opacity" values="0;1;0" keyTimes="0;0.4;1" dur="0.6s" begin="${phase(now, 0.6)}" repeatCount="indefinite"/><animateTransform attributeName="transform" type="translate" values="0 0;0 0;${dx} ${dy}" keyTimes="0;0.4;1" dur="0.6s" begin="${phase(now, 0.6)}" repeatCount="indefinite"/></rect>`
276 }
277 }
278 // A torch beside Claude, lit once the sun goes down
279 const tx = mx - 10
280 world += `<rect x="${tx}" y="${GROUND - 12}" width="2.4" height="12" fill="#7a5230"/><rect x="${tx - 0.4}" y="${GROUND - 15}" width="3.2" height="3.2" fill="#ffb300"/>`
281 if (night > 0.2) {
282 world += `<circle cx="${tx + 1.2}" cy="${GROUND - 13}" r="34" fill="url(#glow)" opacity="${r1(night)}"><animate attributeName="r" values="32;36;33;35;32" dur="1.4s" begin="${phase(now, 1.4)}" repeatCount="indefinite"/></circle>`
283 }
284
285 // The furnace: lit and smoking while /smelt runs, the arrow filling on its own
286 const fx = Math.round(rightStart + Math.max(8, rz * 0.06))
287 if (v.smelt) {
288 const total = v.smelt.endsAt - v.smelt.startedAt
289 const left = Math.max(0, v.smelt.endsAt - now)
290 const done = clamp(1 - left / total, 0, 1)
291 const minutes = left > 60_000 ? Math.ceil(left / 60_000) + 'm' : clockOf(left)
292 world +=
293 `<g><title>Smelting "${esc(v.smelt.label)}": ${clockOf(left)} left. /smelt stop puts it out</title>` +
294 `<g transform="translate(${fx},${GROUND - 32}) scale(4)"><use href="#t-furnace"/></g>` +
295 `<rect x="${fx + 12}" y="${GROUND - 20}" width="8" height="8" fill="#ffe08a" opacity="0.4"><animate attributeName="opacity" values="0.15;0.6;0.3;0.55;0.15" dur="0.9s" begin="${phase(now, 0.9)}" repeatCount="indefinite"/></rect>`
296 for (let i = 0; i < 3; i++) {
297 world += `<rect x="${fx + 13 + i * 3}" y="${GROUND - 36}" width="3.5" height="3.5" fill="#bdbdbd" opacity="0"><animate attributeName="opacity" values="0;0.7;0" dur="2.4s" begin="${phase(now + i * 800, 2.4)}" repeatCount="indefinite"/><animateTransform attributeName="transform" type="translate" values="0 0;${2 - i * 2} -22" dur="2.4s" begin="${phase(now + i * 800, 2.4)}" repeatCount="indefinite"/></rect>`
298 }
299 const ax = fx + 38
300 const ay = GROUND - 22
301 const arrow = `M${ax} ${ay - 3}h16v-3l6 6l-6 6v-3h-16z`
302 world +=
303 `<path d="${arrow}" fill="#3c3c3c" stroke="#000" stroke-width="0.8"/>` +
304 `<clipPath id="arrowfill"><rect x="${ax}" y="${ay - 7}" width="${r1(22 * done)}" height="14"><animate attributeName="width" from="${r1(22 * done)}" to="22" dur="${Math.max(1, Math.round(left / 1000))}s" fill="freeze"/></rect></clipPath>` +
305 `<path d="${arrow}" fill="#fff" clip-path="url(#arrowfill)"/>` +
306 `<text x="${ax + 26}" y="${ay + 3}" font-size="9" fill="#fff" stroke="#000" stroke-width="1.6" paint-order="stroke">${minutes}</text>` +
307 `<g transform="translate(${fx + 16},${GROUND - 42})"><rect x="-34" y="-8" width="68" height="11" fill="#000" opacity="0.5"/><text x="0" y="0.5" text-anchor="middle" font-size="7" fill="#ffd23a">${esc(v.smelt.label.slice(0, 16))}</text></g></g>`
308 } else {
309 world += `<g transform="translate(${fx},${GROUND - 32}) scale(4)"><title>Furnace cold: /smelt 25 <task> lights it</title><use href="#t-furnaceCold"/></g>`
310 }
311
312 // A finished smelt: the furnace pops its XP for four seconds
313 if (v.smeltDone && now - v.smeltDone.at < 4000) {
314 world +=
315 `<g opacity="0"><animate attributeName="opacity" values="1;1;0" keyTimes="0;0.75;1" dur="4s" begin="${since(now, v.smeltDone.at)}" fill="freeze"/>` +
316 `<animateTransform attributeName="transform" type="translate" values="${fx + 16} ${GROUND - 40};${fx + 16} ${GROUND - 70}" dur="4s" begin="${since(now, v.smeltDone.at)}" fill="freeze"/>` +
317 `<path fill="#e6e6e6" stroke="#6b6b6b" stroke-width="0.8" d="M-10 -2h16l4 -6h-16z"/>` +
318 `<text x="0" y="12" text-anchor="middle" font-size="9" fill="#ffd23a" stroke="#000" stroke-width="1.8" paint-order="stroke">Smelted! +${v.smeltDone.minutes} XP</text></g>`
319 }
320
321 // The sign: level and pickaxe, lifetime blocks, today's XP
322 if (rz > 200) {
323 const sx = Math.round(rightStart + rz * 0.56)
324 world +=
325 `<g transform="translate(${sx},${GROUND})"><title>Lifetime: ${v.lifetimeTotal} blocks · today +${v.todayXp} XP</title>` +
326 `<rect x="-2.5" y="-20" width="5" height="20" fill="#6b4f2a"/><rect x="-50" y="-64" width="100" height="46" fill="#b98a50" stroke="#6b4f2a" stroke-width="2.5"/>` +
327 `<text text-anchor="middle" font-size="9" fill="#2b1d0e"><tspan x="0" y="-49">Lv ${v.level} · ${tier}</tspan><tspan x="0" y="-37">${v.lifetimeTotal} mined</tspan><tspan x="0" y="-25">today +${v.todayXp} XP</tspan></text></g>`
328 }
329
330 // Night brings a creeper: the context is nearly full, it wants a /compact
331 if (night >= 0.35 && rz > 120) {
332 const cx = Math.round(rightStart + rz * (rz > 190 ? 0.8 : 0.62))
333 world +=
334 `<g transform="translate(${cx},${GROUND - 16 * 2.4})"><title>Context ${Math.round(p * 100)}% used. Run /compact before the creeper does</title>` +
335 `<g><animateTransform attributeName="transform" type="translate" values="0 0;1.5 0;0 0;-1.5 0;0 0" dur="3.2s" begin="${phase(now, 3.2)}" repeatCount="indefinite"/><g transform="scale(2.4)"><use href="#t-creeper"/></g></g>` +
336 `<g transform="translate(10,-12)"><rect x="-26" y="-9" width="52" height="12" rx="2" fill="#fff"/><path d="M-4 3l4 5l2 -5z" fill="#fff"/><text x="0" y="-0.5" text-anchor="middle" font-size="7" fill="#1d1d1d">ssss… /compact</text></g></g>`
337 }
338
339 // A red test run: a creeper hisses beside Claude for three seconds
340 if (v.creeperAt && now - v.creeperAt < 3000) {
341 const cx = bx + 30
342 world +=
343 `<g transform="translate(${cx},${GROUND - 16 * 2.2})" opacity="1"><animate attributeName="opacity" values="1;1;0" keyTimes="0;0.85;1" dur="3s" begin="${since(now, v.creeperAt)}" fill="freeze"/>` +
344 `<title>Tests failed</title><g transform="scale(2.2)"><use href="#t-creeper"/></g>` +
345 `<rect width="${8 * 2.2}" height="${16 * 2.2}" fill="#fff" opacity="0"><animate attributeName="opacity" values="0;0.85;0" dur="0.3s" begin="${phase(now, 0.3)}" repeatCount="indefinite"/></rect>` +
346 `<text x="9" y="-4" text-anchor="middle" font-size="8" fill="#fff" stroke="#000" stroke-width="1.6" paint-order="stroke">tests failed</text></g>`
347 }
348
349 let tint = night > 0 ? `<rect width="${W}" height="${H}" fill="#0a1033" opacity="${r1(night * 0.42)}"/>` : ''
350 // Rain past 70% of the 5-hour limit; past 90%, lightning too
351 if (isRaining) {
352 for (let i = 0; i < Math.round(W / (isStorm ? 9 : 16)); i++) {
353 const x = r1(rainRand() * (W + 40))
354 const seconds = r1(0.55 + rainRand() * 0.3)
355 tint += `<rect x="${x}" y="-12" width="1.2" height="9" fill="#a9c8ff" opacity="0.55"><animateTransform attributeName="transform" type="translate" values="0 0;-8 ${GROUND + 12}" dur="${seconds}s" begin="${phase(now + i * 131, seconds)}" repeatCount="indefinite"/></rect>`
356 }
357 }
358 // Snowflakes drift down slowly, swaying as they fall
359 if (isSnowing) {
360 for (let i = 0; i < Math.round(W / 11); i++) {
361 const x = r1(snowRand() * W)
362 const seconds = r1(4 + snowRand() * 4)
363 const size = snowRand() > 0.7 ? 2.6 : 1.6
364 const sway = r1(3 + snowRand() * 5)
365 tint += `<rect x="${x}" y="-6" width="${size}" height="${size}" fill="#fff" opacity="0.92"><animateTransform attributeName="transform" type="translate" values="0 0;${sway} ${r1(GROUND * 0.33)};${-sway} ${r1(GROUND * 0.66)};${r1(sway / 2)} ${GROUND + 6}" dur="${seconds}s" begin="${phase(now + i * 173, seconds)}" repeatCount="indefinite"/></rect>`
366 }
367 }
368 if (isStorm) {
369 tint += `<rect width="${W}" height="${H}" fill="#fff" opacity="0"><animate attributeName="opacity" values="0;0;0.7;0;0.45;0;0" keyTimes="0;0.8;0.82;0.85;0.87;0.9;1" dur="7s" begin="${phase(now, 7)}" repeatCount="indefinite"/></rect>`
370 }
371
372 // --- HUD: hearts, hunger, XP bar, hotbar, centered as in the game ---------
373 const shake = (i, on) => (on ? `<animateTransform attributeName="transform" type="translate" values="0 0;0 -0.8;0 0.6;0 0" dur="0.32s" begin="${phase(now + i * 61, 0.32)}" repeatCount="indefinite"/>` : '')
374 let hearts = ''
375 let hunger = ''
376 for (let i = 0; i < 10; i++) {
377 const h = clamp(v.hearts - i * 2, 0, 2)
378 const d = clamp(v.hunger - i * 2, 0, 2)
379 hearts += `<g transform="translate(${i * 8},0)"><g>${shake(i, v.hearts <= 6)}<use href="#h${h}"/></g></g>`
380 hunger += `<g transform="translate(${174 - i * 8},0)"><g>${shake(i, v.hunger <= 6)}<use href="#d${d}"/></g></g>`
381 }
382 const fiveHour = typeof v.fiveHourUsed === 'number' ? `5-hour limit ${v.fiveHourUsed}% used${isStorm ? ': thunderstorm' : isRaining ? ': rain' : ''}` : 'no plan-limit reading yet'
383 const ctx = typeof v.contextUsed === 'number' ? `context ${v.contextUsed}% used` : 'no context reading yet'
384 const filled = Math.round(180 * clamp(v.progress, 0, 1))
385 let hud =
386 `<g><title>Hearts: ${fiveHour}</title>${hearts}</g><g><title>Hunger: ${ctx}${v.hunger <= 6 ? '. Eat: /compact' : ''}</title>${hunger}</g>` +
387 `<g><title>Level ${v.level} · ${v.into}/${v.need} XP to ${v.level + 1} · ${tier} pickaxe</title>` +
388 `<rect x="0" y="11" width="182" height="5" fill="#000"/><rect x="1" y="12" width="180" height="3" fill="#2a2a2a"/>`
389 if (filled > 0) {
390 hud +=
391 `<rect x="1" y="12" width="${filled}" height="3" fill="#80ff20"/>` +
392 `<clipPath id="xpfill"><rect x="1" y="12" width="${filled}" height="3"/></clipPath>` +
393 `<rect y="12" width="8" height="3" fill="#fff" opacity="0.55" clip-path="url(#xpfill)"><animate attributeName="x" values="-8;${filled + 2};${filled + 2}" keyTimes="0;0.5;1" dur="4s" begin="${phase(now, 4)}" repeatCount="indefinite"/></rect>`
394 }
395 for (let n = 1; n < 18; n++) hud += `<rect x="${1 + n * 10}" y="12" width="1" height="3" fill="#000" opacity="0.45"/>`
396 hud += `<text x="91" y="14.5" text-anchor="middle" font-size="8" fill="#80ff20" stroke="#000" stroke-width="1.6" paint-order="stroke">${v.level}</text></g>`
397 hud += `<rect x="1" y="20" width="182" height="22" fill="#000" opacity="0.35"/><rect x="0" y="19" width="182" height="22" fill="#000"/>`
398 HOTBAR.forEach((id, i) => {
399 const count = v.counts[id] ?? 0
400 const x = 1 + i * 20
401 hud +=
402 `<g class="slot"><title>${BLOCKS[id].name} × ${count}: ${HOW[id]}</title><rect x="${x}" y="20" width="20" height="20" fill="#8b8b8b"/><rect x="${x + 1}" y="21" width="18" height="18" fill="#6f6f6f"/>` +
403 `<g class="lift"><g transform="translate(${x + 2},22) scale(2)" opacity="${count > 0 ? 1 : 0.28}"><use href="#t-${id}"/></g></g></g>`
404 })
405 const pick = HOTBAR.indexOf(v.selected)
406 if (pick >= 0) hud += `<rect x="${pick * 20}" y="19" width="22" height="22" fill="none" stroke="#f4f4f4" stroke-width="1.5"/>`
407 HOTBAR.forEach((id, i) => {
408 const count = v.counts[id] ?? 0
409 if (count > 0) hud += `<text x="${20 + i * 20}" y="39" text-anchor="end" font-size="7" fill="#fff" stroke="#3f3f3f" stroke-width="1" paint-order="stroke">${count > 999 ? '999+' : count}</text>`
410 })
411
412 // --- effects: pickups, level-up, milestone --------------------------------
413 let fx2 = ''
414 for (const pickup of v.recent ?? []) {
415 if (now - pickup.at > 1600) continue
416 const i = HOTBAR.indexOf(pickup.id)
417 if (i < 0) continue
418 const x = hudX + (1 + i * 20 + 10) * 2
419 const y = hudY + 36
420 fx2 +=
421 `<g opacity="0"><animate attributeName="opacity" values="1;1;0" keyTimes="0;0.6;1" dur="1.6s" begin="${since(now, pickup.at)}" fill="freeze"/>` +
422 `<animateTransform attributeName="transform" type="translate" values="${x} ${y};${x} ${y - 30}" dur="1.6s" begin="${since(now, pickup.at)}" fill="freeze"/>` +
423 `<g transform="translate(-12,-8) scale(1.5)"><use href="#t-${pickup.id}"/></g><text x="3" y="2" font-size="9" fill="#fff" stroke="#000" stroke-width="1.6" paint-order="stroke">+1</text></g>`
424 }
425 if (v.levelUpAt && now - v.levelUpAt < 3500) {
426 const cx = W / 2
427 fx2 +=
428 `<g transform="translate(${cx},${hudY - 8})" opacity="0"><animate attributeName="opacity" values="1;1;0" keyTimes="0;0.8;1" dur="3.5s" begin="${since(now, v.levelUpAt)}" fill="freeze"/>` +
429 `<g><animateTransform attributeName="transform" type="scale" values="0.2;1.3;1" keyTimes="0;0.15;1" dur="3.5s" begin="${since(now, v.levelUpAt)}" fill="freeze"/>` +
430 `<text text-anchor="middle" font-size="14" fill="#80ff20" stroke="#000" stroke-width="2.4" paint-order="stroke">LEVEL ${v.level}!</text></g></g>`
431 for (let i = 0; i < 8; i++) {
432 const from = `${mx + 18} ${GROUND - 30}`
433 const to = `${hudX + 30 + i * 40} ${hudY + 26}`
434 const begin = since(now, v.levelUpAt + i * 120)
435 fx2 += `<rect width="4" height="4" fill="#c8ff3c" stroke="#5a8a00" stroke-width="0.8" opacity="0"><animate attributeName="opacity" values="1;1;0" keyTimes="0;0.85;1" dur="1.1s" begin="${begin}" fill="freeze"/><animateMotion path="M${from} Q${r1((mx + hudX) / 2)} ${-10 - i * 4} ${to}" dur="1.1s" begin="${begin}" fill="freeze"/></rect>`
436 }
437 }
438 if (v.advancement && now - v.advancement.at < 6000) {
439 const ax = W - 214
440 fx2 +=
441 `<g transform="translate(${ax},8)"><g><animateTransform attributeName="transform" type="translate" values="230 0;0 0;0 0;230 0" keyTimes="0;0.08;0.9;1" dur="6s" begin="${since(now, v.advancement.at)}" fill="freeze"/>` +
442 `<rect width="206" height="38" rx="3" fill="#212121" stroke="#5a5a5a" stroke-width="1.5"/><g transform="translate(8,7) scale(3)"><use href="#t-${v.advancement.icon}"/></g>` +
443 `<text x="42" y="16" font-size="8" fill="#ffff55">Milestone reached!</text><text x="42" y="29" font-size="9" fill="#fff">${esc(v.advancement.title)}</text></g></g>`
444 }
445
446 const style =
447 // color-scheme matches the app's theme, so the frame stays see-through, never white
448 `<style>:root{color-scheme:light dark}text{font-family:ui-monospace,Menlo,Monaco,monospace;font-weight:700}` +
449 `.slot .lift{transition:transform .15s ease-out}.slot:hover .lift{transform:translateY(-1.5px)}</style>`
450 const source =
451 // slice: if the frame's shape is ever off by a pixel, crop the edges rather than letterbox
452 `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${H}" width="${W}" height="${H}" preserveAspectRatio="xMidYMid slice" shape-rendering="crispEdges">${style}<defs>${defs}</defs>` +
453 `<g clip-path="url(#frame)">${sky}${world}${tint}<g transform="translate(${hudX},${hudY}) scale(2)">${hud}</g>${fx2}</g></svg>`
454 return { source, width: W, height: H }
455}
456