SLOPSHOPPER

cappl-pals

LOCKI and CAPPL from the CAPPL app peek over your Claude Code prompt, react to what your session is doing and grow with your work: passing tests, commits and…

newpanespinnerguardcommandtoast
★ 1v0.2.4MITupdated 2026-10-23develinu/cappl-pals/mod
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · cappl-pals
│ ┃ CAPPL ✕ › fix the failing auth test and add an audit log call │ ┃ Pick your partner │ ┃ Click, or ctrl+x Tab for the keyboard ⏺ Read(src/auth.ts) │ ┃ ▀▄ ▄▀▀▄▄▀▀▄ ▄▀ ▀▀▀▄▄▀▀▀ ⎿ Read 6 lines │ ┃ ▀▀▀▄▀▀▀▀▀▀▀▀▄▀▀▀ ▀▀▀▀▀▀▀▀ ⏺ Update(src/auth.ts) │ ┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▄▄▄▀▀▀▀▀▀▀▀▄▄▄▀ ⎿ Added 2 lines, removed 1 line │ ┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀▄ ▀▀▀▀▀▀▀▀▀▀▀▀▀▄ ⏺ Bash(bun test) │ ┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ⎿ 3 pass, 1 fail │ ┃ ▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀ │ ┃ ▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ 1. LOCKI 2. CAPPL │ ┃ Wants to be A drifter in a ✻ Worked for 42s · done 4:20 PM │ ┃ sheriff. Bright, wide brown hat. │ ┃ bouncy, never Slow, steady, › /cappl │ ┃ without the pink always humming a ⎿ cappl-pals: Opened the CAPPL pals pane. /cappl closes it. │ ┃ star. tune. │ ┃ │ ┃ [ Pick LOCKI ] │ ┃ [ Pick CAPPL ] │ ┃ [ Close (/cappl) ] │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · CAPPL
Pick your partner Click, or ctrl+x Tab for the keyboard ▀▄ ▄▀▀▄▄▀▀▄ ▄▀ ▀▀▀▄▄▀▀▀ ▀▀▀▄▀▀▀▀▀▀▀▀▄▀▀▀ ▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▄▄▄▀▀▀▀▀▀▀▀▄▄▄▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▄ ▀▀▀▀▀▀▀▀▀▀▀▀▀▄ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀ 1. LOCKI 2. CAPPL Wants to be A drifter in a sheriff. Bright, wide brown hat. bouncy, never Slow, steady, without the pink always humming a star. tune. [ Pick LOCKI ] [ Pick CAPPL ] [ Close (/cappl) ]
README

cappl-pals

LOCKI and CAPPL, the cowboy-hat pals of the CAPPL app, grow with your Claude Code work. Pick one. They peek over your prompt and follow your session: a thought bubble while Claude thinks, a busy tongue while it edits, a nervous sweat while tests run, a nap when you step away. They cheer when a test passes, a commit lands or a pull request merges. Pet them, ask for a trick, or buy them a treat. Results earn rep and gold, and level chests, daily bounties and lucky nuggets add more gold. Rep raises your rank from Greenhorn to Legend of the West and teaches new tricks; gold buys hats, shades, banjos and backdrops.

Moods, a failing test, a level-up, petting, a treat and a new hat (12-second highlight)

▶ Full demo with sound (1.5 min): English · 한국어

Website: develinu.github.io/cappl-pals. Pet the pals, try on the wardrobe and walk the trail to Lv50 in your browser.

The pane, toasts and commands speak English and Korean.

LOCKI and CAPPL: idle, petted, thinking, eating a treat, celebrating, asleep and dressed up

한국어 README

Install

cappl-pals is a Claude Code mod (a plugin with a hooks module). It needs Claude Code 2.1.287 or later.

/plugin install cappl-pals --marketplace develinu/cappl-pals

Answer y to add the marketplace and keep the user scope. The pane opens in the session where you install it. For sessions that were already open, run /reload-plugins. To install from a shell, run claude plugin install cappl-pals --marketplace develinu/cappl-pals.

In the desktop app, where /plugin doesn't run: click + next to the prompt, choose to add plugins, press + to add a marketplace, pick adding it from a repository, enter develinu/cappl-pals and sync, then add cappl-pals from the plugin list that appears. A plugin installed from a terminal at the user scope also loads in the desktop app's local sessions on the same computer. (Claude Code in WSL keeps its own settings, so a Windows desktop app needs its own install, and keeps its own progress.)

cappl-pals runs wherever Claude Code runs on your computer: the terminal and the desktop app's Code tab, where the pane shows the pals as an animated picture with clickable buttons. Sessions in the cloud, such as Claude Code on the web, don't load plugins from your computer, so it doesn't run there.

Turn on auto-update (recommended)

Marketplaces you add yourself don't update on their own. Turn it on once and every new version arrives by itself:

  • In Claude Code: /plugin → Marketplaces → cappl-pals → Enable auto-update
  • Or in ~/.claude/settings.json, set autoUpdate on the marketplace entry:
"extraKnownMarketplaces": {
  "cappl-pals": {
    "source": { "source": "github", "repo": "develinu/cappl-pals" },
    "autoUpdate": true
  }
}

A new version downloads in the background within about ten minutes of the first message in a session. Sessions that are already open keep the old version and show Plugin updated: cappl-pals · Run /reload-plugins to apply; run /reload-plugins there, or start a new session.

Without auto-update, update by hand with claude plugin update cappl-pals@cappl-pals, then /reload-plugins. To remove it: claude plugin uninstall cappl-pals@cappl-pals.

Use

/capplOpen or close the pane. It works mid-turn. A closed pane stays closed in later sessions.
/cappl-pickChoose your partner. /cappl-pick cappl switches right away; /cappl-pick alone opens the picker.
/cappl-statusLevel, rank, rep and gold in one line.
/cappl-rulesThe reward table and how many of each you earned today.
/cappl-soundCycle the sound: normal → quiet → off.

