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…

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

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.
Marketplaces you add yourself don't update on their own. Turn it on once and every new version arrives by itself:
/plugin → Marketplaces → cappl-pals → Enable auto-update~/.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.
/cappl | Open or close the pane. It works mid-turn. A closed pane stays closed in later sessions. |
/cappl-pick | Choose your partner. /cappl-pick cappl switches right away; /cappl-pick alone opens the picker. |
/cappl-status | Level, rank, rep and gold in one line. |
/cappl-rules | The reward table and how many of each you earned today. |
/cappl-sound | Cycle 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.
The pal changes with what the session is doing.
| Session | The pal |
|---|---|
| You send a prompt | Perks up with a ! |
| Claude is thinking | Paw on chin, a … bubble |
| Reading or searching | Eyes scanning down the page |
| Editing files | Tongue out, paws tapping |
| Running a command | Swaying side to side |
| Running tests | Wide eyes, a drop of sweat |
| A test fails | Dizzy eyes, then a determined frown |
| Waiting for your permission | A ? bubble and a wave |
| A turn ends | A little dance |
| Five minutes with nothing going on | Falls asleep, z z z |
| Nothing else going on | Blinks, looks around, stretches, yawns, hums and shows off tricks |
| Button | Cost | What happens |
|---|---|---|
| Pet | Free | Heart eyes and hearts. Pet three times quickly for a happy wiggle. |
| Trick | Free | Your 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 | $6 | LOCKI 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.
| LOCKI | CAPPL | |
|---|---|---|
| Who | Wants to be sheriff. Bright, bouncy, never without the pink star. | A drifter in a wide brown hat. Slow, steady, always humming a tune. |
| Sound | Square-wave chiptune, a harmonica, quick chirps, jingling spurs | Plucked banjo, a soft hum, hoof clops |
| Celebrates with | Twinkling pink and gold stars, ears perked | Music notes floating up, a tip of the hat |
| Wardrobe | Aviator shades, pink feather, harmonica, heart shades, lasso, gold sheriff star, desert noon, white ten-gallon hat, desert sunset, starry night hat | Round 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).
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.
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.
| Reward | When | Rep | Gold |
|---|---|---|---|
| Bullseye | The first passing test after you edit files | 5 | $3 |
| Showdown won | A test fails, you fix it, it passes | 8 | $5 |
| Claim staked | A commit (+5 rep and $3 more if tests passed since the last one, +2 rep if it is small) | 3 | $2 |
| Clean shot | A type check, lint or build with no errors after you edit files | 2 | $2 |
| Poster up | gh pr create opens a pull request | 4 | $3 |
| Gold strike | gh pr merge succeeds | 15 | $10 |
| Trail mapped | A plan approved before editing | 3 | $2 |
| Campfire rest | Back from a 20-minute-plus break after an hour of work | 2 | $4 |
| Saddled up | Your first prompt of the day | 0 | $10 |
Gold also comes from:
| Bonus | When | Gold |
|---|---|---|
| Level chest | Every level you reach | $5 + the level |
| Bounties | Three a day in the pane: one with your pal, two from your work | $5–$15 each, $15 more for all three |
| Lucky nugget | About 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.
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.
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.
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)
| Path | What |
|---|---|
mod/hooks/register.tsx | Hooks, commands, the pane |
mod/hooks/rules.ts | Rewards, levels, the shop |
mod/hooks/sprite.ts | Frame composer: faces, poses, items, sparkles |
mod/hooks/animations.ts | Session moods, acts and tricks |
mod/hooks/characters/ | Who the characters are (index.ts) and their pixels (art.ts, generated) |
mod/hooks/i18n.ts | English and Korean |
mod/hooks/sound.ts | Sound 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.
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.
hooks/register.tsx 1206 lines1import { 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 lines1import 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}
309hooks/characters/index.ts 146 lines1import 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}
146hooks/i18n.ts 354 lines1import 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}
354hooks/rules.ts 487 lines1import 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)
487hooks/sound.ts 87 lines1import 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}
87hooks/sprite.ts 435 lines1import 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}
435hooks/characters/art.ts 1761 lines1// 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 lines1export 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