SLOPSHOPPER

villager-hp

Your Claude session and weekly usage limits as an animated villager health card, with rank badges and a death screen at 0 HP

newbandcommandtimer
v2.5.1MITupdated 2026-10-10Ashley-Pettit/villager-hp
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · villager-hp
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ 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 › /hp ⎿ villager-hp: SESSION: 69/100 HP ⎿ villager-hp: WEEKLY: no reading yet ⎿ villager-hp: (villager-hp 2.5: animated villager; card and HP commands only) ⟨Claude Code's own drawing⟩ (◕‿◕) SESSION ████████████████████ 69/100 WEEKLY ░░░░░░░░░░░░░░░░░░░░ ??/100 ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
⟨Claude Code's own drawing⟩ (◕‿◕) SESSION ████████████████████ 69/100 WEEKLY ░░░░░░░░░░░░░░░░░░░░ ??/100
README

villager-hp

Your Claude usage limits as a game health card, drawn above the prompt in Claude Code.

A healthy villager at full session HP with a gold GOD MODE badge

How to install

It takes about a minute. You need Claude Code on your computer first.

  1. Open a terminal. On Windows, open PowerShell. On a Mac, open Terminal.
  2. Start Claude Code. Type claude and press Enter.
  3. Copy this line, paste it in, and press Enter:
   /plugin install villager-hp --marketplace Ashley-Pettit/villager-hp
  1. Answer two questions. For the first, type y and press Enter. For the second, just press Enter.
  2. Try it. The health card appears above where you type. Type /hp 40 and press Enter to see it at 40 HP.

Good to know:

  • The line in step 3 only works in the terminal. Pasting it into the Claude desktop app or the Claude website does nothing.
  • You only do this once. After that it also shows up in the Claude desktop app's Code tab.
  • The full animated card shows in the desktop app's Code tab. In a terminal you get a simpler text version.

What you see

Two bars, SESSION (your 5-hour limit) and WEEKLY (your weekly limit across all models), show the hit points you have left. HP is 100 minus the percent used, with the time each one resets. An Age of Empires–style villager watches over them, and his mood follows your lowest bar.

What it does

  • Live HP. Readings refresh after every turn and whenever a limit moves by a point. Reset times count down each minute.
  • An animated villager. He blinks and breathes in every state. At full health he chops away with his pickaxe; as HP falls his swing slows, his head droops, a bandage appears, then tears; near zero he sobs and trembles.
  • Bars that feel alive. They sweep in, energy stripes flow through them, a shine passes along, and sparks drift off the leading edge. The heart beats faster as HP drops.
  • Sci-fi touches. Corner brackets, a hologram scan, a radar ring around the villager, crackling arcs on an overcharged bar (90%+ shows MAX) and red-alert chevrons on a low one.
  • Rank badges. A glowing pill at the right of the card: Scout, Contributor, Innovator, Top contributor or GOD MODE.
  • Death. At 0 HP the villager goes grey, gets X eyes and a halo, and the card reads Claude has been defeated with the time you'll be back.

Low HP: a crying villager, red bars, red-alert chevrons and an Innovator badge

Defeated: the villager at 0 HP

Commands

CommandWhat it does
/hpShow both bars as text
/hp 40Preview the card at any HP from 0 to 100
/hp offBack to your real limits
/dieWatch Claude die (same as /hp 0)
/reviveBring Claude back to your real limits
/rank topChange your badge: scout, contributor, innovator, top or god

What it can reach

It reads your session's usage figures and keeps your chosen rank in its own small store. It makes no network calls, starts no processes, and reads or writes no files of yours.

Readings appear on Claude subscriptions, which report rate-limit windows; on API keys the bars show — until a reading arrives.

More mods by Ash

  • lgtm-flash: a container ship sails past and drops L, G, T and M on its deck every time Claude changes your code.

License

MIT

