A Pokemon companion above the prompt. Hatches from an egg and levels on tokens you spend; Evolve compacts the context and banks the XP.

Pokémon companion above the Claude Code prompt. Starts as an egg. Grows on tokens you spend. Unofficial fan project.
Tokens bank as pending XP. /compact to level up: a compact applies the bank. Long context? It tells you when to compact.
claude plugin marketplace add MichaelNguyen5653/clawd-mon # or a local path
claude plugin install clawd-mon@clawd-mon --scope user # global, every project
claude plugin install clawd-mon@clawd-mon --scope project # this repo only
Private repo → collaborators only:
gh auth login, SSH key, or Git Credential Manager). Test: git clone https://github.com/MichaelNguyen5653/clawd-mon.git.claude plugin marketplace add https://github.com/MichaelNguyen5653/clawd-mon.git.GITHUB_TOKEN (or GH_TOKEN) with read access. Without it: claude plugin marketplace update clawd-mon, then restart.One save across all sessions ($.store). No machine paths; scope = install scope.
Needs a Claude Code build with function hooks (plugin modules). Desktop draws the sprite as SVG; terminal uses the Image element (kitty graphics, e.g. kitty/Ghostty), alt text elsewhere.
| What | Rule |
|---|---|
| XP source | New tokens per turn (input + output + cache writes; cache reads excluded). xpRate XP per 1,000 tokens (default 1). Fallback: positive context growth |
| Pending | Tokens bank as pending. Not applied until a compact |
| Apply | Manual /compact / plugin: 100%. Auto-compact: 50%, rest forfeited. The band shows /compact to level up (amber when recommended) |
| Decay | Context ≥ dangerPercent (80): pending −2% per completed turn |
| Recommend | Context ≥ recommendPercent (60), or avg growth (last 5 turns) projects ≥ 80% within 3 turns |
| Qualifying compact | Context ≥ 40% when it ran. Counts toward gates. Lower: XP still applies |
| Egg | Species fixed when the egg is made: random first-stage non-legendary, hidden until hatch (only a choosen starter shows its name). Roll by rarity tier: Starter 5%, Common 55%, Uncommon 30%, Rare 10%. Hatch level H = clamp(round(BST/60), 4, 10) (Caterpie 4, Bulbasaur 5, Mewtwo 10). Egg XP to hatch = 600 × H, on a 1.5 power curve. Two cracks on the way, then hatch; each stage needs a qualifying compact, max one stage per compact. Cracks show as lines on the egg. The band shows egg level and XP, never when the next stage comes. Hatches at Lv H |
| Rarity | Per evolution line. Starter: ids 1–9 (9). Rare: gift/fossil/special lines, RARE_ROOTS (23). Uncommon: other lines whose strongest form has BST ≥ 490 (55). Common: the rest (59). Legendary: goals only (5). Hatched Pokémon show a colored tier banner beside the name; hatch toast names the tier |
| Level | Species growth table (fast/medium/medium-slow/slow). Applied XP × clamp((320/BST)^0.35, 0.6, 1.1). Lv 1–100 |
| Evolution | Level ≥ species level AND qualifying compacts since hatch: 3 (first), 8 (second). Item: Lv 30. Trade: Lv 36 |
| Branches | Eevee etc.: first by default, /clawd-mon branch <name> picks |
| Box | Many Pokémon and eggs. Only the active one gains XP; pending goes to whoever is active at compact time. switch is free |
| Bonus eggs | +1 egg when the active one evolves into a final form (origin evolved); +1 once when a single-stage species hits Lv 40 (mastery); +1 every 25 counted compacts. New eggs wait in the box, never auto-active. Band shows New egg (n/25) and Box N |
| Pace | Bulbasaur → Venusaur ≈ 30 days at ~1.5M tokens/day, one compact a day alternating manual/auto (23 days all manual) |
Evolution is checked at compact time only. An egg whose XP has reached its hatch level shows Ready to evolve: only a counted compact moves it on.
Two tabs at the start of the title row (selected bright, other dim). Actions is the default each session.
| Tab | Shows |
|---|---|
| Actions | Title row with context 62% (no milestone). Then a summary of now, never a log: Current action - …, one Agent N - [name - ]… row per live agent. Max 3 rows; extra agents fold into (+N more). Rows clipped to 72 chars. Never taller than Levels |
| Levels | The band as before: XP and pending, context bar, agents/tools stats, New egg (n/25) |
Current action: in-progress task (TodoWrite / TaskUpdate) → Waiting on Agent N (foreground agent) → tool label → Thinking (turn running) → Idle.
| Tool | Label |
|---|---|
| Read, Grep, Glob, LSP, ToolSearch | Gathering context |
| Edit, MultiEdit, Write, NotebookEdit | Editing files |
| Bash, PowerShell, Monitor | Running commands |
WebSearch, WebFetch, mcp__* | Researching |
| Agent | Delegating to agents |
| TodoWrite, Task* | Planning |
| Skill | Using <skill> skill (agent rows: … to <its task>) |
| other | Using <tool> |
Agent rows with no tool in flight: Waiting (idle teammate), Starting, else the agent's task description.
/clawd-mon [show|hide|hint|dex|status|box|switch <name|#>|release <name|#> confirm|choose <name>|branch <name>|reset-all confirm]
show / hide: band on/off. × on the band = hide.hint: open the in-band help (same as the !hint button; Cancel closes it).dex: the catalog in the band (same Cancel as !hint; opening one closes the other). Header Dex n/151 · ★ n/4, then a counts line Starters n/9 · Common n/59 · Uncommon n/55 · Rare n/23 (all non-legendary species by tier), then one row per legendary: silhouette and ??? until earned, lore line, goal, progress or locked.status: active entry, evolution gates, days to next evolution (Pokémon only, recent daily pace), box size, egg progress N/25, lifetime totals.box: list entries: number, active marker, name or Egg, level.switch <name|#>: make an entry active. Free, no confirm.release <name|#> confirm: remove an entry. Active one released: first remaining becomes active. Box empty: a fresh random egg is added and made active.choose <name>: starter egg only, before anything has hatched, non-legendary. Any name resolves to its first stage.branch <name>: pick the evolution for a branching species (active entry).reset-all confirm: fresh start. Wipes whole save: box, lifetime, history, settings. New starter egg; choose works again. Without confirm it only warns.reset is gone: use release or reset-all.Save version 2. A version 1 save migrates into box slot 1, active.
Earned by compact discipline. Each arrives once per save as a ★ egg in the box (not auto-active, hatches at the normal hatch level). Not obtainable with choose or from random eggs. reset-all clears them.
| Legendary | Lore | Goal |
|---|---|---|
| Articuno | Frozen bird of legend | Stay cool: 14 days without reaching dangerPercent (80) context. A day with a main turn and max context under the line counts; a day that reaches it resets to 0; days with no turns neither count nor reset |
| Zapdos | Storm bird of legend | Strike first: 30 qualifying compacts in a row without an auto-compact. Any auto-compact resets to 0 |
| Moltres | Phoenix bird of legend | Rebirth: fully evolve 3 lines (final-form evolutions and single-stage Lv 40 claims) |
| Mewtwo | Born in a lab from a legend's DNA | Locked until all three birds are owned. Then 10 fully evolved lines |
Plugin options (/config):
| Key | Default | |
|---|---|---|
xpRate | 1 | XP per 1,000 tokens |
recommendPercent | 60 | recommend /compact at this context % |
dangerPercent | 80 | decay starts at this context % |
node tools/sprite-bounds.mjs # writes data/sprite-bounds.json (content box per sprite; committed)
node tools/preview.mjs # writes preview/review.html (gitignored): band in many states, light + dark
Preview uses the real engine, view helpers, data and sprites. Plain Node 22.18+ (type stripping), no install. hooks/engine.ts and hooks/view.ts stay import-free at runtime for this.
hooks/engine.ts pure rules (tested)
hooks/view.ts pure text + SVG helpers (tested)
tools/ sprite-bounds.mjs, preview.mjs
hooks/register.tsx hooks + band
data/ pokedex.json, growth.json, sprite-bounds.json
assets/sprites/ <id>.png, egg.png
tests/ claude plugin test .
Fresh clone: load the plugin in Claude Code once (it lays .claude-plugin/types) before tsc -p . works.
claude plugin validate .
claude plugin test .
data/CLAUDEMON-LICENSE).LICENSE.Tip: add assets/sprites/ to .gitignore to keep the art out of your repo. Missing sprite = band still works, no picture.
hooks/register.tsx 759 lines1// Clawd-mon: a Pokemon companion in the band above the prompt.
2//
3// It starts as an egg and grows from the tokens you spend. Tokens bank as pending
4// XP; a compact (the band says "/compact to level up") applies the bank. The rules
5// live in engine.ts (pure, unit-tested); this file wires them to the session:
6//
7// session.start load the save from $.store, register /clawd-mon
8// session.measure keep the context reading fresh for the band
9// turn.complete bank the turn's tokens, decay pending in a crowded context
10// session.compact apply pending for compacts someone else started (manual, auto)
11// command.run /clawd-mon [show|hide|status|choose|branch|reset|reset-all]
12// ui.render the AbovePrompt band (Svg sprite on desktop, Image in the terminal)
13//
14// One save lives in $.store under "save" and is re-read before every change, so every
15// session shares one companion. Live values the band draws are in $.state atoms.
16
17import { atom, read, update } from 'claude-code'
18import type { EngineInterface, Register } from 'claude-code'
19
20import {
21 applyCompact,
22 bankTokens,
23 configFrom,
24 DEFAULT_CONFIG,
25 decayPending,
26 dexView,
27 execute,
28 observeContext,
29 observeTurn,
30 overview,
31 progress,
32 recommendation,
33 sanitizeSave,
34 ensureTarget,
35 type CompactTrigger,
36 type Config,
37 type Dex,
38 type Save,
39} from './engine'
40import {
41 AMBER,
42 COMPACT_HINT,
43 GREEN,
44 RARITY_COLOR,
45 TRACK,
46 actionsRows,
47 adviceLine,
48 rarityLabel,
49 dexRows,
50 hintRows,
51 milestoneLine,
52 contextColor,
53 statsLine,
54 eventToast,
55 pendingLine,
56 spriteSvg,
57 textBar,
58 titleOf,
59 xpLine,
60 type Box as SpriteBox,
61} from './view'
62
63const saveAtom = atom({ plugin: 'clawd-mon', key: 'save' } as const, null)
64const usageAtom = atom({ plugin: 'clawd-mon', key: 'usage' } as const, null)
65const hiddenAtom = atom({ plugin: 'clawd-mon', key: 'isHidden' } as const, false)
66const hintAtom = atom({ plugin: 'clawd-mon', key: 'hintOpen' } as const, false)
67const dexAtom = atom({ plugin: 'clawd-mon', key: 'dexOpen' } as const, false)
68const toolsAtom = atom({ plugin: 'clawd-mon', key: 'tools' } as const, {
69 available: null,
70 mcp: 0,
71 usedNames: [],
72 calls: 0,
73 seeded: false,
74})
75const agentsAtom = atom({ plugin: 'clawd-mon', key: 'agents' } as const, { running: null, total: null, list: [] })
76const tabAtom = atom({ plugin: 'clawd-mon', key: 'tab' } as const, 'actions')
77const activityAtom = atom({ plugin: 'clawd-mon', key: 'activity' } as const, { doing: {}, busy: false, task: null, tasks: {} })
78/** The Actions tab clips each row to this many characters so it never wraps. */
79const ACTIONS_WIDTH = 72
80const AGENT_TICK_MS = 4000
81
82const COMMAND = 'clawd-mon'
83const SAVE_KEY = 'save'
84const HIDDEN_KEY = 'isHidden'
85const SPRITE_PX = 80
86
87// ---------- engine reads ----------
88
89async function quietly(work: Promise<unknown>): Promise<void> {
90 try {
91 await work
92 } catch {
93 // A read the engine refused leaves the last value standing.
94 }
95}
96
97let dexPromise: Promise<Dex> | null = null
98
99/** The species and growth tables, read once from the plugin's data/ folder. */
100function loadDex($: EngineInterface): Promise<Dex> {
101 if (dexPromise === null) {
102 const root = $.plugin.root
103 dexPromise = Promise.all([
104 $.fs.read(`${root}/data/pokedex.json`),
105 $.fs.read(`${root}/data/growth.json`),
106 ])
107 .then(([species, growth]) => ({ species: JSON.parse(species), growth: JSON.parse(growth) }) as Dex)
108 .catch(error => {
109 dexPromise = null
110 throw error
111 })
112 }
113 return dexPromise
114}
115
116let boundsPromise: Promise<Record<string, SpriteBox>> | null = null
117
118/** Content boxes of the sprites (data/sprite-bounds.json); empty when the file is missing. */
119function loadBounds($: EngineInterface): Promise<Record<string, SpriteBox>> {
120 if (boundsPromise === null) {
121 boundsPromise = $.fs
122 .read(`${$.plugin.root}/data/sprite-bounds.json`)
123 .then(text => JSON.parse(text) as Record<string, SpriteBox>)
124 .catch(() => ({}) as Record<string, SpriteBox>)
125 }
126 return boundsPromise
127}
128
129const sprites = new Map<string, string | null>()
130
131/** Base64 PNG of a sprite from the plugin's assets, or null when the file is not there. */
132async function loadSprite($: EngineInterface, key: number | 'egg'): Promise<string | null> {
133 const name = String(key)
134 const cached = sprites.get(name)
135 if (cached !== undefined) return cached
136 let base64: string | null = null
137 try {
138 const bytes = await $.fs.read(`${$.plugin.root}/assets/sprites/${name}.png`, { as: 'bytes' })
139 base64 = bytes.base64
140 } catch {
141 base64 = null
142 }
143 sprites.set(name, base64)
144 return base64
145}
146
147async function prefersReducedMotion($: EngineInterface): Promise<boolean> {
148 try {
149 const rows = await $.config.list()
150 return rows.some(
151 row => /reduc\w*\s*motion|reducedmotion/i.test(`${row.key} ${row.label}`) && row.value === true,
152 )
153 } catch {
154 return false
155 }
156}
157
158async function today($: EngineInterface): Promise<string> {
159 return new Date(await $.clock.now()).toISOString().slice(0, 10)
160}
161
162// ---------- hooks ----------
163
164let cfg: Config = DEFAULT_CONFIG
165let reducedMotion = false
166let chain: Promise<unknown> = Promise.resolve()
167
168/** Saves change one at a time: each re-reads the store, so sessions share one companion. */
169function exclusive<T>(work: () => Promise<T>): Promise<T> {
170 const run = chain.then(work, work)
171 chain = run.catch(() => undefined)
172 return run
173}
174
175async function loadSave($: EngineInterface, dex: Dex): Promise<Save> {
176 return ensureTarget(sanitizeSave(await $.store.get(SAVE_KEY), dex), dex)
177}
178
179async function commit($: EngineInterface, save: Save): Promise<void> {
180 await $.store.set(SAVE_KEY, save)
181 await update($, saveAtom, () => save)
182}
183
184/**
185 * Load, change, store, one at a time. Another session may write between our read and our
186 * write, so the store is re-read before the write: if its rev moved, start over (3 tries).
187 */
188function mutate<T>(
189 $: EngineInterface,
190 dex: Dex,
191 fn: (save: Save) => Promise<{ save: Save; value: T }> | { save: Save; value: T },
192): Promise<T> {
193 return exclusive(async () => {
194 for (let attempt = 0; ; attempt++) {
195 const loaded = await loadSave($, dex)
196 const out = await fn(loaded)
197 const current = await loadSave($, dex)
198 if (current.rev !== loaded.rev && attempt < 2) continue
199 await commit($, { ...out.save, rev: current.rev + 1 })
200 return out.value
201 }
202 })
203}
204
205async function currentPercent($: EngineInterface): Promise<number | undefined> {
206 try {
207 const { context } = await $.session.usage()
208 return context.percent
209 } catch {
210 return (await read($, usageAtom))?.percent
211 }
212}
213
214/** Pulls the shared save into this session's atom when another session changed it. */
215async function refreshSave($: EngineInterface): Promise<void> {
216 const dex = await loadDex($)
217 const fresh = await loadSave($, dex)
218 const mine = await read($, saveAtom)
219 if (mine === null || mine.rev !== fresh.rev) await update($, saveAtom, () => fresh)
220}
221
222async function refreshTools($: EngineInterface): Promise<void> {
223 const list = await $.tool.list()
224 const mcp = list.filter(t => t.mcp).length
225 const prev = await read($, toolsAtom)
226 if (prev.available === list.length && prev.mcp === mcp) return
227 await update($, toolsAtom, t => ({ ...t, available: list.length, mcp }))
228}
229
230async function refreshAgents($: EngineInterface): Promise<void> {
231 const all = await $.agent.list()
232 const running = all.filter(a => a.status === 'running').length
233 const list = all.map(a => ({ id: a.id, name: a.name, type: a.type, description: a.description, status: a.status }))
234 const prev = await read($, agentsAtom)
235 if (prev.running === running && prev.total === all.length && JSON.stringify(prev.list) === JSON.stringify(list)) return
236 await update($, agentsAtom, () => ({ running, total: all.length, list }))
237}
238
239/** The in-progress item of a TodoWrite list: its present-tense text, or null when none is. */
240function todoTask(todos: unknown): string | null {
241 if (!Array.isArray(todos)) return null
242 const t = todos.find(x => x && typeof x === 'object' && (x as { status?: unknown }).status === 'in_progress') as
243 | { content?: unknown; activeForm?: unknown }
244 | undefined
245 const text = t ? (t.activeForm ?? t.content) : null
246 return typeof text === 'string' && text.trim() ? text.trim() : null
247}
248
249type CallFacts = { tool: string; tool_use_id: string; agentId?: string } & Record<string, unknown>
250
251/** A loop started a tool call: that is what it is doing now. */
252function startCall($: EngineInterface, e: CallFacts): Promise<void> {
253 const skill = e.tool === 'Skill' && typeof e.skill === 'string' ? e.skill : undefined
254 return update($, activityAtom, a => ({ ...a, doing: { ...a.doing, [e.agentId ?? '']: { callId: e.tool_use_id, tool: e.tool, skill } } }))
255}
256
257/** That call ended: the loop goes back to thinking, unless a later call already took its place. */
258function endCall($: EngineInterface, e: CallFacts): Promise<void> {
259 const loop = e.agentId ?? ''
260 return update($, activityAtom, a => {
261 if (a.doing[loop]?.callId !== e.tool_use_id) return a
262 const { [loop]: _gone, ...doing } = a.doing
263 return { ...a, doing }
264 })
265}
266
267/** The main loop's task list (TodoWrite, or TaskCreate / TaskUpdate) names the task in progress. */
268async function trackTask($: EngineInterface, e: CallFacts, result: unknown): Promise<void> {
269 if (e.agentId !== undefined) return
270 const tool = e.tool
271 const str = (v: unknown) => (typeof v === 'string' && v.trim() ? v.trim() : undefined)
272 if (tool === 'TodoWrite') {
273 await update($, activityAtom, a => ({ ...a, task: todoTask(e.todos) }))
274 } else if (tool === 'TaskCreate') {
275 const id = str((result as { result?: { task?: { id?: unknown } } })?.result?.task?.id)
276 const text = str(e.activeForm) ?? str(e.subject)
277 if (id && text) await update($, activityAtom, a => ({ ...a, tasks: { ...a.tasks, [id]: { text, status: 'pending' } } }))
278 } else if (tool === 'TaskUpdate') {
279 const id = str(e.taskId)
280 if (!id) return
281 await update($, activityAtom, a => {
282 const was = a.tasks[id]
283 const text = str(e.activeForm) ?? str(e.subject) ?? was?.text ?? `Task ${id}`
284 const status = str(e.status) ?? was?.status ?? 'pending'
285 const tasks = { ...a.tasks, [id]: { text, status } }
286 const current = Object.values(tasks).find(t => t.status === 'in_progress')
287 return { ...a, tasks, task: current?.text ?? null }
288 })
289 }
290}
291
292/** Counts tool calls made before the mod loaded, from the main transcript, once. */
293async function seedTools($: EngineInterface): Promise<void> {
294 const tools = await read($, toolsAtom)
295 if (tools.seeded) return
296 const messages = await $.session.messages()
297 const names = new Set<string>(tools.usedNames)
298 let calls = 0
299 for (const m of messages) {
300 for (const use of m.toolUses) {
301 names.add(String(use.tool))
302 calls += 1
303 }
304 }
305 await update($, toolsAtom, t => ({
306 ...t,
307 usedNames: [...new Set([...t.usedNames, ...names])],
308 calls: t.calls + calls,
309 seeded: true,
310 }))
311}
312
313async function refreshUsage($: EngineInterface): Promise<void> {
314 const { context } = await $.session.usage()
315 await update($, usageAtom, () => ({
316 tokens: context.tokens,
317 window: context.window,
318 percent: context.percent,
319 }))
320}
321
322/** Applies the bank for a compact that finished, and announces what it did. */
323function applyFinishedCompact(
324 $: EngineInterface,
325 trigger: CompactTrigger,
326 percent: number | undefined,
327): Promise<void> {
328 return loadDex($).then(async dex => {
329 const result = await mutate($, dex, save => {
330 const r = applyCompact(save, dex, { trigger, percent }, cfg)
331 return { save: r.save, value: r }
332 })
333 for (const ev of result.events) $.ui.toast(eventToast(ev, dex))
334 })
335}
336
337export const register: Register = (on, options) => {
338 cfg = configFrom(options as Record<string, unknown>)
339
340 on('session.start', async ($, e, next) => {
341 const started = await next(e)
342
343 await quietly(
344 $.command.register({
345 name: COMMAND,
346 description: 'Clawd-mon companion: show, hide, status, choose, branch, reset, reset-all',
347 argumentHint: '[show|hide|status|choose <name>|branch <name>|reset|reset-all]',
348 immediate: true,
349 }),
350 )
351 reducedMotion = await prefersReducedMotion($)
352
353 await quietly(
354 (async () => {
355 const dex = await loadDex($)
356 // Eggs without a species get a hidden one now, and every session then shares it.
357 const stored = sanitizeSave(await $.store.get(SAVE_KEY), dex)
358 const rawSave = await $.store.get(SAVE_KEY)
359 const rawEarned =
360 typeof rawSave === 'object' && rawSave !== null && Array.isArray((rawSave as { legendsEarned?: unknown }).legendsEarned)
361 ? (rawSave as { legendsEarned: unknown[] }).legendsEarned.length
362 : -1
363 if (ensureTarget(stored, dex) !== stored || stored.legendsEarned.length !== rawEarned) {
364 await mutate($, dex, fresh => ({ save: fresh, value: null }))
365 }
366 const save = await loadSave($, dex)
367 await update($, saveAtom, () => save)
368 await update($, hiddenAtom, () => false)
369 if ((await $.store.get(HIDDEN_KEY)) === true) await update($, hiddenAtom, () => true)
370 })(),
371 )
372 await quietly(refreshUsage($))
373 await quietly(seedTools($))
374 await quietly(refreshTools($))
375 await quietly(refreshAgents($))
376 $.clock.every(AGENT_TICK_MS, () => {
377 void refreshAgents($).catch(() => undefined)
378 })
379
380 return started
381 })
382
383 on('session.measure', async ($, e, next) => {
384 const c = e.context
385 await quietly(update($, usageAtom, () => ({ tokens: c.tokens, window: c.window, percent: c.percent })))
386 await quietly(refreshSave($))
387 return next(e)
388 })
389
390 on('tool.call', async ($, e, next) => {
391 const name = String(e.tool)
392 await quietly(
393 update($, toolsAtom, t => ({
394 ...t,
395 calls: t.calls + 1,
396 usedNames: t.usedNames.includes(name) ? t.usedNames : [...t.usedNames, name],
397 })),
398 )
399 const facts = { ...(e as Record<string, unknown>), tool: name, tool_use_id: e.tool_use_id, agentId: e.agentId }
400 await quietly(startCall($, facts))
401 let ran: Awaited<ReturnType<typeof next>>
402 try {
403 ran = await next(e)
404 } finally {
405 await quietly(endCall($, facts))
406 }
407 await quietly(trackTask($, facts, ran))
408 if (name === 'Agent' || name === 'Task') await quietly(refreshAgents($))
409 return ran
410 })
411
412 // A foreground agent blocks the loop that spawned it until it returns.
413 on('agent.spawn', async ($, e, next) => {
414 const done = await next(e)
415 const child = 'agentId' in done ? done.agentId : undefined
416 if (child && !e.background) {
417 const loop = e.parentAgentId ?? ''
418 await quietly(
419 update($, activityAtom, a => {
420 const was = a.doing[loop]
421 return was ? { ...a, doing: { ...a.doing, [loop]: { ...was, waitingOn: child } } } : a
422 }),
423 )
424 }
425 await quietly(refreshAgents($))
426 return done
427 })
428
429 on('turn.start', async ($, e, next) => {
430 await quietly(update($, activityAtom, a => ({ ...a, busy: true })))
431 return next(e)
432 })
433
434 on('turn.complete', async ($, e, next) => {
435 const done = await next(e)
436 // A loop that finished its turn is doing nothing; only the main loop's end clears `busy`.
437 const loop = e.agentId ?? ''
438 await quietly(
439 update($, activityAtom, a => {
440 const { [loop]: _done, ...doing } = a.doing
441 return { ...a, busy: loop === '' ? false : a.busy, doing }
442 }),
443 )
444 await quietly(refreshTools($))
445 await quietly(refreshAgents($))
446 await quietly(
447 loadDex($).then(async dex => {
448 const events = await mutate($, dex, async loaded => {
449 let save = loaded
450 const isMain = e.agentId === undefined
451 let ctxTokens: number | undefined
452 let percent: number | undefined
453 if (isMain) {
454 try {
455 const { context } = await $.session.usage()
456 ctxTokens = context.tokens
457 percent = context.percent
458 await update($, usageAtom, () => ({ tokens: context.tokens, window: context.window, percent }))
459 } catch {
460 // No reading this turn: bank what we know, skip decay and projection.
461 }
462 }
463 // New tokens only: cache reads re-count the same context every step.
464 let tokens = 0
465 if (e.usage) {
466 tokens = e.usage.input_tokens + e.usage.output_tokens + e.usage.cache_creation_input_tokens
467 } else if (ctxTokens !== undefined && save.lastContext !== null && ctxTokens > save.lastContext) {
468 tokens = ctxTokens - save.lastContext
469 }
470 save = bankTokens(save, tokens, await today($), cfg)
471 if (isMain) {
472 save = decayPending(save, percent, cfg)
473 save = observeContext(save, ctxTokens)
474 }
475 const found = isMain ? observeTurn(save, { day: await today($), percent }, dex, cfg) : { save, events: [] }
476 return { save: found.save, value: found.events }
477 })
478 for (const ev of events) $.ui.toast(eventToast(ev, dex))
479 }),
480 )
481 return done
482 })
483
484 // The one place the bank is applied: every compact (/compact, auto, another plugin's) comes through here.
485 on('session.compact', async ($, e, next) => {
486 if (e.agentId !== undefined || e.trigger === 'precompute') return next(e)
487 const percent = await currentPercent($)
488 const result = await next(e)
489 if ('skip' in result) return result
490 await quietly(applyFinishedCompact($, e.trigger, percent))
491 return result
492 })
493
494 on('command.run', { command: COMMAND }, async ($, e) => {
495 const dex = await loadDex($)
496 const day = await today($)
497 const result = await mutate($, dex, save => {
498 const r = execute(save, dex, e.args, { day, cfg })
499 return { save: r.save, value: r }
500 })
501 if (result.wipe) {
502 for (const key of await $.store.keys()) await $.store.delete(key)
503 await update($, saveAtom, () => result.save)
504 await update($, hiddenAtom, () => false)
505 return { text: result.text }
506 }
507 if (result.hint) {
508 await update($, hintAtom, () => true)
509 await update($, dexAtom, () => false)
510 }
511 if (result.dex) {
512 await update($, dexAtom, () => true)
513 await update($, hintAtom, () => false)
514 }
515 if (result.hide) {
516 await $.store.set(HIDDEN_KEY, true)
517 await update($, hiddenAtom, () => true)
518 } else if (result.show) {
519 await $.store.delete(HIDDEN_KEY)
520 await update($, hiddenAtom, () => false)
521 }
522 await quietly(refreshUsage($))
523 return { text: result.text }
524 })
525
526 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
527 if (e.props.hasSurvey || (await read($, hiddenAtom))) return next(e)
528
529 let dex: Dex
530 try {
531 dex = await loadDex($)
532 } catch {
533 return next(e)
534 }
535 const stored = await read($, saveAtom)
536 const save =
537 stored ?? ensureTarget(sanitizeSave(await $.store.get(SAVE_KEY).catch(() => undefined), dex), dex)
538 const usage = await read($, usageAtom)
539 const p = progress(save, dex)
540 const rec = recommendation(
541 { percent: usage?.percent, tokens: usage?.tokens, window: usage?.window ?? 0 },
542 save,
543 cfg,
544 )
545 const spriteKey = p.kind === 'egg' ? 'egg' : p.id
546 const sprite = await loadSprite($, spriteKey)
547 const bounds = (await loadBounds($))[String(spriteKey)]
548
549 const title = titleOf(p)
550 const xp = xpLine(p)
551 const pending = pendingLine(p)
552 const tools = await read($, toolsAtom)
553 const agents = await read($, agentsAtom)
554 const ctx = statsLine(usage, tools, agents)
555 const advice = adviceLine(rec, usage, cfg)
556 const hide = async () => {
557 await update($, hiddenAtom, () => true)
558 await $.store.set(HIDDEN_KEY, true)
559 }
560 const barColor = contextColor(usage?.percent, cfg)
561 const hintOpen = await read($, hintAtom)
562 const dexOpen = await read($, dexAtom)
563 const openHint = async () => {
564 await update($, hintAtom, () => true)
565 await update($, dexAtom, () => false)
566 }
567 const closeHint = () => update($, hintAtom, () => false)
568 const openDex = async () => {
569 await update($, dexAtom, () => true)
570 await update($, hintAtom, () => false)
571 }
572 const closeDex = () => update($, dexAtom, () => false)
573 const dexData = dexOpen ? dexView(save, dex, cfg) : null
574 const milestone = milestoneLine(overview(save))
575 // Actions (default): what is happening now, in at most three rows, so it never outgrows Levels.
576 const tab = await read($, tabAtom)
577 const isActions = tab === 'actions'
578 const showActions = () => update($, tabAtom, () => 'actions' as const)
579 const showLevels = () => update($, tabAtom, () => 'levels' as const)
580 // `?? []`: a hot reload keeps the state an older module wrote, which had no list.
581 const actions = isActions ? actionsRows(await read($, activityAtom), agents.list ?? [], 3, ACTIONS_WIDTH) : []
582 const ctxPercent = usage?.percent !== undefined ? `${usage.percent}%` : '—'
583
584 if (e.surface === 'terminal') {
585 const { Box, Text, Button, Image } = $.ui.resolve(e)
586 const bar = (f: number) => textBar(f, 20)
587 const band = (
588 <Box flexDirection="row" gap={2}>
589 {sprite ? (
590 <Image key="sprite" source={{ png: sprite }} columns={8} rows={4} alt={title} />
591 ) : null}
592 <Box flexDirection="column" flexGrow={1}>
593 <Box flexDirection="row" gap={2}>
594 <Box flexGrow={1} flexDirection="row" gap={1}>
595 <Button key="tab-actions" label="Actions" plain dimColor={!isActions} onPress={showActions} />
596 <Button key="tab-levels" label="Levels" plain dimColor={isActions} onPress={showLevels} />
597 <Text bold wrap="truncate-end">{title}</Text>
598 {p.kind === 'mon' ? (
599 <Text key="rarity" color={RARITY_COLOR[p.rarity]} bold>{`[${rarityLabel(p.rarity)}]`}</Text>
600 ) : null}
601 {isActions ? <Text key="ctx" color={barColor}>{ctxPercent}</Text> : null}
602 <Button key="hint" label="!hint" plain dimColor onPress={openHint} />
603 <Button key="dex" label="dex" plain dimColor onPress={openDex} />
604 {isActions ? null : <Text dimColor wrap="truncate-end">{milestone}</Text>}
605 </Box>
606 <Text key="compact-hint" color={rec.recommend ? AMBER : undefined} dimColor={!rec.recommend} bold={rec.recommend}>
607 {COMPACT_HINT}
608 </Text>
609 <Button key="hide" label="×" plain dimColor role="dismiss" onPress={hide} />
610 </Box>
611 {isActions ? (
612 actions.map((line, i) => (
613 <Text key={`act-${i}`} dimColor={i > 0} wrap="truncate-end">{line}</Text>
614 ))
615 ) : (
616 [
617 <Text key="xp" color={GREEN}>{`${bar(p.fraction)} ${xp}`}</Text>,
618 <Text key="pending" dimColor wrap="truncate-end">{pending}</Text>,
619 <Text key="ctx-bar" color={barColor}>{`${bar((usage?.percent ?? 0) / 100)} ${ctx}`}</Text>,
620 ]
621 )}
622 {advice ? <Text color={AMBER} bold>{advice}</Text> : null}
623 </Box>
624 </Box>
625 )
626 if (dexData) {
627 const budget = Math.max(1, e.props.maxRows - (advice ? 5 : 4) - 1)
628 return (
629 <Box flexDirection="column">
630 {band}
631 {dexRows(dexData, budget).map((row, i) => (
632 <Text key={`dex-${i}`} dimColor={i > 0} bold={i === 0} wrap="truncate-end">{row}</Text>
633 ))}
634 <Button key="dex-cancel" label="Cancel" onPress={closeDex} />
635 </Box>
636 )
637 }
638 if (!hintOpen) return band
639 // The band takes 4 rows (5 with advice); the rest of maxRows is for help, Cancel always shown.
640 const budget = Math.max(1, e.props.maxRows - (advice ? 5 : 4) - 1)
641 return (
642 <Box flexDirection="column">
643 {band}
644 {hintRows(cfg, budget).map((row, i) => (
645 <Text key={`hint-${i}`} dimColor wrap="truncate-end">{row}</Text>
646 ))}
647 <Button key="hint-cancel" label="Cancel" onPress={closeHint} />
648 </Box>
649 )
650 }
651
652 const table = $.ui.resolve(e)
653 const { Box, Text, Button } = table
654 const pct = (f: number) => `${Math.round(Math.max(0, Math.min(1, f)) * 100)}%` as const
655 const barRow = (fraction: number, color: string, key?: string) => (
656 <Box key={key} flexDirection="row" width="100%" height={1}>
657 {fraction > 0 ? <Box width={pct(fraction)} height={1} backgroundColor={color} /> : null}
658 <Box flexGrow={1} height={1} backgroundColor={TRACK} />
659 </Box>
660 )
661 const row = (
662 <Box flexDirection="row" alignItems="center" gap={2} width="100%">
663 {sprite && 'Svg' in table ? (
664 <table.Svg
665 source={spriteSvg({ base64: sprite, bounds, size: SPRITE_PX, animate: !reducedMotion, crack: p.kind === 'egg' ? p.stage : 0 })}
666 alt={title}
667 width={SPRITE_PX}
668 height={SPRITE_PX}
669 />
670 ) : null}
671 <Box flexDirection="column" flexGrow={1}>
672 <Box flexDirection="row" alignItems="center" gap={1}>
673 <Button key="tab-actions" label="Actions" plain dimColor={!isActions} onPress={showActions} />
674 <Button key="tab-levels" label="Levels" plain dimColor={isActions} onPress={showLevels} />
675 <Text bold>{title}</Text>
676 {p.kind === 'mon' ? (
677 <Box key="rarity" backgroundColor={RARITY_COLOR[p.rarity]}>
678 <Text color="#FFFFFF" bold>{` ${rarityLabel(p.rarity)} `}</Text>
679 </Box>
680 ) : null}
681 {isActions ? <Text key="ctx" color={barColor}>{ctxPercent}</Text> : null}
682 <Button key="hint" label="!hint" plain dimColor onPress={openHint} />
683 <Button key="dex" label="dex" plain dimColor onPress={openDex} />
684 {isActions ? null : <Text dimColor>{milestone}</Text>}
685 </Box>
686 {isActions ? (
687 actions.map((line, i) => (
688 <Text key={`act-${i}`} dimColor={i > 0}>{line}</Text>
689 ))
690 ) : (
691 [
692 barRow(p.fraction, GREEN, 'xp-bar'),
693 <Text key="xp" dimColor>{`${xp} · ${pending}`}</Text>,
694 barRow((usage?.percent ?? 0) / 100, barColor, 'ctx-bar'),
695 <Text key="ctx" color={barColor}>{ctx}</Text>,
696 ]
697 )}
698 {advice ? <Text color={AMBER} bold>{advice}</Text> : null}
699 </Box>
700 <Text key="compact-hint" color={rec.recommend ? AMBER : undefined} dimColor={!rec.recommend} bold={rec.recommend}>
701 {COMPACT_HINT}
702 </Text>
703 <Button key="hide" label="×" plain dimColor role="dismiss" onPress={hide} />
704 </Box>
705 )
706 if (dexData) {
707 const sprites = await Promise.all(dexData.rows.map(r => loadSprite($, r.id)))
708 const boundsAll = await loadBounds($)
709 return (
710 <Box flexDirection="column" width="100%" gap={1}>
711 {row}
712 <Text bold>{dexRows(dexData, 2)[0]}</Text>
713 <Text dimColor>{dexRows(dexData, 2)[1]}</Text>
714 {dexData.rows.map((r, i) => {
715 const b64 = sprites[i]
716 const state = r.earned ? 'earned' : r.locked ? 'locked' : `${r.progress?.n ?? 0}/${r.progress?.goal ?? 0}`
717 return (
718 <Box flexDirection="row" alignItems="center" gap={2} width="100%">
719 {b64 && 'Svg' in table ? (
720 <table.Svg
721 source={spriteSvg({ base64: b64, bounds: boundsAll[String(r.id)], size: 48, animate: false, silhouette: !r.earned })}
722 alt={r.earned ? r.name : 'Unknown legendary'}
723 width={48}
724 height={48}
725 />
726 ) : null}
727 <Box flexDirection="column" flexGrow={1}>
728 <Text bold>{`${r.earned ? '★ ' : ''}${r.name}`}</Text>
729 <Text dimColor>{r.lore}</Text>
730 <Text>{r.goal}</Text>
731 {r.progress ? barRow(r.progress.n / r.progress.goal, r.earned ? GREEN : AMBER) : null}
732 <Text dimColor>{state}</Text>
733 </Box>
734 </Box>
735 )
736 })}
737 <Box flexDirection="row">
738 <Button key="dex-cancel" label="Cancel" onPress={closeDex} />
739 </Box>
740 </Box>
741 )
742 }
743 if (!hintOpen) return row
744 return (
745 <Box flexDirection="column" width="100%" gap={1}>
746 {row}
747 <Box flexDirection="column" width="100%">
748 {hintRows(cfg, 99).map((line, i) => (
749 <Text key={`hint-${i}`} dimColor>{line}</Text>
750 ))}
751 </Box>
752 <Box flexDirection="row">
753 <Button key="hint-cancel" label="Cancel" onPress={closeHint} />
754 </Box>
755 </Box>
756 )
757 })
758}
759hooks/engine.ts 1116 lines1// Clawd-mon engine: pure rules, no engine `$`, no I/O. Everything the band and the
2// hooks do to the save goes through here so it can be unit-tested.
3//
4// Model: tokens you spend bank as `pending` XP. A compact applies the bank to the ACTIVE
5// entry of the box (manual or plugin: all of it, auto: half, the rest is forfeited). Decay
6// eats pending while the context is dangerously full. Level comes from the species' growth
7// table with a base-stat-total drag; evolution needs a level and a number of qualifying
8// compacts. Finishing a line, hitting Lv 40 with a single-stage species, or every 25
9// qualifying compacts earns a new egg in the box.
10
11import type { ClawdMonEntry, ClawdMonLifetime, ClawdMonSave, ClawdMonStreaks } from '../types'
12
13export type Rate = 'fast' | 'medium' | 'medium-slow' | 'slow'
14
15export type Evolution = {
16 to: number
17 trigger: string
18 level: number | null
19 item: string | null
20}
21
22export type Species = {
23 id: number
24 name: string
25 types: string[]
26 stats: { hp: number; attack: number; defense: number; spAttack: number; spDefense: number; speed: number }
27 growthRate: Rate
28 stage: number
29 evolvesFrom: number | null
30 evolutions: Evolution[]
31 legendary: boolean
32}
33
34/** Tier of a species, shared by its whole evolution line. */
35export type Rarity = 'starter' | 'common' | 'uncommon' | 'rare' | 'legendary'
36
37export type Dex = {
38 species: Species[]
39 /** Cumulative XP by level: `growth[rate][level]` is the XP at which `level` starts. */
40 growth: Record<Rate, number[]>
41}
42
43export type Config = {
44 /** XP banked per 1,000 tokens. */
45 xpRate: number
46 /** Recommend a compact at or above this context percent. */
47 recommendPercent: number
48 /** At or above this context percent pending XP decays each turn. */
49 dangerPercent: number
50}
51
52export type Lifetime = ClawdMonLifetime
53export type Streaks = ClawdMonStreaks
54export type Entry = ClawdMonEntry
55
56/** The save; its shape is the plugin's state contract in types/index.d.ts. */
57export type Save = ClawdMonSave
58
59export type GameEvent =
60 | { kind: 'levelup'; level: number }
61 | { kind: 'crack'; stage: number }
62 | { kind: 'hatch'; speciesId: number; rarity: Rarity }
63 | { kind: 'evolve'; from: number; to: number }
64 | { kind: 'egg'; origin: 'evolved' | 'milestone' | 'mastery' }
65 | { kind: 'legend'; id: number }
66
67export type CompactTrigger = 'manual' | 'auto' | 'plugin' | 'precompute'
68
69export const DEFAULT_CONFIG: Config = { xpRate: 1, recommendPercent: 60, dangerPercent: 80 }
70
71/** Egg XP to hatch is this many per hatch level. */
72export const EGG_XP_PER_LEVEL = 600
73/** Hatch level of an egg whose species is not known yet. */
74export const DEFAULT_HATCH_LEVEL = 5
75/** A compact counts toward gates only at or above this context percent. */
76export const QUALIFY_PERCENT = 40
77/** Qualifying compacts since hatch for the first and second evolution. */
78export const EVOLVE_COMPACTS = [3, 8] as const
79/** Every this many qualifying compacts the box gets a new egg. */
80export const MILESTONE_COMPACTS = 25
81/** A single-stage species earns one egg at this level. */
82export const SINGLE_STAGE_EGG_LEVEL = 40
83export const AUTO_SHARE = 0.5
84export const DECAY_PER_TURN = 0.02
85export const ITEM_EVOLVE_LEVEL = 30
86export const TRADE_EVOLVE_LEVEL = 36
87/** Legendary goals. */
88export const COOL_DAYS_GOAL = 14
89export const STRIKE_GOAL = 30
90export const FULL_LINES_BIRD = 3
91export const FULL_LINES_MEWTWO = 10
92export const MEW_CHANCE = 0.01
93export const ARTICUNO = 144
94export const ZAPDOS = 145
95export const MOLTRES = 146
96export const MEWTWO = 150
97export const MEW = 151
98const BIRDS = [ARTICUNO, ZAPDOS, MOLTRES] as const
99/** The starter lines (Bulbasaur, Charmander, Squirtle) are species 1-9. */
100export const STARTER_MAX_ID = 9
101/**
102 * First-stage species of the special lines (gifts, fossils, Safari Zone, Game Corner, one-offs):
103 * Hitmonlee, Hitmonchan, Chansey, Kangaskhan, Scyther, Pinsir, Tauros, Lapras, Ditto, Eevee,
104 * Porygon, Omanyte, Kabuto, Aerodactyl, Snorlax, Dratini. Their whole line is rare.
105 */
106export const RARE_ROOTS: readonly number[] = [106, 107, 113, 115, 123, 127, 128, 131, 132, 133, 137, 138, 140, 142, 143, 147]
107/** Any other non-starter line whose strongest form has at least this base stat total is uncommon. */
108export const UNCOMMON_LINE_BST = 490
109/** Relative odds of each tier when a random egg is rolled; a tier with no species drops out. */
110export const EGG_ODDS = { starter: 5, common: 55, uncommon: 30, rare: 10 } as const
111const HISTORY_DAYS = 14
112const GROWTH_SAMPLES = 5
113const PROJECT_TURNS = 3
114const MAX_LEVEL = 100
115
116// ---------- save and config ----------
117
118export function newEntry(id: string, origin: Entry['origin'], target: number | null = null): Entry {
119 return {
120 id,
121 phase: 'egg',
122 target,
123 speciesId: null,
124 chosen: false,
125 xp: 0,
126 eggStage: 0,
127 compacts: 0,
128 branch: null,
129 origin,
130 eggClaimed: false,
131 }
132}
133
134export function newSave(): Save {
135 return {
136 version: 2,
137 rev: 0,
138 pending: 0,
139 milestone: 0,
140 activeId: 'a1',
141 box: [newEntry('a1', 'starter')],
142 lifetime: { tokens: 0, xp: 0, compacts: 0, hatches: 0, evolutions: 0, fullLines: 0 },
143 dex: [],
144 legendsEarned: [],
145 streaks: { coolDays: 0, coolDay: null, coolBroken: false, strike: 0 },
146 daily: {},
147 growth: [],
148 lastContext: null,
149 }
150}
151
152/** The entry that gains XP. The box is never empty. */
153export function activeEntry(save: Save): Entry {
154 return save.box.find(e => e.id === save.activeId) ?? save.box[0]!
155}
156
157function setEntry(save: Save, entry: Entry): Save {
158 return { ...save, box: save.box.map(e => (e.id === entry.id ? entry : e)) }
159}
160
161function nextId(box: readonly Entry[]): string {
162 let n = 0
163 for (const e of box) {
164 const m = /^a(\d+)$/.exec(e.id)
165 if (m) n = Math.max(n, Number(m[1]))
166 }
167 return `a${n + 1}`
168}
169
170function isRecord(v: unknown): v is Record<string, unknown> {
171 return typeof v === 'object' && v !== null && !Array.isArray(v)
172}
173
174function num(v: unknown, fallback: number): number {
175 return typeof v === 'number' && Number.isFinite(v) ? v : fallback
176}
177
178function id(v: unknown): number | null {
179 return typeof v === 'number' && Number.isInteger(v) ? v : null
180}
181
182function readEntry(raw: unknown, dex: Dex, fallbackId: string): Entry | null {
183 if (!isRecord(raw)) return null
184 const origin =
185 raw.origin === 'evolved' || raw.origin === 'milestone' || raw.origin === 'mastery' || raw.origin === 'legendary'
186 ? raw.origin
187 : 'starter'
188 const entry: Entry = {
189 id: typeof raw.id === 'string' && /^[\w-]{1,12}$/.test(raw.id) ? raw.id : fallbackId,
190 phase: raw.phase === 'mon' ? 'mon' : 'egg',
191 target: id(raw.target),
192 speciesId: id(raw.speciesId),
193 chosen: raw.chosen === true,
194 xp: Math.max(0, num(raw.xp, 0)),
195 eggStage: raw.eggStage === 1 || raw.eggStage === 2 ? raw.eggStage : 0,
196 compacts: Math.max(0, Math.floor(num(raw.compacts, 0))),
197 branch: id(raw.branch),
198 origin,
199 eggClaimed: raw.eggClaimed === true,
200 }
201 if (entry.target !== null && !find(dex, entry.target)) entry.target = null
202 if (entry.target === null || entry.phase === 'mon') entry.chosen = false
203 if (entry.phase === 'mon') {
204 if (entry.speciesId === null || !find(dex, entry.speciesId)) return null
205 entry.target = null
206 entry.eggStage = 0
207 } else {
208 entry.speciesId = null
209 }
210 return entry
211}
212
213/** Reads whatever the store held into a valid v2 save; a v1 save becomes box[0]; junk, a fresh egg. */
214export function sanitizeSave(raw: unknown, dex: Dex): Save {
215 const base = newSave()
216 if (!isRecord(raw)) return base
217 const life = isRecord(raw.lifetime) ? raw.lifetime : {}
218 const daily: Record<string, number> = {}
219 if (isRecord(raw.daily)) {
220 for (const [k, v] of Object.entries(raw.daily)) {
221 if (/^[\w-]+$/.test(k) && typeof v === 'number' && Number.isFinite(v)) daily[k] = v
222 }
223 }
224 let box: Entry[] = []
225 if (Array.isArray(raw.box)) {
226 const seen = new Set<string>()
227 raw.box.forEach((r, i) => {
228 const e = readEntry(r, dex, `a${i + 1}`)
229 if (e && !seen.has(e.id)) {
230 seen.add(e.id)
231 box.push(e)
232 }
233 })
234 } else if (raw.phase === 'mon' || raw.phase === 'egg') {
235 // Version 1: one flat companion. It becomes the first, active entry of the box.
236 const e = readEntry({ ...raw, id: 'a1', origin: 'starter' }, dex, 'a1')
237 if (e) box = [e]
238 }
239 if (box.length === 0) box = [newEntry('a1', 'starter')]
240 // Finished lines: stored, or counted from the box for a save that predates the counter.
241 const derivedLines = box.filter(e => {
242 const sp = speciesOf(e, dex)
243 return (sp !== undefined && sp.stage > 0 && sp.evolutions.length === 0) || e.eggClaimed
244 }).length
245 const lifetime = {
246 tokens: Math.max(0, num(life.tokens, 0)),
247 xp: Math.max(0, num(life.xp, 0)),
248 compacts: Math.max(0, num(life.compacts, 0)),
249 hatches: Math.max(0, num(life.hatches, 0)),
250 evolutions: Math.max(0, num(life.evolutions, 0)),
251 fullLines: Math.max(0, Math.floor(num(life.fullLines, derivedLines))),
252 }
253 const known = (v: unknown): number[] =>
254 Array.isArray(v)
255 ? [...new Set(v.filter((n): n is number => typeof n === 'number' && Number.isInteger(n) && find(dex, n) !== undefined))].sort((a, b) => a - b)
256 : []
257 let caught = known(raw.dex)
258 if (!Array.isArray(raw.dex)) {
259 const ids = new Set<number>()
260 for (const e of box) {
261 let sp = speciesOf(e, dex)
262 for (let i = 0; sp && i < 5; i++) {
263 ids.add(sp.id)
264 sp = sp.evolvesFrom !== null ? find(dex, sp.evolvesFrom) : undefined
265 }
266 }
267 caught = [...ids].sort((a, b) => a - b)
268 }
269 const earned = Array.isArray(raw.legendsEarned)
270 ? known(raw.legendsEarned).filter(n => find(dex, n)?.legendary === true)
271 : box.filter(e => e.origin === 'legendary' && e.target !== null).map(e => e.target!)
272 const rs = isRecord(raw.streaks) ? raw.streaks : {}
273 const streaks: Streaks = {
274 coolDays: Math.max(0, Math.floor(num(rs.coolDays, 0))),
275 coolDay: typeof rs.coolDay === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(rs.coolDay) ? rs.coolDay : null,
276 coolBroken: rs.coolBroken === true,
277 strike: Math.max(0, Math.floor(num(rs.strike, 0))),
278 }
279 const activeId = typeof raw.activeId === 'string' && box.some(e => e.id === raw.activeId) ? raw.activeId : box[0]!.id
280 const read: Save = {
281 ...base,
282 rev: Math.max(0, Math.floor(num(raw.rev, 0))),
283 pending: Math.max(0, num(raw.pending, 0)),
284 milestone: clamp(Math.floor(num(raw.milestone, 0)), 0, MILESTONE_COMPACTS - 1),
285 activeId,
286 box,
287 lifetime,
288 dex: caught,
289 legendsEarned: [...new Set(earned)].sort((a, b) => a - b),
290 streaks,
291 daily: trimDaily(daily),
292 growth: Array.isArray(raw.growth)
293 ? raw.growth.filter((n): n is number => typeof n === 'number' && n > 0 && Number.isFinite(n)).slice(-GROWTH_SAMPLES)
294 : [],
295 lastContext: typeof raw.lastContext === 'number' && Number.isFinite(raw.lastContext) ? raw.lastContext : null,
296 }
297 // A save already at a goal (older, or edited) gets its egg now rather than at the next compact.
298 return unlockLegends(read, dex).save
299}
300
301function clamp(n: number, lo: number, hi: number): number {
302 return Math.max(lo, Math.min(hi, n))
303}
304
305/** Reads the plugin's userConfig values; anything unusable falls back to the default. */
306export function configFrom(options: Record<string, unknown>): Config {
307 const d = DEFAULT_CONFIG
308 const rate = options.xpRate
309 const pct = (v: unknown, fallback: number) =>
310 typeof v === 'number' && Number.isFinite(v) ? clamp(v, 1, 100) : fallback
311 return {
312 xpRate: typeof rate === 'number' && Number.isFinite(rate) && rate > 0 ? rate : d.xpRate,
313 recommendPercent: pct(options.recommendPercent, d.recommendPercent),
314 dangerPercent: pct(options.dangerPercent, d.dangerPercent),
315 }
316}
317
318// ---------- species, level ----------
319
320function find(dex: Dex, speciesId: number): Species | undefined {
321 return dex.species.find(s => s.id === speciesId)
322}
323
324export function bst(s: Species): number {
325 const t = s.stats
326 return t.hp + t.attack + t.defense + t.spAttack + t.spDefense + t.speed
327}
328
329/** Heavier species level slower: clamp((320 / BST)^0.35, 0.6, 1.1). */
330export function dragFactor(s: Species): number {
331 return clamp((320 / Math.max(1, bst(s))) ** 0.35, 0.6, 1.1)
332}
333
334export function tokensToXp(tokens: number, xpRate: number): number {
335 if (!Number.isFinite(tokens) || tokens <= 0) return 0
336 return (tokens / 1000) * xpRate
337}
338
339/** Highest level whose threshold the XP has reached, 1..100. */
340export function levelOf(xp: number, rate: Rate, growth: Dex['growth']): number {
341 const table = growth[rate]
342 let level = 1
343 for (let l = 2; l <= MAX_LEVEL; l++) {
344 if ((table[l] ?? Number.POSITIVE_INFINITY) <= xp) level = l
345 else break
346 }
347 return level
348}
349
350/** Level an egg of this species hatches at: round(BST / 60), 4..10. */
351export function hatchLevel(s: Species): number {
352 return clamp(Math.round(bst(s) / 60), 4, 10)
353}
354
355/** Egg levels at which it cracks, cracks again, and hatches: strictly rising, first at least 2. */
356export function crackLevels(h: number): [number, number, number] {
357 const first = Math.max(2, Math.round(0.4 * h))
358 let second = Math.max(first + 1, Math.round(0.7 * h))
359 if (second >= h) second = Math.max(first + 1, h - 1)
360 return [first, second, Math.max(h, second + 1)]
361}
362
363/** Applied egg XP at which egg level `level` starts, for hatch level `h`. */
364export function eggXpAt(level: number, h: number): number {
365 if (level <= 1) return 0
366 return EGG_XP_PER_LEVEL * h * ((level - 1) / (h - 1)) ** 1.5
367}
368
369/** Egg level (1..h) for applied egg XP. */
370export function eggLevel(xp: number, h: number): number {
371 let level = 1
372 for (let l = 2; l <= h; l++) if (xp >= eggXpAt(l, h)) level = l
373 return level
374}
375
376function eggHatchLevel(entry: Entry, dex: Dex): number {
377 const sp = entry.target !== null ? find(dex, entry.target) : undefined
378 return sp ? hatchLevel(sp) : DEFAULT_HATCH_LEVEL
379}
380
381function lineBst(dex: Dex, s: Species, depth = 0): number {
382 if (depth > 5) return bst(s)
383 const next = s.evolutions.map(e => find(dex, e.to)).filter((x): x is Species => x !== undefined)
384 return Math.max(bst(s), ...next.map(x => lineBst(dex, x, depth + 1)))
385}
386
387/** The tier of a species: legendary, starter (ids 1-9), or by its line's first stage and strongest form. */
388export function rarity(dex: Dex, sp: Species): Rarity {
389 if (sp.legendary) return 'legendary'
390 if (sp.id >= 1 && sp.id <= STARTER_MAX_ID) return 'starter'
391 const r = root(dex, sp)
392 if (RARE_ROOTS.includes(r.id)) return 'rare'
393 return lineBst(dex, r) >= UNCOMMON_LINE_BST ? 'uncommon' : 'common'
394}
395
396/** A random first-stage non-legendary species: a tier by EGG_ODDS, then any species in it. One roll. */
397function pickStarter(dex: Dex, rng: () => number): Species {
398 const at = (list: Species[], f: number) => list[Math.min(list.length - 1, Math.max(0, Math.floor(f * list.length)))]!
399 const pool = dex.species.filter(x => x.stage === 0 && !x.legendary)
400 if (pool.length === 0) return at(dex.species, rng())
401 const tiers = (Object.keys(EGG_ODDS) as (keyof typeof EGG_ODDS)[])
402 .map(t => ({ w: EGG_ODDS[t], list: pool.filter(s => rarity(dex, s) === t) }))
403 .filter(x => x.w > 0 && x.list.length > 0)
404 if (tiers.length === 0) return at(pool, rng())
405 let roll = rng() * tiers.reduce((a, x) => a + x.w, 0)
406 const last = tiers.length - 1
407 for (const { w, list } of tiers.slice(0, last)) {
408 if (roll < w) return at(list, roll / w)
409 roll -= w
410 }
411 return at(tiers[last]!.list, roll / tiers[last]!.w)
412}
413
414/** Every egg with no species yet gets a random first-stage non-legendary one (tier by EGG_ODDS), kept hidden. */
415export function ensureTarget(save: Save, dex: Dex, rng: () => number = Math.random): Save {
416 if (!save.box.some(e => e.phase === 'egg' && e.target === null)) return save
417 return {
418 ...save,
419 box: save.box.map(e =>
420 e.phase === 'egg' && e.target === null ? { ...e, target: pickStarter(dex, rng).id, chosen: false } : e,
421 ),
422 }
423}
424
425function root(dex: Dex, s: Species): Species {
426 let cur = s
427 for (let i = 0; i < 5 && cur.evolvesFrom !== null; i++) {
428 const parent = find(dex, cur.evolvesFrom)
429 if (!parent) break
430 cur = parent
431 }
432 return cur
433}
434
435function speciesOf(entry: Entry, dex: Dex): Species | undefined {
436 return entry.phase === 'mon' && entry.speciesId !== null ? find(dex, entry.speciesId) : undefined
437}
438
439export type NextEvolution = { to: Species; level: number; compactsNeeded: number }
440
441function entryNext(entry: Entry, dex: Dex): NextEvolution | null {
442 const s = speciesOf(entry, dex)
443 if (!s || s.evolutions.length === 0) return null
444 const chosen = entry.branch !== null ? s.evolutions.find(e => e.to === entry.branch) : undefined
445 const evo = chosen ?? s.evolutions[0]!
446 const to = find(dex, evo.to)
447 if (!to) return null
448 const level =
449 evo.trigger === 'level-up'
450 ? (evo.level ?? ITEM_EVOLVE_LEVEL)
451 : evo.trigger === 'trade'
452 ? TRADE_EVOLVE_LEVEL
453 : ITEM_EVOLVE_LEVEL
454 return { to, level, compactsNeeded: s.stage <= 0 ? EVOLVE_COMPACTS[0] : EVOLVE_COMPACTS[1] }
455}
456
457/** The evolution the active Pokémon is working toward, or null at the end of its line. */
458export function nextEvolution(save: Save, dex: Dex): NextEvolution | null {
459 return entryNext(activeEntry(save), dex)
460}
461
462// ---------- bank, decay, context ----------
463
464function trimDaily(daily: Record<string, number>): Record<string, number> {
465 const keys = Object.keys(daily).sort()
466 const out: Record<string, number> = {}
467 for (const k of keys.slice(-HISTORY_DAYS)) out[k] = daily[k]!
468 return out
469}
470
471/** Banks tokens as pending XP. `day` is a UTC YYYY-MM-DD stamp for the history. */
472export function bankTokens(save: Save, tokens: number, day: string, cfg: Config): Save {
473 const xp = tokensToXp(tokens, cfg.xpRate)
474 if (xp <= 0) return save
475 return {
476 ...save,
477 pending: save.pending + xp,
478 lifetime: { ...save.lifetime, tokens: save.lifetime.tokens + tokens },
479 daily: trimDaily({ ...save.daily, [day]: (save.daily[day] ?? 0) + xp }),
480 }
481}
482
483/** One completed turn at `percent` context: pending loses 2% at or above the danger line. */
484export function decayPending(save: Save, percent: number | undefined, cfg: Config): Save {
485 if (percent === undefined || percent < cfg.dangerPercent || save.pending <= 0) return save
486 return { ...save, pending: save.pending * (1 - DECAY_PER_TURN) }
487}
488
489/** Records a context-size reading; positive growth since the last one feeds the projection. */
490export function observeContext(save: Save, tokens: number | undefined): Save {
491 if (tokens === undefined || !Number.isFinite(tokens)) return save
492 const growth = [...save.growth]
493 if (save.lastContext !== null && tokens > save.lastContext) growth.push(tokens - save.lastContext)
494 return { ...save, growth: growth.slice(-GROWTH_SAMPLES), lastContext: tokens }
495}
496
497export type Recommendation = { recommend: boolean; reason: 'high' | 'projected' | null }
498
499export function recommendation(
500 usage: { percent?: number; tokens?: number; window: number },
501 save: Save,
502 cfg: Config,
503): Recommendation {
504 const { percent } = usage
505 if (percent === undefined) return { recommend: false, reason: null }
506 if (percent >= cfg.recommendPercent) return { recommend: true, reason: 'high' }
507 if (save.growth.length >= 2 && usage.window > 0) {
508 const avg = save.growth.reduce((a, b) => a + b, 0) / save.growth.length
509 const projected = percent + ((avg * PROJECT_TURNS) / usage.window) * 100
510 if (projected >= cfg.dangerPercent) return { recommend: true, reason: 'projected' }
511 }
512 return { recommend: false, reason: null }
513}
514
515// ---------- compact ----------
516
517export type CompactResult = { save: Save; events: GameEvent[] }
518
519function addEgg(save: Save, dex: Dex, rng: () => number, origin: 'evolved' | 'milestone' | 'mastery'): Save {
520 const egg = newEntry(nextId(save.box), origin, pickStarter(dex, rng).id)
521 return { ...save, box: [...save.box, egg] }
522}
523
524function addLegendaryEgg(save: Save, id: number): Save {
525 const egg = newEntry(nextId(save.box), 'legendary', id)
526 return { ...save, box: [...save.box, egg], legendsEarned: [...save.legendsEarned, id].sort((x, y) => x - y) }
527}
528
529function caught(save: Save, id: number): Save {
530 return save.dex.includes(id) ? save : { ...save, dex: [...save.dex, id].sort((x, y) => x - y) }
531}
532
533/** Legendary eggs whose goals are met and that this save has not earned yet. */
534export function unlockLegends(save: Save, dex: Dex): CompactResult {
535 const events: GameEvent[] = []
536 let s = save
537 const has = (id: number) => s.legendsEarned.includes(id)
538 const grant = (id: number) => {
539 s = addLegendaryEgg(s, id)
540 events.push({ kind: 'legend', id })
541 }
542 if (!has(ARTICUNO) && s.streaks.coolDays >= COOL_DAYS_GOAL) grant(ARTICUNO)
543 if (!has(ZAPDOS) && s.streaks.strike >= STRIKE_GOAL) grant(ZAPDOS)
544 if (!has(MOLTRES) && s.lifetime.fullLines >= FULL_LINES_BIRD) grant(MOLTRES)
545 if (!has(MEWTWO) && BIRDS.every(has) && s.lifetime.fullLines >= FULL_LINES_MEWTWO) grant(MEWTWO)
546 return { save: s, events }
547}
548
549/**
550 * One finished main-loop turn at `percent` context on UTC `day`: feeds the Articuno goal.
551 * A day that reaches the danger percent resets the streak at once and does not count; a day
552 * counts once a later day starts. Days with no turns neither count nor reset.
553 */
554export function observeTurn(
555 save: Save,
556 input: { day: string; percent?: number },
557 dex: Dex,
558 cfg: Config,
559 _rng: () => number = Math.random,
560): CompactResult {
561 const hit = input.percent !== undefined && input.percent >= cfg.dangerPercent
562 let st = save.streaks
563 if (st.coolDay === null) {
564 st = { ...st, coolDay: input.day, coolBroken: hit, coolDays: hit ? 0 : st.coolDays }
565 } else if (input.day === st.coolDay) {
566 if (hit) st = { ...st, coolBroken: true, coolDays: 0 }
567 } else if (input.day > st.coolDay) {
568 st = {
569 ...st,
570 coolDays: st.coolBroken || hit ? 0 : st.coolDays + 1,
571 coolDay: input.day,
572 coolBroken: hit,
573 }
574 }
575 if (st === save.streaks) return { save, events: [] }
576 return unlockLegends({ ...save, streaks: st }, dex)
577}
578
579/**
580 * Applies the bank for a finished compact to the ACTIVE entry. `percent` is the context
581 * percent just before it ran (a compact below QUALIFY_PERCENT still pays XP but counts for
582 * no gate). New eggs it earns go to the box; they are never made active.
583 */
584export function applyCompact(
585 save: Save,
586 dex: Dex,
587 input: { trigger: CompactTrigger; percent?: number },
588 _cfg: Config,
589 rng: () => number = Math.random,
590): CompactResult {
591 if (input.trigger === 'precompute') return { save, events: [] }
592 const events: GameEvent[] = []
593 const share = input.trigger === 'auto' ? AUTO_SHARE : 1
594 const raw = save.pending * share
595 const qualifies = input.percent !== undefined && input.percent >= QUALIFY_PERCENT
596 const bump = qualifies ? 1 : 0
597
598 let entry: Entry = { ...activeEntry(save), compacts: activeEntry(save).compacts + bump }
599 let hatched = 0
600 let evolved = 0
601 let fullLines = 0
602 let earnFromEvolve = false
603 let earnSingle = false
604 const newDex: number[] = []
605
606 if (entry.phase === 'egg') {
607 entry.xp += raw
608 const h = eggHatchLevel(entry, dex)
609 const needed = crackLevels(h)[entry.eggStage]
610 if (qualifies && eggLevel(entry.xp, h) >= needed) {
611 if (entry.eggStage === 2) {
612 const sp = (entry.target !== null ? find(dex, entry.target) : undefined) ?? pickStarter(dex, rng)
613 entry = {
614 ...entry,
615 phase: 'mon',
616 eggStage: 0,
617 target: null,
618 chosen: false,
619 speciesId: sp.id,
620 xp: dex.growth[sp.growthRate][hatchLevel(sp)] ?? 0,
621 compacts: 0,
622 branch: null,
623 }
624 hatched = 1
625 newDex.push(sp.id)
626 events.push({ kind: 'hatch', speciesId: sp.id, rarity: rarity(dex, sp) })
627 } else {
628 entry.eggStage = (entry.eggStage + 1) as 1 | 2
629 events.push({ kind: 'crack', stage: entry.eggStage })
630 }
631 }
632 } else {
633 const sp = speciesOf(entry, dex)
634 if (sp) {
635 const before = levelOf(entry.xp, sp.growthRate, dex.growth)
636 entry.xp += raw * dragFactor(sp)
637 const after = levelOf(entry.xp, sp.growthRate, dex.growth)
638 if (after > before) events.push({ kind: 'levelup', level: after })
639
640 const next = entryNext(entry, dex)
641 if (next && after >= next.level && entry.compacts >= next.compactsNeeded) {
642 entry = { ...entry, speciesId: next.to.id, branch: null }
643 evolved = 1
644 newDex.push(next.to.id)
645 events.push({ kind: 'evolve', from: sp.id, to: next.to.id })
646 if (next.to.evolutions.length === 0) {
647 earnFromEvolve = true
648 fullLines += 1
649 }
650 } else if (sp.stage === 0 && sp.evolutions.length === 0 && !entry.eggClaimed && after >= SINGLE_STAGE_EGG_LEVEL) {
651 entry = { ...entry, eggClaimed: true }
652 earnSingle = true
653 fullLines += 1
654 }
655 }
656 }
657
658 // Zapdos: qualifying non-auto compacts in a row; an auto-compact breaks the run.
659 const strike =
660 input.trigger === 'auto' ? 0 : qualifies ? save.streaks.strike + 1 : save.streaks.strike
661
662 let s: Save = setEntry(
663 {
664 ...save,
665 pending: 0,
666 growth: [],
667 lastContext: null,
668 streaks: { ...save.streaks, strike },
669 lifetime: {
670 ...save.lifetime,
671 xp: save.lifetime.xp + raw,
672 compacts: save.lifetime.compacts + bump,
673 hatches: save.lifetime.hatches + hatched,
674 evolutions: save.lifetime.evolutions + evolved,
675 fullLines: save.lifetime.fullLines + fullLines,
676 },
677 },
678 entry,
679 )
680 for (const id of newDex) s = caught(s, id)
681
682 if (earnFromEvolve) {
683 s = addEgg(s, dex, rng, 'evolved')
684 events.push({ kind: 'egg', origin: 'evolved' })
685 }
686 if (earnSingle) {
687 s = addEgg(s, dex, rng, 'mastery')
688 events.push({ kind: 'egg', origin: 'mastery' })
689 }
690 if (qualifies) {
691 const count = s.milestone + 1
692 if (count >= MILESTONE_COMPACTS) {
693 // After Mewtwo, one milestone egg in a hundred is Mew instead (once per save).
694 if (s.legendsEarned.includes(MEWTWO) && !s.legendsEarned.includes(MEW) && rng() < MEW_CHANCE) {
695 s = { ...addLegendaryEgg(s, MEW), milestone: 0 }
696 events.push({ kind: 'legend', id: MEW })
697 } else {
698 s = { ...addEgg(s, dex, rng, 'milestone'), milestone: 0 }
699 events.push({ kind: 'egg', origin: 'milestone' })
700 }
701 } else {
702 s = { ...s, milestone: count }
703 }
704 }
705 const legends = unlockLegends(s, dex)
706 return { save: legends.save, events: [...events, ...legends.events] }
707}
708
709// ---------- view model ----------
710
711export type Progress =
712 | {
713 kind: 'egg'
714 /** Cracks so far, 0-2 (drawn as art on the egg, never spelled out). */
715 stage: number
716 level: number
717 xp: number
718 /** XP gained into the current egg level, and the span to the next. */
719 into: number
720 need: number
721 fraction: number
722 pending: number
723 /** Only when the player chose it; a random egg stays a surprise. */
724 targetName: string | null
725 /** A legendary egg: drawn with a star. */
726 legendary: boolean
727 }
728 | {
729 kind: 'mon'
730 id: number
731 name: string
732 level: number
733 /** XP gained into the current level, and the span to the next. */
734 into: number
735 need: number
736 fraction: number
737 pending: number
738 compacts: number
739 next: { name: string; level: number; compactsNeeded: number } | null
740 legendary: boolean
741 /** The tier banner shown once hatched. */
742 rarity: Rarity
743 }
744
745function entryProgress(entry: Entry, pending: number, dex: Dex): Progress {
746 const sp = speciesOf(entry, dex)
747 if (!sp) {
748 const h = eggHatchLevel(entry, dex)
749 const level = eggLevel(entry.xp, h)
750 const lo = eggXpAt(level, h)
751 const hi = level >= h ? lo : eggXpAt(level + 1, h)
752 const chosen = entry.chosen && entry.target !== null ? find(dex, entry.target) : undefined
753 return {
754 kind: 'egg',
755 stage: entry.eggStage,
756 level,
757 xp: entry.xp,
758 into: Math.max(0, entry.xp - lo),
759 need: hi - lo,
760 fraction: hi > lo ? clamp((entry.xp - lo) / (hi - lo), 0, 1) : 1,
761 pending,
762 targetName: chosen?.name ?? null,
763 legendary: entry.origin === 'legendary',
764 }
765 }
766 const table = dex.growth[sp.growthRate]
767 const level = levelOf(entry.xp, sp.growthRate, dex.growth)
768 const lo = table[level] ?? 0
769 const hi = level >= MAX_LEVEL ? lo : (table[level + 1] ?? lo)
770 const next = entryNext(entry, dex)
771 return {
772 kind: 'mon',
773 id: sp.id,
774 name: sp.name,
775 level,
776 into: Math.max(0, entry.xp - lo),
777 need: hi - lo,
778 fraction: hi > lo ? clamp((entry.xp - lo) / (hi - lo), 0, 1) : 1,
779 pending,
780 compacts: entry.compacts,
781 next: next ? { name: next.to.name, level: next.level, compactsNeeded: next.compactsNeeded } : null,
782 legendary: sp.legendary,
783 rarity: rarity(dex, sp),
784 }
785}
786
787/** The active entry's progress, with the shared pending bank. */
788export function progress(save: Save, dex: Dex): Progress {
789 return entryProgress(activeEntry(save), save.pending, dex)
790}
791
792export type Overview = { boxCount: number; milestone: number; goal: number }
793
794export function overview(save: Save): Overview {
795 return { boxCount: save.box.length, milestone: save.milestone, goal: MILESTONE_COMPACTS }
796}
797
798// ---------- dex ----------
799
800export type DexRow = {
801 id: number
802 earned: boolean
803 /** The species name once earned, `???` before. */
804 name: string
805 lore: string
806 goal: string
807 /** Mewtwo before all three birds. */
808 locked: boolean
809 progress: { n: number; goal: number } | null
810}
811
812export type DexView = {
813 caught: number
814 total: number
815 legendsOwned: number
816 legendsTotal: number
817 /** Species ids 1-9 caught, of all of them. */
818 starters: { n: number; total: number }
819 /** Non-starter, non-legendary species caught by tier (see `rarity`), of all of them. */
820 common: { n: number; total: number }
821 uncommon: { n: number; total: number }
822 rare: { n: number; total: number }
823 rows: DexRow[]
824}
825
826/** The catalog: caught species, and one row per legendary (Mew only once owned). */
827export function dexView(save: Save, dex: Dex, cfg: Pick<Config, 'dangerPercent'>): DexView {
828 const has = (id: number) => save.legendsEarned.includes(id)
829 const name = (id: number) => (has(id) ? (find(dex, id)?.name ?? '???') : '???')
830 const birds = BIRDS.every(has)
831 const lines = save.lifetime.fullLines
832 const row = (id: number, lore: string, goal: string, n: number, g: number, locked = false): DexRow => ({
833 id,
834 earned: has(id),
835 name: name(id),
836 lore,
837 goal,
838 locked: locked && !has(id),
839 // A counter at its goal is granted in the same step; the display never shows it full unearned.
840 progress: locked && !has(id) ? null : { n: has(id) ? g : Math.min(n, g - 1), goal: g },
841 })
842 const rows: DexRow[] = [
843 row(ARTICUNO, 'Frozen bird of legend', `Stay cool: ${COOL_DAYS_GOAL} days without reaching ${cfg.dangerPercent}% context`, save.streaks.coolDays, COOL_DAYS_GOAL),
844 row(ZAPDOS, 'Storm bird of legend', `Strike first: ${STRIKE_GOAL} compacts in a row before an auto-compact`, save.streaks.strike, STRIKE_GOAL),
845 row(MOLTRES, 'Phoenix bird of legend', `Rebirth: fully evolve ${FULL_LINES_BIRD} lines`, lines, FULL_LINES_BIRD),
846 row(MEWTWO, 'Born in a lab from a legend’s DNA', `Needs all three birds, then ${FULL_LINES_MEWTWO} fully evolved lines`, lines, FULL_LINES_MEWTWO, !birds),
847 ]
848 if (has(MEW)) {
849 rows.push({ id: MEW, earned: true, name: name(MEW), lore: 'The one nobody listed', goal: 'Found', locked: false, progress: null })
850 }
851 const tally = (k: Rarity) => ({
852 n: save.dex.filter(id => {
853 const sp = find(dex, id)
854 return sp !== undefined && rarity(dex, sp) === k
855 }).length,
856 total: dex.species.filter(sp => rarity(dex, sp) === k).length,
857 })
858 return {
859 starters: tally('starter'),
860 common: tally('common'),
861 uncommon: tally('uncommon'),
862 rare: tally('rare'),
863 caught: save.dex.length,
864 total: dex.species.length,
865 legendsOwned: [...BIRDS, MEWTWO, MEW].filter(has).length,
866 legendsTotal: has(MEW) ? 5 : 4,
867 rows,
868 }
869}
870
871export function dexCounts(v: DexView): string {
872 return (
873 `Starters ${v.starters.n}/${v.starters.total} · Common ${v.common.n}/${v.common.total} · ` +
874 `Uncommon ${v.uncommon.n}/${v.uncommon.total} · Rare ${v.rare.n}/${v.rare.total}`
875 )
876}
877
878export function dexHeader(v: DexView): string {
879 return `Dex ${v.caught}/${v.total} · ★ ${v.legendsOwned}/${v.legendsTotal}`
880}
881
882/** The dex as plain rows (header first), for chat and the terminal. */
883export function dexText(save: Save, dex: Dex, cfg: Pick<Config, 'dangerPercent'>): string {
884 const v = dexView(save, dex, cfg)
885 const rows = v.rows.map(r => {
886 const state = r.earned ? 'earned' : r.locked ? 'locked' : `${r.progress!.n}/${r.progress!.goal}`
887 return `${r.earned ? '★ ' : ''}${r.name} · ${r.lore} · ${r.goal} · ${state}`
888 })
889 return [dexHeader(v), dexCounts(v), ...rows].join('\n')
890}
891
892// ---------- projection ----------
893
894function dayNumber(day: string): number | null {
895 const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(day)
896 if (!m) return null
897 return Math.floor(Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3])) / 86_400_000)
898}
899
900/** Average raw XP banked per day over the last 7 days of history, or null with none. */
901function averageDaily(save: Save, today: string): number | null {
902 const end = dayNumber(today)
903 if (end === null) return null
904 let sum = 0
905 let earliest = end
906 for (const [k, v] of Object.entries(save.daily)) {
907 const n = dayNumber(k)
908 if (n === null || n > end || n < end - 6) continue
909 sum += v
910 earliest = Math.min(earliest, n)
911 }
912 if (sum <= 0) return null
913 return sum / Math.min(7, end - earliest + 1)
914}
915
916/** Whole days until the active Pokémon's next evolution at the recent pace; null for eggs or if unknowable. */
917export function projectDays(save: Save, dex: Dex, today: string): number | null {
918 const entry = activeEntry(save)
919 const sp = speciesOf(entry, dex)
920 if (!sp) return null
921 const avg = averageDaily(save, today)
922 if (avg === null) return null
923 const next = entryNext(entry, dex)
924 if (!next) return null
925 const left = (dex.growth[sp.growthRate][next.level] ?? 0) - entry.xp
926 if (left <= 0) return 0
927 return Math.ceil(left / (avg * dragFactor(sp)) - 1e-9)
928}
929
930// ---------- commands ----------
931
932export type CommandResult = {
933 save: Save
934 text: string
935 show?: boolean
936 hide?: boolean
937 /** Open the in-band help. */
938 hint?: boolean
939 /** Open the in-band dex. */
940 dex?: boolean
941 /** The whole store should be wiped (reset-all). */
942 wipe?: boolean
943}
944
945const USAGE =
946 'Usage: /clawd-mon [show|hide|hint|dex|status|box|switch <name|#>|release <name|#> confirm|choose <name>|branch <name>|reset-all confirm]'
947
948function slug(name: string): string {
949 return name
950 .toLowerCase()
951 .replace(/♀/g, 'f')
952 .replace(/♂/g, 'm')
953 .replace(/[^a-z0-9]/g, '')
954}
955
956function byName(dex: Dex, name: string, within?: Species[]): Species | undefined {
957 const want = slug(name)
958 if (want === '') return undefined
959 return (within ?? dex.species).find(s => s.name && slug(s.name) === want)
960}
961
962function plural(n: number, word: string): string {
963 return `${n} ${word}${n === 1 ? '' : 's'}`
964}
965
966/** "Bulbasaur Lv 12", "Egg Lv 3", "Egg Lv 3 (Bulbasaur)" when the player chose it. */
967function entryLabel(entry: Entry, dex: Dex): string {
968 const p = entryProgress(entry, 0, dex)
969 const star = p.legendary ? ' ★' : ''
970 if (p.kind === 'egg') return `Egg Lv ${p.level}${p.targetName ? ` (${p.targetName})` : ''}${star}`
971 return `${p.name} Lv ${p.level}${star}`
972}
973
974/** The box as text: number, active marker, label. */
975export function boxText(save: Save, dex: Dex): string {
976 const rows = save.box.map(
977 (e, i) => `${i + 1}. ${e.id === save.activeId ? '*' : ' '} ${entryLabel(e, dex)}`,
978 )
979 return [`Box (${save.box.length}), * = active:`, ...rows].join('\n')
980}
981
982function statusText(save: Save, dex: Dex, day: string): string {
983 const entry = activeEntry(save)
984 const p = progress(save, dex)
985 const lines: string[] = []
986 lines.push(`Active: ${entryLabel(entry, dex)}. XP ${Math.floor(p.into)}/${Math.floor(p.need)} to the next level.`)
987 if (p.kind === 'mon') {
988 if (p.next) {
989 lines.push(
990 `Next: ${p.next.name} at Lv ${p.next.level} with ${p.next.compactsNeeded} qualifying compacts (have ${p.compacts}).`,
991 )
992 const days = projectDays(save, dex, day)
993 if (days !== null) lines.push(`About ${plural(days, 'day')} to evolution at the recent pace.`)
994 } else {
995 lines.push('Fully evolved.')
996 }
997 }
998 lines.push(`Pending ${Math.floor(p.pending)} XP; compact to apply it.`)
999 lines.push(`Box: ${save.box.length}. Next egg: ${save.milestone}/${MILESTONE_COMPACTS} counted compacts.`)
1000 const t = save.lifetime
1001 lines.push(
1002 `Lifetime: ${Math.round(t.tokens / 1000)}k tokens, ${Math.floor(t.xp)} XP, ${t.compacts} qualifying compacts, ${t.hatches} hatched, ${t.evolutions} evolutions.`,
1003 )
1004 return lines.join('\n')
1005}
1006
1007/** Finds a box entry by 1-based number ("2", "#2") or by name ("pikachu", "egg"). */
1008function findEntry(save: Save, dex: Dex, query: string): Entry | undefined {
1009 const q = query.trim()
1010 if (/^#?\d+$/.test(q)) return save.box[Number(q.replace('#', '')) - 1]
1011 const want = slug(q)
1012 if (want === '') return undefined
1013 const hits = save.box.filter(e => {
1014 if (e.phase === 'egg') return want === 'egg'
1015 const sp = speciesOf(e, dex)
1016 return sp !== undefined && slug(sp.name) === want
1017 })
1018 return hits.find(e => e.id !== save.activeId) ?? hits[0]
1019}
1020
1021export function execute(
1022 save: Save,
1023 dex: Dex,
1024 args: string,
1025 ctx: { day: string; rng?: () => number; cfg?: Pick<Config, 'dangerPercent'> },
1026): CommandResult {
1027 const rng = ctx.rng ?? Math.random
1028 const parts = args.trim().split(/\s+/).filter(Boolean)
1029 const sub = (parts[0] ?? 'show').toLowerCase()
1030 const rest = parts.slice(1)
1031 const confirmed = rest.length > 0 && rest[rest.length - 1]!.toLowerCase() === 'confirm'
1032 const nameParts = confirmed ? rest.slice(0, -1) : rest
1033 const name = nameParts.join(' ')
1034 const entry = activeEntry(save)
1035
1036 switch (sub) {
1037 case 'show':
1038 return { save, text: 'Clawd-mon shown.', show: true }
1039 case 'hide':
1040 return { save, text: 'Clawd-mon hidden. /clawd-mon show brings it back.', hide: true }
1041 case 'hint':
1042 return { save, text: 'Help is open in the band.', show: true, hint: true }
1043 case 'dex':
1044 return { save, text: dexText(save, dex, ctx.cfg ?? DEFAULT_CONFIG), show: true, dex: true }
1045 case 'status':
1046 return { save, text: statusText(save, dex, ctx.day), show: true }
1047 case 'box':
1048 return { save, text: boxText(save, dex) }
1049 case 'switch': {
1050 if (name === '') return { save, text: 'Usage: /clawd-mon switch <name|#>. See /clawd-mon box.' }
1051 const target = findEntry(save, dex, name)
1052 if (!target) return { save, text: `Nothing in the box matches "${name}". See /clawd-mon box.` }
1053 if (target.id === save.activeId) return { save, text: `${entryLabel(target, dex)} is already active.` }
1054 return { save: { ...save, activeId: target.id }, text: `Active: ${entryLabel(target, dex)}.` }
1055 }
1056 case 'release': {
1057 if (name === '') return { save, text: 'Usage: /clawd-mon release <name|#> confirm. See /clawd-mon box.' }
1058 const target = findEntry(save, dex, name)
1059 if (!target) return { save, text: `Nothing in the box matches "${name}". See /clawd-mon box.` }
1060 const label = entryLabel(target, dex)
1061 if (!confirmed) {
1062 return { save, text: `This removes ${label} for good. Run /clawd-mon release ${name} confirm to proceed.` }
1063 }
1064 let box = save.box.filter(e => e.id !== target.id)
1065 let activeId = save.activeId
1066 let text = `Released ${label}.`
1067 if (box.length === 0) {
1068 const fresh = newEntry(nextId(save.box), 'starter', pickStarter(dex, rng).id)
1069 box = [fresh]
1070 activeId = fresh.id
1071 text += ' The box was empty: a new egg is active.'
1072 } else if (target.id === save.activeId) {
1073 activeId = box[0]!.id
1074 text += ` Active: ${entryLabel(box[0]!, dex)}.`
1075 }
1076 return { save: { ...save, box, activeId }, text }
1077 }
1078 case 'choose': {
1079 if (name === '') return { save, text: 'Usage: /clawd-mon choose <name>. Name any Gen 1 species; its first stage is used.' }
1080 if (entry.origin !== 'starter' || entry.phase !== 'egg' || save.lifetime.hatches > 0) {
1081 return { save, text: 'Choosing is only for the starter egg, before anything has hatched. Use /clawd-mon reset-all confirm to start over.' }
1082 }
1083 const found = byName(dex, name)
1084 if (!found) return { save, text: `Unknown species "${name}".` }
1085 const first = root(dex, found)
1086 if (first.legendary) return { save, text: `${first.name} is legendary and can't be chosen.` }
1087 return {
1088 save: setEntry(save, { ...entry, target: first.id, chosen: true }),
1089 text: `The egg will hatch into ${first.name}.`,
1090 }
1091 }
1092 case 'branch': {
1093 const sp = speciesOf(entry, dex)
1094 if (!sp) return { save, text: 'Nothing to branch yet: hatch the egg first.' }
1095 const options = sp.evolutions.map(e => find(dex, e.to)).filter((x): x is Species => x !== undefined)
1096 if (options.length < 2) return { save, text: `${sp.name} has only one evolution, no choice to make.` }
1097 const list = options.map(o => o.name).join(', ')
1098 const pick = byName(dex, name, options)
1099 if (!pick) return { save, text: `${name === '' ? 'No name given' : `"${name}" is not an evolution of ${sp.name}`}. Options: ${list}.` }
1100 return { save: setEntry(save, { ...entry, branch: pick.id }), text: `${sp.name} will evolve into ${pick.name}.` }
1101 }
1102 case 'reset':
1103 return { save, text: 'reset is gone. Use /clawd-mon release <name|#> confirm for one entry, or /clawd-mon reset-all confirm for everything.' }
1104 case 'reset-all':
1105 if (!confirmed) {
1106 return {
1107 save,
1108 text: 'This wipes everything: the whole box, lifetime totals, history, settings. Run /clawd-mon reset-all confirm to start fresh.',
1109 }
1110 }
1111 return { save: ensureTarget(newSave(), dex, rng), text: 'Fresh start. Everything wiped; a new egg is waiting.', wipe: true, show: true }
1112 default:
1113 return { save, text: USAGE }
1114 }
1115}
1116hooks/view.ts 330 lines1// Clawd-mon view helpers: the text and SVG the band shows. Pure, with type-only imports,
2// so plain Node can load this file (tools/preview.mjs) as well as the plugin.
3
4import type { ClawdMonActivity, ClawdMonAgent, ClawdMonAgents, ClawdMonTools, ClawdMonUsage } from '../types'
5import type { Config, Dex, DexRow, DexView, GameEvent, Overview, Progress, Rarity, Recommendation } from './engine'
6
7export const GREEN = '#4CAF50'
8export const AMBER = '#E0A030'
9export const RED = '#E5534B'
10export const TRACK = '#80808040'
11/** Where the Evolve button was: a compact is how the companion grows. */
12export const COMPACT_HINT = '/compact to level up'
13/** Banner fill per tier; white text on each stays readable. */
14export const RARITY_COLOR: Record<Rarity, string> = {
15 starter: '#C2410C',
16 common: '#6B7280',
17 uncommon: '#2E7D32',
18 rare: '#1D4ED8',
19 legendary: '#A16207',
20}
21
22export function rarityLabel(r: Rarity): string {
23 return r[0]!.toUpperCase() + r.slice(1)
24}
25/** Sprite PNGs are drawn on a square canvas of this many pixels. */
26export const CANVAS_PX = 96
27
28export type Box = readonly [x: number, y: number, w: number, h: number]
29
30// ---------- numbers and text ----------
31
32function trim(s: string): string {
33 return s.includes('.') ? s.replace(/\.?0+$/, '') : s
34}
35
36/** 23000 -> "23K", 124300 -> "124.3K", 1000000 -> "1M", 1500000 -> "1.5M". */
37export function formatTokens(n: number): string {
38 if (!Number.isFinite(n) || n < 0) return '—'
39 if (n >= 1_000_000) return `${trim((n / 1_000_000).toFixed(1))}M`
40 if (n >= 1000) {
41 const k = trim((n / 1000).toFixed(1))
42 return k === '1000' ? '1M' : `${k}K`
43 }
44 return String(Math.round(n))
45}
46
47export function contextLine(usage: ClawdMonUsage | null): string {
48 if (usage === null) return 'Context: no reading yet'
49 const window = usage.window > 0 ? formatTokens(usage.window) : '—'
50 if (usage.percent === undefined || usage.tokens === undefined) return `Context n/a · — / ${window}`
51 return `Context ${usage.percent}% · ${formatTokens(usage.tokens)} / ${window}`
52}
53
54export function toolsLabel(t: ClawdMonTools): string {
55 const avail = t.available === null ? 'n/a' : String(t.available)
56 const mcp = t.available !== null && t.mcp > 0 ? ` (${t.mcp} MCP)` : ''
57 return `Tools ${avail} avail${mcp} · ${t.usedNames.length} used · ${t.calls} calls`
58}
59
60export function agentsLabel(a: ClawdMonAgents): string {
61 if (a.running === null || a.total === null) return 'Agents n/a'
62 return `Agents ${a.running} running · ${a.total} spawned`
63}
64
65/** The one-line session readout: context, agents, tools. */
66export function statsLine(usage: ClawdMonUsage | null, tools: ClawdMonTools, agents: ClawdMonAgents): string {
67 return [contextLine(usage), agentsLabel(agents), toolsLabel(tools)].join(' · ')
68}
69
70export function contextColor(percent: number | undefined, cfg: Pick<Config, 'recommendPercent' | 'dangerPercent'>): string {
71 if (percent === undefined) return GREEN
72 if (percent >= cfg.dangerPercent) return RED
73 if (percent >= cfg.recommendPercent) return AMBER
74 return GREEN
75}
76
77export function textBar(fraction: number, width: number): string {
78 const filled = Math.round(Math.max(0, Math.min(1, fraction)) * width)
79 return '█'.repeat(filled) + '░'.repeat(width - filled)
80}
81
82export function titleOf(p: Progress): string {
83 const star = p.legendary ? ' ★' : ''
84 if (p.kind === 'egg') return `Egg Lv ${p.level}${p.targetName ? ` (${p.targetName})` : ''}${star}`
85 return `${p.name} Lv ${p.level}${star}`
86}
87
88export function xpLine(p: Progress): string {
89 if (p.kind === 'egg') {
90 // Full: only a counted compact moves it on now. Which stage comes next stays a surprise.
91 return p.need > 0 ? `XP ${Math.floor(p.into)}/${Math.floor(p.need)}` : 'Ready to evolve'
92 }
93 return `XP ${Math.floor(p.into)}/${Math.floor(p.need)}`
94}
95
96export function pendingLine(p: Progress): string {
97 const pending = `Pending +${Math.floor(p.pending)} XP`
98 if (p.kind === 'egg') return pending
99 if (!p.next) return `${pending}, fully evolved`
100 return `${pending}, ${p.compacts}/${p.next.compactsNeeded} compacts for ${p.next.name} (Lv ${p.next.level})`
101}
102
103/** The advice row under the bars; '' when there is nothing to say. */
104export function adviceLine(
105 rec: Recommendation,
106 usage: ClawdMonUsage | null,
107 cfg: Pick<Config, 'dangerPercent'>,
108): string {
109 if (!rec.recommend) return ''
110 const percent = usage?.percent
111 if (percent !== undefined && percent >= cfg.dangerPercent) {
112 return `/compact recommended: pending XP decays 2% a turn at ${percent}%`
113 }
114 if (rec.reason === 'projected') return '/compact recommended: context is filling fast'
115 return `/compact recommended: context at ${percent}%`
116}
117
118export function eventToast(ev: GameEvent, dex: Dex): string {
119 const name = (id: number) => dex.species.find(s => s.id === id)?.name ?? `#${id}`
120 switch (ev.kind) {
121 case 'levelup':
122 return `Level up: Lv ${ev.level}`
123 case 'crack':
124 return 'The egg is cracking'
125 case 'egg':
126 return 'A new egg arrived — /clawd-mon switch to hatch it'
127 case 'legend':
128 return 'A legendary egg arrived ★ — /clawd-mon switch to hatch it'
129 case 'hatch':
130 return `The egg hatched: ${name(ev.speciesId)} (${rarityLabel(ev.rarity)})!`
131 case 'evolve':
132 return `${name(ev.from)} evolved into ${name(ev.to)}!`
133 }
134}
135
136/** Dim row beside the title: progress to the next bonus egg, and the box size once it holds more than one. */
137export function milestoneLine(o: Overview): string {
138 return `New egg (${o.milestone}/${o.goal})${o.boxCount > 1 ? ` · Box ${o.boxCount}` : ''}`
139}
140
141// ---------- actions tab ----------
142
143const CONTEXT_TOOLS = new Set(['Read', 'Grep', 'Glob', 'LSP', 'ToolSearch'])
144const EDIT_TOOLS = new Set(['Edit', 'MultiEdit', 'Write', 'NotebookEdit'])
145const SHELL_TOOLS = new Set(['Bash', 'PowerShell', 'Monitor'])
146const WEB_TOOLS = new Set(['WebSearch', 'WebFetch'])
147const PLAN_TOOLS = new Set(['TodoWrite', 'TaskCreate', 'TaskUpdate', 'TaskList', 'TaskGet'])
148/** Agent statuses that still get a row. */
149const LIVE = new Set<ClawdMonAgent['status']>(['pending', 'running', 'waiting', 'idle'])
150
151/** A tool call in flight, as a few words of what the loop is doing. */
152export function activityLabel(tool: string, skill?: string): string {
153 if (tool === 'Skill') return `Using ${skill || 'a'} skill`
154 if (CONTEXT_TOOLS.has(tool)) return 'Gathering context'
155 if (EDIT_TOOLS.has(tool)) return 'Editing files'
156 if (SHELL_TOOLS.has(tool)) return 'Running commands'
157 if (WEB_TOOLS.has(tool) || tool.startsWith('mcp__')) return 'Researching'
158 if (tool === 'Agent' || tool === 'Task') return 'Delegating to agents'
159 if (PLAN_TOOLS.has(tool)) return 'Planning'
160 return `Using ${tool}`
161}
162
163function clip(s: string, width: number): string {
164 return s.length <= width ? s : `${s.slice(0, Math.max(0, width - 1)).trimEnd()}…`
165}
166
167function lowerFirst(s: string): string {
168 return s ? s[0]!.toLowerCase() + s.slice(1) : s
169}
170
171/**
172 * The Actions tab: "Current action - ..." then one row per live agent, at most `budget` rows,
173 * each clipped to `width`. Agents past the budget fold into "(+N more)" on the last row.
174 */
175export function actionsRows(act: ClawdMonActivity, agents: ClawdMonAgent[], budget: number, width: number): string[] {
176 const live = agents.filter(a => LIVE.has(a.status))
177 const waitingOn = (id: string) => {
178 const n = live.findIndex(a => a.id === id) + 1
179 return n > 0 ? `Waiting on Agent ${n}` : 'Waiting on an agent'
180 }
181 const own = act.doing['']
182 const main =
183 act.task ?? (own?.waitingOn ? waitingOn(own.waitingOn) : own ? activityLabel(own.tool, own.skill) : act.busy ? 'Thinking' : 'Idle')
184 const agentRow = (a: ClawdMonAgent, i: number) => {
185 const d = act.doing[a.id]
186 const what = d?.waitingOn
187 ? waitingOn(d.waitingOn)
188 : d?.tool === 'Skill'
189 ? `${activityLabel('Skill', d.skill)}${a.description ? ` to ${lowerFirst(a.description)}` : ''}`
190 : d
191 ? `${activityLabel(d.tool)}${a.description ? ` · ${a.description}` : ''}`
192 : a.status === 'idle' || a.status === 'waiting'
193 ? 'Waiting'
194 : a.status === 'pending'
195 ? 'Starting'
196 : a.description || 'Working'
197 return `Agent ${i + 1} - ${a.name ? `${a.name} - ` : ''}${what}`
198 }
199 const rows = [`Current action - ${main}`, ...live.map(agentRow)]
200 const keep = Math.max(1, budget)
201 if (rows.length <= keep) return rows.map(r => clip(r, width))
202 const more = ` (+${rows.length - keep} more)`
203 return rows.slice(0, keep).map((r, i) => (i === keep - 1 ? `${clip(r, width - more.length)}${more}` : clip(r, width)))
204}
205
206// ---------- in-band help ----------
207
208/** What the help says, without level spoilers. */
209export function hintRules(cfg: Pick<Config, 'dangerPercent'>): string[] {
210 return [
211 '/compact to level up: it applies pending XP. Only compacts at 40%+ context count.',
212 `Pending XP decays 2%/turn at ${cfg.dangerPercent}%+ context.`,
213 'Only the active Pokémon or egg gains XP.',
214 'New egg: fully evolve the active one, or every 25 counted compacts.',
215 ]
216}
217
218export const HINT_COMMANDS: readonly string[] = [
219 '/clawd-mon show: draw the band',
220 '/clawd-mon hide: hide it (× too)',
221 '/clawd-mon hint: open this help',
222 '/clawd-mon dex: your catalog, legendaries included',
223 '/clawd-mon status: active, box, totals',
224 '/clawd-mon box: list your Pokémon and eggs',
225 '/clawd-mon switch <name|#>: make one active',
226 '/clawd-mon release <name|#> confirm: remove one',
227 '/clawd-mon choose <name>: pick the starter egg (starter only)',
228 '/clawd-mon branch <name>: pick an evolution',
229 '/clawd-mon reset-all confirm: wipe everything',
230]
231
232/** All commands on one row, for tight spaces. */
233export const HINT_COMMANDS_ONE_ROW =
234 'show · hide · hint · dex · status · box · switch <name|#> · release <name|#> confirm · choose <name> (starter only) · branch <name> · reset-all confirm'
235
236/**
237 * The help rows that fit in `budget` rows (Cancel is drawn apart and always stays).
238 * Roomy: rules, then one command per row. Tight: commands collapse to one row. Tighter: that
239 * row comes first and rules are dropped from the end.
240 */
241export function hintRows(cfg: Pick<Config, 'dangerPercent'>, budget: number): string[] {
242 const rules = hintRules(cfg)
243 const full = [...rules, ...HINT_COMMANDS]
244 if (budget >= full.length) return full
245 const compact = [...rules, `Commands: ${HINT_COMMANDS_ONE_ROW}`]
246 if (budget >= compact.length) return compact
247 if (budget <= 0) return []
248 return [`Commands: ${HINT_COMMANDS_ONE_ROW}`, ...rules].slice(0, budget)
249}
250
251// ---------- dex ----------
252
253/** One legendary as a single text row, for chat and the terminal. */
254export function dexRowLine(r: DexRow): string {
255 const state = r.earned ? 'earned' : r.locked ? 'locked' : `${r.progress?.n ?? 0}/${r.progress?.goal ?? 0}`
256 return `${r.earned ? '★ ' : ''}${r.name} · ${r.lore} · ${r.goal} · ${state}`
257}
258
259/** Header and counts line, then as many legendary rows as fit in `budget` rows (the first two always stay). */
260export function dexRows(v: DexView, budget: number): string[] {
261 const header = `Dex ${v.caught}/${v.total} · ★ ${v.legendsOwned}/${v.legendsTotal}`
262 const counts =
263 `Starters ${v.starters.n}/${v.starters.total} · Common ${v.common.n}/${v.common.total} · ` +
264 `Uncommon ${v.uncommon.n}/${v.uncommon.total} · Rare ${v.rare.n}/${v.rare.total}`
265 return [header, counts, ...v.rows.map(dexRowLine)].slice(0, Math.max(2, budget))
266}
267
268// ---------- sprite ----------
269
270/** viewBox [x, y, side, side]: the content box padded to a square, plus 2 px a side. */
271export function spriteViewBox(bounds: Box | null | undefined): [number, number, number, number] {
272 if (!bounds) return [0, 0, CANVAS_PX, CANVAS_PX]
273 const [x, y, w, h] = bounds
274 const side = Math.max(w, h) + 4
275 return [Math.round(x + w / 2 - side / 2), Math.round(y + h / 2 - side / 2), side, side]
276}
277
278// Crack lines as fractions of the egg's content box, one polyline each.
279const CRACKS: ReadonlyArray<ReadonlyArray<readonly [number, number]>> = [
280 [[0.55, 0.12], [0.45, 0.26], [0.58, 0.38], [0.46, 0.52], [0.56, 0.64]],
281 [[0.9, 0.48], [0.75, 0.55], [0.8, 0.68], [0.66, 0.74], [0.7, 0.86]],
282]
283
284/** Dark jagged crack line(s) over the egg: one for stage 1, two for stage 2. */
285export function crackOverlay(bounds: Box | null | undefined, stage: number): string {
286 if (stage <= 0) return ''
287 const [bx, by, bw, bh] = bounds ?? [0, 0, CANVAS_PX, CANVAS_PX]
288 const width = Math.max(1, Math.round(bw / 18))
289 return CRACKS.slice(0, Math.min(2, stage))
290 .map(line => {
291 const pts = line.map(([fx, fy]) => `${Math.round(bx + fx * bw)},${Math.round(by + fy * bh)}`).join(' ')
292 return (
293 `<polyline points="${pts}" fill="none" stroke="#1d1d1d" stroke-width="${width}" ` +
294 `stroke-linejoin="miter" stroke-linecap="square" shape-rendering="crispEdges"/>`
295 )
296 })
297 .join('')
298}
299
300export type SpriteArgs = {
301 base64: string
302 bounds?: Box | null
303 /** Drawn size in CSS px (square). */
304 size: number
305 animate: boolean
306 /** Egg cracks to draw, 0-2. */
307 crack?: number
308 /** Paint the sprite solid black (an unearned legendary). */
309 silhouette?: boolean
310}
311
312/** The sprite as an SVG: the PNG cropped by viewBox to its content, crisp, with egg cracks on top. */
313export function spriteSvg({ base64, bounds, size, animate, crack = 0, silhouette = false }: SpriteArgs): string {
314 const [vx, vy, vw, vh] = spriteViewBox(bounds)
315 const css = animate
316 ? '.bob{animation:bob 2.4s ease-in-out infinite}' +
317 '@keyframes bob{0%,100%{transform:translateY(2px)}50%{transform:translateY(0)}}' +
318 '@media (prefers-reduced-motion: reduce){.bob{animation:none}}'
319 : ''
320 return (
321 `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="${vx} ${vy} ${vw} ${vh}">` +
322 `<style>${css}</style>` +
323 (silhouette
324 ? '<filter id="sil"><feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0"/></filter>'
325 : '') +
326 `<g class="bob"><image href="data:image/png;base64,${base64}" x="0" y="0" width="${CANVAS_PX}" height="${CANVAS_PX}" style="image-rendering:pixelated"${silhouette ? ' filter="url(#sil)"' : ''}/>` +
327 `${crackOverlay(bounds, crack)}</g></svg>`
328 )
329}
330types/index.d.ts 163 lines1// State contract for the Clawd-mon mod. Every value here lives in the host's
2// $.state, so it survives hot reloads of the hooks module. The durable copy of
3// the save is in $.store, shared by every session.
4
5/** Lifetime totals; wiped only by `reset-all`. */
6export type ClawdMonLifetime = {
7 tokens: number
8 xp: number
9 compacts: number
10 hatches: number
11 evolutions: number
12 /** Finished lines: final-form evolutions plus single-stage Lv 40 claims. */
13 fullLines: number
14}
15
16/** One Pokémon or egg in the box. */
17export type ClawdMonEntry = {
18 /** Short uid, unique in the box. */
19 id: string
20 phase: 'egg' | 'mon'
21 /** Egg: the species inside, rolled when the egg was made. */
22 target: number | null
23 /** Pokedex id of the hatched Pokémon; null while an egg. */
24 speciesId: number | null
25 /** True when the player picked the egg's species (its name may then show). */
26 chosen: boolean
27 /** Egg: raw applied XP. Mon: effective XP (already dragged by base stats). */
28 xp: number
29 /** Egg: cracks so far (0-2). */
30 eggStage: 0 | 1 | 2
31 /** Qualifying compacts since hatch. */
32 compacts: number
33 /** Evolution picked for a branching species. */
34 branch: number | null
35 origin: 'starter' | 'evolved' | 'milestone' | 'mastery' | 'legendary'
36 /** A single-stage species already paid out its Lv 40 egg. */
37 eggClaimed: boolean
38}
39
40/** Counters behind the legendary goals. */
41export type ClawdMonStreaks = {
42 /** Finished days in a row with a main turn and context under the danger percent. */
43 coolDays: number
44 /** The day (UTC YYYY-MM-DD) of the latest main turn; its fate is settled when a later day starts. */
45 coolDay: string | null
46 /** That day reached the danger percent. */
47 coolBroken: boolean
48 /** Qualifying non-auto compacts in a row; an auto-compact resets it. */
49 strike: number
50}
51
52/** The whole save: the box, the shared pending bank and the lifetime totals. */
53export type ClawdMonSave = {
54 version: 2
55 /** Bumped on every write; a writer that finds it moved re-reads and retries. */
56 rev: number
57 /** Banked tokens as XP, not yet applied. Goes to whoever is active at compact time. */
58 pending: number
59 /** Counted compacts toward the next bonus egg, 0-24. */
60 milestone: number
61 activeId: string
62 box: ClawdMonEntry[]
63 lifetime: ClawdMonLifetime
64 /** Species caught (hatched, or evolved into), by Pokedex id, ascending. */
65 dex: number[]
66 /** Legendary eggs earned this save (never twice), by Pokedex id. */
67 legendsEarned: number[]
68 streaks: ClawdMonStreaks
69 /** Raw XP banked per UTC day (YYYY-MM-DD), last 14 days. */
70 daily: Record<string, number>
71 /** Recent positive context growth per turn, in tokens, last 5. */
72 growth: number[]
73 /** Context tokens at the last observation. */
74 lastContext: number | null
75}
76
77/** Context window fill, as the status line reads it. */
78export type ClawdMonUsage = {
79 /** Input tokens of the last response; absent before the first response. */
80 tokens?: number
81 /** The model's context window, in tokens. */
82 window: number
83 /** tokens / window, 0-100; absent before the first response. */
84 percent?: number
85}
86
87/** Tools: what the model can call now, and what this session called. */
88export type ClawdMonTools = {
89 /** Tools the model can call now ($.tool.list()); null until first read. */
90 available: number | null
91 /** Of `available`, how many are MCP tools. */
92 mcp: number
93 /** Distinct tool names called this session (main loop and subagents). */
94 usedNames: string[]
95 /** Tool calls this session (main loop and subagents). */
96 calls: number
97 /** True once the count was seeded from the transcript (calls before load). */
98 seeded: boolean
99}
100
101/** One subagent or teammate as the Actions tab needs it. */
102export type ClawdMonAgent = {
103 id: string
104 name?: string
105 type: string
106 /** The Agent call's few-word description of its task. */
107 description: string
108 status: 'pending' | 'running' | 'waiting' | 'idle' | 'completed' | 'failed' | 'killed'
109}
110
111/** Agents: subagents and teammates of this session ($.agent.list()). */
112export type ClawdMonAgents = {
113 /** null until first read. */
114 running: number | null
115 total: number | null
116 /** In list order; the Actions tab numbers the live ones. */
117 list: ClawdMonAgent[]
118}
119
120/** What one loop is doing right now: the tool call in flight, or the agent it waits on. */
121export type ClawdMonDoing = {
122 /** The tool_use_id that set it; only that call's end clears it. */
123 callId: string
124 tool: string
125 /** The Skill tool's skill name. */
126 skill?: string
127 /** A foreground agent this loop is blocked on. */
128 waitingOn?: string
129}
130
131/** The Actions tab: a summary of what is happening now, never a log. */
132export type ClawdMonActivity = {
133 /** Per loop: '' is the main loop, otherwise the agent id. */
134 doing: Record<string, ClawdMonDoing>
135 /** The main loop is mid-turn. */
136 busy: boolean
137 /** The in-progress task's present-tense text (TodoWrite / TaskUpdate), or null. */
138 task: string | null
139 /** TaskCreate'd tasks by id, so a TaskUpdate can name its task. */
140 tasks: Record<string, { text: string; status: string }>
141}
142
143declare module 'claude-code' {
144 interface PluginState {
145 'clawd-mon': {
146 /** The save this session works from; null until loaded. */
147 save: ClawdMonSave | null
148 usage: ClawdMonUsage | null
149 isHidden: boolean
150 /** This session: the dex view is open (closes the help, and the reverse). */
151 dexOpen: boolean
152 /** This session: the in-band help is open. */
153 hintOpen: boolean
154 /** Per session, not shared: tools and agents of this session. */
155 tools: ClawdMonTools
156 agents: ClawdMonAgents
157 /** This session: the band's tab; Actions by default. */
158 tab: 'actions' | 'levels'
159 activity: ClawdMonActivity
160 }
161 }
162}
163