SLOPSHOPPER

clawd-mon

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

newbandguardcommandtoasttimer
v0.5.0MITupdated 2026-10-07MichaelNguyen5653/clawd-mon
A shopper browsing a rack in a slop shop
README

Clawd-mon

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.

Install

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:

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.

Rules

WhatRule
XP sourceNew tokens per turn (input + output + cache writes; cache reads excluded). xpRate XP per 1,000 tokens (default 1). Fallback: positive context growth
PendingTokens bank as pending. Not applied until a compact
ApplyManual /compact / plugin: 100%. Auto-compact: 50%, rest forfeited. The band shows /compact to level up (amber when recommended)
DecayContext ≥ dangerPercent (80): pending −2% per completed turn
RecommendContext ≥ recommendPercent (60), or avg growth (last 5 turns) projects ≥ 80% within 3 turns
Qualifying compactContext ≥ 40% when it ran. Counts toward gates. Lower: XP still applies
EggSpecies 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
RarityPer 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
LevelSpecies growth table (fast/medium/medium-slow/slow). Applied XP × clamp((320/BST)^0.35, 0.6, 1.1). Lv 1–100
EvolutionLevel ≥ species level AND qualifying compacts since hatch: 3 (first), 8 (second). Item: Lv 30. Trade: Lv 36
BranchesEevee etc.: first by default, /clawd-mon branch <name> picks
BoxMany 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
PaceBulbasaur → 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.

Band tabs

Two tabs at the start of the title row (selected bright, other dim). Actions is the default each session.

TabShows
ActionsTitle 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
LevelsThe 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.

ToolLabel
Read, Grep, Glob, LSP, ToolSearchGathering context
Edit, MultiEdit, Write, NotebookEditEditing files
Bash, PowerShell, MonitorRunning commands
WebSearch, WebFetch, mcp__*Researching
AgentDelegating to agents
TodoWrite, Task*Planning
SkillUsing <skill> skill (agent rows: … to <its task>)
otherUsing <tool>

Agent rows with no tool in flight: Waiting (idle teammate), Starting, else the agent's task description.

Commands

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

Legendaries

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.

LegendaryLoreGoal
ArticunoFrozen bird of legendStay 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
ZapdosStorm bird of legendStrike first: 30 qualifying compacts in a row without an auto-compact. Any auto-compact resets to 0
MoltresPhoenix bird of legendRebirth: fully evolve 3 lines (final-form evolutions and single-stage Lv 40 claims)
MewtwoBorn in a lab from a legend's DNALocked until all three birds are owned. Then 10 fully evolved lines

Config

Plugin options (/config):

KeyDefault
xpRate1XP per 1,000 tokens
recommendPercent60recommend /compact at this context %
dangerPercent80decay starts at this context %

Dev

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.

Layout

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 .

Credits

  • Data: claudemon by Sergio Zamarro, MIT (data/CLAUDEMON-LICENSE).
  • Sprites: PokeAPI/sprites.
  • Pokémon © Nintendo / Game Freak / Creatures. Unofficial fan project, not affiliated.
  • Code: MIT, see LICENSE.

Tip: add assets/sprites/ to .gitignore to keep the art out of your repo. Missing sprite = band still works, no picture.

Source 4 files
hooks/register.tsx 759 lines
1// 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}
759
hooks/engine.ts 1116 lines
1// 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}
1116
hooks/view.ts 330 lines
1// 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}
330
types/index.d.ts 163 lines
1// 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