Source 3 files
hooks/register.tsx 158 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Limit } from '../types'
5import { RANKS, backIn, bars, hudSvg, killer, mood } from './hud'
6import type { Rank } from './hud'
7
8const limitsAtom = atom({ plugin: 'villager-hp', key: 'limits' } as const, [] as Limit[])
9const demoAtom = atom({ plugin: 'villager-hp', key: 'demo' } as const, null as number | null)
10const nowAtom = atom({ plugin: 'villager-hp', key: 'now' } as const, 0)
11const rankAtom = atom({ plugin: 'villager-hp', key: 'rank' } as const, 'god' as Rank)
12
13const FACES = { healthy: '(◕‿◕)', hurt: '(•_•)', critical: '(╥﹏╥)', dead: '(x_x)', unknown: '(•‿•)' }
14const SEGMENTS = 20
15// Named by /hp, to tell at a glance which version is loaded.
16const VERSION = '2.5: animated villager; card and HP commands only'
17// AoE-style: green for what's left, red for what's gone.
18const split = (hp: number) => {
19  const full = Math.round((hp / 100) * SEGMENTS)
20  return { green: '█'.repeat(full), red: '█'.repeat(SEGMENTS - full) }
21}
22
23export const register: Register = on => {
24  on('session.start', async ($, e, next) => {
25    const started = await next(e)
26    await $.command.register({
27      name: 'hp',
28      description: "Show Claude's HP, or preview a level: /hp 10, /hp 0 to die, /hp off for real readings",
29      argumentHint: '[0-100 | off]',
30    })
31    await $.command.register({ name: 'die', description: 'Watch Claude die on the health card (same as /hp 0); /revive to undo' })
32    await $.command.register({ name: 'revive', description: 'Bring Claude back to your real health (same as /hp off)' })
33    await $.command.register({
34      name: 'rank',
35      description: 'Change the rank badge on your health card: /rank scout, contributor, innovator, top, god',
36      argumentHint: '[scout | contributor | innovator | top | god]',
37    })
38    // The rank you picked, kept across sessions.
39    const saved = await $.store.get('rank')
40    if (typeof saved === 'string' && saved in RANKS) await update($, rankAtom, () => saved as Rank)
41    const usage = await $.session.usage()
42    await update($, limitsAtom, () => usage.rateLimits)
43    await update($, nowAtom, () => Date.now())
44    // Tick each minute so the reset countdowns move.
45    $.clock.every(60_000, () => {
46      void update($, nowAtom, () => Date.now())
47    })
48    return started
49  })
50
51  on('command.run', { command: 'die' }, async $ => {
52    await update($, demoAtom, () => 0)
53    return { text: 'Claude has been defeated. /revive to bring it back.' }
54  })
55
56  on('command.run', { command: 'revive' }, async $ => {
57    await update($, demoAtom, () => null)
58    return { text: 'Claude is back, at your real health.' }
59  })
60
61  on('command.run', { command: 'rank' }, async ($, e) => {
62    const arg = e.args.trim().toLowerCase().replace(/\s+/g, ' ')
63    const aliases: Record<string, Rank> = { 'top contributor': 'top', 'god mode': 'god', godmode: 'god' }
64    const rank = (arg in RANKS ? arg : aliases[arg]) as Rank | undefined
65    if (!rank) {
66      const current = await read($, rankAtom)
67      const list = (Object.keys(RANKS) as Rank[]).map(r => `${r}${r === current ? ' (yours)' : ''}`).join(', ')
68      return { text: arg ? `No rank called "${arg}". Ranks: ${list}.` : `Your rank: ${RANKS[current].name}. Change it with /rank and one of: ${list}.` }
69    }
70    await $.store.set('rank', rank)
71    await update($, rankAtom, () => rank)
72    return { text: `Your badge is now ${RANKS[rank].name}.` }
73  })
74
75  // Fresh readings arrive after each turn and whenever a window moves a point.
76  on('session.measure', async ($, e, next) => {
77    await update($, limitsAtom, () => e.rateLimits)
78    await update($, nowAtom, () => Date.now())
79    return next(e)
80  })
81
82  on('command.run', { command: 'hp' }, async ($, e) => {
83    const arg = e.args.trim().toLowerCase()
84    if (arg === 'off' || arg === 'real') {
85      await update($, demoAtom, () => null)
86      return { text: 'HP bars show your real limits again.' }
87    }
88    if (arg !== '') {
89      const n = Number(arg)
90      if (!Number.isFinite(n) || n < 0 || n > 100) return { text: 'Give an HP from 0 to 100, or "off".' }
91      await update($, demoAtom, () => Math.round(n))
92      return { text: n <= 0 ? 'Preview: Claude is dead. /hp off to revive.' : `Preview: both bars at ${Math.round(n)} HP. /hp off for real readings.` }
93    }
94    const all = bars(await read($, limitsAtom), await read($, demoAtom), Date.now())
95    return {
96      text: [
97        ...all.map(b => `${b.label}: ${b.hp === null ? 'no reading yet' : `${b.hp}/100 HP${b.respawn ? `, resets in ${b.respawn}` : ''}`}`),
98        `(villager-hp ${VERSION})`,
99      ].join('\n'),
100    }
101  })
102
103  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
104    const below = await next(e)
105    if (e.props.hasSurvey) return below
106
107    const all = bars(await read($, limitsAtom), await read($, demoAtom), await read($, nowAtom) || Date.now())
108    const m = mood(all)
109    const elements = $.ui.resolve(e)
110
111    // The animated card everywhere but the terminal, which gets the text bars.
112    // Chosen by the surface, not by which elements the table lists: a table can
113    // carry an element its surface draws as nothing.
114    if (e.surface !== 'terminal' && 'Svg' in elements) {
115      const { Box, Svg } = elements
116      const alt = all.map(b => `${b.label} ${b.hp === null ? 'unknown' : `${b.hp} of 100`} HP`).join(', ')
117      return (
118        <Box flexDirection="column">
119          {below}
120          <Svg key={`hp-${m}`} source={hudSvg(all, await read($, rankAtom))} alt={`Claude's health: ${alt}`} />
121        </Box>
122      )
123    }
124
125    const { Box, Text } = elements
126    const dead = killer(all)
127    return (
128      <Box flexDirection="column">
129        {below}
130        {dead !== undefined ? (
131          <Text color="#c9a14a" bold>
132            {`(x_x)  Claude has been defeated.  Out of ${dead.label === 'SESSION' ? 'session' : 'weekly'} hit points · ${backIn(dead.respawn)}`}
133          </Text>
134        ) : (
135          <>
136            {all.map((b, i) => (
137              <Box key={b.label} flexDirection="row">
138                <Text color="#d97757">{i === 0 ? `${FACES[m]} ` : '       '}</Text>
139                <Text bold color="#d4ab52">{b.label.padEnd(8)}</Text>
140                {b.hp === null ? (
141                  <Text dimColor>{'░'.repeat(SEGMENTS)} ??/100</Text>
142                ) : (
143                  <>
144                    <Text color="#3fd23f">{split(b.hp).green}</Text>
145                    <Text color="#a01d14">{split(b.hp).red}</Text>
146                    <Text color="#f1e3c0" bold>{` ${b.hp}/100`}</Text>
147                    <Text color="#cdbb94">{b.respawn ? `  ${b.respawn}` : ''}</Text>
148                  </>
149                )}
150              </Box>
151            ))}
152          </>
153        )}
154      </Box>
155    )
156  })
157}
158
hooks/hud.ts 503 lines
1import type { Limit } from '../types'
2
3// Turns the account's rate-limit windows into two game health bars, and
4// draws them as an SVG HUD for surfaces that draw Svg.
5
6export type Bar = {
7  label: 'SESSION' | 'WEEKLY'
8  hp: number | null // 0 to 100; null before the first reading
9  respawn: string // when the window resets: "2h 50m", "38m" or "Sun 10am"
10}
11
12export type Mood = 'healthy' | 'hurt' | 'critical' | 'dead' | 'unknown'
13
14const KINDS: Record<Bar['label'], string> = { SESSION: 'five_hour', WEEKLY: 'seven_day' }
15const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
16
17const respawnIn = (resetsAt: string | undefined, now: number) => {
18  if (resetsAt === undefined) return ''
19  const at = Date.parse(resetsAt)
20  if (Number.isNaN(at)) return ''
21  const mins = Math.max(0, Math.round((at - now) / 60000))
22  if (mins < 60) return `${mins}m`
23  if (mins < 24 * 60) return `${Math.floor(mins / 60)}h ${mins % 60}m`
24  const d = new Date(at)
25  const h = d.getHours()
26  const time = `${h % 12 === 0 ? 12 : h % 12}${d.getMinutes() ? `:${String(d.getMinutes()).padStart(2, '0')}` : ''}${h < 12 ? 'am' : 'pm'}`
27  return `${DAYS[d.getDay()]} ${time}`
28}
29
30export const bars = (limits: Limit[], demo: number | null, now: number): Bar[] =>
31  (['SESSION', 'WEEKLY'] as const).map(label => {
32    const limit = limits.find(l => l.kind === KINDS[label])
33    if (demo !== null) return { label, hp: demo, respawn: respawnIn(limit?.resetsAt, now) || '4h 20m' }
34    if (limit === undefined) return { label, hp: null, respawn: '' }
35    return { label, hp: Math.max(0, Math.round(100 - limit.percentUsed)), respawn: respawnIn(limit.resetsAt, now) }
36  })
37
38const lowest = (all: Bar[]) => {
39  const known = all.flatMap(b => (b.hp === null ? [] : [b.hp]))
40  return known.length === 0 ? null : Math.min(...known)
41}
42
43export const mood = (all: Bar[]): Mood => {
44  const low = lowest(all)
45  if (low === null) return 'unknown'
46  return low <= 0 ? 'dead' : low < 25 ? 'critical' : low < 50 ? 'hurt' : 'healthy'
47}
48
49// "back in 2h 50m", "back Sun 10am"
50export const backIn = (respawn: string) => (respawn ? (/^\d/.test(respawn) ? `back in ${respawn}` : `back ${respawn}`) : 'back soon')
51
52export const killer = (all: Bar[]) => all.find(b => b.hp !== null && b.hp <= 0)
53
54// ---- the SVG HUD: a modern status card with the villager, 960 x 64 ----
55
56export const HUD_W = 960
57export const HUD_H = 64
58const AV = { cx: 34, cy: 32, r: 25 } // the avatar circle
59const ICON_X = 74
60const LABEL_X = 92
61const BAR_X = 172
62// The right of the card is kept for the rank badge: a zone as wide as the
63// widest badge, so every rank sits the same distance from the reset times.
64const BADGE_ZONE = 156
65const BADGE_GAP = 24 // between the reset times and the badge zone
66// The bars fill what the badge leaves; hudSvg fits them to the badge it draws.
67let BAR_W = 960 - 16 - BADGE_ZONE - BADGE_GAP - 78 - 66 - 16 - 172
68const BAR_H = 12
69let VALUE_X = BAR_X + BAR_W + 16
70const ROW_CY = [20, 44] // the two rows' centre lines
71const UI = `font-family="'Segoe UI Variable Display', 'Segoe UI', system-ui, -apple-system, sans-serif"`
72
73const T = {
74  text: '#f1f4f8',
75  muted: '#8f9aa7',
76  faint: '#5d6875',
77  track: '#1b2129',
78  trackEdge: '#2b333d',
79  lost: '#3a1c20', // lost HP, a faint red under the track
80  chip: '#1e252e',
81  green: ['#22c55e', '#86efac'],
82  amber: ['#f59e0b', '#fcd34d'],
83  red: ['#ef4444', '#fca5a5'],
84}
85
86const tone = (hp: number) => (hp > 50 ? 'green' : hp > 25 ? 'amber' : 'red') as 'green' | 'amber' | 'red'
87
88// ---- the villager: a vector bust in a brown cap and a blue tunic ----
89
90const SKIN = { base: '#eab98e', shade: '#d39a6c', ear: '#dca57a' }
91const DEAD = { base: '#9a958e', shade: '#7f7a73', ear: '#8c8780' }
92const INK = '#2b1a12'
93
94// A tear that runs from under an eye down the cheek, over and over.
95const tear = (x: number, delay: number) => `
96  <path d="M ${x},35 q 1.3,2.2 0,3 q -1.3,-0.8 0,-3 z" fill="#5ec4ff" opacity="0">
97    <animateTransform attributeName="transform" type="translate" values="0 0; 0 9" dur="1.1s" begin="${delay}s" repeatCount="indefinite"/>
98    <animate attributeName="opacity" values="0.95;0.95;0" keyTimes="0;0.7;1" dur="1.1s" begin="${delay}s" repeatCount="indefinite"/>
99  </path>`
100
101// His face by how much health is left: grinning, flat, frowning, crying, sobbing.
102const face = (hp: number) => {
103  const ry = hp < 25 ? 1.5 : 2
104  const blink = `keyTimes="0;0.9;0.94;0.98;1" dur="4.2s" repeatCount="indefinite"`
105  const eye = (x: number) =>
106    `<ellipse cx="${x}" cy="32.2" rx="1.7" ry="${ry}" fill="${INK}"><animate attributeName="ry" values="${ry};${ry};0.2;${ry};${ry}" ${blink}/></ellipse>` +
107    `<circle cx="${x + 0.6}" cy="31.4" r="0.6" fill="#fff"><animate attributeName="opacity" values="1;1;0;1;1" ${blink}/></circle>`
108  const eyes = eye(29.8) + eye(38.2)
109  const nose = `<path d="M 34,33.5 q 1.2,1.6 0,2.3" fill="none" stroke="${SKIN.shade}" stroke-width="1.1" stroke-linecap="round"/>`
110  const stroke = (d: string) => `<path d="${d}" fill="none" stroke="${INK}" stroke-width="1.4" stroke-linecap="round"/>`
111  const brows = (sad: boolean) =>
112    sad
113      ? stroke('M 27.4,28.6 L 31.4,27.2') + stroke('M 36.6,27.2 L 40.6,28.6')
114      : stroke('M 27.6,27.6 Q 29.8,26.6 32,27.4') + stroke('M 36,27.4 Q 38.2,26.6 40.4,27.6')
115  const blush = `<circle cx="27.2" cy="36" r="2" fill="#f28b82" opacity="0.35"/><circle cx="40.8" cy="36" r="2" fill="#f28b82" opacity="0.35"/>`
116  if (hp >= 75) return brows(false) + eyes + nose + blush + stroke('M 30.4,38.2 Q 34,41.4 37.6,38.2')
117  if (hp >= 50) return brows(false) + eyes + nose + stroke('M 31,39.2 L 37,39.2')
118  if (hp >= 25) return brows(true) + eyes + nose + stroke('M 30.6,40.4 Q 34,37.8 37.4,40.4')
119  if (hp >= 10) return brows(true) + eyes + nose + stroke('M 30.4,40.6 Q 34,37.4 37.6,40.6') + tear(29.4, 0)
120  return (
121    brows(true) +
122    eyes +
123    nose +
124    `<ellipse cx="34" cy="39.8" rx="3" ry="2" fill="#5a1d14"/>` +
125    tear(29.4, 0) +
126    tear(38.6, 0.55) +
127    tear(29.4, 0.55)
128  )
129}
130
131// How the villager moves in each state: his pickaxe, his head, his breathing.
132type Motion = { pick: string; head: string; breathe: number; tremble?: string }
133const motion = (m: Mood, hp: number): Motion => {
134  if (m === 'dead') return { pick: '', head: '', breathe: 0 }
135  // Happy: hard at work, the pickaxe chopping, head bobbing along.
136  if (hp >= 75)
137    return {
138      pick: `values="0 44 62;-24 44 62;6 44 62;0 44 62" keyTimes="0;0.45;0.6;1" dur="1.6s"`,
139      head: `values="0 0;0 -0.9;0 0" dur="0.8s"`,
140      breathe: 2.4,
141    }
142  // Content: a slower, steadier swing.
143  if (hp >= 50)
144    return { pick: `values="0 44 62;-14 44 62;0 44 62" dur="2.6s"`, head: `values="0 0;0 -0.5;0 0" dur="2.6s"`, breathe: 2.8 }
145  // Hurt: the pickaxe barely lifts, his head drooping.
146  if (hp >= 25)
147    return { pick: `values="0 44 62;-5 44 62;0 44 62" dur="3.6s"`, head: `values="0 0;0 0.8;0 0" dur="3.6s"`, breathe: 3.6 }
148  // Crying: the dropped pickaxe drags a little; he sniffles.
149  if (hp >= 10)
150    return { pick: `values="0 46 62;3 46 62;0 46 62" dur="2.2s"`, head: `values="0 0;0 0.7;0 0;0 0.5;0 0" dur="1.1s"`, breathe: 1.4 }
151  // Sobbing: shoulders heaving, the whole villager trembling.
152  return {
153    pick: `values="0 46 62;4 46 62;0 46 62" dur="1.4s"`,
154    head: `values="0 0;0 1;0 0;0 1;0 0" dur="0.7s"`,
155    breathe: 0.7,
156    tremble: `values="0 0;-0.4 0;0.4 0;0 0" dur="0.18s"`,
157  }
158}
159
160const villager = (m: Mood, hp: number) => {
161  const dead = m === 'dead'
162  const skin = dead ? DEAD : SKIN
163  const tunic = dead ? '#5f6470' : '#3b6fe0'
164  const tunicDark = dead ? '#4c505a' : '#2c56b8'
165  const cap = dead ? '#5a554f' : '#7a4a24'
166  const capDark = dead ? '#4a4540' : '#5e3818'
167  const coping = hp >= 25 || dead
168  const mv = motion(m, hp)
169  const spin = (v: string) => (v ? `<animateTransform attributeName="transform" type="rotate" ${v} repeatCount="indefinite"/>` : '')
170  const move = (v: string) => (v ? `<animateTransform attributeName="transform" type="translate" ${v} repeatCount="indefinite"/>` : '')
171
172  // The pickaxe, behind him: on his shoulder while he's coping, dropped once he isn't.
173  const pick = coping
174    ? `<g>${spin(mv.pick)}
175       <path d="M 44,62 L 53,24" stroke="${dead ? '#6a655e' : '#8b5a2b'}" stroke-width="2.6" stroke-linecap="round"/>
176       <path d="M 45.5,24.5 Q 53,19 60,26" fill="none" stroke="${dead ? '#77726b' : '#c7cfd8'}" stroke-width="3" stroke-linecap="round"/></g>`
177    : `<g>${spin(mv.pick)}
178       <path d="M 46,62 L 60,48" stroke="#8b5a2b" stroke-width="2.6" stroke-linecap="round"/>
179       <path d="M 56,44 Q 62,47 61,54" fill="none" stroke="#c7cfd8" stroke-width="3" stroke-linecap="round"/></g>`
180
181  // His body breathes: the tunic rises and settles.
182  const body = `<g>${mv.breathe ? move(`values="0 0;0 -0.7;0 0" dur="${mv.breathe}s"`) : ''}
183    <ellipse cx="34" cy="63" rx="19" ry="14" fill="${tunic}"/>
184    <path d="M 22,55 Q 34,60 46,55 L 46,60 Q 34,65 22,60 Z" fill="${tunicDark}" opacity="0.6"/>
185    <path d="M 29.5,49.5 L 34,55.5 L 38.5,49.5" fill="none" stroke="${tunicDark}" stroke-width="1.6" stroke-linejoin="round"/>
186    <rect x="30.5" y="41" width="7" height="9" rx="2" fill="${skin.shade}"/></g>`
187
188  const head = `
189    <circle cx="23.8" cy="33.5" r="2.4" fill="${skin.ear}"/>
190    <circle cx="44.2" cy="33.5" r="2.4" fill="${skin.ear}"/>
191    <ellipse cx="34" cy="32.5" rx="10.2" ry="11" fill="${skin.base}"/>
192    <path d="M 40,24.5 Q 45.5,32 40.5,42 Q 44.8,33 40,24.5 Z" fill="${skin.shade}" opacity="0.55"/>
193    <path d="M 23.4,30 Q 23,18.6 34,18.2 Q 45,18.6 44.6,30 Q 41,25.2 34,25.6 Q 27,25.2 23.4,30 Z" fill="${cap}"/>
194    <path d="M 23.4,30 Q 27,25.2 34,25.6 Q 41,25.2 44.6,30 Q 41,27.4 34,27.6 Q 27,27.4 23.4,30 Z" fill="${capDark}"/>`
195
196  const bandage =
197    !dead && hp < 50
198      ? `<rect x="25" y="26.2" width="18" height="3.4" rx="1.2" fill="#f3efe6" transform="rotate(-8 34 28)"/><circle cx="38.5" cy="27.4" r="0.9" fill="#d84a4a"/>`
199      : ''
200  const sweat =
201    !dead && hp < 25
202      ? `<path d="M 45.5,24 q 1.6,2.8 0,3.8 q -1.6,-1 0,-3.8 z" fill="#5ec4ff"><animate attributeName="opacity" values="1;0.2;1" dur="0.9s" repeatCount="indefinite"/></path>`
203      : ''
204  // Dead: X eyes and a halo that bobs above him, glinting.
205  const deadFace = dead
206    ? `<path d="M 28.4,30.6 l 3,3 M 31.4,30.6 l -3,3 M 36.6,30.6 l 3,3 M 39.6,30.6 l -3,3" stroke="${INK}" stroke-width="1.4" stroke-linecap="round"/>
207       <path d="M 31,39.4 L 37,39.4" stroke="${INK}" stroke-width="1.4" stroke-linecap="round"/>
208       <path d="M 35,39.4 q 0.6,2.6 2,0" fill="#e57373"/>
209       <g>${move(`values="0 0;0 -1.6;0 0" dur="2.4s"`)}
210         <ellipse cx="34" cy="14.6" rx="8" ry="2.2" fill="none" stroke="#f5d061" stroke-width="1.8">
211           <animate attributeName="stroke-opacity" values="1;0.55;1" dur="2.4s" repeatCount="indefinite"/>
212         </ellipse></g>`
213    : face(hp)
214
215  const headGroup = `<g>${move(mv.head)}${head}${bandage}${deadFace}${sweat}</g>`
216  const all = pick + body + headGroup
217  return mv.tremble ? `<g>${move(mv.tremble)}${all}</g>` : all
218}
219
220// The villager in a round avatar. His ring is coloured by his health, and a
221// bright arc orbits it.
222const avatar = (m: Mood, low: number | null) => {
223  const ring = m === 'dead' ? '#59616d' : low === null ? '#3a4350' : T[tone(low)][0]
224  const pulse =
225    m === 'critical' ? `<animate attributeName="stroke-opacity" values="1;0.3;1" dur="0.9s" repeatCount="indefinite"/>` : ''
226  const orbit =
227    m === 'dead'
228      ? ''
229      : `<circle cx="${AV.cx}" cy="${AV.cy}" r="${AV.r + 3.5}" fill="none" stroke="${ring}" stroke-width="2" stroke-linecap="round" stroke-dasharray="22 160" opacity="0.85" filter="url(#soft)">
230           <animateTransform attributeName="transform" type="rotate" values="0 ${AV.cx} ${AV.cy};360 ${AV.cx} ${AV.cy}" dur="${low !== null && low < 25 ? 1.2 : 3.2}s" repeatCount="indefinite"/>
231         </circle>`
232  return `
233  <circle cx="${AV.cx}" cy="${AV.cy}" r="${AV.r + 5}" fill="${ring}" opacity="0.10"/>
234  ${orbit}
235  <circle cx="${AV.cx}" cy="${AV.cy}" r="${AV.r}" fill="url(#avbg)"/>
236  <g clip-path="url(#avclip)">${villager(m, low ?? 100)}</g>
237  <circle cx="${AV.cx}" cy="${AV.cy}" r="${AV.r}" fill="none" stroke="${ring}" stroke-width="2.5">${pulse}</circle>`
238}
239
240// ---- the bars ----
241
242// A heart that beats, faster the lower the health.
243const heartIcon = (x: number, cy: number, fill: string, hp: number | null) => {
244  const beat = hp === null || hp <= 0 ? '' : `<animateTransform attributeName="transform" type="scale" values="1;1.28;1;1.16;1" keyTimes="0;0.14;0.3;0.44;1" dur="${hp < 25 ? 0.6 : hp < 50 ? 0.9 : 1.3}s" repeatCount="indefinite"/>`
245  return `<g transform="translate(${x + 6} ${cy})"><g>${beat}<path transform="translate(-6.2 -6.6) scale(0.62)" d="M10 18 L2.4 10.4 A5 5 0 0 1 10 3.6 A5 5 0 0 1 17.6 10.4 Z" fill="${fill}"/></g></g>`
246}
247const calendarIcon = (x: number, cy: number, fill: string) =>
248  `<g transform="translate(${x} ${cy - 6})"><rect x="0.5" y="1.5" width="11" height="10" rx="2" fill="none" stroke="${fill}" stroke-width="1.5"/><path d="M 0.5,5 L 11.5,5 M 3.5,0 L 3.5,3 M 8.5,0 L 8.5,3" stroke="${fill}" stroke-width="1.5" stroke-linecap="round"/></g>`
249
250// One row: icon and label, a living bar, the value and a reset chip. The bar
251// sweeps in from empty, energy stripes flow through it, a shine passes along
252// it now and then, and sparks drift off its glowing leading edge.
253// Sci-fi accents, all drawn over the card without moving anything on it.
254const HUD_CYAN = '#5ec4ff'
255
256// Thin targeting brackets at the card's four corners, pulsing gently.
257const cornerBrackets = () => {
258  const L = 14
259  const i = 6
260  const W = HUD_W
261  const H = HUD_H
262  const d = [
263    `M ${i},${i + L} L ${i},${i} L ${i + L},${i}`,
264    `M ${W - i - L},${i} L ${W - i},${i} L ${W - i},${i + L}`,
265    `M ${i},${H - i - L} L ${i},${H - i} L ${i + L},${H - i}`,
266    `M ${W - i - L},${H - i} L ${W - i},${H - i} L ${W - i},${H - i - L}`,
267  ].join(' ')
268  return `<path d="${d}" fill="none" stroke="${HUD_CYAN}" stroke-width="1.4" stroke-linecap="round" opacity="0.45">
269    <animate attributeName="opacity" values="0.25;0.6;0.25" dur="3.6s" repeatCount="indefinite"/>
270  </path>`
271}
272
273// A faint dot grid, and a light band that sweeps across like a hologram scan.
274const hologram = () => `
275  <g clip-path="url(#cardclip)">
276    <rect x="0" y="0" width="${HUD_W}" height="${HUD_H}" fill="url(#dots)"/>
277    <rect x="-120" y="0" width="90" height="${HUD_H}" fill="url(#scanband)">
278      <animate attributeName="x" values="-120;-120;${HUD_W + 30}" keyTimes="0;0.55;1" dur="7s" repeatCount="indefinite"/>
279    </rect>
280  </g>`
281
282// A dashed radar ring with tick marks, turning against the glowing arc.
283const radar = (m: Mood) =>
284  m === 'dead'
285    ? ''
286    : `<g opacity="0.55">
287    <circle cx="${AV.cx}" cy="${AV.cy}" r="${AV.r + 7.5}" fill="none" stroke="${HUD_CYAN}" stroke-width="0.9" stroke-dasharray="2 5">
288      <animateTransform attributeName="transform" type="rotate" values="360 ${AV.cx} ${AV.cy};0 ${AV.cx} ${AV.cy}" dur="12s" repeatCount="indefinite"/>
289    </circle>
290    ${[0, 90, 180, 270].map(a => `<line x1="${AV.cx}" y1="${AV.cy - AV.r - 5}" x2="${AV.cx}" y2="${AV.cy - AV.r - 10}" stroke="${HUD_CYAN}" stroke-width="1.2" transform="rotate(${a} ${AV.cx} ${AV.cy})"/>`).join('')}
291  </g>`
292
293// Electric arcs crackling along a full bar's edges.
294const zigzag = (x0: number, x1: number, y: number, amp: number, seed: number) => {
295  const pts: string[] = []
296  for (let x = x0, k = 0; x <= x1; x += 7, k++) {
297    const jitter = ((Math.sin((k + 1) * 12.9898 + seed * 78.233) * 43758.5453) % 1 + 1) % 1
298    pts.push(`${x.toFixed(1)},${(y + (k % 2 === 0 ? -1 : 1) * amp * (0.4 + jitter)).toFixed(1)}`)
299  }
300  return pts.join(' ')
301}
302const overcharge = (x0: number, x1: number, top: number) => `
303  <g>
304    <polyline points="${zigzag(x0, x1, top - 1, 2.4, 1)}" fill="none" stroke="#e8fbff" stroke-width="1.1" opacity="0">
305      <animate attributeName="opacity" values="0;0.95;0;0;0.8;0" keyTimes="0;0.08;0.16;0.5;0.58;0.66" dur="1.7s" repeatCount="indefinite"/>
306    </polyline>
307    <polyline points="${zigzag(x0, x1, top + BAR_H + 1, 2.4, 2)}" fill="none" stroke="${HUD_CYAN}" stroke-width="1.1" opacity="0">
308      <animate attributeName="opacity" values="0;0;0.9;0;0;0.7;0" keyTimes="0;0.25;0.33;0.41;0.75;0.83;0.91" dur="1.7s" repeatCount="indefinite"/>
309    </polyline>
310  </g>`
311
312// Flashing red chevrons warning that a bar is low.
313const redAlert = (x: number, cy: number) => `
314  <text x="${x}" y="${cy + 5}" ${UI} font-size="13" font-weight="900" letter-spacing="-1" fill="#ff4d5e">❯❯
315    <animate attributeName="opacity" values="1;0.15;1" dur="0.7s" repeatCount="indefinite"/>
316  </text>`
317
318const row = (bar: Bar, cy: number, id: string, delay: number) => {
319  const hp = bar.hp
320  const top = cy - BAR_H / 2
321  const colour = (hp === null ? T.faint : T[tone(hp)][0]) ?? T.faint
322  const light = (hp === null ? T.faint : T[tone(hp)][1]) ?? T.faint
323  const icon = bar.label === 'SESSION' ? heartIcon(ICON_X, cy, colour, hp) : calendarIcon(ICON_X + 1, cy, colour)
324  const label = `<text x="${LABEL_X}" y="${cy + 4}" ${UI} font-size="11.5" font-weight="700" letter-spacing="1.4" fill="${T.muted}">${bar.label}</text>`
325  const track = `<rect x="${BAR_X}" y="${top}" width="${BAR_W}" height="${BAR_H}" rx="${BAR_H / 2}" fill="${hp === null || hp >= 100 ? T.track : T.lost}" stroke="${T.trackEdge}" stroke-width="1"/>`
326  let ticks = ''
327  for (let k = 1; k < 10; k++) {
328    const x = BAR_X + (BAR_W * k) / 10
329    ticks += `<line x1="${x}" y1="${top + 2}" x2="${x}" y2="${top + BAR_H - 2}" stroke="#0b0e12" stroke-opacity="0.4" stroke-width="1.5"/>`
330  }
331  if (hp === null) {
332    return `${icon}${label}${track}${ticks}<text x="${VALUE_X}" y="${cy + 5}" ${UI} font-size="15" font-weight="600" fill="${T.faint}">—</text>`
333  }
334  const w = hp <= 0 ? 0 : Math.max(BAR_H, Math.round((BAR_W * hp) / 100))
335  const sweep = `begin="${delay}s" dur="1.1s" calcMode="spline" keyTimes="0;1" keySplines="0.16 1 0.3 1" fill="freeze"`
336  const pulse = hp < 25 && hp > 0 ? `<animate attributeName="opacity" values="1;0.55;1" dur="0.9s" repeatCount="indefinite"/>` : ''
337  const edge = BAR_X + w - BAR_H / 2
338  const sparks = [0, 1, 2]
339    .map(
340      k => `<circle cx="${edge}" cy="${cy}" r="1.4" fill="${light}" opacity="0">
341        <animate attributeName="cy" values="${cy};${cy - 13}" dur="1.4s" begin="${delay + 1.1 + k * 0.47}s" repeatCount="indefinite"/>
342        <animate attributeName="cx" values="${edge};${edge + (k - 1) * 5}" dur="1.4s" begin="${delay + 1.1 + k * 0.47}s" repeatCount="indefinite"/>
343        <animate attributeName="opacity" values="0;0.9;0" dur="1.4s" begin="${delay + 1.1 + k * 0.47}s" repeatCount="indefinite"/>
344      </circle>`,
345    )
346    .join('')
347  const fill =
348    w > 0
349      ? `<g>${pulse}
350          <clipPath id="clip-${id}"><rect x="${BAR_X}" y="${top - 1}" width="0" height="${BAR_H + 2}" rx="${BAR_H / 2}"><animate attributeName="width" values="0;${w}" ${sweep}/></rect></clipPath>
351          <g clip-path="url(#clip-${id})">
352            <rect x="${BAR_X}" y="${top}" width="${w}" height="${BAR_H}" rx="${BAR_H / 2}" fill="url(#${id})" filter="url(#soft)"/>
353            <rect x="${BAR_X}" y="${top}" width="${w}" height="${BAR_H}" rx="${BAR_H / 2}" fill="url(#stripes)"/>
354            <rect x="${BAR_X + 4}" y="${top + 2}" width="${Math.max(0, w - 10)}" height="3" rx="1.5" fill="#fff" opacity="0.32"/>
355            <rect x="${BAR_X - 60}" y="${top - 2}" width="46" height="${BAR_H + 4}" fill="url(#shine)">
356              <animate attributeName="x" values="${BAR_X - 60};${BAR_X - 60};${BAR_X + w + 10}" keyTimes="0;0.55;1" dur="3.4s" begin="${delay + 1.2}s" repeatCount="indefinite"/>
357            </rect>
358          </g>
359          <circle cx="${BAR_X + BAR_H / 2}" cy="${cy}" r="${BAR_H / 2 + 1.5}" fill="${light}" opacity="0.5" filter="url(#soft)">
360            <animate attributeName="cx" values="${BAR_X + BAR_H / 2};${edge}" ${sweep}/>
361            <animate attributeName="r" values="${BAR_H / 2};${BAR_H / 2 + 2.5};${BAR_H / 2}" dur="1.6s" repeatCount="indefinite"/>
362          </circle>
363          <circle cx="${BAR_X + BAR_H / 2}" cy="${cy}" r="${BAR_H / 2 - 2}" fill="#fff" opacity="0.85">
364            <animate attributeName="cx" values="${BAR_X + BAR_H / 2};${edge}" ${sweep}/>
365          </circle>
366          ${sparks}
367        </g>`
368      : ''
369  const chipText = bar.respawn
370  const isMax = hp >= 90
371  const value = isMax
372    ? `<text x="${VALUE_X}" y="${cy + 5.5}" ${UI} font-size="15" font-weight="800" letter-spacing="1.5" fill="${HUD_CYAN}" filter="url(#soft)">MAX<tspan dx="4" font-size="12" font-weight="600" letter-spacing="0" fill="${T.muted}">${hp}</tspan></text>`
373    : `<text x="${VALUE_X}" y="${cy + 5.5}" ${UI} font-size="17" font-weight="700" fill="${T.text}">${hp}<tspan font-size="12" font-weight="600" fill="${T.muted}">/100</tspan></text>`
374  const fx = isMax ? overcharge(BAR_X + 4, BAR_X + w - 4, top) : hp > 0 && hp < 25 ? redAlert(VALUE_X + 66 + 78 + 5, cy) : ''
375  const chipX = VALUE_X + 66
376  const CHIP_W = 78 // one width for both rows, so their edges line up
377  const chip = chipText
378    ? `<rect x="${chipX}" y="${cy - 10}" width="${CHIP_W}" height="20" rx="10" fill="${T.chip}" stroke="${T.trackEdge}"/>
379       <text x="${chipX + CHIP_W / 2}" y="${cy + 4.5}" text-anchor="middle" ${UI} font-size="12.5" font-weight="500" fill="${T.muted}">${chipText}</text>`
380    : ''
381  return icon + label + track + fill + ticks + fx + value + chip
382}
383
384// The contributor ranks, lowest first, as the draft rank sheet draws them.
385export type Rank = 'scout' | 'contributor' | 'innovator' | 'top' | 'god'
386type RankLook = { label: string; name: string; stops: [string, string, string]; edge: string; ink: string; glow?: string; shine?: true }
387export const RANKS: Record<Rank, RankLook> = {
388  scout: { label: '◇ SCOUT', name: 'Scout', stops: ['#d9e2ec', '#9aa9b8', '#6c7b8a'], edge: '#f1f5f9', ink: '#1b2430' },
389  contributor: { label: '✦ CONTRIBUTOR', name: 'Contributor', stops: ['#ffd2a8', '#d98a4e', '#a65a26'], edge: '#ffe7d1', ink: '#2e1606' },
390  innovator: { label: '◆ INNOVATOR', name: 'Innovator', stops: ['#c7f0ff', '#5ec4ff', '#2b8fd6'], edge: '#e6f7ff', ink: '#062033', glow: '#5ec4ff' },
391  top: { label: '★ OPEN', name: 'Top contributor', stops: ['#ffe27a', '#f7b733', '#e98a15'], edge: '#fff3c4', ink: '#2b1a03', glow: '#f7b733', shine: true },
392  god: { label: '⚡ GOD MODE', name: 'GOD MODE', stops: ['#ffe27a', '#f7b733', '#e98a15'], edge: '#fff3c4', ink: '#2b1a03', glow: '#f7b733', shine: true },
393}
394
395// The rank badge at the card's right end, past the reset times: a badge to
396// show, not a control. Its right edge sits 16 in from the card's, as the
397// avatar's left does; long names get a wider pill.
398const BADGE_H = 26
399const BADGE_Y = 19
400const badgeBox = (rank: Rank) => {
401  const w = Math.min(BADGE_ZONE, Math.max(108, Math.round(RANKS[rank].label.length * 7.6 + 28)))
402  return { x: 960 - 16 - w, w }
403}
404const rankBadge = (rank: Rank) => {
405  const look = RANKS[rank]
406  const { x, w } = badgeBox(rank)
407  const pill = `<rect x="${x}" y="${BADGE_Y}" width="${w}" height="${BADGE_H}" rx="${BADGE_H / 2}" fill="url(#badgegrad)"/>`
408  const shine = look.shine
409    ? `<g clip-path="url(#badgeclip)">
410    <rect x="${x - 40}" y="${BADGE_Y - 10}" width="22" height="${BADGE_H + 20}" fill="#fff" opacity="0.55" transform="skewX(-20)">
411      <animate attributeName="x" values="${x - 40};${x - 40};${x + w + 30}" keyTimes="0;0.6;1" dur="3s" repeatCount="indefinite"/>
412    </rect>
413  </g>`
414    : ''
415  return `
416  ${look.glow ? `<g filter="url(#badgeglow)">${pill}</g>` : pill}
417  ${shine}
418  <rect x="${x + 0.5}" y="${BADGE_Y + 0.5}" width="${w - 1}" height="${BADGE_H - 1}" rx="${(BADGE_H - 1) / 2}" fill="none" stroke="${look.edge}" stroke-opacity="0.6"/>
419  <text x="${x + w / 2}" y="${BADGE_Y + 17.5}" text-anchor="middle" ${UI} font-size="${look.label.length > 12 ? 11.5 : 12}" font-weight="800" letter-spacing="${look.label.length > 12 ? 0.9 : 1.2}" fill="${look.ink}">${look.label}</text>`
420}
421
422const badgeDefs = (rank: Rank) => {
423  const look = RANKS[rank]
424  const { x, w } = badgeBox(rank)
425  return `<linearGradient id="badgegrad" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="${look.stops[0]}"/><stop offset="0.55" stop-color="${look.stops[1]}"/><stop offset="1" stop-color="${look.stops[2]}"/></linearGradient>
426    <clipPath id="badgeclip"><rect x="${x}" y="${BADGE_Y}" width="${w}" height="${BADGE_H}" rx="${BADGE_H / 2}"/></clipPath>
427    <filter id="badgeglow" x="-20%" y="-60%" width="140%" height="220%"><feGaussianBlur in="SourceAlpha" stdDeviation="3" result="b"/><feFlood flood-color="${look.glow ?? '#000'}" flood-opacity="0.55"/><feComposite in2="b" operator="in" result="g"/><feMerge><feMergeNode in="g"/><feMergeNode in="SourceGraphic"/></feMerge></filter>`
428}
429
430const gradient = (id: string, hp: number | null) => {
431  const [a, b] = T[tone(hp ?? 100)]
432  return `<linearGradient id="${id}" x1="0" y1="0" x2="1" y2="0"><stop offset="0" stop-color="${a}"/><stop offset="1" stop-color="${b}"/></linearGradient>`
433}
434
435// Two soft blobs of the health colour drifting slowly behind the card.
436const aurora = (colour: string) => `
437  <g clip-path="url(#cardclip)" opacity="0.5">
438    <ellipse cx="160" cy="34" rx="170" ry="46" fill="url(#aura)">
439      <animate attributeName="cx" values="120;420;120" dur="14s" repeatCount="indefinite"/>
440    </ellipse>
441    <ellipse cx="700" cy="30" rx="150" ry="40" fill="url(#aura2)">
442      <animate attributeName="cx" values="760;460;760" dur="18s" repeatCount="indefinite"/>
443    </ellipse>
444  </g>
445  <radialGradient id="aura"><stop offset="0" stop-color="${colour}" stop-opacity="0.16"/><stop offset="1" stop-color="${colour}" stop-opacity="0"/></radialGradient>
446  <radialGradient id="aura2"><stop offset="0" stop-color="#3b82f6" stop-opacity="0.10"/><stop offset="1" stop-color="#3b82f6" stop-opacity="0"/></radialGradient>`
447
448export const hudSvg = (all: Bar[], rank: Rank = 'god') => {
449  // Fit the bars to this badge, so the gap before it is the same for every rank.
450  BAR_W = 960 - 16 - badgeBox(rank).w - BADGE_GAP - 78 - 66 - 16 - BAR_X
451  VALUE_X = BAR_X + BAR_W + 16
452  const m = mood(all)
453  const dead = killer(all)
454  const known = all.flatMap(b => (b.hp === null ? [] : [b.hp]))
455  const low = known.length === 0 ? null : Math.min(...known)
456  const session = all[0] ?? { label: 'SESSION' as const, hp: null, respawn: '' }
457  const weekly = all[1] ?? { label: 'WEEKLY' as const, hp: null, respawn: '' }
458  const glow = (m === 'dead' ? '#59616d' : low === null ? '#3a4350' : T[tone(low)][0]) ?? '#3a4350'
459
460  const deathScreen = dead
461    ? `<g>
462        <rect x="0.5" y="0.5" width="${HUD_W - 1}" height="${HUD_H - 1}" rx="14" fill="#0b0d11" opacity="0.9"/>
463        <rect x="0.5" y="0.5" width="${HUD_W - 1}" height="${HUD_H - 1}" rx="14" fill="none" stroke="#ef4444" stroke-opacity="0.55"/>
464        ${avatar(m, 0)}
465        <text x="${ICON_X}" y="29" ${UI} font-size="18" font-weight="700" fill="${T.text}">Claude has been defeated<animate attributeName="opacity" values="0;1" dur="0.8s" fill="freeze"/></text>
466        <text x="${ICON_X}" y="49" ${UI} font-size="13" fill="${T.muted}">Out of ${dead.label === 'SESSION' ? 'session' : 'weekly'} hit points · ${backIn(dead.respawn)}</text>
467      </g>`
468    : ''
469
470  return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${HUD_W} ${HUD_H}" width="${HUD_W * 3}" height="${HUD_H * 3}" preserveAspectRatio="xMinYMin meet">
471  <defs>
472    <linearGradient id="card" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#1a2029"/><stop offset="1" stop-color="#12161c"/></linearGradient>
473    <clipPath id="cardclip"><rect x="0.5" y="0.5" width="${HUD_W - 1}" height="${HUD_H - 1}" rx="14"/></clipPath>
474    <pattern id="dots" width="10" height="10" patternUnits="userSpaceOnUse"><circle cx="1" cy="1" r="0.7" fill="#ffffff" opacity="0.05"/></pattern>
475    <linearGradient id="scanband" x1="0" y1="0" x2="1" y2="0"><stop offset="0" stop-color="${HUD_CYAN}" stop-opacity="0"/><stop offset="0.5" stop-color="${HUD_CYAN}" stop-opacity="0.09"/><stop offset="1" stop-color="${HUD_CYAN}" stop-opacity="0"/></linearGradient>
476    <radialGradient id="cardglow" cx="0.04" cy="0.5" r="0.32"><stop offset="0" stop-color="${glow}" stop-opacity="0.16"/><stop offset="1" stop-color="${glow}" stop-opacity="0"/></radialGradient>
477    <radialGradient id="avbg" cx="0.5" cy="0.3" r="0.8"><stop offset="0" stop-color="#4b7d46"/><stop offset="0.65" stop-color="#2b4a2c"/><stop offset="1" stop-color="#1b2f1d"/></radialGradient>
478    ${badgeDefs(rank)}
479    <clipPath id="avclip"><circle cx="${AV.cx}" cy="${AV.cy}" r="${AV.r - 1}"/></clipPath>
480    <filter id="soft" x="-5%" y="-80%" width="110%" height="260%"><feGaussianBlur in="SourceGraphic" stdDeviation="2.4" result="b"/><feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge></filter>
481    <pattern id="stripes" x="0" y="0" width="14" height="14" patternUnits="userSpaceOnUse" patternTransform="rotate(-35)">
482      <rect width="6" height="14" fill="#ffffff" opacity="0.16"/>
483      <animate attributeName="x" values="0;14" dur="0.9s" repeatCount="indefinite"/>
484    </pattern>
485    <linearGradient id="shine" x1="0" y1="0" x2="1" y2="0"><stop offset="0" stop-color="#fff" stop-opacity="0"/><stop offset="0.5" stop-color="#fff" stop-opacity="0.55"/><stop offset="1" stop-color="#fff" stop-opacity="0"/></linearGradient>
486    ${gradient('g0', session.hp)}
487    ${gradient('g1', weekly.hp)}
488  </defs>
489  <rect x="0.5" y="0.5" width="${HUD_W - 1}" height="${HUD_H - 1}" rx="14" fill="url(#card)" stroke="#2a313b"/>
490  ${aurora(glow)}
491  <rect x="0.5" y="0.5" width="${HUD_W - 1}" height="${HUD_H - 1}" rx="14" fill="url(#cardglow)"/>
492  <line x1="14" y1="1.5" x2="${HUD_W - 14}" y2="1.5" stroke="#ffffff" stroke-opacity="0.06"/>
493  ${hologram()}
494  ${radar(m)}
495  ${avatar(m, low)}
496  ${row(session, ROW_CY[0] ?? 20, 'g0', 0.15)}
497  ${row(weekly, ROW_CY[1] ?? 44, 'g1', 0.35)}
498  ${rankBadge(rank)}
499  ${cornerBrackets()}
500  ${deathScreen}
501</svg>`
502}
503
types/index.d.ts 16 lines
1export type Limit = { kind: string; percentUsed: number; resetsAt?: string }
2
3declare module 'claude-code' {
4  interface PluginState {
5    'villager-hp': {
6      limits: Limit[]
7      // /hp <n> pins both bars to n HP to preview; null shows the real readings.
8      demo: number | null
9      // The clock, ticked each minute so the respawn countdowns move.
10      now: number
11      // The rank badge on the card: scout, contributor, innovator, top or god.
12      rank: 'scout' | 'contributor' | 'innovator' | 'top' | 'god'
13    }
14  }
15}
16