Every control in the pane is a button on its own row: pet, trick and treat, the shop (buy an item, or put it on and take it off), the rules and what each bounty means, your progress (today's rewards, tricks and wardrobe), switching characters, and closing. Click one, or drive the pane from the keyboard: ctrl+x then Tab gives it the keyboard, Tab and the arrow keys move between the buttons (the arrows scroll a pane taller than its window), Enter presses the one in focus, and Esc hands the keyboard back to the prompt. A hint line in the pane says which applies. On a wide fullscreen terminal (110 columns or more) the pane docks on the right. On other terminals it opens above the prompt. A low pane shows the small sprite with your stats beside it.

Several sessions share one profile. Rep, gold, recent rewards, purchases and your choice of character show up in every open pane within about two seconds.

The language follows Claude Code's language setting (Korean or English). To pin it, open /config and change cappl-pals › Language.

Moods and play

The pal changes with what the session is doing.

SessionThe pal
You send a promptPerks up with a !
Claude is thinkingPaw on chin, a … bubble
Reading or searchingEyes scanning down the page
Editing filesTongue out, paws tapping
Running a commandSwaying side to side
Running testsWide eyes, a drop of sweat
A test failsDizzy eyes, then a determined frown
Waiting for your permissionA ? bubble and a wave
A turn endsA little dance
Five minutes with nothing going onFalls asleep, z z z
Nothing else going onBlinks, looks around, stretches, yawns, hums and shows off tricks
ButtonCostWhat happens
PetFreeHeart eyes and hearts. Pet three times quickly for a happy wiggle.
TrickFreeYour pal's newest trick, then the others in turn. Each pal learns ten, from a wave at Lv2 to a big finale at Lv50.
Treat$6LOCKI eats a star cookie, CAPPL a maple biscuit. Then they blush for 10 minutes and show off tricks twice as often.

Petting and tricks never earn rep. On the desktop app the pal plays the same moves, and waves when you hover over it.

The pals

LOCKICAPPL
WhoWants to be sheriff. Bright, bouncy, never without the pink star.A drifter in a wide brown hat. Slow, steady, always humming a tune.
SoundSquare-wave chiptune, a harmonica, quick chirps, jingling spursPlucked banjo, a soft hum, hoof clops
Celebrates withTwinkling pink and gold stars, ears perkedMusic notes floating up, a tip of the hat
WardrobeAviator shades, pink feather, harmonica, heart shades, lasso, gold sheriff star, desert noon, white ten-gallon hat, desert sunset, starry night hatRound specs, wheat straw, tin cup of coffee, outlaw bandana, banjo, sparrow friend, campfire, black hat, starry night, golden banjo

Each character keeps their own wardrobe; gold is shared. Every item has art at both sprite sizes (sprite sheet).

The CAPPL app

LOCKI and CAPPL come from CAPPL, a planning and reflection app. It looks at how you like to plan, how you get things done and how your energy runs through the day, then suggests a schedule template that fits you. cappl-pals cheers for the work you finish; CAPPL helps you plan it and look back on it.

Get CAPPL on the App Store or Google Play. The characters appear here with the app's permission.

What earns rewards

Rewards go to results a person can check, never to how much Claude did. Prompts, turns, tool calls, tokens and lines of code earn nothing.

RewardWhenRepGold
BullseyeThe first passing test after you edit files5$3
Showdown wonA test fails, you fix it, it passes8$5
Claim stakedA commit (+5 rep and $3 more if tests passed since the last one, +2 rep if it is small)3$2
Clean shotA type check, lint or build with no errors after you edit files2$2
Poster upgh pr create opens a pull request4$3
Gold strikegh pr merge succeeds15$10
Trail mappedA plan approved before editing3$2
Campfire restBack from a 20-minute-plus break after an hour of work2$4
Saddled upYour first prompt of the day0$10

Gold also comes from:

BonusWhenGold
Level chestEvery level you reach$5 + the level
BountiesThree a day in the pane: one with your pal, two from your work$5–$15 each, $15 more for all three
Lucky nuggetAbout one result in sixteen$5–$15

If you earn many rewards of one kind in a day, each new one is worth half, then a fifth. There are no streaks and no penalties, and a day off costs nothing. A day starts at 05:00 on your computer's clock. Level 50 takes about 830 active hours at a steady pace. The numbers and their reasoning are in docs/reward-design.md.

Privacy

cappl-pals has no server and makes no network calls. It reads no files, and the validator's report (claude plugin validate ./mod) lists no $.http and no $.fs. It runs outside processes only to play sounds: uname, wslpath, powershell.exe, paplay, pw-play or aplay. It reads one setting, language. Progress stays in ~/.claude/plugins/store/cappl-pals_*.json on your machine. There are no leaderboards.

Tune the pace to your own history

The level curve was calibrated on 28 days (189 active hours) of one heavy user's Claude Code history. To see how fast it runs for you:

python3 -I tools/extract_events.py ~/.claude/projects .data/events.jsonl
bun tools/sim.ts .data/events.jsonl

No prompts or code are kept. For test, check, commit, pull request and merge commands only, the command and the start and end of its output go to .data/, which git ignores. To change the pace, change LEVEL_BASE in mod/hooks/rules.ts.

Develop

claude --plugin-dir ./mod              # run without installing; edits reload on save
claude plugin validate ./mod
claude plugin test ./mod
npx -p typescript@5.9 tsc -p ./mod     # after one run with --plugin-dir lays the types
python3 tools/draw_art.py --png /tmp/draft.png   # redraw the pixel art (writes mod/hooks/characters/art.ts)
python3 tools/make_sfx.py              # resynthesize the sounds (needs numpy)
bun tools/preview.ts docs/cappl-pals-sheet.png
bun site/build.ts                      # the website, into site/dist (deployed by .github/workflows/pages.yml)
PathWhat
mod/hooks/register.tsxHooks, commands, the pane
mod/hooks/rules.tsRewards, levels, the shop
mod/hooks/sprite.tsFrame composer: faces, poses, items, sparkles
mod/hooks/animations.tsSession moods, acts and tricks
mod/hooks/characters/Who the characters are (index.ts) and their pixels (art.ts, generated)
mod/hooks/i18n.tsEnglish and Korean
mod/hooks/sound.tsSound playback per platform
tools/Art, sound, preview, history replay
demo/How the demo video is made (demo/README.md)
site/The website: the page, its words in both languages and the build. The pals on it are drawn by mod/hooks

To add a character, see docs/characters.md.

To release, raise version in both mod/.claude-plugin/plugin.json and .claude-plugin/marketplace.json: auto-update only fetches a new version number.

License

The code is under the MIT License. The LOCKI and CAPPL characters and their art may be used as part of cappl-pals and its forks; any other use needs the CAPPL app's permission. See ART-LICENSE.md.

Source 9 files
hooks/register.tsx 1206 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Act, Activity, CareKind, CharacterId, Fx, FxKind, Language, Ledger, PaneView, ResultKind, Reward, RewardKind, Totals, Wallet } from '../types'
5import { ACTS, DANCES, actTicks, busySlot, idleGap, lookAt, loopTicks, type Moment } from './animations'
6import { CHARACTERS, characterOf, fullId, isCharacterId, learnedTricks, type Character } from './characters'
7import { WORDS, languageOf } from './i18n'
8import {
9  BOUNTY_BONUS,
10  CONTINUOUS_GAP_MS,
11  FRESH_WORK,
12  RULES,
13  SHOP_SIZE,
14  buy,
15  checkOutcome,
16  chestFor,
17  chestsBetween,
18  commitOutcome,
19  countToday,
20  credit,
21  dayOf,
22  emptyLedger,
23  emptyWallet,
24  fansOf,
25  isBreakOver,
26  levelOf,
27  luckyNugget,
28  mergeOutcome,
29  observe,
30  prOutcome,
31  isTestCommand,
32  questsOf,
33  rankOf,
34  rateOf,
35  readBounty,
36  readLedger,
37  readWallet,
38  seedOf,
39  settleBounty,
40  tally,
41  testOutcome,
42  totalsOf,
43  touch,
44  valueOf,
45  wear,
46  wornBy,
47  type Earn,
48  type Quest,
49  type Signal,
50} from './rules'
51import { SOUND_LEVELS, play, soundLevel, type Sfx, type SoundLevel } from './sound'
52import { animatedSvgOf, encode, frame, type Size } from './sprite'
53
54const PANE = 'cappl-pals'
55const SPRITE = 'sprite'
56const TICK_MS = 250
57const EDIT_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit'])
58const READ_TOOLS = new Set(['Read', 'Grep', 'Glob', 'LS', 'WebFetch', 'WebSearch', 'NotebookRead', 'ToolSearch'])
59const STALE_MS = 2 * 24 * 60 * 60_000
60// Five quiet minutes and the pal dozes off.
61const SLEEP_MS = 5 * 60_000
62const GLOW_MS = 10 * 60_000
63// The header (fx line, level, rep, gold) and the narrowest column it fits beside the
64// small sprite (seven rows tall) even when its lines wrap.
65const HEADER_ROWS = 4
66const HEADER_COLUMNS = 16
67// The big sprite (14 rows) only when the header and a few rows below it fit too.
68const BIG_ROWS = 14 + HEADER_ROWS + 6
69// Rows the rules view and my progress take (progress adds a row per reward kind earned today).
70const RULES_ROWS = 44
71// The rules' name-and-value rows stay this narrow on a wide pane, so the eye need not travel.
72const RULES_WIDTH = 52
73const PROGRESS_ROWS = 22
74// The height asked for when /cappl opens the pane above the prompt (the terminal may give less).
75const INLINE_ROWS = 30
76// How often other sessions' rewards and purchases are read back; the store sends no change events.
77const SYNC_MS = 1500
78// Latest rewards kept per session ledger (the pane shows four, all sessions merged).
79const RECENT = 8
80const FX_MS: Record<FxKind, number> = { reward: 1200, comeback: 1600, merge: 2000, level: 2600, stage: 3200, buy: 1500 }
81const FX_COLOR: Record<FxKind, string> = {
82  reward: '#FCD34D', comeback: '#F472B6', merge: '#60A5FA', level: '#F59E0B', stage: '#F59E0B', buy: '#86EFAC',
83}
84
85const totalsAtom = atom({ plugin: 'cappl-pals', key: 'totals' } as const, { fame: 0, dust: 0 })
86const walletAtom = atom({ plugin: 'cappl-pals', key: 'wallet' } as const, emptyWallet())
87const logAtom = atom({ plugin: 'cappl-pals', key: 'log' } as const, [])
88const activityAtom = atom({ plugin: 'cappl-pals', key: 'activity' } as const, 'idle' as Activity)
89const actAtom = atom({ plugin: 'cappl-pals', key: 'act' } as const, null as Act | null)
90const glowAtom = atom({ plugin: 'cappl-pals', key: 'glowUntil' } as const, 0)
91const turnFameAtom = atom({ plugin: 'cappl-pals', key: 'turnFame' } as const, 0)
92const fxAtom = atom({ plugin: 'cappl-pals', key: 'fx' } as const, null as Fx | null)
93const characterAtom = atom({ plugin: 'cappl-pals', key: 'character' } as const, null as CharacterId | null)
94const pickingAtom = atom({ plugin: 'cappl-pals', key: 'picking' } as const, false)
95const viewAtom = atom({ plugin: 'cappl-pals', key: 'view' } as const, 'main' as PaneView)
96
97// Module variables start over on a hot reload: what the pane reads lives in $.state,
98// what must last lives in $.store.
99const runtime = {
100  key: 'ledger:unknown',
101  work: FRESH_WORK,
102  size: 32 as Size,
103  isDrawn: false,
104  tick: 0,
105  drawn: '',
106  language: 'en' as Language,
107  // Minutes east of UTC on this computer's clock.
108  offset: 0,
109  // The totals, wallet, log and character last published; unchanged ones are not redrawn.
110  synced: '',
111  isSyncing: false,
112  // The terminal tick the current act started on, so its frames run from the start.
113  actTick: 0,
114  isTurn: false,
115  turns: 0,
116  lastActiveAt: 0,
117  pets: [] as number[],
118  tricks: 0,
119  // Which idle stretch a desktop loop shows; moves on with each redraw for variety.
120  svgSeed: 0,
121  // Serializes this session's writes. Each session writes its own ledger; the few shared
122  // keys (wallet, chestLevel, bounty:<day>) are each read and written straight back,
123  // which keeps the window for two sessions to collide to a few milliseconds.
124  queue: Promise.resolve() as Promise<unknown>,
125}
126
127const words = () => WORDS[runtime.language]
128
129type World = { own: Ledger; ledgers: Ledger[]; archive: Totals; wallet: Wallet; character: CharacterId | null }
130
131async function loadWorld($: EngineInterface): Promise<World> {
132  const keys = await $.store.keys()
133  const ledgers: Ledger[] = []
134  let own = emptyLedger()
135  for (const key of keys) {
136    if (!key.startsWith('ledger:')) continue
137    const ledger = readLedger(await $.store.get(key))
138    ledgers.push(ledger)
139    if (key === runtime.key) own = ledger
140  }
141  const archive = readLedger(await $.store.get('archive'))
142  const wallet = readWallet(await $.store.get('wallet'))
143  const stored = await $.store.get('character')
144
145  return {
146    own,
147    ledgers,
148    archive: { fame: archive.fame, dust: archive.dust },
149    wallet,
150    character: isCharacterId(stored) ? stored : null,
151  }
152}
153
154async function publish($: EngineInterface, world: World) {
155  const totals = totalsOf([...world.ledgers, { ...emptyLedger(), ...world.archive }], world.wallet)
156  const log = world.ledgers
157    .flatMap(ledger => ledger.recent)
158    .sort((a, b) => a.at - b.at)
159    .slice(-20)
160  const signature = JSON.stringify([totals, world.wallet, log, world.character, world.ledgers.map(ledger => ledger.days)])
161  if (signature !== runtime.synced) {
162    runtime.synced = signature
163    await update($, totalsAtom, () => totals)
164    await update($, walletAtom, () => world.wallet)
165    await update($, logAtom, () => log)
166    await update($, characterAtom, () => world.character)
167  }
168
169  return totals
170}
171
172// Each session writes only its own ledger, so another session's rewards and
173// purchases show up here only by reading the store again.
174function sync($: EngineInterface): Promise<unknown> {
175  if (runtime.isSyncing) return runtime.queue
176  runtime.isSyncing = true
177  runtime.queue = runtime.queue
178    .then(async () => publish($, await loadWorld($)))
179    .catch(() => undefined)
180    .finally(() => {
181      runtime.isSyncing = false
182    })
183
184  return runtime.queue
185}
186
187async function saveOwn($: EngineInterface, world: World, own: Ledger): Promise<World> {
188  await $.store.set(runtime.key, own)
189  const ledgers = world.ledgers.filter(ledger => ledger !== world.own)
190
191  return { ...world, own, ledgers: [...ledgers, own] }
192}
193
194function enqueue($: EngineInterface, job: () => Promise<unknown>): Promise<unknown> {
195  runtime.queue = runtime.queue.then(job).catch(() => undefined)
196
197  return runtime.queue
198}
199
200// ── Moods and acts ──────────────────────────────────────────────────────────
201
202async function setActivity($: EngineInterface, activity: Activity) {
203  runtime.lastActiveAt = await $.clock.now()
204  if ((await read($, activityAtom)) !== activity) await update($, activityAtom, () => activity)
205}
206
207// Starts a one-off animation; it clears itself when its frames run out.
208async function playAct($: EngineInterface, name: string) {
209  const ticks = actTicks(name)
210  if (ticks === 0) return
211  const at = await $.clock.now()
212  runtime.actTick = runtime.tick
213  runtime.lastActiveAt = at
214  await update($, actAtom, () => ({ name, at }))
215  $.clock.after(ticks * TICK_MS + 50, () => void update($, actAtom, act => (act?.at === at ? null : act)))
216}
217
218// Five quiet minutes: asleep. Any activity wakes the pal again.
219async function checkSleep($: EngineInterface) {
220  const now = await $.clock.now()
221  const activity = await read($, activityAtom)
222  if (activity === 'idle' && runtime.lastActiveAt > 0 && now - runtime.lastActiveAt >= SLEEP_MS) {
223    await update($, activityAtom, () => 'sleeping' as Activity)
224  }
225}
226
227function learned(character: Character, totals: Totals): string[] {
228  return learnedTricks(character, levelOf(totals.fame).level).map(trick => trick.act)
229}
230
231// ── Rewards ─────────────────────────────────────────────────────────────────
232
233function award($: EngineInterface, earn: Earn): Promise<unknown> {
234  return enqueue($, () => grant($, earn))
235}
236
237// Credits a reward or bonus to this session's ledger and returns the new world.
238async function pay($: EngineInterface, world: World, kind: RewardKind, fame: number, dust: number, rate = 1) {
239  const now = await $.clock.now()
240  const day = dayOf(now, runtime.offset)
241  const reward: Reward = { at: now, kind, fame, dust, rate }
242  const own = credit(world.own, kind, day, fame, dust)
243
244  return saveOwn($, world, { ...own, recent: [...own.recent, reward].slice(-RECENT) })
245}
246
247async function grant($: EngineInterface, earn: Earn) {
248  const now = await $.clock.now()
249  const day = dayOf(now, runtime.offset)
250  let world = await loadWorld($)
251  const count = countToday(world.ledgers, earn.kind, day)
252  const value = valueOf(earn, count)
253  if (value.rate === 0) return
254  const before = await read($, totalsAtom)
255  world = await pay($, world, earn.kind, value.fame, value.dust, value.rate)
256  const character = characterOf(world.character)
257  const reward: Reward = { at: now, kind: earn.kind, ...value }
258  // Now and then a result turns up a lucky nugget.
259  const nugget = earn.kind === 'daily' ? 0 : luckyNugget(seedOf(`${runtime.key}:${now}:${count}`))
260  if (nugget > 0) world = await pay($, world, 'lucky', 0, nugget)
261  let after = await publish($, world)
262  await update($, turnFameAtom, n => n + value.fame)
263  const levelled = await openChests($, world, before, after)
264  if (levelled !== undefined) {
265    world = levelled.world
266    after = levelled.totals
267  }
268  await celebrate($, before, after, reward, character)
269  if (nugget > 0) {
270    $.ui.toast(words().luckyToast(nugget))
271    $.clock.after(2200, () => void enqueue($, async () => {
272      await playAct($, 'lucky')
273      await play($, 'lucky', character.id)
274    }))
275  }
276  await settleQuests($)
277}
278
279// Every new level opens a chest. `chestLevel` remembers the last level paid, across sessions.
280async function openChests($: EngineInterface, world: World, before: Totals, after: Totals) {
281  const level = levelOf(after.fame).level
282  if (level <= levelOf(before.fame).level) return undefined
283  const paid = Number((await $.store.get('chestLevel')) ?? levelOf(before.fame).level)
284  if (level <= paid) return undefined
285  await $.store.set('chestLevel', level)
286  const gold = chestsBetween(paid, level)
287  const next = await pay($, world, 'chest', 0, gold)
288  $.ui.toast(words().chestToast(level, gold))
289  const id = characterOf(world.character).id
290  $.clock.after(1800, () => void play($, 'chest', id))
291
292  return { world: next, totals: await publish($, next) }
293}
294
295// A reward: one sound, a sparkle around the character, a line in the pane, an act,
296// and a toast for big ones.
297async function celebrate($: EngineInterface, before: Totals, after: Totals, reward: Reward, character: Character) {
298  const w = words()
299  const label = w.rewards[reward.kind]
300  const was = levelOf(before.fame).level
301  const now = levelOf(after.fame).level
302  if (now > was) {
303    const rank = w.ranks[rankOf(now)]!
304    const isNewRank = rankOf(now) !== rankOf(was)
305    $.ui.toast(isNewRank ? w.rankToast(now, rank) : w.levelToast(now, label))
306    const unlocked = character.items.filter(item => item.level > was && item.level <= now)
307    if (unlocked.length > 0) $.ui.toast(w.unlocked(unlocked.map(item => item.name[runtime.language]).join(', ')))
308    await flash($, isNewRank ? 'stage' : 'level', isNewRank ? w.newRank(rank) : w.levelUp(now))
309    await playAct($, 'bigCelebrate')
310    await play($, isNewRank ? 'stage' : 'levelup', character.id)
311    // A new trick: show it right after the cheering.
312    const trick = character.tricks.find(one => one.level > was && one.level <= now)
313    if (trick !== undefined) {
314      $.ui.toast(w.learned(trick.name[runtime.language]))
315      $.clock.after(actTicks('bigCelebrate') * TICK_MS + 200, () => void enqueue($, async () => {
316        await playAct($, trick.act)
317        await play($, 'trick', character.id)
318      }))
319    }
320    return
321  }
322  const isBig = reward.kind === 'merge' || reward.kind === 'comeback'
323  if (isBig) $.ui.toast(w.bigToast(label, reward.fame, reward.dust))
324  const kind: FxKind = reward.kind === 'merge' ? 'merge' : reward.kind === 'comeback' ? 'comeback' : 'reward'
325  const sound: Sfx = reward.kind === 'merge' ? 'fanfare' : reward.kind === 'comeback' ? 'comeback' : 'coin'
326  const gain = reward.fame > 0 ? w.repGain(reward.fame) : `+$${reward.dust}`
327  await flash($, kind, w.rewardLine(label, gain) + (kind === 'reward' ? '' : '!'))
328  await playAct($, isBig ? 'bigCelebrate' : 'celebrate')
329  await play($, sound, character.id)
330}
331
332async function flash($: EngineInterface, kind: FxKind, label: string) {
333  const at = await $.clock.now()
334  await update($, fxAtom, () => ({ kind, label, at }))
335  $.clock.after(FX_MS[kind], () => void update($, fxAtom, fx => (fx?.at === at ? null : fx)))
336}
337
338// Pays any of the day's bounties that are now done (the shared `bounty:<day>` key
339// remembers what was paid), and the bonus once all three are.
340async function settleQuests($: EngineInterface) {
341  const now = await $.clock.now()
342  const day = dayOf(now, runtime.offset)
343  let world = await loadWorld($)
344  const quests = questsOf(day)
345  const key = `bounty:${day}`
346  const settled = settleBounty(quests, kind => countToday(world.ledgers, kind, day), readBounty(await $.store.get(key)))
347  if (settled.finished.length === 0 && !settled.isBonus) return
348  await $.store.set(key, settled.bounty)
349  const w = words()
350  for (const quest of settled.finished) {
351    world = await pay($, world, 'quest', 0, quest.gold)
352    $.ui.toast(w.questToast(w.quests[quest.id] ?? quest.id, quest.gold))
353  }
354  if (settled.isBonus) {
355    world = await pay($, world, 'quest', 0, BOUNTY_BONUS)
356    $.ui.toast(w.bonusToast(BOUNTY_BONUS))
357  }
358  await publish($, world)
359  const character = characterOf(world.character)
360  $.clock.after(1200, () => void enqueue($, async () => {
361    await playAct($, 'bigCelebrate')
362    await play($, 'quest', character.id)
363  }))
364}
365
366// A person's prompt counts as presence: the day's first earns "Saddled up", coming
367// back from a break after a long stretch earns "Campfire rest".
368function arrive($: EngineInterface): Promise<unknown> {
369  return enqueue($, async () => {
370    const now = await $.clock.now()
371    const day = dayOf(now, runtime.offset)
372    const world = await loadWorld($)
373    const lastAt = Math.max(0, ...world.ledgers.map(ledger => ledger.lastAt))
374    const since = Math.min(
375      lastAt,
376      ...world.ledgers.filter(ledger => ledger.lastAt >= lastAt - CONTINUOUS_GAP_MS).map(l => l.activeSince),
377    )
378    const earned: Earn[] = []
379    if (countToday(world.ledgers, 'daily', day) === 0) earned.push({ kind: 'daily' })
380    if (isBreakOver(lastAt, since, now)) earned.push({ kind: 'break' })
381    const presence = touch({ ...world.own, lastAt, activeSince: since }, now)
382    await saveOwn($, world, { ...world.own, lastAt: presence.lastAt, activeSince: presence.activeSince })
383    for (const earn of earned) await grant($, earn)
384  })
385}
386
387// Counts a pet, treat or trick for the day's bounties (nothing is paid for it).
388async function count($: EngineInterface, kind: CareKind) {
389  const day = dayOf(await $.clock.now(), runtime.offset)
390  const world = await loadWorld($)
391  const next = await saveOwn($, world, tally(world.own, kind, day))
392  await publish($, next)
393  await settleQuests($)
394}
395
396async function changeWallet($: EngineInterface, change: (wallet: Wallet, totals: Totals) => Wallet | undefined) {
397  const world = await loadWorld($)
398  const totals = await publish($, world)
399  // Read the wallet again right before writing it, in case another session just did.
400  const wallet = change(readWallet(await $.store.get('wallet')), totals)
401  if (wallet === undefined) return false
402  await $.store.set('wallet', wallet)
403  await publish($, { ...world, wallet })
404
405  return true
406}
407
408// ── Interactions ────────────────────────────────────────────────────────────
409
410// A sleeping pal wakes up; one at work stays at work.
411async function wake($: EngineInterface) {
412  const activity = await read($, activityAtom)
413  await setActivity($, activity === 'sleeping' ? 'idle' : activity)
414}
415
416// A pet makes hearts; three within a few seconds and the pal rolls about.
417async function pet($: EngineInterface) {
418  const now = await $.clock.now()
419  runtime.pets = [...runtime.pets.filter(at => now - at < 6000), now]
420  const character = await currentCharacter($)
421  await wake($)
422  await playAct($, runtime.pets.length >= 3 ? 'petMore' : 'pet')
423  await play($, 'pet', character.id)
424  await enqueue($, () => count($, 'pet'))
425}
426
427// Shows the learned tricks in turn, the newest first.
428async function trick($: EngineInterface) {
429  const character = await currentCharacter($)
430  const known = learned(character, await read($, totalsAtom))
431  if (known.length === 0) {
432    $.ui.toast(words().noTricks)
433    await wake($)
434    await playAct($, 'lookAround')
435    await enqueue($, () => count($, 'trick'))
436    return
437  }
438  const act = known[known.length - 1 - (runtime.tricks % known.length)]!
439  runtime.tricks += 1
440  await wake($)
441  await playAct($, act)
442  await play($, 'trick', character.id)
443  await enqueue($, () => count($, 'trick'))
444}
445
446// A treat costs a little gold: the pal munches it and glows for ten minutes.
447async function treat($: EngineInterface) {
448  const character = await currentCharacter($)
449  const price = character.treat.price
450  const isPaid = await enqueue($, () =>
451    changeWallet($, (wallet, totals) => (totals.dust < price ? undefined : { ...wallet, spent: wallet.spent + price })),
452  )
453  if (isPaid !== true) {
454    $.ui.toast(words().noTreatGold)
455    await play($, 'error', character.id)
456    return
457  }
458  const now = await $.clock.now()
459  const until = now + GLOW_MS
460  await update($, glowAtom, () => until)
461  // Clear it when it wears off, so a desktop loop is drawn again without the blush.
462  $.clock.after(GLOW_MS + 50, () => void update($, glowAtom, current => (current === until ? 0 : current)))
463  await wake($)
464  await playAct($, 'treat')
465  await play($, 'treat', character.id)
466  await enqueue($, () => count($, 'treat'))
467}
468
469// ── Sessions, panes, commands ───────────────────────────────────────────────
470
471// /clear and /resume carry on under a new session id without a new session.start, and
472// the new session's $.state starts from the defaults. Move to that session's ledger
473// (after writes already queued for the old one) and publish everything again.
474function rejoin($: EngineInterface): Promise<unknown> {
475  return enqueue($, async () => {
476    const key = `ledger:${await $.session.id()}`
477    if (key === runtime.key) return
478    runtime.key = key
479    // The new session's values are empty, so publish even what looks unchanged.
480    runtime.synced = ''
481    await publish($, await loadWorld($))
482  })
483}
484
485// Ledgers quiet for two days fold into `archive` to keep the key count down, and old
486// bounty boards go. A short lease keeps two sessions from folding at once.
487async function compact($: EngineInterface) {
488  const now = await $.clock.now()
489  const lease = (await $.store.get('lease')) as { key?: string; until?: number } | undefined
490  if (lease?.until !== undefined && lease.until > now && lease.key !== runtime.key) return
491  await $.store.set('lease', { key: runtime.key, until: now + 30_000 })
492  await $.clock.sleep(300)
493  const held = (await $.store.get('lease')) as { key?: string } | undefined
494  if (held?.key !== runtime.key) return
495  const archive = readLedger(await $.store.get('archive'))
496  const stale: string[] = []
497  const today = dayOf(now, runtime.offset)
498  for (const key of await $.store.keys()) {
499    if (key.startsWith('bounty:') && key < `bounty:${today}`) stale.push(key)
500    if (!key.startsWith('ledger:') || key === runtime.key) continue
501    const ledger = readLedger(await $.store.get(key))
502    if (now - ledger.lastAt < STALE_MS) continue
503    archive.fame += ledger.fame
504    archive.dust += ledger.dust
505    stale.push(key)
506  }
507  if (stale.some(key => key.startsWith('ledger:'))) await $.store.set('archive', { ...archive, days: {}, recent: [] })
508  for (const key of stale) await $.store.delete(key)
509  await $.store.delete('lease')
510}
511
512async function currentCharacter($: EngineInterface): Promise<Character> {
513  return characterOf(await read($, characterAtom))
514}
515
516// A person who closes the pane keeps it closed in later sessions; /cappl opens it again.
517async function showPane($: EngineInterface) {
518  await $.store.set('hidden', false)
519  void play($, 'open', (await currentCharacter($)).id)
520  void sync($)
521
522  return $.ui.open({ id: PANE, title: 'CAPPL', rows: INLINE_ROWS })
523}
524
525async function hidePane($: EngineInterface) {
526  await $.store.set('hidden', true)
527  void play($, 'close', (await currentCharacter($)).id)
528  await $.ui.close({ id: PANE })
529}
530
531async function isPaneOpen($: EngineInterface): Promise<boolean> {
532  return (await $.ui.panes()).some(pane => pane.id === PANE && pane.isPlaced && pane.isShown)
533}
534
535// Opens a view of the pane: the rules and my progress at their top with the focus on
536// "Back"; the pal's view with the focus back on the menu row that opened the other.
537async function showView($: EngineInterface, view: PaneView, from: PaneView = 'main') {
538  await update($, viewAtom, () => view)
539  if (view === 'main') await $.ui.focus({ requestId: PANE, key: from === 'progress' ? 'status' : 'rules' }).catch(() => undefined)
540  else await focusTop($, 'back')
541}
542
543// Puts the focus on `key` once the new drawing has it, then shows that drawing from
544// its top: the person keeps driving the pane by keyboard after a view changes.
545async function focusTop($: EngineInterface, key: string) {
546  await $.ui.focus({ requestId: PANE, key }).catch(() => undefined)
547  await $.ui.scroll({ in: PANE, to: 'start' }).catch(() => undefined)
548}
549
550async function openPicker($: EngineInterface) {
551  await update($, pickingAtom, () => true)
552  const current = await read($, characterAtom)
553  await focusTop($, `pick-${current ?? CHARACTERS[0]!.id}`)
554}
555
556async function pick($: EngineInterface, id: CharacterId) {
557  const character = characterOf(id)
558  await $.store.set('character', id)
559  await update($, characterAtom, () => id)
560  await update($, pickingAtom, () => false)
561  await update($, viewAtom, () => 'main' as PaneView)
562  void focusTop($, 'pet')
563  runtime.drawn = ''
564  $.ui.toast(character.hello[runtime.language])
565  await flash($, 'buy', `✦ ${character.hello[runtime.language]}`)
566  await playAct($, 'hello')
567  await play($, 'hello', id)
568}
569
570// /cappl-sound: normal → quiet → off → normal.
571async function cycleSound($: EngineInterface): Promise<string> {
572  const w = words()
573  const now = await soundLevel($)
574  const next = SOUND_LEVELS[(SOUND_LEVELS.indexOf(now) + 1) % SOUND_LEVELS.length]!
575  await $.store.set('sound', next)
576  if (next !== 'off') await play($, 'coin', (await currentCharacter($)).id)
577  const after: SoundLevel = SOUND_LEVELS[(SOUND_LEVELS.indexOf(next) + 1) % SOUND_LEVELS.length]!
578
579  return w.soundSet(w.sound[next], w.sound[after])
580}
581
582async function shop($: EngineInterface, id: string, level: number) {
583  const character = await currentCharacter($)
584  const item = character.items.find(one => fullId(character.id, one.id) === id)
585  if (item === undefined) return
586  const wallet = readWallet(await $.store.get('wallet'))
587  if (wallet.owned.includes(id)) {
588    await enqueue($, () => changeWallet($, current => wear(current, id)))
589    await play($, 'equip', character.id)
590    return
591  }
592  const isDone = (await enqueue($, () => changeWallet($, (current, now) => buy(current, now, level, id)))) === true
593  if (isDone) {
594    await flash($, 'buy', words().bought(item.name[runtime.language]))
595    await playAct($, 'buy')
596    await play($, 'buy', character.id)
597  } else {
598    $.ui.toast(words().broke)
599    await play($, 'error', character.id)
600  }
601}
602
603// ── Drawing ─────────────────────────────────────────────────────────────────
604
605async function momentNow($: EngineInterface, character: Character, tick: number, actT?: number): Promise<Moment> {
606  const stored = await read($, actAtom)
607  const glowUntil = await read($, glowAtom)
608  const now = await $.clock.now()
609  // An act past its last frame is over, even if the timer that clears it was lost to a reload.
610  const act = stored !== null && actTicks(stored.name) > 0 && now - stored.at < actTicks(stored.name) * TICK_MS ? stored : null
611
612  return {
613    character: character.id,
614    activity: await read($, activityAtom),
615    tick,
616    learned: learned(character, await read($, totalsAtom)),
617    isGlowing: glowUntil > now,
618    ...(act === null ? {} : { act: { name: act.name, t: actT ?? tick - runtime.actTick } }),
619  }
620}
621
622// The terminal sprite is blitted in place every tick; SVG surfaces animate on their
623// own (SMIL inside the SVG) and are drawn again only when the moment changes.
624async function animate($: EngineInterface) {
625  if (!runtime.isDrawn) return
626  runtime.tick += 1
627  const id = await read($, characterAtom)
628  if (id === null || (await read($, pickingAtom)) || (await read($, viewAtom)) !== 'main') return
629  const character = characterOf(id)
630  const wallet = await read($, walletAtom)
631  const fx = await read($, fxAtom)
632  const moment = await momentNow($, character, runtime.tick)
633  const rows = frame({ character: id, look: lookAt(moment), tick: runtime.tick, equipped: wornBy(wallet, id), size: runtime.size, fx: fx?.kind })
634  const signature = rows.join('')
635  if (signature === runtime.drawn) return
636  runtime.drawn = signature
637  const done = await $.ui.blit({ requestId: PANE, key: SPRITE, cells: encode(rows).cells })
638  if (done.deny !== undefined) runtime.isDrawn = false
639}
640
641// One loop of the moment as an animated SVG, waving back when pointed at.
642function loopSvg(moment: Moment, equipped: readonly string[], fx: FxKind | undefined): string {
643  const ticks = loopTicks(moment)
644  const isIdle = moment.act === undefined && moment.activity === 'idle'
645  const start = isIdle ? busySlot(moment.learned, runtime.svgSeed) * idleGap(moment.character, moment.isGlowing === true) : 0
646  const frames = Array.from({ length: ticks }, (_, i) => {
647    const tick = start + i
648    const look = lookAt({ ...moment, tick, ...(moment.act === undefined ? {} : { act: { name: moment.act.name, t: i } }) })
649    return frame({ character: moment.character, look, tick, equipped, size: 32, ...(fx === undefined ? {} : { fx }) })
650  })
651  const hover = Array.from({ length: actTicks('hello') }, (_, t) =>
652    frame({ character: moment.character, look: ACTS.hello!.at(t, moment.character), tick: t, equipped, size: 32 }),
653  )
654
655  return animatedSvgOf(frames, TICK_MS, hover)
656}
657
658function commas(n: number): string {
659  return String(n).replace(/\B(?=(\d{3})+(?!\d))/g, ',')
660}
661
662function utcOffset(minutes: number): string {
663  const sign = minutes < 0 ? '-' : '+'
664  const abs = Math.abs(minutes)
665
666  return `${sign}${Math.floor(abs / 60)}${abs % 60 === 0 ? '' : `:${String(abs % 60).padStart(2, '0')}`}`
667}
668
669// The results in the order a day usually brings them, for the rules view and /cappl-rules.
670const RESULT_ORDER: readonly ResultKind[] = ['verify', 'comeback', 'check', 'commit', 'pr', 'merge', 'plan', 'break', 'daily']
671
672async function ruleRows($: EngineInterface) {
673  const world = await loadWorld($)
674  const day = dayOf(await $.clock.now(), runtime.offset)
675
676  return RESULT_ORDER.map(kind => {
677    const rule = RULES[kind]
678    const n = countToday(world.ledgers, kind, day)
679
680    return { kind, rule, today: n, rate: rateOf(kind, n) }
681  })
682}
683
684async function rulesText($: EngineInterface): Promise<string> {
685  const w = words()
686  const r = w.rules
687  const rows = await ruleRows($)
688  const lines = [`${r.title}: ${r.intro}`, '', r.earn]
689  for (const row of rows) {
690    const limit = row.rule.cap !== undefined ? r.limit.cap(row.rule.cap) : r.limit.taper(row.rule.full)
691    const today = row.today > 0 ? ` ∙ ${r.today(row.today)}${row.rate < 1 ? ` (x${row.rate})` : ''}` : ''
692    lines.push(`- ${w.rewards[row.kind]}: ${r.values(row.rule.fame, row.rule.dust)} ∙ ${limit}${today}`, `  ${r.when[row.kind]}`)
693  }
694  lines.push(r.commitBonus, r.taper, '', r.bountyTitle, ...r.bounty.map(line => `- ${line}`), '', r.bonusTitle, ...r.bonus.map(line => `- ${line}`))
695  lines.push('', `${r.noneTitle}: ${r.none}`, r.fair(5, utcOffset(runtime.offset)))
696
697  return lines.join('\n')
698}
699
700async function statusText($: EngineInterface): Promise<string> {
701  const w = words()
702  const world = await loadWorld($)
703  const totals = await publish($, world)
704  const { level, into, need } = levelOf(totals.fame)
705  const character = characterOf(world.character)
706
707  return w.status({
708    name: character.name,
709    level,
710    rank: w.ranks[rankOf(level)]!,
711    fame: totals.fame,
712    progress: need === 0 ? w.maxLevel : `${into}/${need}`,
713    dust: totals.dust,
714    fans: w.fans(commas(fansOf(totals.fame))),
715    owned: world.wallet.owned.length,
716    total: SHOP_SIZE,
717    tricks: learnedTricks(character, level).length,
718    allTricks: character.tricks.length,
719  })
720}
721
722function bountyLines(quests: readonly Quest[], ledgers: readonly Ledger[], day: string, done: readonly string[]) {
723  return quests.map(quest => ({
724    quest,
725    have: Math.min(quest.target, countToday(ledgers, quest.count, day)),
726    isDone: done.includes(quest.id),
727  }))
728}
729
730export const register: Register = (on, options) => {
731  on('session.start', async ($, e, next) => {
732    runtime.key = `ledger:${await $.session.id()}`
733    runtime.offset = -new Date().getTimezoneOffset()
734    runtime.language = languageOf(options.language, await $.settings.read({ key: 'language' }).catch(() => undefined))
735    runtime.lastActiveAt = await $.clock.now()
736    const w = words()
737    await $.command.register({ name: 'cappl', description: w.commands.toggle, immediate: true })
738    await $.command.register({ name: 'cappl-pick', description: w.commands.pick, argumentHint: 'locki | cappl', immediate: true })
739    await $.command.register({ name: 'cappl-status', description: w.commands.status })
740    await $.command.register({ name: 'cappl-rules', description: w.commands.rules })
741    await $.command.register({ name: 'cappl-sound', description: w.commands.sound, immediate: true })
742    const totals = await publish($, await loadWorld($))
743    // Chests start from the level a profile already has; only new levels pay.
744    if ((await $.store.get('chestLevel')) === undefined) await $.store.set('chestLevel', levelOf(totals.fame).level)
745    $.clock.every(TICK_MS, () => void animate($).catch(() => undefined))
746    $.clock.every(SYNC_MS, () => void sync($))
747    $.clock.every(15_000, () => void checkSleep($).catch(() => undefined))
748    // Desktop loops show a different idle stretch now and then.
749    $.clock.every(30_000, () => {
750      runtime.svgSeed += 1
751    })
752    if ((await $.store.get('hidden')) !== true) void $.ui.open({ id: PANE, title: 'CAPPL' })
753    void compact($).catch(() => undefined)
754
755    return next(e)
756  })
757
758  on('classic.SessionStart', async ($, e, next) => {
759    const started = await next(e)
760    if (e.source !== 'startup') await rejoin($)
761
762    return started
763  })
764
765  // Waiting on a permission prompt: the pal waves for attention.
766  on('classic.Notification', async ($, e, next) => {
767    const done = await next(e)
768    if (/permission/i.test(e.notification_type)) await setActivity($, 'waiting')
769
770    return done
771  })
772
773  on('command.run', { command: 'cappl' }, async $ => {
774    const w = words()
775    if (await isPaneOpen($)) {
776      await hidePane($)
777
778      return { text: w.closed }
779    }
780    const opened = await showPane($)
781
782    return { text: opened.isPlaced ? w.opened : w.waiting }
783  })
784
785  // /cappl-pick cappl picks at once; /cappl-pick alone opens the picker in the pane.
786  on('command.run', { command: 'cappl-pick' }, async ($, e) => {
787    const w = words()
788    const wanted = e.args.trim().toLowerCase()
789    if (wanted !== '') {
790      const found = CHARACTERS.find(one => one.id === wanted || one.name.toLowerCase() === wanted)
791      if (found === undefined) return { text: w.unknownCharacter(CHARACTERS.map(one => one.id).join(', ')) }
792      await pick($, found.id)
793
794      return { text: w.picked(found.name) }
795    }
796    await update($, pickingAtom, () => true)
797    if (!(await isPaneOpen($))) await showPane($)
798
799    return { text: w.pickTitle }
800  })
801
802  // Closing with ✕ or ctrl+x x is remembered the same way.
803  on('ui.close', { id: PANE }, async ($, e, next) => {
804    const closed = await next(e)
805    if (e.origin.kind === 'person') await $.store.set('hidden', true)
806
807    return closed
808  })
809
810  on('command.run', { command: 'cappl-status' }, async $ => ({ text: await statusText($) }))
811
812  on('command.run', { command: 'cappl-rules' }, async $ => ({ text: await rulesText($) }))
813
814  on('command.run', { command: 'cappl-sound' }, async $ => ({ text: await cycleSound($) }))
815
816  on('prompt.submit', async ($, e, next) => {
817    await arrive($)
818    if ((await read($, activityAtom)) === 'sleeping') await setActivity($, 'idle')
819    await playAct($, 'listen')
820
821    return next(e)
822  })
823
824  on('turn.start', async ($, e, next) => {
825    if (e.agentId === undefined) {
826      runtime.isTurn = true
827      await setActivity($, 'thinking')
828      await update($, turnFameAtom, () => 0)
829    }
830
831    return next(e)
832  })
833
834  on('turn.complete', async ($, e, next) => {
835    const done = await next(e)
836    if (e.agentId === undefined) {
837      runtime.isTurn = false
838      runtime.turns += 1
839      await setActivity($, 'idle')
840      // A dance to end the turn, after whatever cheering is still going.
841      const act = await read($, actAtom)
842      const now = await $.clock.now()
843      const left = act === null ? 0 : Math.max(0, act.at + actTicks(act.name) * TICK_MS - now)
844      const turn = runtime.turns
845      $.clock.after(left + 100, () => {
846        // Not if the next turn has already begun.
847        if (!runtime.isTurn && runtime.turns === turn) void playAct($, DANCES[turn % DANCES.length]!)
848      })
849    }
850
851    return done
852  })
853
854  on('turn.abort', async ($, e, next) => {
855    const done = await next(e)
856    runtime.isTurn = false
857    await setActivity($, 'idle')
858
859    return done
860  })
861
862  on('tool.call', async ($, e, next) => {
863    const isMain = !('agentId' in e) || e.agentId === undefined
864    const tool = String(e.tool)
865    const command = e.tool === 'Bash' ? e.command : ''
866    if (isMain) {
867      const doing: Activity = EDIT_TOOLS.has(tool)
868        ? 'editing'
869        : READ_TOOLS.has(tool)
870          ? 'reading'
871          : isTestCommand(command) || /\btest\b/.test(command)
872            ? 'testing'
873            : 'running'
874      await setActivity($, doing)
875    }
876    const ran = await next(e)
877    if (isMain) await setActivity($, runtime.isTurn ? 'thinking' : 'idle')
878    if (ran.deny !== undefined) return ran
879    const isError = ran.isError === true
880    // One command can do several things (`npm test && git commit && gh pr create`),
881    // taken in the order they happen.
882    const signals: Signal[] = []
883    if (EDIT_TOOLS.has(tool)) {
884      if (!isError) signals.push({ type: 'edit' })
885    } else if (e.tool === 'ExitPlanMode') {
886      if (!isError) signals.push({ type: 'plan' })
887    } else if (e.tool === 'Bash') {
888      const output = ran.text ?? ''
889      const isPass = testOutcome(command, output, isError)
890      if (isPass !== undefined) {
891        signals.push({ type: 'test', isPass })
892        if (!isPass) {
893          await playAct($, 'fail')
894          await play($, 'fail', (await currentCharacter($)).id)
895        }
896      } else {
897        const isClean = checkOutcome(command, output, isError)
898        if (isClean !== undefined) signals.push({ type: 'check', isPass: isClean })
899      }
900      if (commitOutcome(command, output, isError)) signals.push({ type: 'commit' })
901      if (prOutcome(command, output, isError)) signals.push({ type: 'pr' })
902      if (mergeOutcome(command, output, isError)) signals.push({ type: 'merge' })
903    }
904    for (const signal of signals) {
905      const result = observe(runtime.work, signal)
906      runtime.work = result.work
907      // The result goes back once the reward is written (a few ms of store writes).
908      for (const earn of result.earned) await award($, earn)
909    }
910
911    return ran
912  })
913
914  // While a turn runs, the spinner shows the rep earned in it.
915  on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
916    const fame = await read($, turnFameAtom)
917    if (fame === 0) return next(e)
918
919    return next({ ...e, props: { ...e.props, suffix: `… ✦+${fame}` } })
920  })
921
922  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
923    const { Box, Text, Button } = $.ui.resolve(e)
924    const w = words()
925    const lang = runtime.language
926    const chosen = await read($, characterAtom)
927    const isPicking = chosen === null || (await read($, pickingAtom))
928    const view = await read($, viewAtom)
929    const isTerminal = e.surface === 'terminal'
930    const columns = isTerminal ? e.props.bodyColumns : 80
931    const bodyRows = isTerminal ? e.props.scroll.bodyRows : 60
932    // Driven by a click, or from the keyboard: the focus walks the controls top to
933    // bottom (Tab, and ⇅ while the tree fits; ⇅ scrolls a taller one), Enter presses.
934    // So every control gets its own row: a row of buttons would look like left and
935    // right work too. No hotkeys: a Korean layout never sends the Latin letter one needs.
936    const menuButton = (key: string, label: string, onPress: () => unknown) => <Button key={key} label={label} onPress={onPress} />
937    const hintFor = (isLong: boolean) =>
938      isTerminal ? <Text key="hint" dimColor>{!e.props.isFocused ? w.hint.idle : isLong ? w.hint.scroll : w.hint.focused}</Text> : null
939    const back = () => menuButton('back', w.menu.back, () => showView($, 'main', view))
940    const close = () => menuButton('close', w.close, () => hidePane($))
941
942    if (isPicking) {
943      runtime.isDrawn = false
944      const size: Size = columns >= 70 && bodyRows >= 14 + 6 ? 32 : 16
945      // Two cards side by side fit a 40-column dock with the small sprites.
946      const cardWidth = size === 32 ? 34 : 18
947      const cards = CHARACTERS.map((character, index) => {
948        let art
949        if (isTerminal) {
950          const { Raster } = $.ui.resolve(e)
951          const look = ACTS.hello!.at(1, character.id)
952          const cells = encode(frame({ character: character.id, look, tick: 0, equipped: [], size }))
953          art = <Raster key={`art-${character.id}`} columns={cells.columns} rows={cells.rows} cells={cells.cells} />
954        } else {
955          const { Svg } = $.ui.resolve(e)
956          const moment: Moment = { character: character.id, activity: 'idle', tick: 0, learned: [], act: { name: 'hello', t: 0 } }
957          art = <Svg source={loopSvg(moment, [], undefined)} alt={character.name} width={128} height={112} isInteractive />
958        }
959
960        return (
961          <Box key={`card-${character.id}`} flexDirection="column" width={cardWidth} marginRight={index === 0 ? 2 : 0}>
962            {art}
963            <Text bold color={character.color}>
964              {index + 1}. {character.name}
965              {chosen === character.id ? w.current : ''}
966            </Text>
967            <Text dimColor>{character.blurb[lang]}</Text>
968          </Box>
969        )
970      })
971
972      return (
973        <Box flexDirection="column">
974          <Text bold>{w.pickTitle}</Text>
975          {hintFor(false)}
976          <Box flexDirection={columns >= cardWidth * 2 + 2 ? 'row' : 'column'}>{cards}</Box>
977          <Text> </Text>
978          {CHARACTERS.map((character, index) =>
979            menuButton(`pick-${character.id}`, w.pick(character.name), () => pick($, character.id)),
980          )}
981          {close()}
982        </Box>
983      )
984    }
985
986    const character = characterOf(chosen)
987    const totals = await read($, totalsAtom)
988    const wallet = await read($, walletAtom)
989    const log = await read($, logAtom)
990    const fx = await read($, fxAtom)
991    const { level, into, need } = levelOf(totals.fame)
992    const filled = need === 0 ? 10 : Math.floor((into / need) * 10)
993    const worn = wornBy(wallet, character.id)
994    const nextLocked = character.items.find(item => item.level > level)
995    const nextTrick = character.tricks.find(one => one.level > level)
996    const now = await $.clock.now()
997    const day = dayOf(now, runtime.offset)
998    const world = await loadWorld($)
999    const rankLine = (
1000      <Text key="rank" bold>
1001        <Text color={character.color}>{character.name}</Text> Lv{level} {w.ranks[rankOf(level)]}
1002      </Text>
1003    )
1004    const repLine = (
1005      <Text key="rep">
1006        {w.rep} {'▰'.repeat(filled)}
1007        {'▱'.repeat(10 - filled)} {need === 0 ? w.maxLevel : `${into}/${need}`}
1008      </Text>
1009    )
1010    const goldLine = (
1011      <Text key="gold">
1012        {w.gold} ${totals.dust} <Text dimColor>∙ {w.fans(commas(fansOf(totals.fame)))}</Text>
1013      </Text>
1014    )
1015
1016    if (view === 'rules') {
1017      runtime.isDrawn = false
1018      const r = w.rules
1019      const rows = await ruleRows($)
1020
1021      return (
1022        <Box flexDirection="column">
1023          <Text bold>{r.title}</Text>
1024          <Text dimColor>{r.intro}</Text>
1025          {hintFor(bodyRows < RULES_ROWS)}
1026          {back()}
1027          <Text> </Text>
1028          <Text bold>{r.earn}</Text>
1029          {rows.map(row => (
1030            <Box key={`rule-${row.kind}`} flexDirection="column">
1031              <Box flexDirection="row" justifyContent="space-between" width={Math.min(columns, RULES_WIDTH)}>
1032                <Text bold>{w.rewards[row.kind]}</Text>
1033                <Text color="#FCD34D">{r.values(row.rule.fame, row.rule.dust)}</Text>
1034              </Box>
1035              <Text dimColor>
1036                {'  '}
1037                {r.when[row.kind].replaceAll('`', '')}
1038                {row.today > 0 ? ` (${r.today(row.today)})` : ''}
1039              </Text>
1040            </Box>
1041          ))}
1042          <Text dimColor>{r.commitBonus}</Text>
1043          <Text dimColor>{r.taper}</Text>
1044          <Text> </Text>
1045          <Text bold>{r.bountyTitle}</Text>
1046          {r.bounty.map((line, i) => (
1047            <Text key={`bounty-${i}`}>  {line}</Text>
1048          ))}
1049          <Text bold>{r.bonusTitle}</Text>
1050          {r.bonus.map((line, i) => (
1051            <Text key={`bonus-${i}`}>  {line}</Text>
1052          ))}
1053          <Text bold>{r.noneTitle}</Text>
1054          <Text dimColor>  {r.none}</Text>
1055          <Text dimColor>{r.fair(5, utcOffset(runtime.offset))}</Text>
1056        </Box>
1057      )
1058    }
1059
1060    if (view === 'progress') {
1061      runtime.isDrawn = false
1062      const p = w.progress
1063      const earned = (Object.keys(w.rewards) as RewardKind[])
1064        .map(kind => ({ kind, n: countToday(world.ledgers, kind, day) }))
1065        .filter(one => one.n > 0)
1066      const tricks = learnedTricks(character, level)
1067      const owned = character.items.filter(item => wallet.owned.includes(fullId(character.id, item.id)))
1068
1069      return (
1070        <Box flexDirection="column">
1071          <Text bold>{p.title}</Text>
1072          {hintFor(bodyRows < PROGRESS_ROWS + earned.length)}
1073          {back()}
1074          <Text> </Text>
1075          {rankLine}
1076          {repLine}
1077          {need > 0 && <Text dimColor>{p.toNext(need - into, level + 1)}</Text>}
1078          {goldLine}
1079          <Text> </Text>
1080          <Text bold>{p.today}</Text>
1081          {earned.length === 0 && <Text dimColor>  {p.nothingToday}</Text>}
1082          {earned.map(one => (
1083            <Text key={`today-${one.kind}`}>  {p.count(w.rewards[one.kind], one.n)}</Text>
1084          ))}
1085          <Text bold>{w.recent}</Text>
1086          {log.length === 0 && <Text dimColor>  {w.noRewards}</Text>}
1087          {log.slice(-5).map(one => (
1088            <Text key={`recent-${one.at}-${one.kind}`} dimColor>
1089              {'  '}
1090              {w.rewards[one.kind]}
1091              {one.fame > 0 ? ` +${one.fame}` : ''}
1092              {one.dust > 0 ? ` $${one.dust}` : ''}
1093              {one.rate < 1 ? ` (x${one.rate})` : ''}
1094            </Text>
1095          ))}
1096          <Text bold>{p.tricks(tricks.length, character.tricks.length)}</Text>
1097          <Text dimColor>  {nextTrick === undefined ? p.allLearned : w.nextTrick(nextTrick.level, nextTrick.name[lang])}</Text>
1098          <Text bold>{p.items(owned.length, character.items.length)}</Text>
1099          <Text dimColor>  {nextLocked === undefined ? p.allUnlocked : w.next(nextLocked.level, nextLocked.name[lang])}</Text>
1100        </Box>
1101      )
1102    }
1103
1104    const visible = character.items.filter(item => item.level <= level || wallet.owned.includes(fullId(character.id, item.id)))
1105    const quests = questsOf(day)
1106    const bounty = readBounty(await $.store.get(`bounty:${day}`))
1107    const lines = bountyLines(quests, world.ledgers, day, bounty.done)
1108    const recent = log.slice(-3)
1109    // Rows below the header: the hint, the actions, the bounties, recent rewards, the
1110    // shop, what's next and the menu.
1111    const below =
1112      (isTerminal ? 1 : 0) + 3 + 1 + lines.length + 1 + 1 + Math.max(1, recent.length) + 1 + visible.length + (nextLocked ? 1 : 0) + (nextTrick ? 1 : 0) + 1 + 4
1113
1114    let sprite
1115    let isBeside = false
1116    let isOverflowing = false
1117    if (isTerminal) {
1118      const { Raster } = $.ui.resolve(e)
1119      // bodyRows is the most the pane can show at once; rows past it need focus and scrolling.
1120      runtime.size = columns >= 32 && bodyRows >= BIG_ROWS ? 32 : 16
1121      const moment = await momentNow($, character, runtime.tick)
1122      const rows = frame({ character: character.id, look: lookAt(moment), tick: runtime.tick, equipped: worn, size: runtime.size, fx: fx?.kind })
1123      const art = encode(rows)
1124      runtime.drawn = rows.join('')
1125      runtime.isDrawn = true
1126      sprite = <Raster key={SPRITE} columns={art.columns} rows={art.rows} cells={art.cells} />
1127      // When the stack does not fit, put the header beside the small sprite if there is room.
1128      const stacked = art.rows + HEADER_ROWS + below
1129      isBeside = runtime.size === 16 && stacked > bodyRows && columns >= art.columns + 2 + HEADER_COLUMNS
1130      isOverflowing = (isBeside ? Math.max(art.rows, HEADER_ROWS + 3) + below - 1 : stacked) > bodyRows
1131    } else if (e.surface === 'desktop' || e.surface === 'vscode' || e.surface === 'mobile') {
1132      const { Svg } = $.ui.resolve(e)
1133      const moment = await momentNow($, character, 0, 0)
1134      const source = loopSvg(moment, worn, fx?.kind)
1135      sprite = <Svg source={source} alt={`${character.name} Lv${level}`} width={128} height={112} isInteractive />
1136    }
1137
1138    // Ambiguous-width glyphs (■ □ · — ★ ×) take two cells on CJK terminals and push
1139    // the lines; only single-width ones are used here.
1140    const header = [
1141      <Text key="fx" bold {...(fx === null ? {} : { color: FX_COLOR[fx.kind] })}>{fx === null ? ' ' : fx.label}</Text>,
1142      rankLine,
1143      repLine,
1144      goldLine,
1145    ]
1146    const done = lines.filter(line => line.isDone).length
1147
1148    // The hint, and that there is more below: beside the small sprite there is room for
1149    // them next to the header, so a pane of seven rows still says how to use it.
1150    const guide = [hintFor(isOverflowing), isOverflowing ? <Text key="more" dimColor>{w.scrollHint}</Text> : null]
1151
1152    return (
1153      <Box flexDirection="column">
1154        {isBeside ? (
1155          <Box key="beside" flexDirection="row">
1156            {sprite}
1157            <Box flexDirection="column" marginLeft={2}>
1158              {header}
1159              {guide}
1160            </Box>
1161          </Box>
1162        ) : (
1163          [sprite, ...header, ...guide]
1164        )}
1165        {menuButton('pet', w.pet, () => pet($))}
1166        {menuButton('trick', w.trick, () => trick($))}
1167        {menuButton('treat', w.treat(character.treat.name[lang], character.treat.price), () => treat($))}
1168        <Text dimColor>{w.bountyHeader(done, lines.length)}</Text>
1169        {lines.map(line => (
1170          <Text key={`quest-${line.quest.id}`} {...(line.isDone ? { color: '#86EFAC' } : { dimColor: true })}>
1171            {w.questLine(w.quests[line.quest.id] ?? line.quest.id, line.have, line.quest.target, line.quest.gold, line.isDone)}
1172          </Text>
1173        ))}
1174        <Text dimColor>{w.bonusLine(BOUNTY_BONUS, bounty.bonus)}</Text>
1175        <Text dimColor>{w.recent}</Text>
1176        {recent.length === 0 && <Text dimColor>{w.noRewards}</Text>}
1177        {recent.map(one => (
1178          <Text key={`recent-${one.at}-${one.kind}`} dimColor>
1179            {w.rewards[one.kind]}
1180            {one.fame > 0 ? ` +${one.fame}` : ''}
1181            {one.dust > 0 ? ` $${one.dust}` : ''}
1182            {one.rate < 1 ? ` (x${one.rate})` : ''}
1183          </Text>
1184        ))}
1185        <Text dimColor>{w.shop}</Text>
1186        {visible.map((item, index) => {
1187          const id = fullId(character.id, item.id)
1188          const isOwned = wallet.owned.includes(id)
1189          const label = isOwned
1190            ? `${item.name[lang]} ∙ ${worn.includes(item.id) ? w.takeOff : w.wear}`
1191            : `${item.name[lang]} $${commas(item.price)}`
1192
1193          return menuButton(`shop-${item.id}`, label, () => shop($, id, level))
1194        })}
1195        {nextLocked !== undefined && <Text dimColor>{w.next(nextLocked.level, nextLocked.name[lang])}</Text>}
1196        {nextTrick !== undefined && <Text dimColor>{w.nextTrick(nextTrick.level, nextTrick.name[lang])}</Text>}
1197        <Text> </Text>
1198        {menuButton('rules', w.menu.rules, () => showView($, 'rules'))}
1199        {menuButton('status', w.menu.status, () => showView($, 'progress'))}
1200        {menuButton('switch', w.switchCharacter, () => openPicker($))}
hooks/animations.ts 309 lines
1import type { Activity, CharacterId } from '../types'
2import type { Look } from './sprite'
3
4export type { Activity }
5
6// What the pal looks like on each 250 ms tick. A session is in one Activity (what
7// Claude is doing); an Act is a short one-off on top of it (a reward, a pet, a trick).
8// Idle pals fill the quiet with small actions and the tricks they have learned.
9
10type Anim = { ticks: number; at: (t: number, who: CharacterId) => Look }
11
12const pick = <T,>(list: readonly T[], t: number): T => list[((t % list.length) + list.length) % list.length]!
13const isLocki = (who: CharacterId) => who === 'locki'
14
15// ── Activities: looped while they last ──────────────────────────────────────
16
17const ACTIVITY: Readonly<Record<Activity, (t: number, who: CharacterId) => Look>> = {
18  idle: t => ({ eyes: t % 24 === 23 ? 'blink' : 'open', ears: t % 40 === 20 ? 1 : 0 }),
19  // A nap: LOCKI's ears droop, CAPPL tips the hat over the eyes.
20  sleeping: (t, who) => ({
21    eyes: 'closed',
22    bubble: t % 8 < 5 ? 'zzz' : undefined,
23    body: [0, t % 16 < 8 ? 0 : 1],
24    ...(isLocki(who) ? { ears: -1 } : { hat: -2 }),
25  }),
26  thinking: t => ({ paws: 'chin', gaze: [Math.floor(t / 4) % 2 === 0 ? -1 : 1, -1], bubble: 'dots' }),
27  reading: t => ({ gaze: pick<[number, number]>([[-1, 1], [0, 1], [1, 1], [0, 1]], Math.floor(t / 2)), ears: t % 16 === 0 ? 1 : 0 }),
28  editing: t => ({ paws: t % 2 === 0 ? 'tapL' : 'tapR', gaze: [0, 1], mouth: 'tongue', hideHand: true }),
29  running: (t, who) => ({
30    body: pick<[number, number]>([[0, 0], [1, 0], [0, 0], [-1, 0]], Math.floor(t / (isLocki(who) ? 2 : 4))),
31    mouth: 'smile',
32  }),
33  testing: t => ({ paws: 'hold', eyes: 'wide', mouth: 'o', sweat: t % 4 < 2, body: [t % 4 === 0 ? 1 : 0, 0], hideHand: true }),
34  waiting: t => ({ bubble: '?', paws: Math.floor(t / 2) % 2 === 0 ? 'waveHi' : 'waveLo', eyes: 'shiny', mouth: 'open' }),
35}
36
37// ── Acts: one-offs ──────────────────────────────────────────────────────────
38
39const wave: Anim = {
40  ticks: 10,
41  at: t => ({
42    paws: t % 4 < 2 ? 'waveHi' : 'waveLo',
43    eyes: t < 6 ? 'shiny' : 'happy',
44    mouth: 'open',
45    blush: true,
46    body: [0, t % 4 === 0 ? -1 : 0],
47    bubble: t >= 4 ? 'heart' : undefined,
48  }),
49}
50const hop: Anim = {
51  ticks: 10,
52  at: t => {
53    const dy = pick([0, -2, -1, 0, -2, -1, 0, -2, -1, 0], t)
54    return { body: [0, dy], paws: dy < 0 ? 'up' : 'rest', eyes: dy < 0 ? 'happy' : 'shiny', mouth: 'open', blush: true, ears: dy < 0 ? 1 : 0 }
55  },
56}
57const twoStep = (slow: boolean, extra: Look = {}): Anim => ({
58  ticks: 16,
59  at: t => {
60    const step = slow ? Math.floor(t / 2) : t
61    return {
62      body: pick<[number, number]>([[-1, 0], [-1, -1], [0, 0], [1, 0], [1, -1], [0, 0]], step),
63      paws: step % 2 === 0 ? 'tapL' : 'tapR',
64      eyes: 'happy',
65      mouth: 'open',
66      blush: true,
67      ...extra,
68    }
69  },
70})
71const tumble: Anim = {
72  ticks: 12,
73  at: t => ({
74    body: [pick([-3, -2, -1, 0, 1, 2, 3, 2, 1, 0, 0, 0], t), t < 9 ? -1 : 0],
75    paws: t % 2 === 0 ? 'cheer' : 'up',
76    eyes: t < 7 ? 'x' : 'happy',
77    mouth: 'open',
78  }),
79}
80const peekaboo = (withHat: boolean): Anim => ({
81  ticks: 12,
82  at: t => {
83    if (t < 6) return withHat ? { hat: -3, mouth: 'smile' } : { paws: 'cover', eyes: 'closed', mouth: 'smile' }
84    if (t < 8) return { eyes: 'wide', bubble: '!', mouth: 'open', ...(withHat ? { hat: 3 } : { paws: 'stretch' }) }
85    return { eyes: 'happy', mouth: 'open', blush: true }
86  },
87})
88
89export const ACTS: Readonly<Record<string, Anim>> = {
90  listen: { ticks: 6, at: t => ({ eyes: 'wide', bubble: '!', ears: 1, body: [0, t < 2 ? -1 : 0] }) },
91  celebrate: {
92    ticks: 8,
93    at: (t, who) => ({
94      paws: 'cheer',
95      eyes: t % 4 < 2 ? 'shiny' : 'happy',
96      mouth: 'open',
97      blush: true,
98      body: [0, t % 2 === 0 ? -1 : 0],
99      particles: isLocki(who) ? 'stars' : 'notes',
100    }),
101  },
102  bigCelebrate: {
103    ticks: 16,
104    at: t => ({
105      paws: t % 4 < 2 ? 'cheer' : 'up',
106      eyes: 'star',
107      mouth: 'open',
108      blush: true,
109      body: [0, pick([0, -2, -3, -2, 0, 0, -1, 0], t)],
110      hat: pick([0, 2, 4, 5, 4, 2, 0, 0], t),
111      particles: 'confetti',
112    }),
113  },
114  lucky: { ticks: 8, at: t => ({ eyes: 'star', bubble: 'star', mouth: t < 3 ? 'o' : 'open', particles: 'stars', blush: true }) },
115  fail: {
116    ticks: 10,
117    at: t =>
118      t < 4
119        ? { eyes: 'x', mouth: 'o', sweat: true, bubble: '!', body: [t % 2 === 0 ? 1 : -1, 0] }
120        : { brows: 'determined', paws: t >= 6 ? 'up' : 'rest', mouth: 'smile' },
121  },
122  dance1: twoStep(false),
123  dance2: {
124    ticks: 12,
125    at: t => ({
126      body: [0, pick([0, -2, -3, -1], t)],
127      paws: t % 4 === 2 ? 'cheer' : 'up',
128      eyes: t % 4 === 2 ? 'star' : 'happy',
129      mouth: 'open',
130      hat: t % 4 === 2 ? 3 : 0,
131      blush: true,
132    }),
133  },
134  dance3: {
135    ticks: 12,
136    at: (t, who) => ({ paws: t % 4 < 2 ? 'waveHi' : 'waveLo', eyes: 'happy', mouth: 'w', bubble: isLocki(who) ? 'heart' : 'note', blush: true }),
137  },
138  pet: { ticks: 8, at: t => ({ eyes: t < 5 ? 'heart' : 'happy', blush: true, mouth: 'w', particles: 'hearts', body: [0, t < 2 ? 1 : 0], ears: t % 2 }) },
139  petMore: {
140    ticks: 12,
141    at: t => ({
142      eyes: 'happy',
143      blush: true,
144      mouth: 'open',
145      particles: 'hearts',
146      bubble: 'heart',
147      body: pick<[number, number]>([[-2, 0], [-1, -1], [0, 0], [1, -1], [2, 0], [1, 0], [0, 0], [-1, 0]], t),
148      ears: t % 2,
149    }),
150  },
151  treat: {
152    ticks: 16,
153    at: t =>
154      t < 10
155        ? { paws: 'hold', treat: Math.floor(t / 3), mouth: t % 3 === 1 ? 'chomp' : 'open', eyes: t < 2 ? 'shiny' : 'happy', hideHand: true, body: [0, t % 3 === 1 ? 1 : 0] }
156        : { eyes: 'shiny', blush: true, mouth: 'w', particles: 'hearts', bubble: 'heart' },
157  },
158  hello: { ticks: 8, at: t => ({ paws: t % 4 < 2 ? 'waveHi' : 'waveLo', eyes: 'shiny', mouth: 'open', blush: true }) },
159  buy: { ticks: 8, at: t => ({ eyes: 'shiny', mouth: 'open', particles: 'stars', body: [0, t % 2 === 0 ? -1 : 0], blush: true }) },
160
161  // Small idle actions every pal does.
162  lookAround: { ticks: 10, at: t => ({ gaze: pick<[number, number]>([[-1, 0], [-1, 0], [-1, 0], [1, 0], [1, 0], [1, 0], [0, -1], [0, -1], [0, 0], [0, 0]], t) }) },
163  stretch: {
164    ticks: 10,
165    at: t => (t < 6 ? { paws: 'stretch', mouth: t >= 2 ? 'yawn' : undefined, eyes: t >= 2 ? 'closed' : 'open' } : { eyes: 'happy' }),
166  },
167  yawn: { ticks: 8, at: t => (t < 5 ? { mouth: 'yawn', eyes: 'sleepy' } : { eyes: t === 5 ? 'blink' : 'open' }) },
168  earWiggle: { ticks: 8, at: t => ({ ears: pick([1, 0, 1, 0, 1, 0, 0, 0], t), eyes: 'happy' }) },
169  hum: {
170    ticks: 12,
171    at: t => ({ eyes: 'closed', bubble: 'note', particles: t >= 4 ? 'notes' : undefined, body: [Math.floor(t / 3) % 2 === 0 ? 0 : 1, 0], mouth: 'smile' }),
172  },
173
174  // Tricks (characters/index.ts says who learns which, at which level).
175  wave,
176  hop,
177  spin: {
178    ticks: 12,
179    at: t => ({
180      gaze: pick<[number, number]>([[-1, 0], [0, -1], [1, 0], [0, 1]], t),
181      hatDx: pick([-1, 0, 1, 0], t),
182      body: [0, t % 2 === 0 ? -1 : 0],
183      eyes: t >= 10 ? 'happy' : 'open',
184      mouth: 'o',
185    }),
186  },
187  hatToss: {
188    ticks: 14,
189    at: t => ({
190      hat: pick([0, 3, 6, 9, 11, 11, 9, 6, 3, 0, 0, 0, 0, 0], t),
191      gaze: t < 9 ? [0, -1] : [0, 0],
192      paws: t < 3 ? 'cheer' : t >= 10 ? 'cheer' : 'up',
193      eyes: t >= 10 ? 'star' : 'wide',
194      mouth: 'open',
195    }),
196  },
197  salute: { ticks: 10, at: t => ({ paws: 'waveHi', brows: 'determined', mouth: 'smile', bubble: t >= 4 ? 'star' : undefined }) },
198  peekaboo: peekaboo(false),
199  hatPeekaboo: peekaboo(true),
200  heartBurst: { ticks: 12, at: () => ({ eyes: 'heart', blush: true, mouth: 'w', particles: 'hearts', bubble: 'heart' }) },
201  starDance: twoStep(false, { particles: 'stars' }),
202  cartwheel: tumble,
203  fireworks: {
204    ticks: 16,
205    at: t => ({ paws: t % 4 < 2 ? 'cheer' : 'up', eyes: 'star', mouth: 'open', blush: true, particles: 'confetti', bubble: 'star', body: [0, pick([0, -2, -1, 0], t)] }),
206  },
207  nod: {
208    ticks: 12,
209    at: t => ({
210      body: [t % 4 < 2 ? -1 : 1, t % 2 === 1 ? 2 : 0],
211      hat: t % 2 === 1 ? 1 : 0,
212      paws: t % 2 === 0 ? 'tapL' : 'tapR',
213      eyes: 'closed',
214      mouth: t % 2 === 1 ? 'open' : 'smile',
215      bubble: 'note',
216      particles: 'notes',
217      blush: true,
218    }),
219  },
220  hatTip: {
221    ticks: 10,
222    at: t => ({ hat: pick([0, 1, 2, 3, 3, 2, 1, 0, 0, 0], t), eyes: t >= 2 && t <= 7 ? 'happy' : 'open', body: [0, t >= 2 && t <= 7 ? 1 : 0], mouth: 'smile' }),
223  },
224  strum: { ticks: 12, at: t => ({ paws: t % 2 === 0 ? 'tapR' : 'rest', bubble: 'note', eyes: 'closed', particles: 'notes', mouth: 'smile' }) },
225  sip: {
226    ticks: 12,
227    at: t => (t < 7 ? { paws: 'hold', mouth: 'o', eyes: 'closed', hideHand: true } : { eyes: 'happy', mouth: 'w', blush: true, bubble: 'heart' }),
228  },
229  twoStep: twoStep(true),
230  tumble,
231  campfireSong: {
232    ticks: 16,
233    at: t => ({ eyes: t % 8 < 4 ? 'closed' : 'happy', particles: t % 8 < 4 ? 'notes' : 'stars', bubble: 'note', mouth: 'open', body: [0, t % 4 === 0 ? -1 : 0], blush: true }),
234  },
235}
236
237export const DANCES = ['dance1', 'dance2', 'dance3'] as const
238
239// ── Idle: base pose plus an action now and then ─────────────────────────────
240
241// Ticks between idle actions: LOCKI fidgets more than CAPPL.
242const IDLE_GAP: Readonly<Record<CharacterId, number>> = { locki: 40, cappl: 64 }
243// What every pal does from the start; learned tricks join these as levels go up.
244const IDLE_ACTIONS: readonly string[] = ['lookAround', 'stretch', 'yawn', 'earWiggle']
245
246function hash(n: number): number {
247  let x = (n * 2654435761) >>> 0
248  x ^= x >>> 15
249  return x
250}
251
252// The idle action for one stretch of `gap` ticks, from the basics and learned tricks.
253export function idleAction(learned: readonly string[], slot: number): string | undefined {
254  const pool = [...IDLE_ACTIONS, ...learned]
255  // Every third slot stays plain, so the pal also just sits.
256  if (hash(slot) % 3 === 0) return undefined
257  return pool[hash(slot + 7) % pool.length]
258}
259
260export type Moment = {
261  character: CharacterId
262  activity: Activity
263  // The act playing and how many ticks into it.
264  act?: { name: string; t: number }
265  tick: number
266  learned: readonly string[]
267  // After a treat: blushing, and idle actions twice as often.
268  isGlowing?: boolean
269}
270
271export function lookAt(moment: Moment): Look {
272  const { character: who, activity, act, tick } = moment
273  const playing = act === undefined ? undefined : ACTS[act.name]
274  if (playing !== undefined && act !== undefined && act.t >= 0 && act.t < playing.ticks) return playing.at(act.t, who)
275  if (activity !== 'idle') return ACTIVITY[activity](tick, who)
276  const gap = idleGap(who, moment.isGlowing === true)
277  const offset = tick % gap
278  const name = idleAction(moment.learned, Math.floor(tick / gap))
279  const action = name === undefined ? undefined : ACTS[name]
280  if (action !== undefined && offset >= gap - action.ticks) return action.at(offset - (gap - action.ticks), who)
281  const base = ACTIVITY.idle(tick, who)
282
283  return moment.isGlowing === true ? { ...base, blush: true } : base
284}
285
286export function actTicks(name: string): number {
287  return ACTS[name]?.ticks ?? 0
288}
289
290// Ticks between idle actions; a glowing pal (after a treat) shows off twice as often.
291export function idleGap(who: CharacterId, isGlowing = false): number {
292  return IDLE_GAP[who] / (isGlowing ? 2 : 1)
293}
294
295// The first idle stretch at or after `slot` that has an action, so a desktop loop
296// (one stretch long) always shows the pal doing something.
297export function busySlot(learned: readonly string[], slot: number): number {
298  for (let s = slot; s < slot + 6; s += 1) if (idleAction(learned, s) !== undefined) return s
299  return slot
300}
301
302// How many ticks a desktop loop of this moment needs: an act once, one idle
303// stretch, or an activity's cycle.
304export function loopTicks(moment: Moment): number {
305  if (moment.act !== undefined) return actTicks(moment.act.name)
306  if (moment.activity === 'idle') return idleGap(moment.character, moment.isGlowing === true)
307  return 16
308}
309
hooks/characters/index.ts 146 lines
1import type { CharacterId, FxKind, Language } from '../../types'
2
3// Who you can pick, what they wear, how they celebrate. The pixels live in art.ts
4// (generated by tools/draw_art.py) and the sounds in assets/sfx/<id>/.
5// To add a character: draw it in tools/draw_art.py, add an entry here, add its
6// id to CharacterId in types/index.d.ts and its voice to tools/make_sfx.py.
7
8export type Text = Readonly<Record<Language, string>>
9export type Slot = 'eyes' | 'mouth' | 'head' | 'hat' | 'hand' | 'stage'
10export type Item = { id: string; slot: Slot; price: number; level: number; name: Text }
11// A trick is an act in hooks/animations.ts the pal learns at a level.
12export type Trick = { act: string; level: number; name: Text }
13
14export type Character = {
15  id: CharacterId
16  name: string
17  // The pane's accent colour.
18  color: string
19  blurb: Text
20  hello: Text
21  // `twinkle`: four-point stars; `note`: little music notes that float up.
22  sparkle: 'twinkle' | 'note'
23  fxColors: Readonly<Record<FxKind, readonly string[]>>
24  items: readonly Item[]
25  tricks: readonly Trick[]
26  treat: { name: Text; price: number }
27}
28
29// Both wardrobes together cost about what a typical user earns on the way to
30// level 50 (docs/reward-design.md), so finishing both is the long goal.
31export const CHARACTERS: readonly Character[] = [
32  {
33    id: 'locki',
34    name: 'LOCKI',
35    color: '#F2728F',
36    blurb: {
37      en: 'Wants to be sheriff. Bright, bouncy, never without the pink star.',
38      ko: '보안관이 꿈인 씩씩한 친구. 모자의 분홍 별이 자랑이에요.',
39    },
40    hello: { en: "Howdy! LOCKI's on duty.", ko: '하우디! LOCKI 근무 시작!' },
41    sparkle: 'twinkle',
42    fxColors: {
43      reward: ['P', 'y', 'w'],
44      comeback: ['P', 'l', 'w'],
45      merge: ['y', 'l', 'w'],
46      level: ['y', 'P', 'l', 'w'],
47      stage: ['y', 'P', 'D', 'w'],
48      buy: ['w', 'i', 'P'],
49    },
50    tricks: [
51      { act: 'wave', level: 2, name: { en: 'Wave', ko: '손 흔들기' } },
52      { act: 'hop', level: 4, name: { en: 'Hop hop', ko: '콩콩 뛰기' } },
53      { act: 'spin', level: 7, name: { en: 'Spin', ko: '빙글 돌기' } },
54      { act: 'hatToss', level: 12, name: { en: 'Hat toss', ko: '모자 던지기' } },
55      { act: 'salute', level: 17, name: { en: 'Sheriff salute', ko: '보안관 경례' } },
56      { act: 'peekaboo', level: 23, name: { en: 'Peekaboo', ko: '까꿍' } },
57      { act: 'heartBurst', level: 29, name: { en: 'Heart burst', ko: '하트 뿅뿅' } },
58      { act: 'starDance', level: 35, name: { en: 'Star dance', ko: '별빛 춤' } },
59      { act: 'cartwheel', level: 42, name: { en: 'Cartwheel', ko: '옆돌기' } },
60      { act: 'fireworks', level: 50, name: { en: 'Fireworks show', ko: '불꽃 쇼' } },
61    ],
62    treat: { name: { en: 'Star cookie', ko: '별 쿠키' }, price: 6 },
63    items: [
64      { id: 'aviators', slot: 'eyes', price: 50, level: 1, name: { en: 'Aviator shades', ko: '보안관 선글라스' } },
65      { id: 'feather', slot: 'head', price: 60, level: 3, name: { en: 'Pink feather', ko: '분홍 깃털' } },
66      { id: 'harmonica', slot: 'hand', price: 250, level: 6, name: { en: 'Harmonica', ko: '하모니카' } },
67      { id: 'heart-shades', slot: 'eyes', price: 500, level: 10, name: { en: 'Heart shades', ko: '하트 선글라스' } },
68      { id: 'lasso', slot: 'hand', price: 800, level: 14, name: { en: 'Lasso', ko: '올가미 밧줄' } },
69      { id: 'gold-star', slot: 'head', price: 1_100, level: 19, name: { en: 'Gold sheriff star', ko: '금빛 보안관 별' } },
70      { id: 'desert', slot: 'stage', price: 1_400, level: 24, name: { en: 'Desert noon', ko: '한낮의 사막' } },
71      { id: 'white-hat', slot: 'hat', price: 1_900, level: 30, name: { en: 'White ten-gallon hat', ko: '하얀 카우보이모자' } },
72      { id: 'sunset', slot: 'stage', price: 2_500, level: 38, name: { en: 'Desert sunset', ko: '사막의 노을' } },
73      { id: 'galaxy-hat', slot: 'hat', price: 3_300, level: 46, name: { en: 'Starry night hat', ko: '별밤 모자' } },
74    ],
75  },
76  {
77    id: 'cappl',
78    name: 'CAPPL',
79    color: '#4A979B',
80    blurb: {
81      en: 'A drifter in a wide brown hat. Slow, steady, always humming a tune.',
82      ko: '넓은 갈색 모자를 쓴 떠돌이. 느긋하고 꾸준하며 늘 콧노래를 불러요.',
83    },
84    hello: { en: "Howdy, partner. CAPPL's here.", ko: '반가워, 파트너. CAPPL 왔어.' },
85    sparkle: 'note',
86    fxColors: {
87      reward: ['Q', 'l', 'w'],
88      comeback: ['R', 'Q', 'l'],
89      merge: ['y', 'l', 'Q'],
90      level: ['y', 'Q', 'l', 'w'],
91      stage: ['y', 'Q', 'R', 'F', 'w'],
92      buy: ['w', 'C', 'R'],
93    },
94    tricks: [
95      { act: 'wave', level: 2, name: { en: 'Wave', ko: '손 흔들기' } },
96      { act: 'nod', level: 4, name: { en: 'Nod along', ko: '끄덕끄덕' } },
97      { act: 'hatTip', level: 7, name: { en: 'Hat tip', ko: '모자 인사' } },
98      { act: 'strum', level: 12, name: { en: 'Strum', ko: '밴조 튕기기' } },
99      { act: 'sip', level: 17, name: { en: 'Coffee sip', ko: '커피 한 모금' } },
100      { act: 'hatPeekaboo', level: 23, name: { en: 'Hat peekaboo', ko: '모자 까꿍' } },
101      { act: 'hum', level: 29, name: { en: 'Hum a tune', ko: '콧노래' } },
102      { act: 'twoStep', level: 35, name: { en: 'Two-step', ko: '투스텝' } },
103      { act: 'tumble', level: 42, name: { en: 'Tumble', ko: '데굴데굴' } },
104      { act: 'campfireSong', level: 50, name: { en: 'Campfire song', ko: '모닥불 노래' } },
105    ],
106    treat: { name: { en: 'Maple biscuit', ko: '메이플 비스킷' }, price: 6 },
107    items: [
108      { id: 'round-specs', slot: 'eyes', price: 50, level: 1, name: { en: 'Round specs', ko: '동그란 안경' } },
109      { id: 'straw', slot: 'mouth', price: 60, level: 3, name: { en: 'Wheat straw', ko: '밀짚 한 가닥' } },
110      { id: 'coffee', slot: 'hand', price: 250, level: 6, name: { en: 'Tin cup of coffee', ko: '양철 커피잔' } },
111      { id: 'bandana', slot: 'mouth', price: 500, level: 10, name: { en: 'Outlaw bandana', ko: '무법자 두건' } },
112      { id: 'banjo', slot: 'hand', price: 800, level: 14, name: { en: 'Banjo', ko: '밴조' } },
113      { id: 'sparrow', slot: 'head', price: 1_100, level: 19, name: { en: 'Sparrow friend', ko: '참새 친구' } },
114      { id: 'campfire', slot: 'stage', price: 1_400, level: 24, name: { en: 'Campfire', ko: '모닥불' } },
115      { id: 'black-hat', slot: 'hat', price: 1_900, level: 30, name: { en: 'Black hat', ko: '검은 카우보이모자' } },
116      { id: 'starry-night', slot: 'stage', price: 2_500, level: 38, name: { en: 'Starry night', ko: '별이 빛나는 밤' } },
117      { id: 'golden-banjo', slot: 'hand', price: 3_300, level: 46, name: { en: 'Golden banjo', ko: '황금 밴조' } },
118    ],
119  },
120]
121
122export function characterOf(id: CharacterId | null | undefined): Character {
123  return CHARACTERS.find(one => one.id === id) ?? CHARACTERS[0]!
124}
125
126export function isCharacterId(value: unknown): value is CharacterId {
127  return CHARACTERS.some(one => one.id === value)
128}
129
130// `locki/aviators` → the character and the item.
131export function itemOf(fullId: string): { character: Character; item: Item } | undefined {
132  const [owner, id] = fullId.split('/')
133  const character = CHARACTERS.find(one => one.id === owner)
134  const item = character?.items.find(one => one.id === id)
135
136  return character !== undefined && item !== undefined ? { character, item } : undefined
137}
138
139export function learnedTricks(character: Character, level: number): Trick[] {
140  return character.tricks.filter(trick => trick.level <= level)
141}
142
143export function fullId(character: CharacterId, item: string): string {
144  return `${character}/${item}`
145}
146
hooks/i18n.ts 354 lines
1import type { Language, ResultKind, RewardKind } from '../types'
2
3// Every word the pane, the toasts and the commands show. Item names and character
4// lines live with each character (characters/index.ts).
5
6type Words = {
7  ranks: readonly string[]
8  rewards: Record<RewardKind, string>
9  rep: string
10  gold: string
11  fans: (n: string) => string
12  maxLevel: string
13  recent: string
14  noRewards: string
15  shop: string
16  wear: string
17  takeOff: string
18  next: (level: number, name: string) => string
19  close: string
20  switchCharacter: string
21  scrollHint: string
22  pickTitle: string
23  pick: (name: string) => string
24  current: string
25  rewardLine: (label: string, gain: string) => string
26  repGain: (n: number) => string
27  levelUp: (level: number) => string
28  newRank: (rank: string) => string
29  levelToast: (level: number, label: string) => string
30  rankToast: (level: number, rank: string) => string
31  unlocked: (names: string) => string
32  bigToast: (label: string, fame: number, dust: number) => string
33  bought: (name: string) => string
34  broke: string
35  commands: { toggle: string; pick: string; status: string; rules: string; sound: string }
36  opened: string
37  closed: string
38  waiting: string
39  picked: (name: string) => string
40  unknownCharacter: (names: string) => string
41  status: (p: { name: string; level: number; rank: string; fame: number; progress: string; dust: number; fans: string; owned: number; total: number; tricks: number; allTricks: number }) => string
42  pet: string
43  trick: string
44  treat: (name: string, price: number) => string
45  noTreatGold: string
46  noTricks: string
47  nextTrick: (level: number, name: string) => string
48  learned: (name: string) => string
49  chestToast: (level: number, gold: number) => string
50  luckyToast: (gold: number) => string
51  bounties: string
52  quests: Record<string, string>
53  questLine: (label: string, have: number, target: number, gold: number, isDone: boolean) => string
54  bonusLine: (gold: number, isDone: boolean) => string
55  questToast: (label: string, gold: number) => string
56  bonusToast: (gold: number) => string
57  sound: Record<'normal' | 'quiet' | 'off', string>
58  soundSet: (now: string, next: string) => string
59  // How to drive the pane from the keyboard: before and after it takes the focus.
60  hint: { idle: string; focused: string; scroll: string }
61  bountyHeader: (done: number, total: number) => string
62  menu: { rules: string; status: string; back: string }
63  rules: {
64    title: string
65    intro: string
66    earn: string
67    when: Record<ResultKind, string>
68    values: (fame: number, dust: number) => string
69    today: (n: number) => string
70    limit: { taper: (full: number) => string; cap: (n: number) => string }
71    taper: string
72    commitBonus: string
73    bountyTitle: string
74    bounty: readonly string[]
75    bonusTitle: string
76    bonus: readonly string[]
77    noneTitle: string
78    none: string
79    fair: (hour: number, offset: string) => string
80  }
81  progress: {
82    title: string
83    toNext: (n: number, level: number) => string
84    today: string
85    nothingToday: string
86    count: (label: string, n: number) => string
87    tricks: (have: number, all: number) => string
88    items: (have: number, all: number) => string
89    allLearned: string
90    allUnlocked: string
91  }
92}
93
94const EN: Words = {
95  ranks: [
96    'Greenhorn', 'Stable Hand', 'Trail Rider', 'Wrangler', 'Rodeo Rookie', 'Deputy',
97    'Ranger', 'Sheriff', 'Marshal', 'Frontier Hero', 'Legend of the West',
98  ],
99  rewards: {
100    verify: 'Bullseye',
101    comeback: 'Showdown won',
102    commit: 'Claim staked',
103    merge: 'Gold strike',
104    plan: 'Trail mapped',
105    break: 'Campfire rest',
106    daily: 'Saddled up',
107    check: 'Clean shot',
108    pr: 'Poster up',
109    chest: 'Level chest',
110    quest: 'Bounty',
111    lucky: 'Lucky nugget',
112  },
113  rep: 'Rep',
114  gold: 'Gold',
115  fans: n => `${n} fans`,
116  maxLevel: 'max level',
117  recent: '▸ Recent rewards',
118  noRewards: 'Pass a test or commit to earn rewards',
119  shop: '▸ Shop',
120  wear: 'put on',
121  takeOff: 'take off',
122  next: (level, name) => `Next: Lv${level} ${name}`,
123  close: 'Close (/cappl)',
124  switchCharacter: 'Switch character',
125  scrollHint: '▾ More below ∙ Esc: leave',
126  pickTitle: 'Pick your partner',
127  pick: name => `Pick ${name}`,
128  current: ' (riding now)',
129  rewardLine: (label, gain) => `✦ ${label} ${gain}`,
130  repGain: n => `+${n} rep`,
131  levelUp: level => `✸ LEVEL UP! Lv${level}`,
132  newRank: rank => `✸ New rank! ${rank}`,
133  levelToast: (level, label) => `✦ Lv${level} reached: ${label}`,
134  rankToast: (level, rank) => `✸ Lv${level}, new rank: ${rank}`,
135  unlocked: names => `New in the shop: ${names}`,
136  bigToast: (label, fame, dust) => `${label} +${fame} rep +$${dust}`,
137  bought: name => `✦ ${name} on!`,
138  broke: 'Not enough gold yet.',
139  commands: {
140    toggle: 'Open or close the CAPPL pals pane',
141    pick: 'Pick your character (locki or cappl)',
142    status: 'Level, rank, rep and gold in one line',
143    rules: 'What earns rewards, and how many you got today',
144    sound: 'Sound effects: normal → quiet → off',
145  },
146  opened: 'Opened the CAPPL pals pane. /cappl closes it.',
147  closed: 'Closed the CAPPL pals pane. /cappl opens it again.',
148  waiting: 'The pane is waiting for room (make the terminal wider).',
149  picked: name => `${name} is riding with you now.`,
150  unknownCharacter: names => `No such character. Pick one of: ${names}`,
151  status: p =>
152    `${p.name} Lv${p.level} ${p.rank} · Rep ${p.fame} (${p.progress}) · Gold $${p.dust} · ${p.fans} · Items ${p.owned}/${p.total} · Tricks ${p.tricks}/${p.allTricks}`,
153  pet: 'Pet',
154  trick: 'Trick',
155  treat: (name, price) => `Treat: ${name} $${price}`,
156  noTreatGold: 'Not enough gold for a treat yet.',
157  noTricks: 'No tricks yet. The first one comes at Lv2.',
158  nextTrick: (level, name) => `Next trick: Lv${level} ${name}`,
159  learned: name => `New trick learned: ${name}!`,
160  chestToast: (level, gold) => `Lv${level} chest: +$${gold}`,
161  luckyToast: gold => `Lucky nugget! +$${gold}`,
162  bounties: "▸ Today's bounties",
163  // Bounties say what to do, in plain words (the reward names are flavour).
164  quests: {
165    bullseye3: 'Pass tests after edits ×3', commit2: 'Commit twice', check2: 'Clean check or build ×2',
166    comeback1: 'Fix a failing test', merge1: 'Merge a pull request', pr1: 'Open a pull request',
167    break1: 'Take a break, come back', plan1: 'Approve a plan first',
168    pet3: 'Pet your pal ×3', treat1: 'Give a treat', trick2: 'Ask for 2 tricks',
169  },
170  questLine: (label, have, target, gold, isDone) => (isDone ? `✓ ${label} +$${gold}` : `▸ ${label} ${have}/${target} $${gold}`),
171  bonusLine: (gold, isDone) => (isDone ? `✓ All three +$${gold}` : `  All three: +$${gold}`),
172  questToast: (label, gold) => `Bounty done: ${label} +$${gold}`,
173  bonusToast: gold => `All of today's bounties done! +$${gold}`,
174  sound: { normal: 'normal', quiet: 'quiet', off: 'off' },
175  soundSet: (now, next) => `Sound effects: ${now}. Run /cappl-sound again for ${next}.`,
176  hint: { idle: 'Click, or ctrl+x Tab for the keyboard', focused: '⇅ move ∙ Enter select ∙ Esc leave', scroll: 'Tab move ∙ ⇅ scroll ∙ Enter select' },
177  bountyHeader: (done, total) => `▸ Today's bounties ${done}/${total}`,
178  menu: { rules: 'Rules and bounties', status: 'My progress', back: 'Back' },
179  rules: {
180    title: 'How rewards work',
181    intro: 'Only results a person can check pay.',
182    earn: '▸ Results (rep, gold)',
183    when: {
184      verify: 'The first passing test after you edit files',
185      comeback: 'A failing test, fixed, then passing',
186      check: 'A type check, lint or build with no errors after edits',
187      commit: 'A commit (more if tested, or small)',
188      pr: '`gh pr create` opens a pull request',
189      merge: '`gh pr merge` merges one',
190      plan: 'A plan approved before editing',
191      break: 'Back from a 20-minute break after an hour of work',
192      daily: 'Your first prompt of the day',
193    },
194    values: (fame, dust) => (fame > 0 ? `+${fame} rep ∙ $${dust}` : `$${dust}`),
195    today: n => `today ${n}`,
196    limit: { taper: full => `full value ${full} a day`, cap: n => `up to ${n} a day` },
197    taper: 'Many of one kind in a day: each new one is worth half, then a fifth.',
198    commitBonus: 'A tested commit earns +5 rep and $3 more; a small one (8 edits or fewer), +2 rep.',
199    bountyTitle: '▸ Bounties',
200    bounty: ['Three a day: one with your pal, two from your work.', 'Each pays once. All three pay $15 more.'],
201    bonusTitle: '▸ Bonus gold',
202    bonus: ['Level chest: $5 + the level, at every level', 'Lucky nugget: about 1 result in 16, $5-15', 'Treat: $6, a snack and extra tricks'],
203    noneTitle: '▸ No reward',
204    none: 'Prompts, tokens, tool calls, lines of code',
205    fair: (hour, offset) => `No streaks, no penalties. A day starts at ${hour}:00 (UTC${offset}).`,
206  },
207  progress: {
208    title: 'My progress',
209    toNext: (n, level) => `${n} rep to Lv${level}`,
210    today: '▸ Today',
211    nothingToday: 'Nothing yet. Pass a test or commit.',
212    count: (label, n) => `${label} ×${n}`,
213    tricks: (have, all) => `▸ Tricks ${have}/${all}`,
214    items: (have, all) => `▸ Wardrobe ${have}/${all}`,
215    allLearned: 'Every trick learned',
216    allUnlocked: 'Every item unlocked',
217  },
218}
219
220const KO: Words = {
221  ranks: [
222    '새내기', '마구간지기', '길잡이 기수', '카우보이', '로데오 신인', '보안관 보좌관',
223    '레인저', '보안관', '연방 보안관', '개척지 영웅', '서부의 전설',
224  ],
225  rewards: {
226    verify: '과녁 명중',
227    comeback: '결투 승리',
228    commit: '말뚝 박기',
229    merge: '금맥 발견',
230    plan: '지도 펼치기',
231    break: '모닥불 휴식',
232    daily: '안장 얹기',
233    check: '깔끔한 사격',
234    pr: '전단 붙이기',
235    chest: '레벨 상자',
236    quest: '현상금',
237    lucky: '행운의 사금',
238  },
239  rep: '명성',
240  gold: '골드',
241  fans: n => `팬 ${n}명`,
242  maxLevel: '최고 레벨',
243  recent: '▸ 최근 보상',
244  noRewards: '테스트를 통과시키거나 커밋하면 보상이 쌓여요',
245  shop: '▸ 상점',
246  wear: '쓰기',
247  takeOff: '벗기',
248  next: (level, name) => `다음 소품: Lv${level} ${name}`,
249  close: '창 닫기 (/cappl)',
250  switchCharacter: '캐릭터 바꾸기',
251  scrollHint: '▾ 아래에 더 있어요 ∙ Esc 나가기',
252  pickTitle: '함께할 친구를 고르세요',
253  pick: name => `${name} 고르기`,
254  current: ' (지금 함께하는 중)',
255  rewardLine: (label, gain) => `✦ ${label} ${gain}`,
256  repGain: n => `+${n} 명성`,
257  levelUp: level => `✸ LEVEL UP! Lv${level}`,
258  newRank: rank => `✸ 새 계급! ${rank}`,
259  levelToast: (level, label) => `✦ Lv${level} 달성: ${label}`,
260  rankToast: (level, rank) => `✸ Lv${level}, 새 계급: ${rank}`,
261  unlocked: names => `상점에 새 소품: ${names}`,
262  bigToast: (label, fame, dust) => `${label} +${fame} 명성 +$${dust}`,
263  bought: name => `✦ ${name} 착용!`,
264  broke: '골드가 아직 모자라요.',
265  commands: {
266    toggle: 'CAPPL 창 열기·닫기',
267    pick: '캐릭터 고르기(locki 또는 cappl)',
268    status: '레벨·계급·명성·골드 한 줄 요약',
269    rules: '보상표와 오늘 받은 횟수',
270    sound: '효과음 크기: 보통 → 작게 → 끔',
271  },
272  opened: 'CAPPL 창을 열었어요. 닫으려면 /cappl',
273  closed: 'CAPPL 창을 닫았어요. 다시 열려면 /cappl',
274  waiting: '창이 열릴 자리를 기다려요(터미널을 넓혀 주세요).',
275  picked: name => `이제 함께하는 친구: ${name}`,
276  unknownCharacter: names => `그런 캐릭터는 없어요. 고를 수 있는 이름: ${names}`,
277  status: p =>
278    `${p.name} Lv${p.level} ${p.rank} · 명성 ${p.fame} (${p.progress}) · 골드 $${p.dust} · ${p.fans} · 소품 ${p.owned}/${p.total} · 재주 ${p.tricks}/${p.allTricks}`,
279  pet: '쓰다듬기',
280  trick: '재주',
281  treat: (name, price) => `간식: ${name} $${price}`,
282  noTreatGold: '간식 살 골드가 아직 모자라요.',
283  noTricks: '아직 배운 재주가 없어요. 첫 재주는 Lv2에 배워요.',
284  nextTrick: (level, name) => `다음 재주: Lv${level} ${name}`,
285  learned: name => `새 재주를 배웠어요: ${name}!`,
286  chestToast: (level, gold) => `Lv${level} 상자: +$${gold}`,
287  luckyToast: gold => `행운의 사금! +$${gold}`,
288  bounties: '▸ 오늘의 현상금',
289  quests: {
290    bullseye3: '고친 뒤 테스트 통과 3번', commit2: '커밋 2번', check2: '오류 없는 검사/빌드 2번',
291    comeback1: '실패한 테스트 고치기', merge1: 'PR 병합하기', pr1: 'PR 열기',
292    break1: '쉬었다 돌아오기', plan1: '고치기 전에 계획 승인',
293    pet3: '쓰다듬기 3번', treat1: '간식 주기', trick2: '재주 2번 보기',
294  },
295  questLine: (label, have, target, gold, isDone) => (isDone ? `✓ ${label} +$${gold}` : `▸ ${label} ${have}/${target} $${gold}`),
296  bonusLine: (gold, isDone) => (isDone ? `✓ 셋 다 완료 +$${gold}` : `  셋 다 하면 +$${gold}`),
297  questToast: (label, gold) => `현상금 완료: ${label} +$${gold}`,
298  bonusToast: gold => `오늘 현상금을 모두 마쳤어요! +$${gold}`,
299  sound: { normal: '보통', quiet: '작게', off: '끔' },
300  soundSet: (now, next) => `효과음: ${now}. /cappl-sound를 한 번 더 입력하면 ${next}`,
301  hint: { idle: '클릭하거나 ctrl+x Tab으로 조작', focused: '⇅ 이동 ∙ Enter 선택 ∙ Esc 나가기', scroll: 'Tab 이동 ∙ ⇅ 스크롤 ∙ Enter 선택' },
302  bountyHeader: (done, total) => `▸ 오늘의 현상금 ${done}/${total}`,
303  menu: { rules: '규칙과 현상금', status: '내 현황', back: '돌아가기' },
304  rules: {
305    title: '보상 규칙',
306    intro: '사람이 확인할 수 있는 결과에만 보상해요.',
307    earn: '▸ 결과 보상 (명성, 골드)',
308    when: {
309      verify: '파일을 고친 뒤 처음 통과한 테스트',
310      comeback: '실패한 테스트를 고쳐서 통과',
311      check: '고친 뒤 오류 없이 끝난 타입 검사, 린트, 빌드',
312      commit: '커밋 (테스트로 확인했거나 작으면 더 받아요)',
313      pr: '`gh pr create`로 PR을 열었을 때',
314      merge: '`gh pr merge`로 병합했을 때',
315      plan: '고치기 전에 계획을 승인했을 때',
316      break: '한 시간 넘게 일하고 20분 넘게 쉬었다 돌아옴',
317      daily: '그날 첫 프롬프트',
318    },
319    values: (fame, dust) => (fame > 0 ? `명성 +${fame} ∙ $${dust}` : `$${dust}`),
320    today: n => `오늘 ${n}번`,
321    limit: { taper: full => `하루 ${full}번까지 제값`, cap: n => `하루 ${n}번까지` },
322    taper: '같은 보상을 하루에 많이 받으면 값이 절반, 다시 5분의 1로 줄어요.',
323    commitBonus: '테스트로 확인한 커밋은 명성 +5와 $3을, 파일 수정 8번 이하의 작은 커밋은 명성 +2를 더 받아요.',
324    bountyTitle: '▸ 현상금',
325    bounty: ['하루 세 개: 친구와 하는 일 하나, 작업 결과 둘.', '하나는 한 번만 받고, 셋 다 하면 $15를 더 받아요.'],
326    bonusTitle: '▸ 보너스 골드',
327    bonus: ['레벨 상자: 레벨이 오를 때마다 $5 + 레벨', '행운의 사금: 결과 16번에 한 번꼴로 $5-15', '간식: $6, 먹으면 재주를 더 자주 부려요'],
328    noneTitle: '▸ 보상 없음',
329    none: '프롬프트, 토큰, 도구 호출, 코드 줄 수',
330    fair: (hour, offset) => `연속 출석 기록도 벌칙도 없어요. 하루는 ${hour}시에 바뀌어요(UTC${offset}).`,
331  },
332  progress: {
333    title: '내 현황',
334    toNext: (n, level) => `Lv${level}까지 명성 ${n}`,
335    today: '▸ 오늘',
336    nothingToday: '아직 없어요. 테스트를 통과하거나 커밋해 보세요.',
337    count: (label, n) => `${label} ${n}번`,
338    tricks: (have, all) => `▸ 재주 ${have}/${all}`,
339    items: (have, all) => `▸ 옷장 ${have}/${all}`,
340    allLearned: '재주를 모두 배웠어요',
341    allUnlocked: '소품이 모두 열렸어요',
342  },
343}
344
345export const WORDS: Readonly<Record<Language, Words>> = { en: EN, ko: KO }
346
347// `auto` follows Claude Code's own `language` setting ("Korean", "한국어", "ko"...).
348export function languageOf(option: unknown, setting: unknown): Language {
349  if (option === 'en' || option === 'ko') return option
350  const text = typeof setting === 'string' ? setting.trim().toLowerCase() : ''
351
352  return /^(ko|kor)|korean|한국/.test(text) ? 'ko' : 'en'
353}
354
hooks/rules.ts 487 lines
1import type { CareKind, CharacterId, DayCounts, Ledger, ResultKind, RewardKind, Totals, Wallet } from '../types'
2import { CHARACTERS, itemOf } from './characters'
3
4// ── Reward table ───────────────────────────────────────────────────────────
5// Principle (docs/reward-design.md): pay for results a person can check, not for
6// how much Claude did. Prompts, turns, tool calls, tokens and lines earn nothing.
7// After `full` of one kind in a day a reward is worth half, after twice that a
8// fifth; a kind with `cap` stops there.
9
10type Rule = { fame: number; dust: number; full: number; cap?: number }
11
12export const RULES: Record<ResultKind, Rule> = {
13  verify: { fame: 5, dust: 3, full: 12 },
14  check: { fame: 2, dust: 2, full: 15 },
15  pr: { fame: 4, dust: 3, full: 5 },
16  comeback: { fame: 8, dust: 5, full: 5 },
17  commit: { fame: 3, dust: 2, full: 10 },
18  merge: { fame: 15, dust: 10, full: 4 },
19  plan: { fame: 3, dust: 2, full: 5 },
20  break: { fame: 2, dust: 4, full: 4, cap: 4 },
21  daily: { fame: 0, dust: 10, full: 1, cap: 1 },
22}
23
24// Commit bonus: more for a change tested since the last commit, and for a small one.
25// Agents mostly commit with `git commit -q`, which prints no line counts, so the
26// size is guessed from the number of file edits since the last commit.
27export const COMMIT_VERIFIED = { fame: 5, dust: 3 }
28export const COMMIT_SMALL = { fame: 2, dust: 0 }
29export const SMALL_COMMIT_EDITS = 8
30
31// A day starts at 05:00 on this computer's clock, so a late night counts as the day before.
32export const DAY_STARTS_AT_HOUR = 5
33
34// A break: back after 20 minutes to 4 hours away, having worked over an hour straight.
35export const BREAK_AFTER_WORK_MS = 60 * 60_000
36export const BREAK_MIN_GAP_MS = 20 * 60_000
37export const BREAK_MAX_GAP_MS = 4 * 60 * 60_000
38export const CONTINUOUS_GAP_MS = 15 * 60_000
39
40// `offsetMinutes` is the local clock's distance from UTC (+540 in Seoul).
41export function dayOf(now: number, offsetMinutes: number): string {
42  const shifted = now + (offsetMinutes - DAY_STARTS_AT_HOUR * 60) * 60_000
43
44  return new Date(shifted).toISOString().slice(0, 10)
45}
46
47export function rateOf(kind: ResultKind, countToday: number): number {
48  const rule = RULES[kind]
49  if (rule.cap !== undefined && countToday >= rule.cap) return 0
50  if (countToday < rule.full) return 1
51  if (countToday < rule.full * 2) return 0.5
52
53  return 0.2
54}
55
56export type Earn = { kind: ResultKind; isSmall?: boolean; isVerified?: boolean }
57
58export function valueOf(earn: Earn, countToday: number): { fame: number; dust: number; rate: number } {
59  const rule = RULES[earn.kind]
60  let fame = rule.fame
61  let dust = rule.dust
62  if (earn.kind === 'commit') {
63    if (earn.isVerified === true) {
64      fame += COMMIT_VERIFIED.fame
65      dust += COMMIT_VERIFIED.dust
66    }
67    if (earn.isSmall === true) {
68      fame += COMMIT_SMALL.fame
69      dust += COMMIT_SMALL.dust
70    }
71  }
72  const rate = rateOf(earn.kind, countToday)
73  const scaled = (n: number) => (n === 0 || rate === 0 ? 0 : Math.max(1, Math.round(n * rate)))
74
75  return { fame: scaled(fame), dust: scaled(dust), rate }
76}
77
78// ── Reading Bash results ──────────────────────────────────────────────────
79// The exit code is not trusted: agents pipe test runs (`pytest | tail -5`), which
80// turns a failure into 0 (about a third of test commands in a month of logs).
81// The runner's summary line decides instead.
82
83const TEST_COMMAND =
84  /\b(?:(?:npm|pnpm|yarn)\s+(?:run\s+)?test(?::\S+)?|bun\s+(?:run\s+)?test|npx\s+(?:vitest|jest)|vitest|jest|pytest|py\.test|go\s+test|cargo\s+(?:test|nextest)|make\s+test|mvn\s+(?:\S+\s+)*test|gradlew?\s+(?:\S+\s+)*test|dotnet\s+test|rspec|phpunit|flutter\s+test|dart\s+test|php\s+artisan\s+test|deno\s+test|claude\s+plugin\s+test)\b/
85// node --test prints the count after the word: `ℹ pass 3`, `# fail 1`.
86const FAIL_COUNT = /(\d+)\s+(?:failed|failing|failures?|errors?|fail)\b|^[#ℹ]\s*fail\s+(\d+)/gim
87const PASS_COUNT = /(\d+)\s+(?:passed|passing|pass)\b|(\d+)\s+examples?,\s+0\s+failures|OK\s+\((\d+)\s+tests?|^[#ℹ]\s*pass\s+(\d+)/gim
88const FAIL_MARK = /Some tests failed|FAILURES!|^FAIL\b|^--- FAIL|test result: FAILED|^FAILED\b|Tests? failed/m
89const PASS_MARK = /All tests passed!|^ok\s+\S+|test result: ok\./m
90
91export function isTestCommand(command: string): boolean {
92  return TEST_COMMAND.test(command)
93}
94
95export function testOutcome(command: string, output: string, isError: boolean): boolean | undefined {
96  if (!TEST_COMMAND.test(command)) return undefined
97  const failed = sum(output, FAIL_COUNT)
98  const passed = sum(output, PASS_COUNT)
99  if (failed > 0 || FAIL_MARK.test(output)) return false
100  if (passed > 0 || PASS_MARK.test(output)) return true
101
102  // No summary: an error exit still counts as a failure (a compile error); a pass needs a summary.
103  return isError ? false : undefined
104}
105
106function sum(text: string, pattern: RegExp): number {
107  let total = 0
108  for (const match of text.matchAll(pattern)) {
109    total += Number(match[1] ?? match[2] ?? match[3] ?? match[4] ?? 0)
110  }
111
112  return total
113}
114
115// Empty commits, --amend and dry runs are not new results.
116const COMMIT_COMMAND = /\bgit\s+(?:-C\s+\S+\s+)?commit\b(?!.*(?:--dry-run|--allow-empty|--amend))/
117const COMMIT_EMPTY = /nothing to commit|nothing added to commit|no changes added to commit/i
118
119export function commitOutcome(command: string, output: string, isError: boolean): boolean {
120  return !isError && COMMIT_COMMAND.test(command) && !COMMIT_EMPTY.test(output)
121}
122
123// A type check, lint or build that ends clean after edits. These rarely pipe, and
124// print errors when they fail, so a clean exit with no error line is a pass. The tool
125// must be what runs (first in the command or after `&&`, `;`, `|`, `npx` and the like),
126// so `npm install -D eslint`, `grep tsc` or `tsc --version` are not checks.
127const RUN_AT = String.raw`(?:^|\n|&&|\|\||;|\||\$\(|\b(?:npx|bunx|pnpm\s+(?:exec|dlx)|yarn\s+(?:exec|dlx)|uv\s+run|poetry\s+run|python3?\s+-m))\s*`
128const CHECK_TOOL = String.raw`(?:tsc|vue-tsc|eslint|biome\s+(?:check|lint)|ruff\s+check|mypy|pyright|flake8|pylint|cargo\s+(?:check|build|clippy)|go\s+(?:build|vet)|(?:npm|pnpm|yarn|bun)\s+(?:run\s+)?(?:build|lint|typecheck|type-check|check)|swift\s+build|dotnet\s+build|gradlew?\s+build|mvn\s+(?:\S+\s+)*compile|make\s+(?:build|lint)|claude\s+plugin\s+validate)\b`
129const CHECK_COMMAND = new RegExp(`${RUN_AT}(?:\\S*/)?${CHECK_TOOL}`)
130const CHECK_NOT_RUN = /\s--(?:version|help|init)\b|\s-v\s*$/
131const CHECK_FAIL = /\berror(?![-.\w/])|\berrors?:|\b[1-9]\d*\s+errors?\b|✖|✘|\b[1-9]\d*\s+problems?\b|\bfailed\b|FAILED|Traceback|panicked/i
132const CHECK_CLEAN = /\b0 errors?\b|\bno (?:errors|issues|problems)\b|found 0/i
133
134export function checkOutcome(command: string, output: string, isError: boolean): boolean | undefined {
135  if (!CHECK_COMMAND.test(command) || CHECK_NOT_RUN.test(command) || TEST_COMMAND.test(command)) return undefined
136  if (isError) return false
137  if (CHECK_FAIL.test(output) && !CHECK_CLEAN.test(output)) return false
138
139  return true
140}
141
142// `gh pr create` prints the new PR's address; "already exists" names an old one.
143const PR_COMMAND = /\bgh\s+pr\s+create\b/
144export function prOutcome(command: string, output: string, isError: boolean): boolean {
145  return !isError && PR_COMMAND.test(command) && /https:\/\/github\.com\/\S+\/pull\/\d+/.test(output) && !/already exists/i.test(output)
146}
147
148const MERGE_COMMAND = /\bgh\s+pr\s+merge\b/
149export function mergeOutcome(command: string, output: string, isError: boolean): boolean {
150  return (
151    !isError &&
152    MERGE_COMMAND.test(command) &&
153    /merged/i.test(output) &&
154    !/automatically|auto-merge|will be merged/i.test(output)
155  )
156}
157
158// ── The work loop: edit → test → commit ────────────────────────────────────
159
160export type Work = {
161  isDirty: boolean
162  isFailing: boolean
163  isFixing: boolean
164  isVerified: boolean
165  // Edits since the last clean type check, lint or build.
166  isUnchecked: boolean
167  edits: number
168}
169export const FRESH_WORK: Work = { isDirty: false, isFailing: false, isFixing: false, isVerified: false, isUnchecked: false, edits: 0 }
170
171export type Signal =
172  | { type: 'edit' }
173  | { type: 'test'; isPass: boolean }
174  | { type: 'check'; isPass: boolean }
175  | { type: 'commit' }
176  | { type: 'merge' }
177  | { type: 'pr' }
178  | { type: 'plan' }
179
180export function observe(work: Work, signal: Signal): { work: Work; earned: Earn[] } {
181  switch (signal.type) {
182    case 'edit':
183      return { work: { ...work, isDirty: true, isUnchecked: true, isFixing: work.isFailing, edits: work.edits + 1 }, earned: [] }
184    case 'test': {
185      if (!signal.isPass) {
186        return { work: { ...work, isFailing: true, isFixing: false }, earned: [] }
187      }
188      const earned: Earn[] = []
189      if (work.isDirty) earned.push({ kind: 'verify' })
190      if (work.isFailing && work.isFixing) earned.push({ kind: 'comeback' })
191
192      return {
193        work: {
194          ...work,
195          isDirty: false,
196          isFailing: false,
197          isFixing: false,
198          isVerified: work.isVerified || work.isDirty,
199        },
200        earned,
201      }
202    }
203    case 'commit':
204      // Agents also write files through Bash, so a commit with no edits still earns the base;
205      // only the small-commit bonus needs counted edits.
206      return {
207        work: { ...work, isVerified: false, edits: 0 },
208        earned: [
209          { kind: 'commit', isSmall: work.edits > 0 && work.edits <= SMALL_COMMIT_EDITS, isVerified: work.isVerified },
210        ],
211      }
212    case 'check':
213      if (!signal.isPass) return { work, earned: [] }
214      return { work: { ...work, isUnchecked: false }, earned: work.isUnchecked ? [{ kind: 'check' }] : [] }
215    case 'merge':
216      return { work, earned: [{ kind: 'merge' }] }
217    case 'pr':
218      return { work, earned: [{ kind: 'pr' }] }
219    case 'plan':
220      return { work, earned: work.isDirty ? [] : [{ kind: 'plan' }] }
221  }
222}
223
224// ── Breaks and the first prompt of the day ─────────────────────────────────
225
226export function isBreakOver(lastAt: number, activeSince: number, now: number): boolean {
227  const gap = now - lastAt
228
229  return (
230    lastAt > 0 &&
231    gap >= BREAK_MIN_GAP_MS &&
232    gap <= BREAK_MAX_GAP_MS &&
233    lastAt - activeSince >= BREAK_AFTER_WORK_MS
234  )
235}
236
237// Records activity; a gap over 15 minutes starts a new stretch of work.
238export function touch(ledger: Ledger, now: number): Ledger {
239  const isContinuous = now - ledger.lastAt <= CONTINUOUS_GAP_MS
240
241  return { ...ledger, lastAt: now, activeSince: isContinuous ? ledger.activeSince : now }
242}
243
244// ── Ledgers ───────────────────────────────────────────────────────────────
245
246export function emptyLedger(): Ledger {
247  return { fame: 0, dust: 0, days: {}, lastAt: 0, activeSince: 0, recent: [] }
248}
249
250export function readLedger(raw: unknown): Ledger {
251  if (typeof raw !== 'object' || raw === null) return emptyLedger()
252  const value = raw as Partial<Ledger>
253
254  return {
255    fame: typeof value.fame === 'number' ? value.fame : 0,
256    dust: typeof value.dust === 'number' ? value.dust : 0,
257    days: typeof value.days === 'object' && value.days !== null ? value.days : {},
258    lastAt: typeof value.lastAt === 'number' ? value.lastAt : 0,
259    activeSince: typeof value.activeSince === 'number' ? value.activeSince : 0,
260    recent: Array.isArray(value.recent) ? value.recent : [],
261  }
262}
263
264// Keeps today's and yesterday's counts only (the daily taper reads today's).
265export function credit(ledger: Ledger, kind: RewardKind, day: string, fame: number, dust: number): Ledger {
266  const counts: DayCounts = ledger.days[day] ?? {}
267  const kept = Object.fromEntries(Object.entries(ledger.days).filter(([key]) => key >= previousDay(day)))
268
269  return {
270    ...ledger,
271    fame: ledger.fame + fame,
272    dust: ledger.dust + dust,
273    days: { ...kept, [day]: { ...counts, [kind]: (counts[kind] ?? 0) + 1 } },
274  }
275}
276
277function previousDay(day: string): string {
278  return new Date(Date.parse(`${day}T00:00:00Z`) - 86_400_000).toISOString().slice(0, 10)
279}
280
281// Counts one more pet, treat or trick for the day, with nothing paid.
282export function tally(ledger: Ledger, kind: CareKind, day: string): Ledger {
283  const counts: DayCounts = ledger.days[day] ?? {}
284  const kept = Object.fromEntries(Object.entries(ledger.days).filter(([key]) => key >= previousDay(day)))
285
286  return { ...ledger, days: { ...kept, [day]: { ...counts, [kind]: (counts[kind] ?? 0) + 1 } } }
287}
288
289export function countToday(ledgers: readonly Ledger[], kind: RewardKind | CareKind, day: string): number {
290  return ledgers.reduce((total, ledger) => total + (ledger.days[day]?.[kind] ?? 0), 0)
291}
292
293export function totalsOf(ledgers: readonly Ledger[], wallet: Wallet): Totals {
294  const fame = ledgers.reduce((total, ledger) => total + ledger.fame, 0)
295  const dust = ledgers.reduce((total, ledger) => total + ledger.dust, 0)
296
297  return { fame, dust: dust - wallet.spent }
298}
299
300// ── Growth: levels and ranks ───────────────────────────────────────────────
301// Rep needed for the next level = round(LEVEL_BASE × level^LEVEL_POWER), up to 50.
302// Tuned so a steady user reaches 50 in about 1,000 active hours (docs/reward-design.md).
303
304export const MAX_LEVEL = 50
305export const LEVEL_BASE = 30
306export const LEVEL_POWER = 0.9
307
308export function needFor(level: number): number {
309  return Math.round(LEVEL_BASE * level ** LEVEL_POWER)
310}
311
312export function fameFor(level: number): number {
313  let total = 0
314  for (let l = 1; l < level; l += 1) total += needFor(l)
315
316  return total
317}
318
319export function levelOf(fame: number): { level: number; into: number; need: number } {
320  let level = 1
321  let rest = fame
322  while (level < MAX_LEVEL && rest >= needFor(level)) {
323    rest -= needFor(level)
324    level += 1
325  }
326
327  return level === MAX_LEVEL ? { level, into: 0, need: 0 } : { level, into: rest, need: needFor(level) }
328}
329
330// A new rank every five levels; the names are in i18n.ts (`ranks`).
331export const RANK_LEVELS: readonly number[] = [1, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50]
332
333export function rankOf(level: number): number {
334  let rank = 0
335  RANK_LEVELS.forEach((from, index) => {
336    if (level >= from) rank = index
337  })
338
339  return rank
340}
341
342// Fans are a decorative number that grows with rep (a taste of an idle game).
343export function fansOf(fame: number): number {
344  return Math.floor(fame * 3 + (fame / 40) ** 2)
345}
346
347// ── Bonuses: level chests, lucky nuggets, the day's bounties ───────────────────
348
349// Every level reached opens a chest of gold, bigger as levels go up.
350export function chestFor(level: number): number {
351  return 5 + level
352}
353
354export function chestsBetween(from: number, to: number): number {
355  let gold = 0
356  for (let level = from + 1; level <= to; level += 1) gold += chestFor(level)
357
358  return gold
359}
360
361function hash(n: number): number {
362  let x = Math.imul(n | 0, 2654435761) >>> 0
363  x ^= x >>> 15
364  x = Math.imul(x, 2246822519) >>> 0
365  x ^= x >>> 13
366
367  return x >>> 0
368}
369
370export function seedOf(text: string): number {
371  let n = 7
372  for (let i = 0; i < text.length; i += 1) n = (Math.imul(n, 31) + text.charCodeAt(i)) | 0
373
374  return hash(n)
375}
376
377// About one result in sixteen turns up a lucky nugget of $5–$15.
378export const LUCKY_ODDS = 16
379export function luckyNugget(seed: number): number {
380  const roll = hash(seed)
381  if (roll % LUCKY_ODDS !== 0) return 0
382
383  return 5 + (Math.floor(roll / LUCKY_ODDS) % 11)
384}
385
386export type Quest = { id: string; count: ResultKind | CareKind; target: number; gold: number }
387
388// Work bounties, weighted toward the ones most days can finish.
389const WORK_QUESTS: readonly (Quest & { weight: number })[] = [
390  { id: 'bullseye3', count: 'verify', target: 3, gold: 8, weight: 3 },
391  { id: 'commit2', count: 'commit', target: 2, gold: 8, weight: 3 },
392  { id: 'check2', count: 'check', target: 2, gold: 8, weight: 2 },
393  { id: 'comeback1', count: 'comeback', target: 1, gold: 10, weight: 1 },
394  { id: 'merge1', count: 'merge', target: 1, gold: 15, weight: 1 },
395  { id: 'pr1', count: 'pr', target: 1, gold: 10, weight: 1 },
396  { id: 'break1', count: 'break', target: 1, gold: 8, weight: 1 },
397  { id: 'plan1', count: 'plan', target: 1, gold: 8, weight: 1 },
398]
399const CARE_QUESTS: readonly Quest[] = [
400  { id: 'pet3', count: 'pet', target: 3, gold: 5 },
401  { id: 'treat1', count: 'treat', target: 1, gold: 5 },
402  { id: 'trick2', count: 'trick', target: 2, gold: 5 },
403]
404// Paid once all three of the day's bounties are done.
405export const BOUNTY_BONUS = 15
406
407// The day's three bounties: one to do with the pal, two from the work.
408export function questsOf(day: string): Quest[] {
409  const seed = seedOf(day)
410  const care = CARE_QUESTS[seed % CARE_QUESTS.length]!
411  const bag = WORK_QUESTS.flatMap(quest => Array.from({ length: quest.weight }, () => quest))
412  const first = bag[hash(seed + 1) % bag.length]!
413  const rest = bag.filter(quest => quest.id !== first.id)
414  const second = rest[hash(seed + 2) % rest.length]!
415  const strip = ({ id, count, target, gold }: Quest): Quest => ({ id, count, target, gold })
416
417  return [strip(care), strip(first), strip(second)]
418}
419
420export type Bounty = { done: string[]; bonus: boolean }
421
422export function readBounty(raw: unknown): Bounty {
423  if (typeof raw !== 'object' || raw === null) return { done: [], bonus: false }
424  const value = raw as Partial<Bounty>
425
426  return { done: Array.isArray(value.done) ? value.done.filter(id => typeof id === 'string') : [], bonus: value.bonus === true }
427}
428
429// What finishing the day's bounties pays now, given today's counts and what was paid.
430export function settleBounty(quests: readonly Quest[], count: (kind: Quest['count']) => number, bounty: Bounty) {
431  const finished = quests.filter(quest => !bounty.done.includes(quest.id) && count(quest.count) >= quest.target)
432  const done = [...bounty.done, ...finished.map(quest => quest.id)]
433  const isBonus = !bounty.bonus && quests.every(quest => done.includes(quest.id))
434
435  return { finished, isBonus, bounty: { done, bonus: bounty.bonus || isBonus } }
436}
437
438// ── The shop ───────────────────────────────────────────────────────────────
439// Each character has its own wardrobe (characters/index.ts); one item per slot.
440
441export function emptyWallet(): Wallet {
442  return { spent: 0, owned: [], equipped: [] }
443}
444
445export function readWallet(raw: unknown): Wallet {
446  if (typeof raw !== 'object' || raw === null) return emptyWallet()
447  const value = raw as Partial<Wallet>
448  const ids = (list: unknown) => (Array.isArray(list) ? list.filter(id => typeof id === 'string' && itemOf(id) !== undefined) : [])
449
450  return {
451    spent: typeof value.spent === 'number' ? value.spent : 0,
452    owned: ids(value.owned),
453    equipped: ids(value.equipped),
454  }
455}
456
457export function buy(wallet: Wallet, totals: Totals, level: number, id: string): Wallet | undefined {
458  const found = itemOf(id)
459  if (found === undefined || wallet.owned.includes(id) || level < found.item.level || totals.dust < found.item.price) {
460    return undefined
461  }
462
463  return wear({ ...wallet, spent: wallet.spent + found.item.price, owned: [...wallet.owned, id] }, id)
464}
465
466// Puts an owned item on, or takes it off when it is on. Another item in the same
467// slot of the same character comes off; the other character's items stay.
468export function wear(wallet: Wallet, id: string): Wallet {
469  const found = itemOf(id)
470  if (found === undefined || !wallet.owned.includes(id)) return wallet
471  if (wallet.equipped.includes(id)) {
472    return { ...wallet, equipped: wallet.equipped.filter(one => one !== id) }
473  }
474  const kept = wallet.equipped.filter(one => {
475    const other = itemOf(one)
476    return other === undefined || other.character.id !== found.character.id || other.item.slot !== found.item.slot
477  })
478
479  return { ...wallet, equipped: [...kept, id] }
480}
481
482export function wornBy(wallet: Wallet, character: CharacterId): string[] {
483  return wallet.equipped.filter(id => itemOf(id)?.character.id === character).map(id => id.split('/')[1]!)
484}
485
486export const SHOP_SIZE = CHARACTERS.reduce((total, one) => total + one.items.length, 0)
487
hooks/sound.ts 87 lines
1import type { EngineInterface } from 'claude-code'
2
3import type { CharacterId } from '../types'
4
5// Sound effects. macOS plays through the engine's $.audio.play (afplay); the engine
6// is silent on Linux and Windows, so WSL and Windows go through PowerShell's
7// SoundPlayer and other Linux systems through paplay, pw-play or aplay.
8// Each character has its own voice: assets/sfx/<character>/<name>.wav.
9
10export type Sfx =
11  | 'coin' | 'comeback' | 'fanfare' | 'levelup' | 'stage' | 'buy' | 'equip' | 'error' | 'open' | 'close' | 'hello'
12  | 'pet' | 'treat' | 'trick' | 'fail' | 'lucky' | 'quest' | 'chest'
13
14const PRIORITY: Record<Sfx, number> = {
15  open: 0, close: 0, equip: 1, error: 1, fail: 1, pet: 2, coin: 2, trick: 2, treat: 3, hello: 3, buy: 3, comeback: 3,
16  lucky: 4, quest: 4, fanfare: 4, chest: 5, levelup: 5, stage: 6,
17}
18
19type Player =
20  | { kind: 'mac' }
21  | { kind: 'powershell'; root: string }
22  | { kind: 'linux'; bin: string; root: string }
23  | { kind: 'none' }
24
25const audio = { player: undefined as Promise<Player> | undefined, lastAt: 0, lastPriority: -1 }
26
27async function detectPlayer($: EngineInterface): Promise<Player> {
28  const root = $.plugin.root
29  try {
30    const os = await $.process.run(['uname', '-s'], { timeoutMs: 3000 })
31    if (os.stdout.trim() === 'Darwin') return { kind: 'mac' }
32    const wsl = await $.process.run(
33      ['sh', '-c', 'command -v powershell.exe >/dev/null 2>&1 && wslpath -w "$1"', 'sh', root],
34      { timeoutMs: 3000 },
35    )
36    if (wsl.exitCode === 0 && wsl.stdout.trim() !== '') return { kind: 'powershell', root: wsl.stdout.trim() }
37    const bin = await $.process.run(['sh', '-c', 'command -v paplay || command -v pw-play || command -v aplay'], {
38      timeoutMs: 3000,
39    })
40    const found = bin.stdout.trim().split('\n')[0] ?? ''
41    return bin.exitCode === 0 && found !== '' ? { kind: 'linux', bin: found, root } : { kind: 'none' }
42  } catch {
43    // No uname: Claude Code runs on Windows itself.
44    try {
45      await $.process.run(['powershell.exe', '-NoProfile', '-NonInteractive', '-Command', 'exit'], { timeoutMs: 5000 })
46      return { kind: 'powershell', root }
47    } catch {
48      return { kind: 'none' }
49    }
50  }
51}
52
53// Volume: normal (about a chat notification) · quiet (about the Windows "Ding") · off.
54export type SoundLevel = 'normal' | 'quiet' | 'off'
55export const SOUND_LEVELS: readonly SoundLevel[] = ['normal', 'quiet', 'off']
56
57export async function soundLevel($: EngineInterface): Promise<SoundLevel> {
58  const value = await $.store.get('sound')
59  if (value === false || value === 'off') return 'off'
60  return value === 'quiet' ? 'quiet' : 'normal'
61}
62
63// Sounds that land together keep only the most important (no coin over a level-up).
64export async function play($: EngineInterface, name: Sfx, character: CharacterId): Promise<void> {
65  const level = await soundLevel($)
66  if (level === 'off') return
67  const now = await $.clock.now()
68  if (now - audio.lastAt < 400 && PRIORITY[name] <= audio.lastPriority) return
69  audio.lastAt = now
70  audio.lastPriority = PRIORITY[name]
71  audio.player ??= detectPlayer($)
72  const player = await audio.player
73  const file = level === 'quiet' ? `assets/sfx/${character}/quiet/${name}.wav` : `assets/sfx/${character}/${name}.wav`
74  if (player.kind === 'mac') {
75    void $.audio.play({ asset: file }).catch(() => undefined)
76  } else if (player.kind === 'powershell') {
77    const path = `${player.root}\\${file.replaceAll('/', '\\')}`.replaceAll("'", "''")
78    void $.process
79      .run(['powershell.exe', '-NoProfile', '-NonInteractive', '-Command', `(New-Object Media.SoundPlayer '${path}').PlaySync()`], {
80        timeoutMs: 10000,
81      })
82      .catch(() => undefined)
83  } else if (player.kind === 'linux') {
84    void $.process.run([player.bin, `${player.root}/${file}`], { timeoutMs: 10000 }).catch(() => undefined)
85  }
86}
87
hooks/sprite.ts 435 lines
1import type { CharacterId, FxKind } from '../types'
2import { characterOf } from './characters'
3import {
4  BODY,
5  BUBBLES,
6  ITEM_ART,
7  PALETTE,
8  TREAT_ART,
9  type BodyArt,
10  type BrowKind,
11  type BubbleKind,
12  type EyeKind,
13  type ItemArt,
14  type ItemLayer,
15  type Layer,
16  type MouthKind,
17} from './characters/art'
18
19// Composes a character frame from its layers (characters/art.ts) and a Look: which
20// eyes, mouth, paws and extras to show this tick (hooks/animations.ts decides). One
21// terminal cell holds two pixels stacked with a half block, so 32×28 pixels is 32×14 cells.
22
23export type Size = 32 | 16
24export type PawPose = 'rest' | 'tapL' | 'tapR' | 'cheer' | 'waveHi' | 'waveLo' | 'chin' | 'hold' | 'up' | 'stretch' | 'cover'
25export type Particles = 'hearts' | 'stars' | 'notes' | 'confetti'
26
27export type Look = {
28  eyes?: EyeKind
29  // Where the eyes look, in pixels (applies to every eye kind).
30  gaze?: readonly [number, number]
31  brows?: BrowKind
32  mouth?: MouthKind
33  blush?: boolean
34  bubble?: BubbleKind
35  sweat?: boolean
36  paws?: PawPose
37  // The whole pal shifted (hops, sways, bows).
38  body?: readonly [number, number]
39  // Pixels the hat rises; negative pulls it down over the eyes.
40  hat?: number
41  hatDx?: number
42  ears?: number
43  // Put down what the paws hold (instruments, cups, ropes).
44  hideHand?: boolean
45  // A treat held up to the mouth, and how many bites are gone (0 whole, 3 crumbs).
46  treat?: number
47  particles?: Particles
48}
49
50export type Pose = {
51  character: CharacterId
52  look: Look
53  tick: number
54  // Item ids of this character (`aviators`, not `locki/aviators`).
55  equipped: readonly string[]
56  size: Size
57  fx?: FxKind
58}
59
60const DEFAULT = 0x01000000
61const UPPER = 0x2580
62const LOWER = 0x2584
63const SPACE = 0x20
64
65type Grid = string[][]
66
67function blank(width: number, height: number): Grid {
68  return Array.from({ length: height }, () => Array.from({ length: width }, () => '.'))
69}
70
71function px(grid: Grid, x: number, y: number, color: string, isBehind = false) {
72  const row = grid[y]
73  if (row === undefined || x < 0 || x >= row.length) return
74  if (isBehind && row[x] !== '.') return
75  row[x] = color
76}
77
78// Bites come off the top right corner, a diagonal at a time.
79function bitten(layer: Layer, bites: number): Layer {
80  const width = layer[0]?.length ?? 0
81  const gone = bites * (width <= 2 ? 1 : 2)
82  return layer.map((row, y) => [...row].map((key, x) => (width - 1 - x + y < gone ? '.' : key)).join(''))
83}
84
85function stamp(grid: Grid, x0: number, y0: number, art: Layer, recolor: Readonly<Record<string, string>> = {}) {
86  art.forEach((line, dy) => {
87    for (let dx = 0; dx < line.length; dx += 1) {
88      const pixel = line[dx]!
89      if (pixel !== '.') px(grid, x0 + dx, y0 + dy, recolor[pixel] ?? pixel)
90    }
91  })
92}
93
94// Items animate at half the frame rate (every 500 ms).
95function frameOf(art: ItemArt, tick: number): Layer {
96  return art.frames[Math.floor(tick / 2) % art.frames.length] ?? art.frames[0]!
97}
98
99// ── Paws ────────────────────────────────────────────────────────────────────
100
101type Offsets = readonly [readonly [number, number], readonly [number, number]]
102
103const PAWS: Readonly<Record<Size, Readonly<Record<Exclude<PawPose, 'cover'>, Offsets>>>> = {
104  32: {
105    rest: [[0, 0], [0, 0]],
106    tapL: [[0, -1], [0, 0]],
107    tapR: [[0, 0], [0, -1]],
108    cheer: [[-4, -12], [4, -12]],
109    waveHi: [[0, 0], [4, -11]],
110    waveLo: [[0, 0], [4, -8]],
111    chin: [[2, -5], [0, 0]],
112    up: [[0, -2], [0, -2]],
113    stretch: [[-4, -6], [4, -6]],
114    hold: [[1, -2], [-1, -2]],
115  },
116  16: {
117    rest: [[0, 0], [0, 0]],
118    tapL: [[0, -1], [0, 0]],
119    tapR: [[0, 0], [0, -1]],
120    cheer: [[-2, -6], [2, -6]],
121    waveHi: [[0, 0], [2, -5]],
122    waveLo: [[0, 0], [2, -4]],
123    chin: [[1, -2], [0, 0]],
124    up: [[0, -1], [0, -1]],
125    stretch: [[-2, -3], [2, -3]],
126    hold: [[0, -1], [0, -1]],
127  },
128}
129
130function bounds(layer: Layer): { x: number; y: number; w: number; h: number } {
131  let x0 = 99, y0 = 99, x1 = -1, y1 = -1
132  layer.forEach((line, y) => {
133    for (let x = 0; x < line.length; x += 1) {
134      if (line[x] === '.') continue
135      x0 = Math.min(x0, x); x1 = Math.max(x1, x); y0 = Math.min(y0, y); y1 = Math.max(y1, y)
136    }
137  })
138  return { x: x0, y: y0, w: x1 - x0 + 1, h: y1 - y0 + 1 }
139}
140
141// Covering the eyes depends on where each pal's eyes are.
142function pawOffsets(body: BodyArt, pose: PawPose, size: Size): Offsets {
143  if (pose !== 'cover') return PAWS[size][pose]
144  return body.paws.map((paw, side) => {
145    const box = bounds(paw)
146    const [ex, ey] = body.face.eyeAt[side]!
147    return [Math.round(ex - (box.x + box.w / 2) + (size === 32 ? 1 : 0)), ey - box.y - 1] as const
148  }) as unknown as Offsets
149}
150
151// ── Sparkles and particles: drawn only on empty cells around the pal ──────────
152
153const ringCache = new Map<string, [number, number][]>()
154
155// Sixteen empty spots one to three pixels off the silhouette, spread evenly around it.
156function ringSpots(body: BodyArt): [number, number][] {
157  const key = `${body.width}x${body.height}:${body.head[Math.floor(body.height / 2)]}`
158  const cached = ringCache.get(key)
159  if (cached !== undefined) return cached
160  const solid = (x: number, y: number) =>
161    [body.ears, body.head, body.hat, ...body.paws].some(layer => (layer[y]?.[x] ?? '.') !== '.')
162  const near: [number, number, number][] = []
163  for (let y = 0; y < body.height; y += 1) {
164    for (let x = 0; x < body.width; x += 1) {
165      if (solid(x, y)) continue
166      let best = 99
167      for (let yy = Math.max(0, y - 3); yy <= Math.min(body.height - 1, y + 3); yy += 1) {
168        for (let xx = Math.max(0, x - 3); xx <= Math.min(body.width - 1, x + 3); xx += 1) {
169          if (solid(xx, yy)) best = Math.min(best, Math.max(Math.abs(xx - x), Math.abs(yy - y)))
170        }
171      }
172      if (best >= 1 && best <= 3) near.push([x, y, Math.atan2(y - body.height / 2, x - body.width / 2)])
173    }
174  }
175  near.sort((a, b) => a[2] - b[2])
176  const spots = Array.from({ length: 16 }, (_, i): [number, number] => {
177    const [x, y] = near[Math.floor((i * near.length) / 16)] ?? [0, 0]
178    return [x, y]
179  })
180  ringCache.set(key, spots)
181
182  return spots
183}
184
185const SPOTS_FOR: Record<FxKind, number> = { reward: 8, comeback: 10, merge: 12, level: 16, stage: 16, buy: 10 }
186const NOTE: Layer = ['..XX', '..X.', '..X.', 'XXX.', 'XX..']
187const HEART: Layer = ['X.X', 'XXX', '.X.']
188const CONFETTI = ['P', 'y', 'D', 'e', 'r', 'l', 'v']
189
190function shape(grid: Grid, art: Layer, x: number, y: number, color: string) {
191  art.forEach((line, dy) => {
192    for (let dx = 0; dx < line.length; dx += 1) if (line[dx] === 'X') px(grid, x + dx - 1, y + dy - 2, color, true)
193  })
194}
195
196function sparkles(grid: Grid, body: BodyArt, character: CharacterId, kind: FxKind, size: Size, tick: number) {
197  const style = characterOf(character)
198  const colors = style.fxColors[kind]
199  const spots = ringSpots(body)
200  const count = SPOTS_FOR[kind]
201  for (let i = 0; i < count; i += 1) {
202    const [x, y] = spots[Math.floor((i * spots.length) / count)]!
203    const color = colors[(i + Math.floor(tick / 2)) % colors.length]!
204    const phase = (tick + i * 2) % 4
205    if (phase === 3) continue
206    if (style.sparkle === 'note') {
207      // Notes rise a pixel per tick and fade on the fourth.
208      if (size === 16) px(grid, x, y - phase, color, true)
209      else shape(grid, NOTE, x - 1, y - 2 - phase, color)
210      continue
211    }
212    px(grid, x, y, color, true)
213    if (phase >= 1) for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]] as const) px(grid, x + dx, y + dy, color, true)
214    if (phase === 2 && size === 32) {
215      for (const [dx, dy] of [[2, 0], [-2, 0], [0, 2], [0, -2]] as const) px(grid, x + dx, y + dy, 'w', true)
216    }
217  }
218}
219
220// Hearts, stars, notes or confetti drifting up around the pal.
221function particles(grid: Grid, body: BodyArt, kind: Particles, size: Size, tick: number) {
222  const spots = ringSpots(body)
223  for (let i = 0; i < 6; i += 1) {
224    const [x, y] = spots[(i * 5 + 2) % spots.length]!
225    const rise = (tick + i * 3) % 6
226    if (rise === 5) continue
227    const yy = y - (size === 32 ? rise : Math.floor(rise / 2))
228    if (size === 16 || kind === 'confetti') {
229      const color = kind === 'hearts' ? 'P' : kind === 'notes' ? 'D' : kind === 'stars' ? 'y' : CONFETTI[(i + tick) % CONFETTI.length]!
230      px(grid, x, yy, color, true)
231      if (kind === 'confetti' && size === 32) px(grid, x + 2, yy + 3, CONFETTI[(i + tick + 3) % CONFETTI.length]!, true)
232      continue
233    }
234    if (kind === 'hearts') shape(grid, HEART, x, yy + 1, i % 2 === 0 ? 'P' : 'h')
235    else if (kind === 'notes') shape(grid, NOTE, x, yy, i % 2 === 0 ? 'D' : 'y')
236    else {
237      const color = i % 2 === 0 ? 'y' : 'O'
238      px(grid, x, yy, color, true)
239      for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]] as const) px(grid, x + dx, yy + dy, color, true)
240    }
241  }
242}
243
244// ── A frame ─────────────────────────────────────────────────────────────────
245
246export function frame(pose: Pose): string[] {
247  const { tick, size, look } = pose
248  const character = characterOf(pose.character)
249  const body: BodyArt = BODY[character.id][size]
250  const face = body.face
251  const art = ITEM_ART[character.id] ?? {}
252  const worn = pose.equipped.flatMap(id => (art[id]?.[size] === undefined ? [] : [art[id]![size]]))
253  const on = (where: ItemLayer) => worn.filter(item => item.layer === where)
254
255  // The pal on its own grid first, so the whole of it can hop and sway.
256  const fig = blank(body.width, body.height)
257  stamp(fig, 0, -(look.ears ?? 0), body.ears)
258  stamp(fig, 0, 0, body.head)
259  if (look.blush === true) for (const [x, y] of face.blush.at) stamp(fig, x, y, face.blush.art)
260  const eye = face.eyes[look.eyes ?? 'open']
261  const [gx, gy] = look.gaze ?? [0, 0]
262  for (const [x, y] of face.eyeAt) stamp(fig, x + eye.dx + gx, y + eye.dy + gy, eye.art)
263  if (look.brows !== undefined) {
264    face.brows[look.brows].forEach((brow, side) => {
265      const [x, y] = face.eyeAt[side]!
266      stamp(fig, x + brow.dx, y + brow.dy, brow.art)
267    })
268  }
269  stamp(fig, body.nose.at[0], body.nose.at[1], body.nose.art)
270  if (look.mouth !== undefined) {
271    const mouth = face.mouths[look.mouth]
272    stamp(fig, face.mouthAt[0] + mouth.dx, face.mouthAt[1] + mouth.dy, mouth.art)
273  }
274  for (const item of on('face')) stamp(fig, 0, 0, frameOf(item, tick))
275  if (look.sweat === true) stamp(fig, face.sweat.at[0], face.sweat.at[1], face.sweat.art)
276
277  const hatX = look.hatDx ?? 0
278  const hatY = -(look.hat ?? 0)
279  const recolor = Object.assign({}, ...on('hat').map(item => item.recolor ?? {})) as Record<string, string>
280  stamp(fig, hatX, hatY, body.hat, recolor)
281  for (const item of on('hat')) stamp(fig, hatX, hatY, frameOf(item, tick))
282
283  const paws = look.paws ?? 'rest'
284  if (look.hideHand !== true && look.treat === undefined && paws === 'rest') for (const item of on('hand')) stamp(fig, 0, 0, frameOf(item, tick))
285  const offsets = pawOffsets(body, paws, size)
286  body.paws.forEach((paw, side) => stamp(fig, offsets[side]![0], offsets[side]![1], paw))
287  // The treat sits between the paws, in front of them.
288  if (look.treat !== undefined) stamp(fig, face.treatAt[0], face.treatAt[1], bitten(TREAT_ART[character.id]![size], look.treat))
289  for (const item of on('front')) stamp(fig, 0, 0, frameOf(item, tick))
290  if (look.bubble !== undefined) stamp(fig, face.bubbleAt[0], face.bubbleAt[1], BUBBLES[size][look.bubble])
291
292  const grid = blank(body.width, body.height)
293  const [bx, by] = look.body ?? [0, 0]
294  fig.forEach((line, y) => line.forEach((pixel, x) => pixel !== '.' && px(grid, x + bx, y + by, pixel)))
295
296  if (pose.fx !== undefined) sparkles(grid, body, character.id, pose.fx, size, tick)
297  if (look.particles !== undefined) particles(grid, body, look.particles, size, tick)
298
299  // Backgrounds go under whatever is still empty, sparkles included.
300  for (const item of on('back')) {
301    const back = blank(body.width, body.height)
302    stamp(back, 0, 0, frameOf(item, tick))
303    back.forEach((line, y) => line.forEach((pixel, x) => pixel !== '.' && px(grid, x, y, pixel, true)))
304  }
305
306  return grid.map(line => line.join(''))
307}
308
309// Raster `cells`: row-major, three little-endian u32 per cell [code point, fg, bg].
310export function encode(rows: readonly string[]): { cells: string; columns: number; rows: number } {
311  const columns = rows[0]?.length ?? 0
312  const height = Math.ceil(rows.length / 2)
313  const words = new Uint32Array(columns * height * 3)
314
315  for (let cy = 0; cy < height; cy += 1) {
316    for (let cx = 0; cx < columns; cx += 1) {
317      const top = PALETTE[rows[cy * 2]?.[cx] ?? '.']
318      const bottom = PALETTE[rows[cy * 2 + 1]?.[cx] ?? '.']
319      const at = (cy * columns + cx) * 3
320
321      if (top !== undefined) {
322        words[at] = UPPER
323        words[at + 1] = top
324        words[at + 2] = bottom ?? DEFAULT
325      } else if (bottom !== undefined) {
326        words[at] = LOWER
327        words[at + 1] = bottom
328        words[at + 2] = DEFAULT
329      } else {
330        words[at] = SPACE
331        words[at + 1] = DEFAULT
332        words[at + 2] = DEFAULT
333      }
334    }
335  }
336
337  return { cells: base64(new Uint8Array(words.buffer)), columns, rows: height }
338}
339
340const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
341
342// Uint8Array.toBase64 is not in the TS lib yet.
343function base64(bytes: Uint8Array): string {
344  let out = ''
345  for (let i = 0; i < bytes.length; i += 3) {
346    const a = bytes[i] ?? 0
347    const b = bytes[i + 1] ?? 0
348    const c = bytes[i + 2] ?? 0
349    const n = (a << 16) | (b << 8) | c
350    out += ALPHABET[(n >> 18) & 63]! + ALPHABET[(n >> 12) & 63]!
351    out += i + 1 < bytes.length ? ALPHABET[(n >> 6) & 63]! : '='
352    out += i + 2 < bytes.length ? ALPHABET[n & 63]! : '='
353  }
354
355  return out
356}
357
358// Desktop, VS Code and mobile: the grid as SVG paths, one per colour, each a list
359// of horizontal runs (much shorter than a <rect> per pixel).
360function pathsOf(rows: readonly string[]): string {
361  const runs = new Map<string, string[]>()
362  rows.forEach((line, y) => {
363    let x = 0
364    while (x < line.length) {
365      const key = line[x]!
366      let end = x + 1
367      while (end < line.length && line[end] === key) end += 1
368      if (PALETTE[key] !== undefined) {
369        const list = runs.get(key) ?? []
370        list.push(`M${x} ${y}h${end - x}v1h-${end - x}z`)
371        runs.set(key, list)
372      }
373      x = end
374    }
375  })
376
377  return [...runs].map(([key, d]) => `<path fill="#${PALETTE[key]!.toString(16).padStart(6, '0')}" d="${d.join('')}"/>`).join('')
378}
379
380function svgFrame(width: number, height: number, body: string): string {
381  return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" shape-rendering="crispEdges">${body}</svg>`
382}
383
384export function svgOf(rows: readonly string[]): string {
385  return svgFrame(rows[0]?.length ?? 0, rows.length, pathsOf(rows))
386}
387
388// The SVG Svg element's 131,072-character limit, with room to spare.
389const SVG_LIMIT = 120_000
390
391// A looping animation of frames shown `tickMs` apart, as SMIL inside the SVG: each
392// distinct frame is a group whose visibility steps in turn. The desktop draws an
393// interactive Svg in a sandboxed frame, where SMIL runs without any redraws.
394function loopGroups(frames: readonly (readonly string[])[], tickMs: number): string {
395  const distinct: string[] = []
396  const order = frames.map(rows => {
397    const signature = rows.join('\n')
398    let index = distinct.indexOf(signature)
399    if (index < 0) index = distinct.push(signature) - 1
400    return index
401  })
402  if (distinct.length < 2) return pathsOf(frames[0] ?? [])
403  const duration = `${(frames.length * tickMs) / 1000}s`
404
405  return distinct
406    .map((signature, index) => {
407      const values = order.map(shown => (shown === index ? 'visible' : 'hidden')).join(';')
408      const start = order[0] === index ? 'visible' : 'hidden'
409      const animate = `<animate attributeName="visibility" values="${values}" dur="${duration}" calcMode="discrete" repeatCount="indefinite"/>`
410
411      return `<g visibility="${start}">${pathsOf(signature.split('\n'))}${animate}</g>`
412    })
413    .join('')
414}
415
416// With `hover` frames, pointing at the picture plays them instead (CSS :hover inside
417// the sandboxed frame), so a desktop pal reacts to the mouse.
418export function animatedSvgOf(frames: readonly (readonly string[])[], tickMs: number, hover?: readonly (readonly string[])[]): string {
419  const first = frames[0] ?? []
420  const width = first[0]?.length ?? 0
421  const main = loopGroups(frames, tickMs)
422  const body =
423    hover === undefined || hover.length === 0
424      ? main
425      : `<style>.h{display:none}svg:hover .h{display:inline}svg:hover .m{display:none}</style><g class="m">${main}</g><g class="h">${loopGroups(hover, tickMs)}</g>`
426  const svg = svgFrame(width, first.length, body)
427
428  // Too long for the element: drop the hover loop, then every other frame, then to a still.
429  if (svg.length <= SVG_LIMIT) return svg
430  if (hover !== undefined) return animatedSvgOf(frames, tickMs)
431  if (frames.length >= 4) return animatedSvgOf(frames.filter((_, i) => i % 2 === 0), tickMs * 2)
432
433  return svgOf(first)
434}
435
hooks/characters/art.ts 1761 lines
1// Generated by tools/draw_art.py: edit that script and run it again, not this file.
2// One character is a stack of full-canvas layers ('.' is empty) plus eye and nose
3// stamps; hooks/sprite.ts composes them every frame. Letters are PALETTE keys.
4
5export type Layer = readonly string[]
6export type ItemLayer = 'back' | 'face' | 'hat' | 'hand' | 'front'
7export type BodyArt = {
8  width: number
9  height: number
10  ears: Layer
11  head: Layer
12  hat: Layer
13  paws: readonly [Layer, Layer]
14  eyes: {
15    at: readonly (readonly [number, number])[]
16    open: Layer
17    blink: Layer
18    happy: Layer
19    happyAt: readonly (readonly [number, number])[]
20  }
21  nose: { at: readonly [number, number]; art: Layer }
22  face: Face
23}
24export type Stamp = { art: Layer; dx: number; dy: number }
25export type EyeKind = 'open' | 'blink' | 'closed' | 'happy' | 'shiny' | 'heart' | 'star' | 'wide' | 'x' | 'sleepy'
26export type BrowKind = 'worried' | 'determined'
27export type MouthKind = 'open' | 'w' | 'o' | 'tongue' | 'chomp' | 'yawn' | 'smile'
28export type BubbleKind = '!' | '?' | 'heart' | 'note' | 'dots' | 'star' | 'zzz'
29export type Face = {
30  eyeAt: readonly (readonly [number, number])[]
31  eyes: Readonly<Record<EyeKind, Stamp>>
32  brows: Readonly<Record<BrowKind, readonly [Stamp, Stamp]>>
33  mouthAt: readonly [number, number]
34  mouths: Readonly<Record<MouthKind, Stamp>>
35  blush: { at: readonly (readonly [number, number])[]; art: Layer }
36  sweat: { at: readonly [number, number]; art: Layer }
37  bubbleAt: readonly [number, number]
38  treatAt: readonly [number, number]
39}
40export type ItemArt = { layer: ItemLayer; frames: readonly Layer[]; recolor?: Readonly<Record<string, string>> }
41
42export const PALETTE: Readonly<Record<string, number>> = {
43  "N": 0x2b4a8b,
44  "n": 0x1b2c57,
45  "H": 0x4568b0,
46  "P": 0xf2728f,
47  "p": 0xc8526f,
48  "i": 0xf6cfcf,
49  "B": 0x9c6b42,
50  "b": 0x6e4a2c,
51  "R": 0xc08e5e,
52  "T": 0x2f7a7e,
53  "t": 0x1d5257,
54  "Q": 0x4a979b,
55  "C": 0xfff3dc,
56  "c": 0xeeddbd,
57  "o": 0xc4a57e,
58  "K": 0x1f1d24,
59  "w": 0xffffff,
60  "k": 0x16161a,
61  "g": 0xd1d5db,
62  "G": 0x6b7280,
63  "y": 0xfacc15,
64  "Y": 0xb7791f,
65  "l": 0xfef3a0,
66  "r": 0xe5484d,
67  "d": 0xa3272b,
68  "m": 0xd9a066,
69  "M": 0xa0703f,
70  "x": 0x8b5a2b,
71  "X": 0x5c3a1a,
72  "e": 0x5ba35e,
73  "s": 0xc9d1db,
74  "S": 0x7c8796,
75  "F": 0xf97316,
76  "a": 0xfde047,
77  "A": 0xe7eef5,
78  "u": 0xfb923c,
79  "U": 0xe879a8,
80  "v": 0x7c5cc4,
81  "z": 0x27235e,
82  "Z": 0x17143d,
83  "W": 0xf4f1ea,
84  "V": 0xcfc8ba,
85  "h": 0xf7a1bd,
86  "O": 0xf59e0b,
87  "q": 0x2a2a30,
88  "j": 0xa7b0bc,
89  "D": 0x8ecdf0,
90  "I": 0xc4e6f6,
91  "E": 0x3e7d47,
92  "J": 0xe8c98f,
93}
94
95export const BODY = {
96  locki: {
97    32: {
98      width: 32,
99      height: 28,
100      ears: [
101        "................................",
102        "..o..........................o..",
103        "..ooo......................ooo..",
104        "..oiCoo..................ooCio..",
105        "..oCiiCoo..............ooCiiCo..",
106        "...oiiiiCoo..........ooCiiiio...",
107        "...oiiiiiCo..........oCiiiiio...",
108        "...oCiiioo............ooiiiCo...",
109        "...oCioo................ooiCo...",
110        "....oo....................oo....",
111        "....o......................o....",
112        "................................",
113        "................................",
114        "................................",
115        "................................",
116        "................................",
117        "................................",
118        "................................",
119        "................................",
120        "................................",
121        "................................",
122        "................................",
123        "................................",
124        "................................",
125        "................................",
126        "................................",
127        "................................",
128        "................................",
129      ],
130      head: [
131        "................................",
132        "................................",
133        "................................",
134        "................................",
135        ".............oooooo.............",
136        "...........ooCCCCCCoo...........",
137        "..........oCCCCCCCCCCo..........",
138        ".........oCCCCCCCCCCCCo.........",
139        ".........oCCCCCCCCCCCCo.........",
140        ".........oCCCCCCCCCCCCo.........",
141        "........oCCCCCCCCCCCCCCo........",
142        "......ooCCCCCCCCCCCCCCCCoo......",
143        ".....oCCCCCCCCCCCCCCCCCCCCo.....",
144        ".....oCCCCCCCCCCCCCCCCCCCCo.....",
145        "....oCCCCCCCCCCCCCCCCCCCCCCo....",
146        "....oCCCCCCCCCCCCCCCCCCCCCCo....",
147        "...oCCCCCCCCCCCCCCCCCCCCCCCCo...",
148        "...oCCCCCCCCCCCCCCCCCCCCCCCCo...",
149        "...oCCCCCCCCCCCCCCCCCCCCCCCCo...",
150        "....oCCCCCCCCCCCCCCCCCCCCCCo....",
151        "....oCCCCCCCCCCCCCCCCCCCCCCo....",
152        ".....oCCCCCCCCCCCCCCCCCCCCo.....",
153        ".....oCCCCCCCCCCCCCCCCCCCCo.....",
154        "......ooCCCCCCCCCCCCCCCCoo......",
155        "........ooCCCCCCCCCCCCoo........",
156        "..........oooooooooooo..........",
157        "................................",
158        "................................",
159      ],
160      hat: [
161        "................................",
162        "...........HHNN..NNNN...........",
163        "..........HHNNNPPNNNNN..........",
164        ".........HNNNPPPPPPNNNN.........",
165        ".........HNNNNPPPPNNNNN.........",
166        ".........NNNNNPNNPNNNNN.........",
167        ".........NNNNNNNNNNNNNN.........",
168        ".NN......PPPPPPPPPPPPPP......NN.",
169        ".NNNN....PPPPPPPPPPPPPP....NNNN.",
170        ".NHHHHHNNNNNNNNNNNNNNNNNNNNNNNN.",
171        "..NNNNNNNNNNNNNNNNNNNNNNNNNNNN..",
172        ".....nnnnnnnnnnnnnnnnnnnnnn.....",
173        "................................",
174        "................................",
175        "................................",
176        "................................",
177        "................................",
178        "................................",
179        "................................",
180        "................................",
181        "................................",
182        "................................",
183        "................................",
184        "................................",
185        "................................",
186        "................................",
187        "................................",
188        "................................",
189      ],
190      paws: [[
191        "................................",
192        "................................",
193        "................................",
194        "................................",
195        "................................",
196        "................................",
197        "................................",
198        "................................",
199        "................................",
200        "................................",
201        "................................",
202        "................................",
203        "................................",
204        "................................",
205        "................................",
206        "................................",
207        "................................",
208        "................................",
209        "................................",
210        "................................",
211        "................................",
212        "................................",
213        "................................",
214        "......oooooo....................",
215        ".....oCCCCCCo...................",
216        ".....oCCCCCCo...................",
217        ".....oCoCCoCo...................",
218        ".....oooooooo...................",
219      ], [
220        "................................",
221        "................................",
222        "................................",
223        "................................",
224        "................................",
225        "................................",
226        "................................",
227        "................................",
228        "................................",
229        "................................",
230        "................................",
231        "................................",
232        "................................",
233        "................................",
234        "................................",
235        "................................",
236        "................................",
237        "................................",
238        "................................",
239        "................................",
240        "................................",
241        "................................",
242        "................................",
243        "....................oooooo......",
244        "...................oCCCCCCo.....",
245        "...................oCCCCCCo.....",
246        "...................oCoCCoCo.....",
247        "...................oooooooo.....",
248      ]],
249      eyes: { at: [[10, 15], [20, 15]], open: ["wK", "KK", "KK"], blink: ["..", "..", "KK"], happy: [".KK.", "K..K"], happyAt: [[9, 16], [19, 16]] },
250      nose: { at: [14, 19], art: ["KKKK", ".KK."] },
251      face: {"eyeAt": [[10, 15], [20, 15]], "eyes": {"open": {"art": ["wK", "KK", "KK"], "dx": 0, "dy": 0}, "blink": {"art": ["..", "..", "KK"], "dx": 0, "dy": 0}, "closed": {"art": ["K..K", ".KK."], "dx": -1, "dy": 1}, "happy": {"art": [".KK.", "K..K"], "dx": -1, "dy": 1}, "shiny": {"art": ["wK", "KK", "Kw"], "dx": 0, "dy": 0}, "heart": {"art": ["P.P", "PPP", ".P."], "dx": -1, "dy": 0}, "star": {"art": [".y.", "yly", ".y."], "dx": -1, "dy": 0}, "wide": {"art": [".KK.", "KwKK", "KKKK", ".KK."], "dx": -1, "dy": -1}, "x": {"art": ["K.K", ".K.", "K.K"], "dx": -1, "dy": 0}, "sleepy": {"art": ["..", "KK", "KK"], "dx": 0, "dy": 0}}, "brows": {"worried": [{"art": [".K", "K."], "dx": -1, "dy": -3}, {"art": ["K.", ".K"], "dx": 1, "dy": -3}], "determined": [{"art": ["K.", ".K"], "dx": -1, "dy": -3}, {"art": [".K", "K."], "dx": 1, "dy": -3}]}, "mouthAt": [14, 21], "mouths": {"open": {"art": ["KPPK", ".KK."], "dx": 0, "dy": 0}, "w": {"art": ["K.KK.K", ".K..K."], "dx": -1, "dy": 0}, "o": {"art": [".KK.", "K..K", ".KK."], "dx": 0, "dy": 0}, "tongue": {"art": ["KKKP", "...P"], "dx": 0, "dy": 0}, "chomp": {"art": ["KKKK"], "dx": 0, "dy": 0}, "yawn": {"art": [".KK.", "KPPK", "KPPK", ".KK."], "dx": 0, "dy": 0}, "smile": {"art": ["K..K", ".KK."], "dx": 0, "dy": 0}}, "blush": {"at": [[5, 19], [24, 19]], "art": ["hhh"]}, "sweat": {"at": [27, 11], "art": [".D", "DD", "DA"]}, "bubbleAt": [25, 0], "treatAt": [14, 22]},
252    },
253    16: {
254      width: 16,
255      height: 14,
256      ears: [
257        "o..............o",
258        "oo............oo",
259        "oio..........oio",
260        "oCio........oiCo",
261        "ooCCo......oCCoo",
262        ".oCCCo....oCCCo.",
263        "................",
264        "................",
265        "................",
266        "................",
267        "................",
268        "................",
269        "................",
270        "................",
271      ],
272      head: [
273        "................",
274        ".......oo.......",
275        ".....ooCCoo.....",
276        "....oCCCCCCo....",
277        "....oCCCCCCo....",
278        "...oCCCCCCCCo...",
279        "..oCCCCCCCCCCo..",
280        "..oCCCCCCCCCCo..",
281        ".oCCCCCCCCCCCCo.",
282        ".oCCCCCCCCCCCCo.",
283        "..oCCCCCCCCCCo..",
284        "...oCCCCCCCCo...",
285        "....oooooooo....",
286        "................",
287      ],
288      hat: [
289        ".....HN..NN.....",
290        "....HNNPPNNN....",
291        "....NNPNNPNN....",
292        "N...PPPPPPPP...N",
293        "NHHHNNNNNNNNNNNN",
294        "...nnnnnnnnnn...",
295        "................",
296        "................",
297        "................",
298        "................",
299        "................",
300        "................",
301        "................",
302        "................",
303      ],
304      paws: [[
305        "................",
306        "................",
307        "................",
308        "................",
309        "................",
310        "................",
311        "................",
312        "................",
313        "................",
314        "................",
315        "................",
316        "...oo...........",
317        "..ooCo..........",
318        "..oooo..........",
319      ], [
320        "................",
321        "................",
322        "................",
323        "................",
324        "................",
325        "................",
326        "................",
327        "................",
328        "................",
329        "................",
330        "................",
331        "...........oo...",
332        "..........oCoo..",
333        "..........oooo..",
334      ]],
335      eyes: { at: [[5, 7], [10, 7]], open: ["K", "K"], blink: [".", "K"], happy: [".K.", "K.K"], happyAt: [[4, 7], [9, 7]] },
336      nose: { at: [7, 9], art: ["KK"] },
337      face: {"eyeAt": [[5, 7], [10, 7]], "eyes": {"open": {"art": ["K", "K"], "dx": 0, "dy": 0}, "blink": {"art": [".", "K"], "dx": 0, "dy": 0}, "closed": {"art": [".", "K"], "dx": 0, "dy": 0}, "happy": {"art": [".K.", "K.K"], "dx": -1, "dy": 0}, "shiny": {"art": ["w", "K"], "dx": 0, "dy": 0}, "heart": {"art": ["P", "P"], "dx": 0, "dy": 0}, "star": {"art": ["y", "l"], "dx": 0, "dy": 0}, "wide": {"art": ["KK", "KK"], "dx": 0, "dy": 0}, "x": {"art": ["K.K", ".K."], "dx": -1, "dy": 0}, "sleepy": {"art": [".", "K"], "dx": 0, "dy": 0}}, "brows": {"worried": [{"art": ["K"], "dx": 0, "dy": -1}, {"art": ["K"], "dx": 0, "dy": -1}], "determined": [{"art": ["K"], "dx": 0, "dy": -1}, {"art": ["K"], "dx": 0, "dy": -1}]}, "mouthAt": [7, 10], "mouths": {"open": {"art": ["PP"], "dx": 0, "dy": 0}, "w": {"art": ["K.K"], "dx": 0, "dy": 0}, "o": {"art": ["K"], "dx": 1, "dy": 0}, "tongue": {"art": [".P"], "dx": 0, "dy": 0}, "chomp": {"art": ["KK"], "dx": 0, "dy": 0}, "yawn": {"art": ["PP", "PP"], "dx": 0, "dy": 0}, "smile": {"art": ["KK"], "dx": 0, "dy": 0}}, "blush": {"at": [[3, 9], [12, 9]], "art": ["h"]}, "sweat": {"at": [13, 5], "art": ["D", "D"]}, "bubbleAt": [12, 0], "treatAt": [6, 10]},
338    },
339  },
340  cappl: {
341    32: {
342      width: 32,
343      height: 28,
344      ears: [
345        ".........tttt......tttt.........",
346        "........tTTTTt....tTTTTt........",
347        "........tTQQTt....tTQQTt........",
348        "........tQQQQt....tQQQQt........",
349        "........tTQQTt....tTQQTt........",
350        ".........tttt......tttt.........",
351        "................................",
352        "................................",
353        "................................",
354        "................................",
355        "................................",
356        "................................",
357        "................................",
358        "................................",
359        "................................",
360        "................................",
361        "................................",
362        "................................",
363        "................................",
364        "................................",
365        "................................",
366        "................................",
367        "................................",
368        "................................",
369        "................................",
370        "................................",
371        "................................",
372        "................................",
373      ],
374      head: [
375        "................................",
376        "................................",
377        "................................",
378        "................................",
379        ".............tttttt.............",
380        "...........ttTTTTTTtt...........",
381        "..........tTTTTTTTTTTt..........",
382        ".........tTTTTTTTTTTTTt.........",
383        ".........tTTTTTTTTTTTTt.........",
384        ".........tTTTTTTTTTTTTt.........",
385        "........tTTTTTTTTTTTTTTt........",
386        "......ttTTTTTTTTTTTTTTTTtt......",
387        ".....tTTTTTTTTTTTTTTTTTTTTt.....",
388        ".....tTTTTTTTTTTTTTTTTTTTTt.....",
389        "....tTTTTTCCCTTTTTTCCCTTTTTt....",
390        "....tTTTCCCCCCCTTCCCCCCCTTTt....",
391        "...tTTTCCCCCCCCCCCCCCCCCCTTTt...",
392        "...tTTTCCCCCCCCCCCCCCCCCCTTTt...",
393        "...tTTTCCCCCCCCCCCCCCCCCCTTTt...",
394        "....tTTCCCCCCCCCCCCCCCCCCTTt....",
395        "....tTTCCCCCCCCCCCCCCCCCCTTt....",
396        ".....tCCCCCCCCCCCCCCCCCCCCt.....",
397        ".....tTCCCCCCCCCCCCCCCCCCTt.....",
398        "......tCCCCCCCCCCCCCCCCCCt......",
399        "........CCCCCCCCCCCCCCCC........",
400        "..........tttttttttttt..........",
401        "................................",
402        "................................",
403      ],
404      hat: [
405        "................................",
406        "................................",
407        "...........RRBB..BBBB...........",
408        "..........RRBBBbbBBBBB..........",
409        ".........RBBBBBBBBBBBBB.........",
410        ".........RBBBBBBBBBBBBB.........",
411        ".........BBBBBBBBBBBBBB.........",
412        ".........BBBBBBBBBBBBBB.........",
413        "BB.......bbbbbbbbbbbbbb.......BB",
414        "BBBB.....bbbbbbbbbbbbbb.....BBBB",
415        "BRRRRRBBBBBBBBBBBBBBBBBBBBBBBBBB",
416        ".BBBBBBBBBBBBBBBBBBBBBBBBBBBBBB.",
417        ".....bbbbbbbbbbbbbbbbbbbbbb.....",
418        "................................",
419        "................................",
420        "................................",
421        "................................",
422        "................................",
423        "................................",
424        "................................",
425        "................................",
426        "................................",
427        "................................",
428        "................................",
429        "................................",
430        "................................",
431        "................................",
432        "................................",
433      ],
434      paws: [[
435        "................................",
436        "................................",
437        "................................",
438        "................................",
439        "................................",
440        "................................",
441        "................................",
442        "................................",
443        "................................",
444        "................................",
445        "................................",
446        "................................",
447        "................................",
448        "................................",
449        "................................",
450        "................................",
451        "................................",
452        "................................",
453        "................................",
454        "................................",
455        "................................",
456        "................................",
457        "................................",
458        "......oooooo....................",
459        ".....oCCCCCCo...................",
460        ".....oCCCCCCo...................",
461        ".....oCoCCoCo...................",
462        ".....oooooooo...................",
463      ], [
464        "................................",
465        "................................",
466        "................................",
467        "................................",
468        "................................",
469        "................................",
470        "................................",
471        "................................",
472        "................................",
473        "................................",
474        "................................",
475        "................................",
476        "................................",
477        "................................",
478        "................................",
479        "................................",
480        "................................",
481        "................................",
482        "................................",
483        "................................",
484        "................................",
485        "................................",
486        "................................",
487        "....................oooooo......",
488        "...................oCCCCCCo.....",
489        "...................oCCCCCCo.....",
490        "...................oCoCCoCo.....",
491        "...................oooooooo.....",
492      ]],
493      eyes: { at: [[11, 17], [19, 17]], open: ["wt", "tt", "tt"], blink: ["..", "..", "tt"], happy: [".tt.", "t..t"], happyAt: [[10, 18], [18, 18]] },
494      nose: { at: [14, 21], art: ["tttt", ".tt."] },
495      face: {"eyeAt": [[11, 17], [19, 17]], "eyes": {"open": {"art": ["wt", "tt", "tt"], "dx": 0, "dy": 0}, "blink": {"art": ["..", "..", "tt"], "dx": 0, "dy": 0}, "closed": {"art": ["t..t", ".tt."], "dx": -1, "dy": 1}, "happy": {"art": [".tt.", "t..t"], "dx": -1, "dy": 1}, "shiny": {"art": ["wt", "tt", "tw"], "dx": 0, "dy": 0}, "heart": {"art": ["P.P", "PPP", ".P."], "dx": -1, "dy": 0}, "star": {"art": [".y.", "yly", ".y."], "dx": -1, "dy": 0}, "wide": {"art": [".tt.", "twtt", "tttt", ".tt."], "dx": -1, "dy": -1}, "x": {"art": ["t.t", ".t.", "t.t"], "dx": -1, "dy": 0}, "sleepy": {"art": ["..", "tt", "tt"], "dx": 0, "dy": 0}}, "brows": {"worried": [{"art": [".t", "t."], "dx": -1, "dy": -3}, {"art": ["t.", ".t"], "dx": 1, "dy": -3}], "determined": [{"art": ["t.", ".t"], "dx": -1, "dy": -3}, {"art": [".t", "t."], "dx": 1, "dy": -3}]}, "mouthAt": [14, 23], "mouths": {"open": {"art": ["tPPt", ".tt."], "dx": 0, "dy": 0}, "w": {"art": ["t.tt.t", ".t..t."], "dx": -1, "dy": 0}, "o": {"art": [".tt.", "t..t", ".tt."], "dx": 0, "dy": 0}, "tongue": {"art": ["tttP", "...P"], "dx": 0, "dy": 0}, "chomp": {"art": ["tttt"], "dx": 0, "dy": 0}, "yawn": {"art": [".tt.", "tPPt", "tPPt", ".tt."], "dx": 0, "dy": 0}, "smile": {"art": ["t..t", ".tt."], "dx": 0, "dy": 0}}, "blush": {"at": [[7, 21], [23, 21]], "art": ["hhh"]}, "sweat": {"at": [27, 13], "art": [".D", "DD", "DA"]}, "bubbleAt": [25, 0], "treatAt": [14, 22]},
496    },
497    16: {
498      width: 16,
499      height: 14,
500      ears: [
501        "....TTT..TTT....",
502        "....TTT..TTT....",
503        "....TTT..TTT....",
504        "................",
505        "................",
506        "................",
507        "................",
508        "................",
509        "................",
510        "................",
511        "................",
512        "................",
513        "................",
514        "................",
515      ],
516      head: [
517        "................",
518        ".......tt.......",
519        ".....ttTTtt.....",
520        "....tTTTTTTt....",
521        "....tTTTTTTt....",
522        "...tTTTTTTTTt...",
523        "..tTTTTTTTTTTt..",
524        "..tTCCCCCCCCTt..",
525        ".tTCCCCCCCCCCTt.",
526        ".tTCCCCCCCCCCTt.",
527        "..tCCCCCCCCCCt..",
528        "...CCCCCCCCCC...",
529        "....CCCCCCCC....",
530        "................",
531      ],
532      hat: [
533        "................",
534        ".....RB..BB.....",
535        "....RBBBBBBB....",
536        "....BBBBBBBB....",
537        "B...bbbbbbbb...B",
538        "BRRRBBBBBBBBBBBB",
539        "...bbbbbbbbbb...",
540        "................",
541        "................",
542        "................",
543        "................",
544        "................",
545        "................",
546        "................",
547      ],
548      paws: [[
549        "................",
550        "................",
551        "................",
552        "................",
553        "................",
554        "................",
555        "................",
556        "................",
557        "................",
558        "................",
559        "................",
560        "...oo...........",
561        "..ooCo..........",
562        "..oooo..........",
563      ], [
564        "................",
565        "................",
566        "................",
567        "................",
568        "................",
569        "................",
570        "................",
571        "................",
572        "................",
573        "................",
574        "................",
575        "...........oo...",
576        "..........oCoo..",
577        "..........oooo..",
578      ]],
579      eyes: { at: [[5, 8], [10, 8]], open: ["t", "t"], blink: [".", "t"], happy: [".t.", "t.t"], happyAt: [[4, 8], [9, 8]] },
580      nose: { at: [7, 10], art: ["tt"] },
581      face: {"eyeAt": [[5, 8], [10, 8]], "eyes": {"open": {"art": ["t", "t"], "dx": 0, "dy": 0}, "blink": {"art": [".", "t"], "dx": 0, "dy": 0}, "closed": {"art": [".", "t"], "dx": 0, "dy": 0}, "happy": {"art": [".t.", "t.t"], "dx": -1, "dy": 0}, "shiny": {"art": ["w", "t"], "dx": 0, "dy": 0}, "heart": {"art": ["P", "P"], "dx": 0, "dy": 0}, "star": {"art": ["y", "l"], "dx": 0, "dy": 0}, "wide": {"art": ["tt", "tt"], "dx": 0, "dy": 0}, "x": {"art": ["t.t", ".t."], "dx": -1, "dy": 0}, "sleepy": {"art": [".", "t"], "dx": 0, "dy": 0}}, "brows": {"worried": [{"art": ["t"], "dx": 0, "dy": -1}, {"art": ["t"], "dx": 0, "dy": -1}], "determined": [{"art": ["t"], "dx": 0, "dy": -1}, {"art": ["t"], "dx": 0, "dy": -1}]}, "mouthAt": [7, 11], "mouths": {"open": {"art": ["PP"], "dx": 0, "dy": 0}, "w": {"art": ["t.t"], "dx": 0, "dy": 0}, "o": {"art": ["t"], "dx": 1, "dy": 0}, "tongue": {"art": [".P"], "dx": 0, "dy": 0}, "chomp": {"art": ["tt"], "dx": 0, "dy": 0}, "yawn": {"art": ["PP", "PP"], "dx": 0, "dy": 0}, "smile": {"art": ["tt"], "dx": 0, "dy": 0}}, "blush": {"at": [[3, 10], [12, 10]], "art": ["h"]}, "sweat": {"at": [13, 6], "art": ["D", "D"]}, "bubbleAt": [12, 0], "treatAt": [6, 11]},
582    },
583  },
584} as const satisfies Record<string, Record<32 | 16, BodyArt>>
585
586export const BUBBLES: Readonly<Record<32 | 16, Readonly<Record<BubbleKind, Layer>>>> = {"32": {"!": [".ggggg.", "gwwrwwg", "gwwrwwg", "gwwrwwg", "gwwwwwg", "gwwrwwg", ".ggggg.", ".g....."], "?": [".ggggg.", "gwkkkwg", "gwwwkwg", "gwwkwwg", "gwwwwwg", "gwwkwwg", ".ggggg.", ".g....."], "heart": [".ggggg.", "gwwwwwg", "gPPwPPg", "gPPPPPg", "gwPPPwg", "gwwPwwg", ".ggggg.", ".g....."], "note": [".ggggg.", "gwwkkwg", "gwwkwkg", "gwwkwwg", "gkkkwwg", "gkkwwwg", ".ggggg.", ".g....."], "dots": [".ggggg.", "gwwwwwg", "gwwwwwg", "gGwGwGg", "gwwwwwg", "gwwwwwg", ".ggggg.", ".g....."], "star": [".ggggg.", "gwwywwg", "gwyyywg", "gyyyyyg", "gwyyywg", "gwywywg", ".ggggg.", ".g....."], "zzz": ["..AAAA.", "....A..", "...A...", "..AAAA.", "AAA....", ".A.....", "AAA...."]}, "16": {"!": ["r", "r", ".", "r"], "?": ["kk", ".k", "k.", "..", "k."], "heart": ["P.P", "PPP", ".P."], "note": [".k", ".k", "kk"], "dots": ["G.G.G"], "star": [".y.", "yyy", ".y."], "zzz": ["AAA", ".A.", "AAA"]}}
587
588export const TREAT_ART: Readonly<Record<string, Record<32 | 16, Layer>>> = {"locki": {"32": ["..P..", ".mPm.", "mPmPm", ".mmm.", ".m.m."], "16": ["mP", "Pm"]}, "cappl": {"32": [".xxx.", "xmlmx", "xmmmx", "xmlmx", ".xxx."], "16": ["xm", "mx"]}}
589
590export const ITEM_ART: Readonly<Record<string, Readonly<Record<string, Record<32 | 16, ItemArt>>>>> = {
591  locki: {
592    "aviators": {
593      32: { layer: 'face', frames: [[
594          "................................",
595          "................................",
596          "................................",
597          "................................",
598          "................................",
599          "................................",
600          "................................",
601          "................................",
602          "................................",
603          "................................",
604          "................................",
605          "................................",
606          "................................",
607          "................................",
608          "................................",
609          "......yyykgkkyyyyyykgkkyyy......",
610          ".........kkkk......kkkk.........",
611          "..........kk........kk..........",
612          "................................",
613          "................................",
614          "................................",
615          "................................",
616          "................................",
617          "................................",
618          "................................",
619          "................................",
620          "................................",
621          "................................",
622        ]] },
623      16: { layer: 'face', frames: [[
624          "................",
625          "................",
626          "................",
627          "................",
628          "................",
629          "................",
630          "................",
631          "....kkkyykkk....",
632          ".....kk..kk.....",
633          "................",
634          "................",
635          "................",
636          "................",
637          "................",
638        ]] },
639    },
640    "feather": {
641      32: { layer: 'hat', frames: [[
642          "...........................P....",
643          "..........................PP....",
644          ".........................PP.....",
645          "........................PpP.....",
646          ".......................PpP......",
647          "......................PpP.......",
648          "......................pP........",
649          ".....................p..........",
650          "................................",
651          "................................",
652          "................................",
653          "................................",
654          "................................",
655          "................................",
656          "................................",
657          "................................",
658          "................................",
659          "................................",
660          "................................",
661          "................................",
662          "................................",
663          "................................",
664          "................................",
665          "................................",
666          "................................",
667          "................................",
668          "................................",
669          "................................",
670        ]] },
671      16: { layer: 'hat', frames: [[
672          "............PP..",
673          "............P...",
674          "...........p....",
675          "................",
676          "................",
677          "................",
678          "................",
679          "................",
680          "................",
681          "................",
682          "................",
683          "................",
684          "................",
685          "................",
686        ]] },
687    },
688    "harmonica": {
689      32: { layer: 'hand', frames: [[
690          "................................",
691          "................................",
692          "................................",
693          "................................",
694          "................................",
695          "................................",
696          "................................",
697          "................................",
698          "................................",
699          "................................",
700          "................................",
701          "................................",
702          "................................",
703          "................................",
704          "................................",
705          "................................",
706          "................................",
707          "................................",
708          "................................",
709          "................................",
710          "................................",
711          "................................",
712          "................................",
713          "..........ssssssssssss..........",
714          "..........sSsSsSsSsSsS..........",
715          "..........SSSSSSSSSSSS..........",
716          "................................",
717          "................................",
718        ]] },
719      16: { layer: 'hand', frames: [[
720          "................",
721          "................",
722          "................",
723          "................",
724          "................",
725          "................",
726          "................",
727          "................",
728          "................",
729          "................",
730          "................",
731          ".....ssssss.....",
732          ".....SSSSSS.....",
733          "................",
734        ]] },
735    },
736    "heart-shades": {
737      32: { layer: 'face', frames: [[
738          "................................",
739          "................................",
740          "................................",
741          "................................",
742          "................................",
743          "................................",
744          "................................",
745          "................................",
746          "................................",
747          "................................",
748          "................................",
749          "................................",
750          "................................",
751          "................................",
752          "................................",
753          "........wP.PP......wP.PP........",
754          "........PPPPPppppppPPPPP........",
755          ".........PPP........PPP.........",
756          "..........P..........P..........",
757          "................................",
758          "................................",
759          "................................",
760          "................................",
761          "................................",
762          "................................",
763          "................................",
764          "................................",
765          "................................",
766        ]] },
767      16: { layer: 'face', frames: [[
768          "................",
769          "................",
770          "................",
771          "................",
772          "................",
773          "................",
774          "................",
775          "....P.P..P.P....",
776          "....PPP..PPP....",
777          ".....P....P.....",
778          "................",
779          "................",
780          "................",
781          "................",
782        ]] },
783    },
784    "lasso": {
785      32: { layer: 'hand', frames: [[
786          "................................",
787          "................................",
788          "................................",
789          "................................",
790          "................................",
791          "................................",
792          "................................",
793          "................................",
794          "................................",
795          "................................",
796          "................................",
797          "................................",
798          "................................",
799          "..........................mmm...",
800          "........................Mmm.mm..",
801          "........................mm...mm.",
802          "........................m.....m.",
803          ".......................mm.....mm",
804          ".......................m.......M",
805          ".......................mm.....mm",
806          "........................m.....m.",
807          "........................mm...mM.",
808          ".........................mm.mm..",
809          "..........................mmm...",
810          "..........................m.....",
811          ".........................m......",
812          "................................",
813          "................................",
814        ]] },
815      16: { layer: 'hand', frames: [[
816          "................",
817          "................",
818          "................",
819          "................",
820          "................",
821          "................",
822          ".............mm.",
823          "............m..m",
824          "............m..m",
825          ".............mm.",
826          "................",
827          "................",
828          "................",
829          "................",
830        ]] },
831    },
832    "gold-star": {
833      32: { layer: 'hat', frames: [[
834          "................................",
835          "...............yy...............",
836          "...............yy...............",
837          "............yyyllyyy............",
838          ".............yyllyy.............",
839          "..............yyyy..............",
840          ".............yy..yy.............",
841          ".............Y....Y.............",
842          "................................",
843          "................................",
844          "................................",
845          "................................",
846          "................................",
847          "................................",
848          "................................",
849          "................................",
850          "................................",
851          "................................",
852          "................................",
853          "................................",
854          "................................",
855          "................................",
856          "................................",
857          "................................",
858          "................................",
859          "................................",
860          "................................",
861          "................................",
862        ]] },
863      16: { layer: 'hat', frames: [[
864          "................",
865          ".......yy.......",
866          "......yyyy......",
867          "................",
868          "................",
869          "................",
870          "................",
871          "................",
872          "................",
873          "................",
874          "................",
875          "................",
876          "................",
877          "................",
878        ]] },
879    },
880    "desert": {
881      32: { layer: 'back', frames: [[
882          "DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD",
883          "DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD",
884          "DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD",
885          "DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD",
886          "DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD",
887          "DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD",
888          "DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD",
889          "DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD",
890          "DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD",
891          "DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD",
892          "IIIIIIIIIIIIIIIIIIIIIIIIIIIIIIII",
893          "IIIIIIIIIIIIIIIIIIIIIIIIIIIIIIII",
894          "IIIIIIIIIIIIIIIIIIIIIIIIIIIIIIII",
895          "IIIIIIIIIIIIIIIIIIIIIIIIIIIIIIII",
896          "IeEIIIIIIIIIIIIIIIIIIIIIIIIIIIeE",
897          "IeEIIIIIIIIIIIIIIIIIIIIIIIIIIIeE",
898          "IeEIIIIIIIIIIIIIIIIIIIIIIIIIIIeE",
899          "IeEIIIIIIIIIIIIIIIIIIIIIIIIIIIeE",
900          "eeEJJJJJJJJJJJJJJJJJJJJJJJJJJJee",
901          "eeEJJJJJJJJJJJJJJJJJJJJJJJJJJJee",
902          "JeEJJJJJJJJJJJJJJJJJJJJJJJJJJJeE",
903          "JeEJJJJJJJJJJJJJJJJJJJJJJJJJJJeE",
904          "JeEJJJJJJJJJJJJJJJJJJJJJJJJJJJeE",
905          "JeEJJJJJJJJJJJJJJJJJJJJJJJJJJJeE",
906          "JeEJJJJJJJJJJJJJJJJJJJJJJJJJJJeE",
907          "JeEJJJJJJJJJJJJJJJJJJJJJJJJJJJeE",
908          "JeEJJJJJJJJJJJJJJJJJJJJJJJJJJJeE",
909          "JJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJJ",
910        ]] },
911      16: { layer: 'back', frames: [[
912          "DDDDDDDDDDDDDDDD",
913          "DDDDDDDDDDDDDDDD",
914          "DDDDDDDDDDDDDDDD",
915          "DDDDDDDDDDDDDDDD",
916          "DDDDDDDDDDDDDDDD",
917          "IIIIIIIIIIIIIIII",
918          "IIIIIIIIIIIIIIII",
919          "eIIIIIIIIIIIIIIe",
920          "eIIIIIIIIIIIIIIe",
921          "eJJJJJJJJJJJJJJe",
922          "eJJJJJJJJJJJJJJe",
923          "eJJJJJJJJJJJJJJe",
924          "eJJJJJJJJJJJJJJe",
925          "JJJJJJJJJJJJJJJJ",
926        ]] },
927    },
928    "white-hat": {
929      32: { layer: 'hat', frames: [[
930          "................................",
931          "................................",
932          "................................",
933          "................................",
934          "................................",
935          "................................",
936          "................................",
937          "................................",
938          "................................",
939          "................................",
940          "................................",
941          "................................",
942          "................................",
943          "................................",
944          "................................",
945          "................................",
946          "................................",
947          "................................",
948          "................................",
949          "................................",
950          "................................",
951          "................................",
952          "................................",
953          "................................",
954          "................................",
955          "................................",
956          "................................",
957          "................................",
958        ]], recolor: {"N": "W", "n": "V", "H": "w"} },
959      16: { layer: 'hat', frames: [[
960          "................",
961          "................",
962          "................",
963          "................",
964          "................",
965          "................",
966          "................",
967          "................",
968          "................",
969          "................",
970          "................",
971          "................",
972          "................",
973          "................",
974        ]], recolor: {"N": "W", "n": "V", "H": "w"} },
975    },
976    "sunset": {
977      32: { layer: 'back', frames: [[
978          "vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv",
979          "vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv",
980          "vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv",
981          "vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv",
982          "vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv",
983          "vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv",
984          "UUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUU",
985          "UUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUU",
986          "UUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUU",
987          "UUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUU",
988          "UUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUU",
989          "UUUUUUUUUUUUUUUUUUUUUUUUUUUUUUUU",
990          "uuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuu",
991          "uuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuu",
992          "uuuuuuuuuuuuuuuuuuuuuuuuulllluuu",
993          "uuuuuuuuuuuuuuuuuuuuuuuulllllluu",
994          "uuuuuuuuuuuuuuuuuuuuuuullllllllu",
995          "aaaaaaaaaaaaaaaaaaaaaaalllllllla",
996          "aaaaaaaaaaaaaaaaaaaaaaalllllllla",
997          "aaaaaaaaaaaaaaaaaaaaaaalllllllla",
998          "aaaaaaaaaaaaaaaaaaaaaaaallllllaa",
999          "aaaaaaaaaaaaaaaaaaaaaaaaallllaaa",
1000          "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX",
1001          "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX",
1002          "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX",
1003          "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX",
1004          "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX",
1005          "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX",
1006        ]] },
1007      16: { layer: 'back', frames: [[
1008          "vvvvvvvvvvvvvvvv",
1009          "vvvvvvvvvvvvvvvv",
1010          "vvvvvvvvvvvvvvvv",
1011          "UUUUUUUUUUUUUUUU",
1012          "UUUUUUUUUUUUUUUU",
1013          "UUUUUUUUUUUUUUUU",
1014          "uuuuuuuuuuuuuuuu",
1015          "uuuuuuuuuuuuuuuu",
1016          "uuuuuuuuuuuuuull",
1017          "aaaaaaaaaaaaaall",
1018          "aaaaaaaaaaaaaaaa",
1019          "XXXXXXXXXXXXXXXX",
1020          "XXXXXXXXXXXXXXXX",
1021          "XXXXXXXXXXXXXXXX",
1022        ]] },
1023    },
1024    "galaxy-hat": {
1025      32: { layer: 'hat', frames: [[
1026          "................................",
1027          "................................",
1028          ".............y..................",
1029          "...........l....................",
1030          ".................y..............",
1031          "...................l............",
1032          "..............l......y..........",
1033          "................................",
1034          "................................",
1035          "..........................l.....",
1036          "....l..y................y.......",
1037          "................................",
1038          "................................",
1039          "................................",
1040          "................................",
1041          "................................",
1042          "................................",
1043          "................................",
1044          "................................",
1045          "................................",
1046          "................................",
1047          "................................",
1048          "................................",
1049          "................................",
1050          "................................",
1051          "................................",
1052          "................................",
1053          "................................",
1054        ], [
1055          "................................",
1056          "................................",
1057          ".............l..................",
1058          "...........y....................",
1059          ".................l..............",
1060          "...................y............",
1061          "..............y......l..........",
1062          "................................",
1063          "................................",
1064          "..........................y.....",
1065          "....y..l................l.......",
1066          "................................",
1067          "................................",
1068          "................................",
1069          "................................",
1070          "................................",
1071          "................................",
1072          "................................",
1073          "................................",
1074          "................................",
1075          "................................",
1076          "................................",
1077          "................................",
1078          "................................",
1079          "................................",
1080          "................................",
1081          "................................",
1082          "................................",
1083        ]], recolor: {"N": "z", "n": "Z", "H": "v"} },
1084      16: { layer: 'hat', frames: [[
1085          "................",
1086          ".....l.y........",
1087          ".........l.y....",
1088          "................",
1089          "..l..........y..",
1090          "................",
1091          "................",
1092          "................",
1093          "................",
1094          "................",
1095          "................",
1096          "................",
1097          "................",
1098          "................",
1099        ], [
1100          "................",
1101          ".....y.l........",
1102          ".........y.l....",
1103          "................",
1104          "..y..........l..",
1105          "................",
1106          "................",
1107          "................",
1108          "................",
1109          "................",
1110          "................",
1111          "................",
1112          "................",
1113          "................",
1114        ]], recolor: {"N": "z", "n": "Z", "H": "v"} },
1115    },
1116  },
1117  cappl: {
1118    "round-specs": {
1119      32: { layer: 'face', frames: [[
1120          "................................",
1121          "................................",
1122          "................................",
1123          "................................",
1124          "................................",
1125          "................................",
1126          "................................",
1127          "................................",
1128          "................................",
1129          "................................",
1130          "................................",
1131          "................................",
1132          "................................",
1133          "................................",
1134          "................................",
1135          "................................",
1136          "...........bb......bb...........",
1137          "..........b..bbbbbb..b..........",
1138          "..........b..b....b..b..........",
1139          "..........b..b....b..b..........",
1140          "...........bb......bb...........",
1141          "................................",
1142          "................................",
1143          "................................",
1144          "................................",
1145          "................................",
1146          "................................",
1147          "................................",
1148        ]] },
1149      16: { layer: 'face', frames: [[
1150          "................",
1151          "................",
1152          "................",
1153          "................",
1154          "................",
1155          "................",
1156          "................",
1157          ".....k....k.....",
1158          "....k.k..k.k....",
1159          "....k.k..k.k....",
1160          ".....k....k.....",
1161          "................",
1162          "................",
1163          "................",
1164        ]] },
1165    },
1166    "straw": {
1167      32: { layer: 'face', frames: [[
1168          "................................",
1169          "................................",
1170          "................................",
1171          "................................",
1172          "................................",
1173          "................................",
1174          "................................",
1175          "................................",
1176          "................................",
1177          "................................",
1178          "................................",
1179          "................................",
1180          "................................",
1181          "................................",
1182          "................................",
1183          "................................",
1184          "................................",
1185          "................................",
1186          "..........................M.....",
1187          ".........................MM.....",
1188          "........................mM......",
1189          "......................mm........",
1190          "....................mm..........",
1191          "..................mm............",
1192          "................................",
1193          "................................",
1194          "................................",
1195          "................................",
1196        ]] },
1197      16: { layer: 'face', frames: [[
1198          "................",
1199          "................",
1200          "................",
types/index.d.ts 71 lines
1export type CharacterId = 'locki' | 'cappl'
2export type Language = 'en' | 'ko'
3
4// Results a person can check (see rules.ts), plus bonuses paid on top of them.
5export type ResultKind = 'daily' | 'verify' | 'comeback' | 'commit' | 'merge' | 'plan' | 'break' | 'check' | 'pr'
6export type BonusKind = 'chest' | 'quest' | 'lucky'
7export type RewardKind = ResultKind | BonusKind
8// Things the pal and the person do together; counted for the day's bounties, never paid.
9export type CareKind = 'pet' | 'treat' | 'trick'
10
11export type Reward = {
12  at: number
13  kind: RewardKind
14  fame: number
15  dust: number
16  // Below 1 when the same kind was earned many times that day.
17  rate: number
18}
19
20export type DayCounts = Partial<Record<RewardKind | CareKind, number>>
21
22// A session writes only its own ledger (`ledger:<session id>`), so sessions running
23// side by side never overwrite each other. Every pane sums all of them.
24export type Ledger = {
25  fame: number
26  dust: number
27  days: Record<string, DayCounts>
28  lastAt: number
29  activeSince: number
30  // This session's latest rewards; the pane merges every session's by time.
31  recent: Reward[]
32}
33
34// Item ids carry their character: `locki/aviators`, `cappl/banjo`.
35export type Wallet = {
36  spent: number
37  owned: string[]
38  equipped: string[]
39}
40
41export type Totals = { fame: number; dust: number }
42
43export type PaneView = 'main' | 'rules' | 'progress'
44export type Activity = 'idle' | 'sleeping' | 'thinking' | 'reading' | 'editing' | 'running' | 'testing' | 'waiting'
45// A one-off animation (a reward, a pet, a trick) and when it started, in ms.
46export type Act = { name: string; at: number }
47
48// The sparkle around the character and the line in the pane at a reward or a purchase.
49export type FxKind = 'reward' | 'comeback' | 'merge' | 'level' | 'stage' | 'buy'
50export type Fx = { kind: FxKind; label: string; at: number }
51
52declare module 'claude-code' {
53  interface PluginState {
54    'cappl-pals': {
55      totals: Totals
56      wallet: Wallet
57      log: Reward[]
58      activity: Activity
59      act: Act | null
60      // A treat makes the pal glow (blush, livelier) until this time, in ms.
61      glowUntil: number
62      turnFame: number
63      fx: Fx | null
64      character: CharacterId | null
65      picking: boolean
66      // What the pane shows besides the picker: the pal, the rules or my progress.
67      view: PaneView
68    }
69  }
70}
71