SLOPSHOPPER

Tokencraft

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

newbandspinnerguardcommandtoast
v1.0.0MITupdated 2026-10-02DanielPodolsky/tokencraft
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · tokencraft
› fix the failing auth test and add an audit log call ╭────────────╮ │ tokencraft │ ⏺ Read(src/auth.ts) │ ⬆ Level 1 │ ⎿ Read 6 lines ╰────────────╯ ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM +2 🪨 stone · +3 🪵 planks · +3 🧨 TNT · 💥 1 creeper · +14 XP · ⬆ Level 1 › /f3 ⎿ tokencraft: Tokencraft F3 · Claude Code 2.1.289 ⎿ tokencraft: Branch not a git checkout ⎿ tokencraft: Context 49% used · 103k of 200k left · hunger 5/10 ⎿ tokencraft: Limits 5h 31% · 7d ?% · hearts 7/10 ⎿ tokencraft: Level 1 · 7/9 XP to 2 · Wooden pickaxe ⎿ tokencraft: Session 🪨 2 · 🪵 3 · 🧨 3 ✻ Thinking ⛏ 8… ♥♥♥♥♥♥♥♡♡♡ Lv 1 ━━━━━━━━━━━━━━━━──── ●●●●●○○○○○ 🪨 2 🪵 3 🧨 3 🔥 focus ▱▱▱▱▱▱▱▱▱▱ 25m ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
♥♥♥♥♥♥♥♡♡♡ Lv 1 ━━━━━━━━━━━━━━━━──── ●●●●●○○○○○ 🪨 2 🪵 3 🧨 3 🔥 focus ▱▱▱▱▱▱▱▱▱▱ 25m
README

Tokencraft

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

Install

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.

What the world means

Everything in the band is a live reading of your session. Hover any part on Desktop for its exact numbers.

You seeIt means
❤ Heartshow much of your 5-hour plan limit is left
🍗 Hungerhow much context is left (compacting is eating)
the hotbarthis session's blocks; the last one earned is framed, each pickup floats up as +1
the XP bar and levellifetime XP, on Minecraft's own XP curve (level 30 = 1,395 XP)
Claude swinging a pickaxea turn is running; idle, Claude stands by
the pickaxe's coloryour tier: wood → stone (Lv 5) → iron (Lv 10) → diamond (Lv 20) → netherite (Lv 30)
the sky: sunrise → noon → sunset → nighthow full the context window is
a creeper saying ssss… /compactnight has fallen (context about 85% full): compact soon
rain · thunderstorm5-hour limit past 70% · past 90%
snow, the ground turning white7-day limit past 70%
a lit, smoking furnacea /smelt focus timer is running
the signlevel, lifetime blocks, today's XP
LEVEL 14!, orbs flying into the bara level-up
a "Milestone reached!" carda 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).

Blocks and XP

Claude doesYou 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 turn3 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.

Commands

They run at once, even while Claude is working, and never start a Claude turn.

CommandWhat it does
/f3The 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
/weatherclear · rain · storm · snow for this session; auto (the default) follows your plan limits
/hudon · off · sound on · sound off; with no argument it toggles the HUD

Settings

One option, in /config (or /plugin configure tokencraft@tokencraft):

OptionDefaultWhat it does
px_per_column8.35How 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.

What it can reach

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:

  • reads .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
  • keeps its save in Claude Code's plugin store ($.store): lifetime XP and blocks, milestones, prefs, the running furnace. Every session on your machine shares it, so XP from parallel sessions adds up
  • plays two sounds it ships (sounds/*.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.

Tested with

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.

Develop

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.

Files

FileJob
.claude-plugin/plugin.jsonthe manifest, with the px_per_column option
.claude-plugin/marketplace.jsonlets claude plugin marketplace add find the plugin
hooks/register.jsevery hook and every mods-API call
hooks/blocks.jspure logic and the terminal HUD; no mods API, so it's testable on its own
hooks/scene.jsthe 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.mjsrenders docs/previews/*.svg from scene.js
tools/make_sounds.pysynthesizes sounds/*.wav

License

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.

Source 3 files
hooks/register.js 493 lines
1// 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}
493
hooks/blocks.js 212 lines
1// 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}
212
hooks/scene.js 456 lines
1// 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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[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 &lt;task&gt; 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