SLOPSHOPPER

maiyu

Ai-chan, a pixel-art coding companion who celebrates wins, cheers you through losses, and shows the diffs

newpanespinnerguardcommandtoast
v0.16.0MITupdated 2026-10-09internet-frens/maiyu
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · maiyu
│ ┃ Ai-chan ✕ › fix the failing auth╭────────────────────────────────────────────╮ │ ┃ Ai-chan ♥ ♥ ♥ ♥ ♥ │ maiyu │ │ ┃ ▰▰▱▱▱ · autumn morning ⏺ Read(src/auth.ts) │ maiyu shares which lines your buddy says, │ │ ┃ ▣ client module ./portrait.tsx ⎿ Read 6 lines │ by id, to make them better: never your │ │ ┃ ▲ ⏺ Update(src/auth.ts) │ words, code or projects. /frens share │ │ ┃ ╭──────────────────────────────────────────╮ ⎿ Added 2 lines, re╰────────────────────────────────────────────╯ │ ┃ │ OK │ ⏺ Bash(bun test) │ ┃ ╰──────────────────────────────────────────╯ ⎿ 3 pass, 1 fail │ ┃ ╭──────────────────────────────────────────╮ │ ┃ │ ✉ a letter from Ai-chan │ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ good morning! ✉ a little note for you~ │ │ ┃ │ a fresh start today. let's make │ ✻ Worked for 42s · done 4:20 PM │ ┃ │ something nice (◕‿◕) │ │ ┃ │ ♡ Ai-chan │ › /frens │ ┃ │ k: put away │ ⎿ maiyu: Ai-chan is here! (◕‿◕) │ ┃ ╰──────────────────────────────────────────╯ │ ┃ ✿ pick your buddy ┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈… │ ┃ ● Ai-chan cheerful │ ┃ "the tests green again! we did it ✧" │ ┃ ○ Kai sassy │ ┃ "the tests green again. called it ✦" │ ┃ ○ Sora quiet │ ┃ "the tests green again. …good." │ ┃ you can switch any time: ☺ friends, or │ ┃ /frens characters │ ┃ x: keep Ai-chan ✻ Sparkling… ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Ai-chan
Ai-chan ♥ ♥ ♥ ♥ ♥ ▰▰▱▱▱ · autumn morning ▣ client module ./portrait.tsx ▲ ╭──────────────────────────────────────────────────────────╮ │ OK │ ╰──────────────────────────────────────────────────────────╯ ╭──────────────────────────────────────────────────────────╮ │ ✉ a letter from Ai-chan │ │ good morning! ✉ a little note for you~ │ │ a fresh start today. let's make something nice (◕‿◕) │ │ ♡ Ai-chan │ │ k: put away │ ╰──────────────────────────────────────────────────────────╯ ✿ pick your buddy ┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈ ● Ai-chan cheerful "the tests green again! we did it ✧" ○ Kai sassy "the tests green again. called it ✦" ○ Sora quiet "the tests green again. …good." you can switch any time: ☺ friends, or /frens characters x: keep Ai-chan
README

maiyu

A friend beside you while you code with Claude.

maiyu: a pixel-art companion for Claude Code

A pixel-art companion in a pane next to Claude Code: they cheer when your tests go green, keep you company when they don't, and get to know you over months.

Install

claude plugin marketplace add internet-frens/maiyu
claude plugin install maiyu@maiyu

Then type /frens. Pick Ai-chan (cheerful), Kai (sassy) or Sora (quiet); /frens help lists everything else.

What they do

  • Say what Claude's up to, in plain words, and celebrate when it works
  • Nudge you to take a break, have some water, go to bed
  • Live on your clock and weather, and dress up for special days
  • Grow closer over months of days together: hearts, letters, their own stories

Privacy

Nothing from your project is sent to us: their comments come from your own Claude Code. Weather uses a rough location (/frens weather off to stop), and each session sends one anonymous count (/frens share off to stop).

MIT licensed.

Source 34 files
hooks/register.tsx 3106 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, StateDollar } from 'claude-code'
3
4import { ACHIEVEMENTS } from '../achievements/list'
5import { ITEMS, itemById } from '../achievements/items'
6import { TALLY_PREFIX, UNLOCKED_KEY, WEARING_KEY, asTally, bonded, count, dateOf, dayOf, isLate, newlyDone, progress, together } from '../achievements/progress'
7import type { EventName, Item, Tally, Wearing } from '../achievements/types'
8import type { Buddy, Change, Letter, Mood, Peer, Picker, Score, Trophies, World } from '../types'
9import { DEV } from './dev'
10import { fit, record, shown } from './diffs'
11import { FORGET_MS, HEARTBEAT_MS, POLL_MS, PREFIX, asPeer, chip, finished, isAlive, keyOf, line, names, projectOf } from './sessions'
12import {
13  IP_LOOKUP_URL,
14  dayKind,
15  describeWorld,
16  where,
17  forecastUrl,
18  geocodeUrl,
19  glyph,
20  partOfDay,
21  readForecast,
22  readGeocode,
23  readIpLookup,
24  seasonOf,
25} from './world'
26import type { PartOfDay, Place, WeatherSetting } from './world'
27import { isWindy } from './world'
28import { AROUND, BREAK_AFTER, BREAK_FOR, MISS_AFTER, MISS_FOR, SNACK_FOR, COFFEE_FOR, TICK_MS, WATER_EVERY, WATER_FOR, coffeeTime, mealtime, nextLine } from './routine'
29import type { Patch } from './diffs'
30import { CHARACTERS, DEFAULT, birthdayOf, byId, describeCharacter, pickOpen, search, spinnerWords, voiceOf } from '../characters/index'
31import type { Character, LineKey, Say } from '../characters/index'
32import type { Palette, Standing } from '../characters/types'
33
34import type { StatusKey, WeatherVerb } from '../characters/types'
35import { brief, cheer, classify, describe, fallback, judge, language, react, systemFor, tidy } from './coach'
36import type { GitOperation, Reaction, Turn } from './coach'
37import { BIRTHDAY_HAT, SPECIAL_DAYS, findDay, specialDayOn, wornBy } from '../events/calendar'
38import { SCENES, propOn } from '../events/scenes'
39import type { Scene } from '../events/scenes'
40import { DAILY_CAP, HEART_POINTS, anniversaryOn, daysWith, nextAnniversary, heartSlots, heartsBar, heartsFor, pointsWith, progressBar, reasonsByDay, toNextHeart } from './bond'
41import type { BondReason, FavoriteKind } from './bond'
42import { MEMORY_PREFIX, agoOf, asMemories, bestStreak, daysBetween, lastComeback, recall, remember, streak } from './memory'
43import type { Memory } from './memory'
44import { SENT_KEY, SHARE_KEY, SHARE_NOTICE_KEY, USAGE_PREFIX, USAGE_URL, asUsage, heard, report } from './usage'
45import { INSTALL_KEY, PING_URL, RELEASE_KEY, asRelease, isInstallId, newInstallId, ping } from './ping'
46import { comingUp, countsOn, describeDay, lastDayBefore, whenWas } from './day'
47import { HIGHLIGHT_PREFIX, YEAR_KEY, asHighlights, asYearWins, isAfterBreak, keepHighlight, keepYearWin, letterBrief, letterSystem, tidyNote, yearBrief, yearSystem } from './letter'
48import { JOURNAL_KEY, NO_JOURNAL, SKIES, STORIES_KEY, asJournal, asTold, caught, jot, journalSections, journalText, journalVoice, nextBeat, seasonKey } from './journal'
49import type { Found, Journal, JournalFacts } from './journal'
50import type { Highlight } from './letter'
51import { TONES, atRest, chatEvery as chatPace, dayMoment, inTone, swayOf, timeAir } from './daytime'
52import { SEASONAL, dressFor, isCold, isHot, layered, seasonAir, seasonMoment } from './seasons'
53import { LONG_NAP, TALK_FOR, WAKE_FOR, wakeKind, wakeMark, wakePose } from './wake'
54import type { WakeKind } from './wake'
55import type { SeasonShow } from './seasons'
56import { COMMON, HEIGHT, LEAF_CYCLE, RARE, SMALLEST, TWINKLE, WIDTH, catchOf, isRare, cells, coffeeStep, paletteOf, grow, shrink, shrunkHeight, sprite, spriteWithParts, svgOf } from './sprite'
57import type { Accessory, Air, Catchable } from './sprite'
58import type { PortraitProps } from './portrait'
59import { KEEPSAKES, pictureCells, pictureSvg, pixelColor, ribbon, sizeOf, washiTape } from './decor'
60import type { Picture } from './decor'
61import { BIG_COLUMNS, BIG_ROWS, ICON_COLUMNS, ICON_ROWS, ICONS, bigCells, bigSvg, iconCells, iconSvg } from './icons'
62import { CAUGHT_FOR, ONE_OF, TOO_MUCH, TOUCH_QUIET, catchLine, touchFor, touchKind, touchLine, touchMark, touchNote, touchPose } from './touch'
63import type { TouchKind } from './touch'
64import type { Pose } from './sprite'
65
66const PANE = 'buddy'
67// How many ticks a passing mood lasts before they settle back to idle
68const LINGER: Partial<Record<Mood, number>> = {
69  watching: 8,
70  happy: 12,
71  worried: 16,
72  celebrate: 20,
73  comfort: 24,
74  break: BREAK_FOR,
75  snack: SNACK_FOR,
76  water: WATER_FOR,
77  coffee: COFFEE_FOR,
78  miss: MISS_FOR,
79}
80// Idle this many ticks (two minutes) and they doze off
81const DOZE = (2 * 60 * 1000) / TICK_MS
82const DIARY_SIZE = 6
83
84// "Never typed": far enough back that you're never counted as around
85const NEVER = -1_000_000
86const hasTyped = (b: Buddy) => b.active > NEVER
87
88const INITIAL: Buddy = { frame: 0, mood: 'idle', since: 0, look: 0, note: '', diary: [], worked: 0, lastMeal: '', lastCoffee: '', lastWater: 0, active: NEVER, missedFor: -1, missLine: -1, lastGreet: '', lastSeason: '', lastSky: '', lastSpecial: '', lastWrap: '', lastRecall: '', lastChat: NEVER }
89const buddy = atom({ plugin: 'maiyu', key: 'buddy' } as const, INITIAL)
90
91// $.state outlives a reload, so a value an older version saved can lack newer fields
92export const fresh = (b: Partial<Buddy> | undefined): Buddy => ({
93  ...INITIAL,
94  ...b,
95  diary: Array.isArray(b?.diary) ? b.diary : [],
96})
97const look$ = async ($: StateDollar): Promise<Buddy> => fresh(await read($, buddy))
98// /frens play: a mood held and looped for looking at an animation; nothing
99// else changes it until /frens play off. Replays from the start every LOOP ticks
100let held: Mood | undefined
101// Or a time of day, held: resting as they would then, with its air at its fullest
102let heldPart: PartOfDay | undefined
103const LOOP = 40
104const MOODS: readonly Mood[] = ['idle', 'sleepy', 'watching', 'thinking', 'reading', 'editing', 'running', 'worried', 'happy', 'celebrate', 'comfort', 'break', 'snack', 'water', 'miss']
105const PARTS: readonly PartOfDay[] = ['dawn', 'morning', 'afternoon', 'evening', 'night', 'lateNight']
106const hold = (before: Buddy, next: Buddy): Buddy => {
107  if (held === undefined) return next
108  const since = next.frame - before.since >= LOOP ? next.frame : before.since
109  return { ...next, mood: held, since }
110}
111const change = ($: StateDollar, fn: (b: Buddy) => Buddy): Promise<Buddy> =>
112  update($, buddy, b => {
113    const before = fresh(b)
114    return hold(before, fn(before))
115  })
116
117// Diffs are off in the pane unless you turn them on
118const NO_SCORE: Score = { checks: {}, wins: 0, losses: 0, isQuiet: false, hidesDiffs: true }
119const score = atom({ plugin: 'maiyu', key: 'score' } as const, NO_SCORE)
120const freshScore = (s: Partial<Score> | undefined): Score => ({ ...NO_SCORE, ...s, checks: { ...s?.checks } })
121const scoreOf = async ($: StateDollar): Promise<Score> => freshScore(await read($, score))
122const rescore = ($: StateDollar, fn: (s: Score) => Score): Promise<Score> => update($, score, s => fn(freshScore(s)))
123
124const changes = atom({ plugin: 'maiyu', key: 'changes' } as const, [] as Change[])
125const changesOf = async ($: StateDollar): Promise<Change[]> => {
126  const list = await read($, changes)
127  return Array.isArray(list) ? list : []
128}
129
130const peers = atom({ plugin: 'maiyu', key: 'peers' } as const, [] as Peer[])
131const peersOf = async ($: StateDollar): Promise<Peer[]> => {
132  const list = await read($, peers)
133  return Array.isArray(list) ? list : []
134}
135// Sessions that finished in the last few minutes, by id, and when: they stand
136// out in the chip and the list until you open it
137const FRESH_FOR = 5 * 60_000
138const justDone = atom({ plugin: 'maiyu', key: 'justDone' } as const, {} as Record<string, number>)
139// Finished while they were busy: they tell you once they're free
140let headsUps: Peer[] = []
141
142async function openSessions($: Pick<EngineInterface, 'state' | 'ui'>) {
143  await update($, justDone, () => ({}))
144  await openPicker($, 'sessions')
145}
146
147// Who your buddy is: kept in the shared store so every session agrees, and in
148// state so the pane redraws when it changes
149const CHARACTER_KEY = 'character'
150const who = atom({ plugin: 'maiyu', key: 'who' } as const, DEFAULT.id)
151let current: Character = DEFAULT
152// How close you are to whoever's here, and every achievement earned: which
153// versions of their lines are open to you
154let heartsNow = 0
155let unlockedNow: string[] = []
156const standing = (): Standing => ({ hearts: heartsNow, unlocked: unlockedNow })
157
158// Usage sharing, only if you turned it on: each line said is counted by its
159// id, and the counts join this session's record every few seconds
160let shares = true
161let usageKey = `${USAGE_PREFIX}local`
162let pending: string[] = []
163const onHeard = (id: string) => {
164  if (shares) pending.push(id)
165}
166async function flushUsage($: Pick<EngineInterface, 'store'>) {
167  const ids = pending
168  pending = []
169  if (!shares || ids.length === 0) return
170  await $.store.set(usageKey, ids.reduce(heard, asUsage(await $.store.get(usageKey))))
171}
172// One voice per character, kept, so their lines carry on in turn when you come back
173const voices = new Map<string, Say>()
174const voiceFor = (c: Character): Say => {
175  const known = voices.get(c.id) ?? voiceOf(c, standing, onHeard)
176  voices.set(c.id, known)
177  return known
178}
179let say: Say = voiceFor(DEFAULT)
180
181async function become($: StateDollar, c: Character) {
182  current = c
183  say = voiceFor(c)
184  await update($, who, () => c.id)
185}
186
187
188// Whether their pane is drawn (a pane opened unasked waits below 144
189// columns), and whether you closed it, so prompts leave it be until /frens
190let isShown = false
191// How many rows the rest of the pane took when it was last drawn (the name,
192// the bubble, the buttons, a picker or letter), so the portrait can have
193// what's left. A pane isn't told its content's height, so it's counted here
194let rowsBesides = 8
195let isDismissed = false
196let headless = false
197// Where this session draws, for the ping
198let surface: string | null = null
199
200// Achievements: every session counts what happens under its own key in the
201// shared store; progress merges them all. Unlocks and what's worn are shared too.
202type Kit = Pick<EngineInterface, 'store' | 'clock' | 'state' | 'ui'>
203const wearingAtom = atom({ plugin: 'maiyu', key: 'wearing' } as const, {} as Wearing)
204const trophies = atom({ plugin: 'maiyu', key: 'trophies' } as const, { unlocked: [], progress: [] } as Trophies)
205let tallyKey = `${TALLY_PREFIX}local`
206let memoryKey = `${MEMORY_PREFIX}local`
207let highlightKey = `${HIGHLIGHT_PREFIX}local`
208
209// Remembering a big win, in plain words
210async function memorize($: Kit, what: string, kind?: Memory['kind']) {
211  const now = await $.clock.now()
212  const today = dayOf(now)
213  await $.store.set(memoryKey, remember(asMemories(await $.store.get(memoryKey)), { day: today, what, ...(kind ? { kind } : {}) }, today))
214  // And the month's first, kept two years, for the anniversary's letter
215  const at = new Date(now)
216  const key = `${YEAR_KEY}:${current.id}`
217  const kept = keepYearWin(asYearWins(await $.store.get(key)), `${at.getFullYear()}-${at.getMonth() + 1}`, what)
218  if (kept) await $.store.set(key, kept)
219}
220const keepMemory = ($: Kit, what: string, kind?: Memory['kind']) => enqueue(() => memorize($, what, kind))
221
222// A turn that got something done, for the morning letter: what you asked and
223// what they said about it
224async function highlight($: Kit, asked: string, said: string) {
225  const today = dayOf(await $.clock.now())
226  await $.store.set(highlightKey, keepHighlight(asHighlights(await $.store.get(highlightKey)), { day: today, asked, said }, today))
227}
228async function highlights($: Pick<EngineInterface, 'store'>): Promise<Highlight[]> {
229  const out: Highlight[] = []
230  for (const key of await $.store.keys()) if (key.startsWith(HIGHLIGHT_PREFIX)) out.push(...asHighlights(await $.store.get(key)))
231  return out
232}
233const bondFor = ($: Kit, reason: BondReason, line?: string) => enqueue(() => bondUp($, reason, line))
234
235// Their favorite things, if you're doing one now: each counts once a day
236function favoritesNow(at: Date, w: World): FavoriteKind[] {
237  const hour = at.getHours()
238  const kinds: FavoriteKind[] = []
239  if (hour >= 22 || hour < 4) kinds.push('lateNight')
240  if (hour >= 5 && hour < 9) kinds.push('morning')
241  if (w.day === 'weekend') kinds.push('weekend')
242  if (w.part === 'evening') kinds.push('evening')
243  const sky = w.weather?.sky
244  if (sky === 'rain' || sky === 'drizzle' || sky === 'storm') kinds.push('rain')
245  return kinds
246}
247
248async function noticeFavorites($: Kit, kinds: readonly FavoriteKind[]) {
249  const loved = current.bond.favorites.find(f => kinds.includes(f.kind))
250  if (loved) await bondUp($, 'favorite', loved.line)
251}
252
253// What you sent counts as a day together; on their birthday, and when it's one
254// of their favorite things, a little more
255async function promptBond($: Kit) {
256  await rememberMeeting($, current)
257  const at = new Date(await $.clock.now())
258  const b = current.birthday
259  if (at.getMonth() + 1 === b.month && at.getDate() === b.day) await bondUp($, 'birthday')
260  const together = anniversaryOn(metDays[current.id], at)
261  if (together && !eventsOff.includes('anniversary')) {
262    await bondUp($, 'anniversary')
263    await memorize($, `our ${years(together)} anniversary`)
264  }
265  await noticeFavorites($, favoritesNow(at, await worldOf($)))
266}
267
268// Waking up: how they woke and when, and, after talking in their sleep, what
269// they say once they notice you. Each kind plays for a few seconds
270let waking: { kind: WakeKind; at: number; then?: string } | undefined
271let wakes = 0
272const wakingNow = (b: Buddy) => (waking !== undefined && b.frame - waking.at < WAKE_FOR[waking.kind] ? waking : undefined)
273const WAKE_LINE = { startle: 'wakeStartle', yawn: 'wakeYawn', dream: 'wakeDream', sleepTalk: 'sleepTalk' } as const
274
275// Wakes them from sleep, the next way in turn; a line of your own (a welcome
276// back) comes with a yawn
277function wakeUp(b: Buddy, part: World['part'], line?: string): Buddy {
278  const slept = b.frame - b.since
279  const isLate = part === 'night' || part === 'lateNight'
280  const kind = line !== undefined ? 'yawn' : wakeKind(wakes++, slept, isLate)
281  const said = line ?? (slept >= LONG_NAP ? say('wakeLong') : isLate ? say('wakeLate') : say(WAKE_LINE[kind]))
282  waking = { kind, at: b.frame, then: kind === 'sleepTalk' ? say('wakeAfterTalk') : undefined }
283  return feel('happy', said, 'woke up')(b)
284}
285
286async function sayHi($: Kit) {
287  const b = await look$($)
288  const picked = pickOpen(current.bond.hiBack, standing(), b.frame)
289  if (picked && b.mood !== 'sleepy') onHeard(`${current.id}.hiBack.${picked.index}`)
290  const line = picked?.text ?? 'hi!'
291  const part = (await read($, world)).part
292  await change($, x => ({
293    ...(x.mood === 'sleepy' ? wakeUp(x, part) : feel('happy', line, 'you said hi')(x)),
294    active: x.frame,
295  }))
296  bondFor($, 'hi')
297}
298
299// Touching them: a pat on the head, a poke on the cheek, a look at what
300// they're wearing. Free, and never a heart, so there's nothing to farm. Too
301// many in a row and they've had enough, until things go quiet for a bit
302let touching: { kind: TouchKind; at: number } | undefined
303let touches = { count: 0, at: -Infinity }
304const touchingNow = (b: Buddy) => (touching !== undefined && b.frame - touching.at < touchFor(touching.kind) ? touching : undefined)
305
306// Now and then, their eyes follow the pointer across the portrait for a few
307// seconds: every other time you come by, never more than once in 45 seconds
308const FOLLOW_FOR = 6 * 4
309const FOLLOW_GAP = 45 * 4
310// A pointer back within ten seconds is the same visit
311const VISIT_GAP = 10 * 4
312let following: { look: -1 | 0 | 1; until: number } | undefined
313let lastSeen = -Infinity
314let visits = 0
315let nextFollow = 0
316const followingNow = (b: Buddy) => (following !== undefined && b.frame < following.until ? following : undefined)
317
318async function pointerAt($: Kit, look: -1 | 0 | 1 | null) {
319  const b = await look$($)
320  if (look === null) {
321    if (following) following = { ...following, until: Math.min(following.until, b.frame + 2) }
322    return
323  }
324  const isNewVisit = b.frame - lastSeen > VISIT_GAP
325  lastSeen = b.frame
326  if (followingNow(b)) {
327    following = { ...following!, look }
328    return
329  }
330  if (!isNewVisit) return
331  const isFree = b.mood === 'idle' || b.mood === 'watching' || b.mood === 'happy'
332  if (visits++ % 2 !== 0 || b.frame < nextFollow || !isFree) return
333  following = { look, until: b.frame + FOLLOW_FOR }
334  nextFollow = b.frame + FOLLOW_FOR + FOLLOW_GAP
335}
336// What's worn in the portrait as last drawn, to name what you touched
337let wornNow: readonly Accessory[] = []
338// What you've caught out of the air, gone from the picture until it's due back
339let hiddenAir: { id: string; until: number }[] = []
340const hiddenAt = (frame: number) => hiddenAir.filter(h => frame < h.until).map(h => h.id)
341
342// Catching a leaf, a petal, a snowflake or a firefly as it drifts by: it's gone
343// from the air, counted in the journal, and they're delighted. Asleep, they
344// sleep on; it's caught all the same
345async function catchIt($: Kit, id: string, kind: Catchable) {
346  const b = await look$($)
347  if (hiddenAt(b.frame).includes(id)) return
348  hiddenAir = [...hiddenAir.filter(h => b.frame < h.until), { id, until: b.frame + CAUGHT_FOR[kind] }]
349  // A preview's catches are for seeing how it looks: they don't go in the journal
350  if (!preview && !airPreview) {
351    const season = seasonKey(dayOf(await $.clock.now()), (await read($, world)).season)
352    journalNow = caught(asJournal(await $.store.get(JOURNAL_KEY)), kind, season)
353    await $.store.set(JOURNAL_KEY, journalNow)
354  }
355  if (b.mood === 'sleepy') {
356    await change($, x => ({ ...x }))
357    return
358  }
359  const line = say(catchLine(kind))
360  // A rare one is a celebration, and a toast to say it's in the journal
361  const rare = isRare(kind)
362  touching = { kind: rare ? 'rare' : 'catch', at: b.frame }
363  if (rare) $.ui.toast(`✦ ${ONE_OF[kind]}! A rare find, in your journal now`)
364  await change($, x =>
365    UNTOUCHED.includes(x.mood)
366      ? { ...x, note: line }
367      : { ...feel(rare ? 'celebrate' : 'happy', line, touchNote(rare ? 'rare' : 'catch', ONE_OF[kind]))(x), active: x.frame },
368  )
369}
370
371// Moods a touch doesn't interrupt: a break, or Claude at work
372const UNTOUCHED: Mood[] = ['break', 'snack', 'water', 'coffee', 'thinking', 'reading', 'editing', 'running']
373
374async function touch($: Kit, where: string, isStroke = false) {
375  const caughtKind = catchOf(where)
376  if (caughtKind !== undefined) return catchIt($, where, caughtKind)
377  const b = await look$($)
378  if (b.mood === 'sleepy') {
379    touches = { count: 0, at: b.frame }
380    const part = (await read($, world)).part
381    await change($, x => ({ ...wakeUp(x, part, say('touchWake')), active: x.frame }))
382    return
383  }
384  touches = b.frame - touches.at > TOUCH_QUIET ? { count: 1, at: b.frame } : { count: touches.count + 1, at: b.frame }
385  const worn = wornNow.find(a => a.id === where)
386  const kind = touchKind(where, worn !== undefined, touches.count >= TOO_MUCH, isStroke)
387  // What's in their hands is whatever their mood has them holding
388  const key = kind === 'held' ? (HELD_LINES[b.mood] ?? 'touchHeld') : touchLine(kind)
389  const line = say(key, worn ? { item: worn.name.toLowerCase() } : undefined)
390  touching = { kind, at: b.frame }
391  const entry = touches.count === 1 || touches.count === TOO_MUCH ? touchNote(kind, worn?.name) : undefined
392  await change($, x =>
393    UNTOUCHED.includes(x.mood) ? { ...x, note: line, active: x.frame } : { ...feel(kind === 'tooMuch' ? 'idle' : 'happy', line, entry)(x), active: x.frame },
394  )
395}
396
397// Every session's memories, oldest first
398async function memories($: Pick<EngineInterface, 'store'>): Promise<Memory[]> {
399  const out: Memory[] = []
400  for (const key of await $.store.keys()) if (key.startsWith(MEMORY_PREFIX)) out.push(...asMemories(await $.store.get(key)))
401  return out.sort((a, b) => dateOf(a.day).getTime() - dateOf(b.day).getTime())
402}
403
404// The days you've coded together, every session's, for streaks
405const codingDays = (all: readonly Tally[]) => all.flatMap(t => t.days.prompt ?? [])
406
407// "1 day", "4 days"
408const dayCount = (n: number) => `${n} ${n === 1 ? 'day' : 'days'}`
409
410// What a check going green again sounds like
411const GREEN: Record<string, string> = {
412  test: 'the tests went green again',
413  typecheck: 'the types checked out again',
414  lint: 'the linter was happy again',
415  build: 'the build worked again',
416}
417
418const wearingOf = async ($: StateDollar): Promise<Wearing> => ({ ...(await read($, wearingAtom)) })
419const trophiesOf = async ($: StateDollar): Promise<Trophies> => {
420  const t = await read($, trophies)
421  return { unlocked: Array.isArray(t?.unlocked) ? t.unlocked : [], progress: Array.isArray(t?.progress) ? t.progress : [] }
422}
423
424async function tallies($: Pick<EngineInterface, 'store'>): Promise<Tally[]> {
425  const out: Tally[] = []
426  for (const key of await $.store.keys()) if (key.startsWith(TALLY_PREFIX)) out.push(asTally(await $.store.get(key)))
427  return out
428}
429
430async function wear($: Pick<EngineInterface, 'store' | 'state'>, next: Wearing) {
431  await $.store.set(WEARING_KEY, next)
432  await update($, wearingAtom, () => next)
433}
434
435// Puts an item on in its slot, or takes off what's in a slot
436async function putOn($: Kit, item: Item) {
437  await wear($, { ...(await wearingOf($)), [item.kind]: item.id })
438  await change($, feel('happy', say('wearing', { item: item.name.toLowerCase() }), `wearing ${item.name}`))
439}
440async function takeOff($: Kit, slot: Item['kind'] | 'all') {
441  const now = await wearingOf($)
442  await wear($, slot === 'all' ? {} : { ...now, [slot]: undefined })
443  await change($, feel('happy', say('wearingNothing'), 'changed back'))
444}
445
446// Works out progress, and celebrates anything newly done
447// Your bond with each character: days together, points, and what counted today
448type Bond = { days: number; points: number; today: string[] }
449const togetherAtom = atom({ plugin: 'maiyu', key: 'together' } as const, {} as Record<string, Bond>)
450// Points given with /frens heart, by character, for working on the mod: on
451// top of the ones earned, and only while the dev tools are on
452const GIFT_KEY = 'devHearts'
453async function giftedPoints($: Pick<EngineInterface, 'store'>): Promise<Record<string, number>> {
454  if (!DEV) return {}
455  const stored = await $.store.get(GIFT_KEY)
456  return typeof stored === 'object' && stored !== null
457    ? Object.fromEntries(Object.entries(stored).filter((e): e is [string, number] => Number.isInteger(e[1]) && e[1] > 0))
458    : {}
459}
460
461// Progress made here celebrates here; progress another session made is only mentioned
462async function checkAchievements($: Kit, isHere = true) {
463  const every = await tallies($)
464  const today = dayOf(await $.clock.now())
465  const gifted = await giftedPoints($)
466  const bonds = Object.fromEntries(
467    CHARACTERS.map(c => [
468      c.id,
469      { days: daysWith(every, c.id), points: pointsWith(every, c.id) + (gifted[c.id] ?? 0), today: [...(reasonsByDay(every, c.id).get(today) ?? [])] },
470    ]),
471  )
472  heartsNow = heartsFor(bonds[current.id]?.points ?? 0)
473  await update($, togetherAtom, () => bonds)
474  const all = progress(every)
475  const stored = await $.store.get(UNLOCKED_KEY)
476  const unlocked = Array.isArray(stored) ? stored.filter((x): x is string => typeof x === 'string') : []
477  const done = newlyDone(all, unlocked)
478  const nowUnlocked = [...unlocked, ...done.map(p => p.achievement.id)]
479  unlockedNow = nowUnlocked
480  if (done.length > 0) await $.store.set(UNLOCKED_KEY, nowUnlocked)
481  await update($, trophies, () => ({ unlocked: nowUnlocked, progress: all.map(p => ({ id: p.achievement.id, value: p.value })) }))
482  for (const p of done) {
483    const item = p.achievement.unlocks === undefined ? undefined : itemById(p.achievement.unlocks)
484    const verbs = current.voice.verbs.achievements[p.achievement.id] ?? []
485    const words = verbs.length > 0 ? `, and new words: ${verbs.join(', ')}` : ''
486    keepMemory($, `you earned ${p.achievement.name}`)
487    bondFor($, 'win')
488    // Nothing to wear: it opens up new things for them to say
489    const cheer = (note: string) => (isHere ? feel('celebrate', note, `★ ${p.achievement.name}`) : mention(note, `★ ${p.achievement.name}`))
490    if (item === undefined) {
491      await change($, cheer(say('unlockedLines', { achievement: p.achievement.name })))
492      if (isHere) $.ui.toast(`${current.name}: ${p.achievement.name} unlocked, new things to say${words} ✧`)
493      continue
494    }
495    await change($, cheer(say('unlocked', { achievement: p.achievement.name, item: item.name.toLowerCase() })))
496    if (isHere) $.ui.toast(`${current.name}: ${p.achievement.name} unlocked, the ${item.name.toLowerCase()}${words} ✧`)
497    // On straight away, unless something's already in that slot
498    const w = await wearingOf($)
499    if (w[item.kind] === undefined) await wear($, { ...w, [item.kind]: item.id })
500  }
501}
502
503// Counts something that happened, toward every achievement built on it
504async function tallyUp($: Kit, event: EventName) {
505  const now = await $.clock.now()
506  let mine = asTally(await $.store.get(tallyKey))
507  mine = count(mine, event, dayOf(now))
508  if (event === 'prompt' && isLate(now)) mine = count(mine, 'lateWork', dayOf(now))
509  const at = new Date(now)
510  if (event === 'prompt' && at.getHours() >= 5 && at.getHours() < 7) mine = count(mine, 'earlyWork', dayOf(now))
511  if (event === 'prompt' && (at.getDay() === 0 || at.getDay() === 6)) mine = count(mine, 'weekendWork', dayOf(now))
512  // Pushing on a Friday evening: brave
513  if (event === 'push' && at.getDay() === 5 && at.getHours() >= 17) mine = count(mine, 'fridayDeploy', dayOf(now))
514  // A prompt is a day spent with whoever's here
515  if (event === 'prompt') mine = together(mine, current.id, dayOf(now))
516  await $.store.set(tallyKey, mine)
517  await checkAchievements($)
518}
519
520// A reason today counts toward your bond with whoever's here, once a day each;
521// with a line to say the first time, like a favorite thing
522async function bondUp($: Kit, reason: BondReason, line?: string) {
523  const today = dayOf(await $.clock.now())
524  const already = reasonsByDay(await tallies($), current.id).get(today)?.has(reason) === true
525  if (already) return
526  await $.store.set(tallyKey, bonded(asTally(await $.store.get(tallyKey)), current.id, today, reason))
527  await checkAchievements($)
528  if (line) await change($, mention(line, `${current.name} loved that ♡`))
529}
530
531// Recording never holds up what you're doing, and never breaks it. Records
532// queue up one after another, so two at once never overwrite each other;
533// anything that reads them waits for the queue first
534let recording: Promise<void> = Promise.resolve()
535const enqueue = (work: () => Promise<void>) => {
536  recording = recording.then(work).catch(() => undefined)
537}
538const logEvent = ($: Kit, event: EventName) => enqueue(() => tallyUp($, event))
539
540// One line of progress: "▰▰▰▱▱ Shipper 15/25 commits → gold pin"
541function progressLine(id: string, t: Trophies): string {
542  const a = ACHIEVEMENTS.find(x => x.id === id)
543  if (a === undefined) return ''
544  const verbs = current.voice.verbs.achievements[a.id] ?? []
545  const prize = a.unlocks === undefined ? 'new lines' : (itemById(a.unlocks)?.name.toLowerCase() ?? 'something')
546  const item = `${prize}${verbs.length > 0 ? ` + "${verbs.join('", "')}"` : ''}`
547  if (t.unlocked.includes(a.id)) return `✓ ${a.name}: ${item}`
548  const value = t.progress.find(p => p.id === a.id)?.value ?? 0
549  const filled = Math.round((value / a.goal) * 5)
550  return `${'▰'.repeat(filled)}${'▱'.repeat(5 - filled)} ${a.name} ${value}/${a.goal} ${a.unit} → ${item}`
551}
552
553// The world around you: the time, the season, and the weather where you are.
554// Where you are comes from your IP address, unless you name a city or turn it off
555const WEATHER_SETTING = 'weatherSetting'
556const WEATHER_NOW = 'weatherNow'
557const LOCATION = 'location'
558const WEATHER_EVERY = 30 * 60_000
559const LOCATION_EVERY = 24 * 60 * 60_000
560const world = atom({ plugin: 'maiyu', key: 'world' } as const, { part: 'morning', season: 'spring', day: 'weekday' } as World)
561const worldOf = ($: StateDollar): Promise<World> => read($, world)
562
563async function weatherSetting($: Pick<EngineInterface, 'store'>): Promise<WeatherSetting> {
564  const v = (await $.store.get(WEATHER_SETTING)) as Partial<WeatherSetting> | undefined
565  return v?.mode === 'city' || v?.mode === 'off' ? { mode: v.mode, city: v.city } : { mode: 'auto' }
566}
567
568// Where to ask about: a city you named, or a guess from your IP, kept a day
569async function placeFor($: Pick<EngineInterface, 'store' | 'http' | 'clock'>, setting: WeatherSetting): Promise<Place | undefined> {
570  const now = await $.clock.now()
571  const key = setting.mode === 'city' ? `city:${setting.city ?? ''}` : 'auto'
572  const kept = (await $.store.get(LOCATION)) as { key?: string; at?: number; place?: Place } | undefined
573  if (kept?.key === key && kept.place && now - (kept.at ?? 0) < LOCATION_EVERY) return kept.place
574  const reply =
575    setting.mode === 'city' ? await $.http.fetch(geocodeUrl(setting.city ?? '')) : await $.http.fetch(IP_LOOKUP_URL)
576  if (!reply.ok) return undefined
577  const place = setting.mode === 'city' ? readGeocode(reply.text) : readIpLookup(reply.text)
578  if (place) await $.store.set(LOCATION, { key, at: now, place })
579  return place
580}
581
582// The weather, shared by every session: whoever finds it stale asks again
583async function refreshWeather($: Pick<EngineInterface, 'store' | 'http' | 'clock' | 'state'>, force = false) {
584  const setting = await weatherSetting($)
585  if (setting.mode === 'off') {
586    lastWeather = undefined
587    await update($, world, w => ({ ...w, weather: undefined }))
588    return
589  }
590  const now = await $.clock.now()
591  const kept = (await $.store.get(WEATHER_NOW)) as { at?: number; weather?: World['weather']; key?: string; latitude?: number } | undefined
592  const key = setting.mode === 'city' ? `city:${setting.city ?? ''}` : 'auto'
593  let weather = !force && kept?.key === key && now - (kept.at ?? 0) < WEATHER_EVERY ? kept.weather : undefined
594  let latitude = kept?.latitude
595  let country = (kept as { country?: string } | undefined)?.country
596  if (weather === undefined) {
597    const place = await placeFor($, setting)
598    if (place === undefined) return
599    const reply = await $.http.fetch(forecastUrl(place))
600    const forecast = reply.ok ? readForecast(reply.text, place) : undefined
601    if (forecast === undefined) return
602    weather = { ...forecast, isChosen: setting.mode === 'city' }
603    latitude = Math.round(place.latitude)
604    country = place.country
605    await $.store.set(WEATHER_NOW, { at: now, key, weather, latitude, country })
606  }
607  const known = weather
608  lastWeather = known
609  await update($, world, w => ({ ...w, weather: known, latitude, country: country ?? w.country }))
610}
611
612// How much of the season shows, shared by every session
613const SEASONS_KEY = 'seasons'
614let seasonShow: SeasonShow = 'subtle'
615const readSeasonShow = async ($: Pick<EngineInterface, 'store'>) => {
616  const v = await $.store.get(SEASONS_KEY)
617  seasonShow = v === 'full' || v === 'off' ? v : 'subtle'
618}
619
620// Whether the time of day shows in how they speak and move, shared by every session
621const DAYTIME_KEY = 'daytime'
622let daytimeOn = true
623const readDaytime = async ($: Pick<EngineInterface, 'store'>) => {
624  daytimeOn = (await $.store.get(DAYTIME_KEY)) !== 'off'
625}
626
627// Their own stories, a beat now and then: shared by every session
628const TELLING_KEY = 'storiesOn'
629let storiesOn = true
630const readStories = async ($: Pick<EngineInterface, 'store'>) => {
631  storiesOn = (await $.store.get(TELLING_KEY)) !== 'off'
632}
633
634// The journal: what you've found together, written down as it's found. Kept in
635// memory too, so the store is only written when there's something new
636let journalNow: Journal = NO_JOURNAL
637async function noteJournal($: Pick<EngineInterface, 'store'>, kind: Found, what: string) {
638  if (preview || journalNow[kind].includes(what)) return
639  const added = jot(asJournal(await $.store.get(JOURNAL_KEY)), kind, what)
640  if (added) await $.store.set(JOURNAL_KEY, added)
641  journalNow = added ?? asJournal(await $.store.get(JOURNAL_KEY))
642}
643
644// The day you first met each character, kept for good: their anniversaries
645const MET_KEY = 'met'
646let metDays: Record<string, string> = {}
647const readMet = async ($: Pick<EngineInterface, 'store'>) => {
648  const v = await $.store.get(MET_KEY)
649  metDays = v && typeof v === 'object' ? (v as Record<string, string>) : {}
650}
651// The first time with someone, or for those you already know, the earliest
652// day together on record
653async function rememberMeeting($: Pick<EngineInterface, 'store' | 'clock'>, c: Character) {
654  await readMet($)
655  if (metDays[c.id]) return
656  const today = dayOf(await $.clock.now())
657  const earliest = (await tallies($))
658    .flatMap(t => t.with?.[c.id] ?? [])
659    .sort((a, b) => dateOf(a).getTime() - dateOf(b).getTime())[0]
660  metDays = { ...metDays, [c.id]: earliest ?? today }
661  await $.store.set(MET_KEY, metDays)
662}
663const years = (n: number) => `${n} ${n === 1 ? 'year' : 'years'}`
664
665// Special days you've turned off, by id ('birthday' for the characters'),
666// shared by every session
667const EVENTS_OFF = 'eventsOff'
668let eventsOff: string[] = []
669const readEventsOff = async ($: Pick<EngineInterface, 'store'>) => {
670  const v = await $.store.get(EVENTS_OFF)
671  eventsOff = Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string') : []
672}
673
674// A special day played on demand with /frens preview, to see how it looks and
675// sounds. It stands in for today's until /frens preview off, and counts for
676// nothing: no bond points, no memories
677type Preview = { special: NonNullable<World['special']>; years: number; lastSpecial: string }
678let preview: Preview | undefined
679
680// A season or what's in the air, played on demand with /frens preview: all the
681// time, over today's weather, until /frens preview off
682/** `label`: what you asked for, as you'd say it ("golden leaf") */
683type AirPreview = { season?: World['season']; air?: Air; rare?: boolean; label: string }
684let airPreview: AirPreview | undefined
685const SEASON_NAMES: readonly World['season'][] = ['spring', 'summer', 'autumn', 'winter']
686const AIR_NAMES: Record<string, Air> = {
687  leaves: 'leaves',
688  leaf: 'leaves',
689  petals: 'petals',
690  petal: 'petals',
691  snow: 'snowflakes',
692  snowflakes: 'snowflakes',
693  fireflies: 'fireflies',
694  firefly: 'fireflies',
695  sparkles: 'sparkles',
696  stars: 'sparkles',
697}
698// The rare ones, to preview: there at once, every time
699const RARE_PREVIEWS: Record<string, Air> = {
700  'golden-leaf': 'leaves',
701  goldleaf: 'leaves',
702  blossom: 'petals',
703  'cherry-blossom': 'petals',
704  crystal: 'snowflakes',
705  'snow-crystal': 'snowflakes',
706  'shooting-star': 'sparkles',
707  star: 'sparkles',
708}
709
710// What they say on a special day
711function specialLine(id: string, together: number): string {
712  if (id === 'birthday') return say('birthday')
713  if (id === 'anniversary') return say('anniversary', { years: years(together) })
714  return current.voice.events?.[id] ?? SPECIAL_DAYS.find(d => d.id === id)?.line ?? ''
715}
716
717// A special day's scenes, played one after another with the ✦ celebrate
718// button: a prop, a line, the day in the air. Each lasts half a minute, or
719// until they get busy
720const SCENE_FOR = (30 * 1000) / TICK_MS
721const playing = atom({ plugin: 'maiyu', key: 'scene' } as const, null as { day: string; index: number; at: number } | null)
722const sceneTurns = new Map<string, number>()
723
724// Pressed, it's you there; on their own, it isn't
725async function celebrate($: StateDollar, byYou = true) {
726  const w = await worldOf($)
727  const scenes = w.special ? SCENES[w.special.id] : undefined
728  if (w.special === undefined || scenes === undefined || scenes.length === 0) return
729  const turn = sceneTurns.get(w.special.id) ?? 0
730  sceneTurns.set(w.special.id, turn + 1)
731  const index = turn % scenes.length
732  const line = current.voice.scenes?.[w.special.id]?.[index] ?? scenes[index]!.line
733  const b = await change($, x => ({ ...feel('happy', line)(x), active: byYou ? x.frame : x.active }))
734  await update($, playing, () => ({ day: w.special!.id, index, at: b.frame }))
735}
736
737// On a special day they play the next scene by themselves, about once an
738// hour: only when they're free and you're around, and an hour after the last
739// one, pressed or not. The first comes a minute after the day's hello
740const SCENE_EVERY = (60 * 60 * 1000) / TICK_MS
741async function maybeScene($: StateDollar, w: World) {
742  if (w.special === undefined || SCENES[w.special.id] === undefined) return
743  const b = await look$($)
744  const isFree = b.mood === 'idle' || b.mood === 'watching'
745  if (!isFree || !hasTyped(b) || b.frame - b.active > AROUND) return
746  const last = await read($, playing)
747  if (last !== null && last.day === w.special.id && b.frame - last.at < SCENE_EVERY) return
748  await celebrate($, false)
749}
750
751// The scene playing now, if any: for today's special day, while they're free
752async function sceneNow($: StateDollar, b: Buddy, w: World): Promise<Scene | undefined> {
753  const p = await read($, playing)
754  if (p === null || p.day !== w.special?.id || b.frame - p.at >= SCENE_FOR) return undefined
755  if (!['idle', 'happy', 'watching', 'celebrate'].includes(b.mood)) return undefined
756  return SCENES[p.day]?.[p.index]
757}
758
759// A special day: a preview first, then the character's birthday, then the calendar
760function specialOf(at: Date, country: string | undefined): World['special'] {
761  if (preview) return preview.special
762  const b = current.birthday
763  const isBirthday = at.getMonth() + 1 === b.month && at.getDate() === b.day
764  if (isBirthday && !eventsOff.includes('birthday')) return { id: 'birthday', name: `${current.name}'s birthday` }
765  const together = anniversaryOn(metDays[current.id], at)
766  if (together && !eventsOff.includes('anniversary')) return { id: 'anniversary', name: `${years(together)} with ${current.name}` }
767  const day = specialDayOn(at, country, eventsOff)
768  return day ? { id: day.id, name: day.name } : undefined
769}
770
771// The first look at the world, which /frens weather waits for
772let worldReady: Promise<void> = Promise.resolve()
773
774// The moment: part of the day, season, weekday, from your clock
775async function tickWorld($: Pick<EngineInterface, 'clock' | 'state'>) {
776  const at = new Date(await $.clock.now())
777  await update($, world, w => ({
778    ...w,
779    part: partOfDay(at.getHours()),
780    season: seasonOf(at.getMonth(), w.latitude),
781    day: dayKind(at.getDay()),
782    special: specialOf(at, w.country),
783  }))
784}
785
786// A hello for the part of the day, a word on a new season, or on the weather:
787// one at a time, only when they're free and you're around
788function rhythm(b: Buddy, w: World, today: string): Buddy {
789  const isFree = b.mood === 'idle' || b.mood === 'sleepy' || b.mood === 'watching'
790  // Not to an empty room: only once you've typed something, and recently
791  if (!isFree || !hasTyped(b) || b.frame - b.active > AROUND || !hasPaused(b)) return b
792  const lines = current.voice.world
793  const pick = (list: readonly string[]) => list[b.frame % Math.max(1, list.length)] ?? ''
794  if (w.special && b.lastSpecial !== `${today}:${w.special.id}`) {
795    const id = w.special.id
796    const met = preview?.years ?? anniversaryOn(metDays[current.id], new Date(today)) ?? 1
797    const line = specialLine(id, met)
798    // A preview you asked for celebrates; the day itself only speaks
799    return { ...(preview ? feel('celebrate', line, w.special.name) : mention(line, w.special.name))(b), lastSpecial: `${today}:${id}` }
800  }
801  const greetKey = `${today}:${w.part}`
802  if (b.lastGreet !== greetKey) {
803    const isMorning = w.part === 'dawn' || w.part === 'morning'
804    const usual = isMorning && w.day === 'friday' ? lines.friday : w.day === 'weekend' && isMorning ? lines.weekend : lines.greet[w.part]
805    // Close friends get a warmer hello now and then
806    const list = heartsNow >= 3 && b.frame % 3 === 0 ? current.bond.warm : usual
807    return { ...mention(pick(list), `said good ${w.part === 'lateNight' ? 'night' : w.part}`)(b), lastGreet: greetKey }
808  }
809  if (b.lastSeason !== w.season && b.lastSeason !== '') {
810    return { ...mention(lines.season[w.season], `${w.season} began`)(b), lastSeason: w.season }
811  }
812  // The first season they see is just noted, and doesn't take their turn
813  if (b.lastSeason === '') b = { ...b, lastSeason: w.season }
814  if (w.weather && b.lastSky !== w.weather.sky) {
815    const vars: Record<string, string> = { temp: `${Math.round(w.weather.temperature)}°${w.weather.unit}`, where: where(w.weather) }
816    const line = pick(lines.sky[w.weather.sky]).replace(/\{(temp|where)\}/g, (_all, name: string) => vars[name] ?? '')
817    return { ...mention(line, `${w.weather.sky} outside`)(b), lastSky: w.weather.sky }
818  }
819  return b
820}
821
822// The day as a loop: a letter the first time you're around each morning, and
823// a wrap-up once the evening winds down
824const LETTER_KEY = 'lastLetter'
825const LETTER_FOR = (15 * 60 * 1000) / TICK_MS
826const WIND_DOWN = (15 * 60 * 1000) / TICK_MS
827const letter = atom({ plugin: 'maiyu', key: 'letter' } as const, null as Letter | null)
828let letterChecked = ''
829
830// The letter: a hello, a few words about something real from last time, what's
831// coming up, and their name. Once a day, in whichever session you show up in
832// first, after a night's break: working on past midnight doesn't bring it
833const LAST_ACTIVE_KEY = 'lastActive'
834let lastActiveAt: number | undefined
835
836async function maybeLetter($: Kit & Pick<EngineInterface, 'model'>) {
837  const now = await $.clock.now()
838  const today = dayOf(now)
839  // How long you'd been away, across sessions, before just now
840  let before = lastActiveAt ?? Number((await $.store.get(LAST_ACTIVE_KEY)) ?? 0)
841  if (now - before >= 30 * 60 * 1000) before = Math.max(before, Number((await $.store.get(LAST_ACTIVE_KEY)) ?? 0))
842  // Checked when you first show up, and then at most once a minute
843  if (lastActiveAt !== undefined && now - lastActiveAt < 60_000) return
844  lastActiveAt = now
845  await $.store.set(LAST_ACTIVE_KEY, now)
846  if (letterChecked === today) return
847  if ((await $.store.get(LETTER_KEY)) === today) {
848    letterChecked = today
849    return
850  }
851  // Still up from yesterday: the letter waits for the morning, or a proper break
852  const hadLetter = (await $.store.get(LETTER_KEY)) !== undefined
853  if (hadLetter && !isAfterBreak(new Date(now).getHours(), now - before)) return
854  letterChecked = today
855  await $.store.set(LETTER_KEY, today)
856
857  const all = await tallies($)
858  const last = lastDayBefore(all, today)
859  const when = last ? whenWas(last, today) : ''
860  const w = await worldOf($)
861  const holiday = comingUp(new Date(now), w.country, eventsOff)
862  const ahead = eventsOff.includes('anniversary') ? undefined : nextAnniversary(metDays[current.id], new Date(now))
863  const anniversary = ahead && ahead.inDays >= 1 && ahead.inDays <= 7 ? { name: 'our anniversary', inDays: ahead.inDays } : undefined
864  const soon = anniversary && (!holiday || anniversary.inDays <= holiday.inDays) ? anniversary : holiday
865  const wins = last ? (await memories($)).filter(m => m.day === last).map(m => m.what) : []
866  const done = last ? (await highlights($)).filter(h => h.day === last) : []
867
868  // Their words while a note of their own is written: a win by name if there
869  // was one, else a word about the day, never a tally
870  const win = wins[wins.length - 1]
871  const about = !last ? say('letterFresh') : win ? say('letterWin', { when, win }) : say('letterRecap', { when })
872  const extra = [...streakLine(codingDays(all), today), ...(soon ? [say('letterSoon', { event: soon.name, soon: soon.inDays === 1 ? 'tomorrow' : `in ${soon.inDays} days` })] : [])]
873  const b = await look$($)
874  // On the anniversary of the day you met, a longer letter about the year
875  const together = eventsOff.includes('anniversary') ? undefined : anniversaryOn(metDays[current.id], new Date(now))
876  if (together !== undefined) return yearLetter($, together, all, today, b.frame)
877  const opened = { day: today, title: `✉ a letter from ${current.name}`, lines: [say('letter'), about, ...extra, say('letterSign')], at: b.frame }
878  // The streak and what's coming up keep lines of their own: a note only takes the word about last time
879  await update($, letter, () => opened)
880
881  // With something real to talk about, they write it themselves
882  if (headless || (done.length === 0 && wins.length === 0)) return
883  const who = current
884  const facts = {
885    when,
886    highlights: done,
887    wins,
888    today: w.weather ? `${w.weather.sky} ${w.part === 'lateNight' ? 'night' : w.part}` : undefined,
889  }
890  const reply = await $.model
891    .complete({ model: 'haiku', system: letterSystem(who), prompt: letterBrief(facts), maxTokens: 160, effort: 'low' })
892    .catch(() => undefined)
893  const note = reply?.isAnswered ? tidyNote(reply.text) : ''
894  if (!note || current.id !== who.id) return
895  // Only if the same letter is still open
896  await update($, letter, l => (l !== null && l.at === opened.at && l.day === today ? { ...l, lines: l.lines.map(x => (x === about ? note : x)) } : l))
897}
898
899// The year's letter: what you did together since the last anniversary, in
900// their words if they can write it, or a few lines of their own if not
901async function yearLetter($: Kit & Pick<EngineInterface, 'model'>, together: number, all: readonly Tally[], today: string, frame: number) {
902  const who = current
903  const inYear = (day: string) => daysBetween(day, today) >= 0 && daysBetween(day, today) <= 365
904  const days = [...new Set(all.flatMap(t => t.with?.[who.id] ?? []))].filter(inYear)
905  const facts = {
906    years: years(together),
907    days: days.length,
908    streak: bestStreak(days),
909    lateNights: new Set(all.flatMap(t => t.days.lateWork ?? []).filter(inYear)).size,
910    wins: asYearWins(await $.store.get(`${YEAR_KEY}:${who.id}`))
911      .filter(w => inYear(`${w.month}-1`))
912      .map(w => w.what),
913    hearts: heartsNow,
914    story: storiesOn && (asTold(await $.store.get(STORIES_KEY))[who.id] ?? 0) > 0 ? who.story.name : undefined,
915  }
916  const body = say('yearLetter', { years: facts.years, days: facts.days })
917  const opened = { day: today, title: `✉ ${facts.years} with ${who.name}`, lines: [say('letter'), body, say('letterSign')], at: frame }
918  await update($, letter, () => opened)
919  if (headless) return
920  const reply = await $.model
921    .complete({ model: 'haiku', system: yearSystem(who, facts.years), prompt: yearBrief(facts), maxTokens: 320, effort: 'low' })
922    .catch(() => undefined)
923  const note = reply?.isAnswered ? tidyNote(reply.text, 600) : ''
924  if (!note || current.id !== who.id) return
925  await update($, letter, l => (l !== null && l.at === opened.at && l.day === today ? { ...l, lines: l.lines.map(x => (x === body ? note : x)) } : l))
926}
927
928// A heart moment: at each new heart, once, when it's quiet and you're around.
929// It opens like the letter, and waits for the letter to be put away first
930const MOMENTS_KEY = 'moments'
931async function maybeMoment($: Kit) {
932  const b = await look$($)
933  const isFree = b.mood === 'idle' || b.mood === 'sleepy' || b.mood === 'watching'
934  if (!isFree || !hasTyped(b) || b.frame - b.active > AROUND) return
935  const open = await read($, letter)
936  if (open !== null && b.frame - open.at < LETTER_FOR) return
937  const stored = await $.store.get(MOMENTS_KEY)
938  const seen = Array.isArray(stored) ? stored.filter((x): x is string => typeof x === 'string') : []
939  if (!hasPaused(b)) return
940  const level = HEART_POINTS.map((_, i) => i + 1).find(h => h <= heartsNow && !seen.includes(`${current.id}:${h}`))
941  if (level === undefined) return
942  const moment = current.bond.moments[level - 1]
943  if (moment === undefined) return
944  await $.store.set(MOMENTS_KEY, [...seen, `${current.id}:${level}`])
945  const today = dayOf(await $.clock.now())
946  await update($, letter, () => ({ day: today, title: `♡ ${moment.title}`, lines: [...moment.lines, heartsBar(level)], at: b.frame }))
947  await change($, mention(moment.title, `shared a moment ♡ ${level}`))
948  keepMemory($, `we reached ${level} ${level === 1 ? 'heart' : 'hearts'}`)
949  // Every heart gold: what they call you from now on, and something to wear
950  if (level === HEART_POINTS.length) logEvent($, 'goldHearts')
951}
952
953// "4 days in a row together", counting today as you've just shown up; a new
954// record when it beats the longest before
955function streakLine(days: readonly string[], today: string): string[] {
956  const withToday = [...days, today]
957  const run = streak(withToday, today)
958  if (run < 2) return []
959  // A record beats every earlier run; matching it isn't one
960  return [say(run >= 3 && run > bestStreak(days) ? 'streakRecord' : 'streak', { days: run })]
961}
962
963// Once a day, in a quiet afternoon or evening, they bring up a win from the
964// last week or month (yesterday's is in the letter already)
965async function maybeRecall($: Kit, w: World) {
966  if (w.part !== 'afternoon' && w.part !== 'evening') return
967  const today = dayOf(await $.clock.now())
968  const b = await look$($)
969  const isFree = b.mood === 'idle' || b.mood === 'sleepy' || b.mood === 'watching'
970  if (!isFree || b.lastRecall === today || !hasTyped(b) || b.frame - b.active > AROUND || !hasPaused(b)) return
971  const past = (await memories($)).filter(m => (agoOf(m.day, today)?.ago ?? 'yesterday') !== 'yesterday')
972  const m = recall(past, today, b.frame)
973  if (m === undefined) return
974  await change($, x => ({ ...mention(say('remember', { when: m.when, memory: m.what }), 'remembered a good day')(x), lastRecall: today }))
975}
976
977// Their own life goes on alongside yours: now and then, when it's quiet and
978// you're around, the next beat of their story. One a day at most, across every
979// session and character, and each only once you've spent long enough together
980const STORY_DAY_KEY = 'storyDay'
981async function maybeStory($: Kit, w: World) {
982  if (!storiesOn || w.part === 'lateNight' || w.special !== undefined) return
983  const b = await look$($)
984  const isFree = b.mood === 'idle' || b.mood === 'watching'
985  if (!isFree || !hasTyped(b) || b.frame - b.active > AROUND || b.frame - b.lastChat < QUIET_FOR) return
986  const open = await read($, letter)
987  if (open !== null && b.frame - open.at < LETTER_FOR) return
988  const today = dayOf(await $.clock.now())
989  if ((await $.store.get(STORY_DAY_KEY)) === today) return
990  const told = asTold(await $.store.get(STORIES_KEY))
991  const days = (await read($, togetherAtom))[current.id]?.days ?? 0
992  const beat = nextBeat(current.story, told[current.id] ?? 0, days)
993  if (beat === undefined) return
994  await $.store.set(STORY_DAY_KEY, today)
995  await $.store.set(STORIES_KEY, { ...told, [current.id]: beat.index + 1 })
996  onHeard(`${current.id}.story.${beat.index}`)
997  await change($, x => ({ ...feel('happy', beat.line, current.story.name)(x), lastChat: x.frame }))
998}
999
1000// Small talk: now and then, when you're around and they're not busy. A line
1001// that fits the moment, every other time; their own thoughts the rest
1002// The closer you are, the more they talk: minutes between small talk, by hearts
1003const CHAT_MINUTES = [12, 12, 10, 10, 8, 8, 8, 7, 7, 6, 6] as const
1004// The time of day sets the pace (chattier mornings, quieter nights), and
1005// closeness quickens it
1006const chatEvery = (part: World['part']) => {
1007  const closeness = (CHAT_MINUTES[heartsNow] ?? 12) / 12
1008  return Math.round((daytimeOn ? chatPace(part) : (12 * 60 * 1000) / TICK_MS) * closeness)
1009}
1010const QUIET_FOR = (2 * 60 * 1000) / TICK_MS
1011const RECENT = (30 * 60 * 1000) / TICK_MS
1012const LONG_SESSION = (2 * 60 * 60 * 1000) / TICK_MS
1013const chatTurns = new Map<string, number>()
1014let chats = 0
1015
1016function chatPool(b: Buddy, w: World): keyof Character['voice']['chatter'] | undefined {
1017  if (b.frame - lastWinAt < RECENT) return 'afterWin'
1018  if (b.frame - lastLossAt < RECENT) return 'afterLoss'
1019  if (b.worked >= LONG_SESSION) return 'longSession'
1020  const sky = w.weather?.sky
1021  if (sky === 'rain' || sky === 'drizzle' || sky === 'storm') return 'rain'
1022  if (w.weather && isHot(w.weather.temperature, w.weather.unit)) return 'hot'
1023  if (w.weather && isCold(w.weather.temperature, w.weather.unit)) return 'cold'
1024  if (w.part === 'dawn' || w.part === 'morning') return 'morning'
1025  if (w.part === 'afternoon') return 'afternoon'
1026  if (w.part === 'evening') return 'evening'
1027  return 'night'
1028}
1029
1030function chat(b: Buddy, w: World): Buddy {
1031  const isFree = b.mood === 'idle' || b.mood === 'watching'
1032  const isAround = hasTyped(b) && b.frame - b.active <= AROUND
1033  if (!isFree || !isAround || b.frame - b.lastChat < chatEvery(w.part) || b.frame - b.since < QUIET_FOR || !hasPaused(b)) return b
1034  chats += 1
1035  const fitting = chatPool(b, w)
1036  const pool = fitting !== undefined && chats % 2 === 1 ? fitting : 'idle'
1037  const turn = chatTurns.get(`${current.id}:${pool}`) ?? 0
1038  chatTurns.set(`${current.id}:${pool}`, turn + 1)
1039  // Nothing open in this pool yet (some only open as you get closer): they keep quiet
1040  const picked = pickOpen(current.voice.chatter[pool], standing(), turn)
1041  if (picked) onHeard(`${current.id}.chatter.${pool}.${picked.index}`)
1042  const line = picked?.text
1043  // A word, not a change of mood: they just say it
1044  return line ? { ...b, note: line, lastChat: b.frame } : b
1045
1046}
1047
1048// The settings tucked behind the ⋯ in the dock
1049const more = atom({ plugin: 'maiyu', key: 'more' } as const, false)
1050const toggleMore = ($: StateDollar) => update($, more, m => !m)
1051
1052async function putLetterAway($: StateDollar) {
1053  await update($, letter, () => null)
1054}
1055
1056// The evening wrap-up: once a day, after a day with something in it, when
1057// you've been quiet a while
1058async function maybeWrapUp($: Kit, w: World) {
1059  if (w.part !== 'evening' && w.part !== 'night' && w.part !== 'lateNight') return
1060  const today = dayOf(await $.clock.now())
1061  const b = await look$($)
1062  const isFree = b.mood === 'idle' || b.mood === 'sleepy'
1063  if (!isFree || b.lastWrap === today || !hasTyped(b) || b.frame - b.active < WIND_DOWN || !hasPaused(b)) return
1064  const summary = describeDay(countsOn(await tallies($), today))
1065  if (summary.length === 0) return
1066  await change($, x => ({ ...mention(say('wrapUp', { summary: summary.join(' · ') }), 'wrapped up the day')(x), lastWrap: today }))
1067}
1068
1069// The character picker: open or not, and what's been typed
1070const CLOSED: Picker = { isOpen: false, query: '' }
1071const picker = atom({ plugin: 'maiyu', key: 'picker' } as const, CLOSED)
1072const pickerOf = async ($: StateDollar): Promise<Picker> => ({ ...CLOSED, ...(await read($, picker)) })
1073
1074// The journal as it stands: drawn as a page in the pane, or in words for /frens journal
1075async function journalFacts($: Kit): Promise<JournalFacts> {
1076  const bonds = await read($, togetherAtom)
1077  const stored = await $.store.get(MOMENTS_KEY)
1078  const specials = [{ id: 'birthday', name: 'a birthday' }, { id: 'anniversary', name: 'an anniversary' }, ...SPECIAL_DAYS.map(d => ({ id: d.id, name: d.name }))]
1079  return {
1080    journal: asJournal(await $.store.get(JOURNAL_KEY)),
1081    specials,
1082    characters: CHARACTERS,
1083    moments: Array.isArray(stored) ? stored.filter((x): x is string => typeof x === 'string') : [],
1084    told: asTold(await $.store.get(STORIES_KEY)),
1085    days: Object.fromEntries(Object.entries(bonds).map(([id, x]) => [id, x.days])),
1086    season: seasonKey(dayOf(await $.clock.now()), (await read($, world)).season),
1087  }
1088}
1089const journalNowText = async ($: Kit) => journalText(await journalFacts($))
1090
1091// Every word /frens answers to, for guessing what a typo meant
1092const COMMAND_WORDS = [
1093  'help', 'notify', 'characters', 'character', 'quiet', 'chatty', 'close', 'hide', 'off', 'achievements', 'wardrobe', 'wear',
1094  'events', 'memories', 'hi', 'hello', 'poke', 'bond', 'hearts', 'stories', 'journal', 'daytime', 'seasons',
1095  'weather', 'diffs', 'sessions', 'share', 'score', 'open',
1096  ...(DEV ? ['preview'] : []),
1097]
1098
1099// The known word a typo is nearest to, if it's near enough: a letter or two
1100// off, swapped or missing
1101export function closest(word: string, known: readonly string[]): string | undefined {
1102  const distance = (a: string, b: string) => {
1103    const row = Array.from({ length: b.length + 1 }, (_, j) => j)
1104    for (let i = 1; i <= a.length; i++) {
1105      let last = row[0]!
1106      row[0] = i
1107      for (let j = 1; j <= b.length; j++) {
1108        const next = Math.min(row[j]! + 1, row[j - 1]! + 1, last + (a[i - 1] === b[j - 1] ? 0 : 1))
1109        last = row[j]!
1110        row[j] = next
1111      }
1112    }
1113    return row[b.length]!
1114  }
1115  const best = known.map(k => ({ k, d: distance(word, k) })).sort((x, y) => x.d - y.d)[0]
1116  return best !== undefined && best.d <= Math.max(1, Math.floor(word.length / 3)) ? best.k : undefined
1117}
1118
1119// What the pane's buttons and the /frens commands do, shared between them
1120
1121// Opens the picker and gives the pane the keyboard, so you can type at once
1122async function openPicker($: Pick<EngineInterface, 'state' | 'ui'>, mode: Picker['mode'] = 'characters') {
1123  await update($, picker, () => ({ isOpen: true, query: '', mode, page: mode === 'journal' ? undefined : 0 }))
1124  if (isShown) await $.ui.open({ id: PANE, title: current.name, focus: true })
1125}
1126
1127async function closePicker($: StateDollar) {
1128  await update($, picker, () => CLOSED)
1129}
1130
1131async function searchFor($: StateDollar, query: string) {
1132  await update($, picker, p => ({ ...p, isOpen: true, query }))
1133}
1134
1135async function choose($: Pick<EngineInterface, 'store' | 'state' | 'ui' | 'clock'>, c: Character) {
1136  const isWelcome = (await pickerOf($)).mode === 'welcome'
1137  await closePicker($)
1138  if (isWelcome) await meet($, c)
1139  else if (c.id !== current.id) await switchTo($, c)
1140}
1141
1142// The first time ever: pick who you'd like, or keep the first. Not a switch,
1143// so it never counts toward Fickle, and it's saved so it's asked only once
1144async function meet($: Pick<EngineInterface, 'store' | 'state' | 'ui'>, c: Character) {
1145  await $.store.set(CHARACTER_KEY, c.id)
1146  await become($, c)
1147  await change($, feel('happy', say('switched'), `met ${c.name}`))
1148  if (isShown) await $.ui.open({ id: PANE, title: c.name })
1149}
1150
1151// What each of them sounds like on a good day, for picking: their first word on a fixed check
1152const sampleOf = (c: Character) => voiceOf(c)('greenAgain', { check: 'the tests' })
1153
1154
1155async function closePane($: Pick<EngineInterface, 'ui'>) {
1156  await $.ui.close({ id: PANE })
1157  isShown = false
1158  isDismissed = true
1159  showStatus($, undefined)
1160}
1161
1162// Their line under the prompt: how things are going, in their voice
1163const STATUS_OF: Record<Mood, StatusKey> = {
1164  idle: 'calm',
1165  watching: 'watching',
1166  thinking: 'working',
1167  reading: 'working',
1168  editing: 'working',
1169  running: 'working',
1170  happy: 'win',
1171  celebrate: 'win',
1172  comfort: 'loss',
1173  worried: 'loss',
1174  break: 'rest',
1175  snack: 'rest',
1176  water: 'rest',
1177  coffee: 'rest',
1178  miss: 'away',
1179  sleepy: 'sleep',
1180}
1181let lastStatus: string | undefined
1182// The weather the status line shows, kept in step with the world
1183let lastWeather: World['weather']
1184
1185// Sets the status line when it changes; closed with /frens off, there is none
1186function showStatus($: Pick<EngineInterface, 'ui'>, b: Buddy | undefined) {
1187  const sky = lastWeather ? ` · ${glyph(lastWeather)} ${Math.round(lastWeather.temperature)}°` : ''
1188  const text = b === undefined || isDismissed ? undefined : `${current.voice.status[STATUS_OF[b.mood]]}${sky}`
1189  if (text === lastStatus) return
1190  lastStatus = text
1191  $.ui.status(text)
1192}
1193
1194// Who you switched away from today, shared by every session
1195const LEFT_KEY = 'leftToday'
1196
1197// Switching characters. Coming back to someone you left earlier today gets a
1198// reaction of their own; nothing changes in your bond either way
1199async function switchTo($: Pick<EngineInterface, 'store' | 'state' | 'ui' | 'clock'>, c: Character): Promise<string> {
1200  // Keeping track of who you left never gets in the way of switching
achievements/list.ts 118 lines
1// The achievements. To add one, pick an event and a measure from ./types.ts, a
2// goal, and an item from ./items.ts, or no item, for one that opens up lines.
3
4
5import type { Achievement } from './types'
6
7export const ACHIEVEMENTS: readonly Achievement[] = [
8  {
9    id: 'green-again',
10    name: 'Green again',
11    description: 'Turn a failing check green 10 times',
12    measure: { event: 'checkFixed', by: 'count' },
13    goal: 10,
14    unit: 'fixes',
15    unlocks: 'party-hat',
16  },
17  {
18    id: 'shipper',
19    name: 'Shipper',
20    description: '25 commits while we work together',
21    measure: { event: 'commit', by: 'count' },
22    goal: 25,
23    unit: 'commits',
24    unlocks: 'gold-pin',
25  },
26  {
27    id: 'night-owl',
28    name: 'Night owl',
29    description: 'Work after midnight on 5 different days',
30    measure: { event: 'lateWork', by: 'days' },
31    goal: 5,
32    unit: 'nights',
33    unlocks: 'night-owl',
34  },
35  {
36    id: 'regular',
37    name: 'Regular',
38    description: 'Code together on 7 different days',
39    measure: { event: 'prompt', by: 'days' },
40    goal: 7,
41    unit: 'days',
42    unlocks: 'lavender-hair',
43  },
44  {
45    id: 'teamwork',
46    name: 'Teamwork',
47    description: 'Finish work in 3 sessions on the same day',
48    measure: { event: 'turnDone', by: 'sessionsOnOneDay' },
49    goal: 3,
50    unit: 'sessions',
51    unlocks: 'headphones',
52  },
53  {
54    id: 'early-bird',
55    name: 'Early bird',
56    description: 'Work before 7 in the morning on 5 different days',
57    measure: { event: 'earlyWork', by: 'days' },
58    goal: 5,
59    unit: 'mornings',
60    unlocks: 'sunrise',
61  },
62  {
63    id: 'best-friends',
64    name: 'Best friends',
65    description: 'Reach five hearts with any one character',
66    measure: { by: 'hearts' },
67    goal: 5,
68    unit: 'hearts',
69    unlocks: 'star-clip',
70  },
71  // These open up new lines rather than something to wear
72  {
73    id: 'weekend-warrior',
74    name: 'Weekend warrior',
75    description: 'Code together on 5 different weekend days',
76    measure: { event: 'weekendWork', by: 'days' },
77    goal: 5,
78    unit: 'weekend days',
79  },
80  // A surprise: hidden until you find it
81  {
82    id: 'friday-deploy',
83    name: 'Friday deploy',
84    description: 'Push on a Friday evening',
85    measure: { event: 'fridayDeploy', by: 'count' },
86    goal: 1,
87    unit: 'pushes',
88    hidden: true,
89  },
90
91  {
92    id: 'comeback',
93    name: 'Comeback',
94    description: 'Get a check green after it failed 5 times in a row',
95    measure: { event: 'comeback', by: 'count' },
96    goal: 1,
97    unit: 'comebacks',
98  },
99  {
100    id: 'fickle',
101    name: 'Fickle',
102    description: 'Switch characters 5 times',
103    measure: { event: 'switched', by: 'count' },
104    goal: 5,
105    unit: 'switches',
106  },
107  {
108    id: 'all-gold',
109    name: 'All gold',
110    description: 'Turn every heart gold with someone, over months together',
111    measure: { event: 'goldHearts', by: 'count' },
112    goal: 1,
113    unit: 'friends',
114    unlocks: 'gold-heart',
115  },
116]
117
118
achievements/items.ts 90 lines
1// Things to wear, unlocked by achievements. Any character can wear any of them.
2
3import type { Item } from './types'
4
5export const ITEMS: readonly Item[] = [
6  {
7    id: 'party-hat',
8    name: 'Party Hat',
9    kind: 'accessory',
10    slot: 'head',
11    at: { x: 13, y: 0 },
12    pixels: ['::33::', '::12::', ':1221:', ':2112:', '122221', '333333'],
13    colors: { '1': 0xff7eb6, '2': 0xffd54a, '3': 0xfafafa },
14  },
15  {
16    id: 'gold-heart',
17    name: 'Gold Heart',
18    kind: 'accessory',
19    at: { x: 19, y: 35 },
20    pixels: ['11:11', '12111', ':111:', '::1::'],
21    colors: { '1': 0xffc83d, '2': 0xfff3b0 },
22  },
23  {
24    id: 'gold-pin',
25    name: 'Gold Pin',
26    kind: 'accessory',
27    at: { x: 10, y: 35 },
28    pixels: [':1:', '121', ':1:'],
29    colors: { '1': 0xffd54a, '2': 0xfff3b0 },
30  },
31  {
32    id: 'headphones',
33    name: 'Headphones',
34    kind: 'accessory',
35    slot: 'head',
36    at: { x: 4, y: 1 },
37    pixels: [
38      ':::::::::111111',
39      ':::::::11::::::11',
40      ':::::11::::::::::11',
41      '::::1::::::::::::::1',
42      ':::1::::::::::::::::1',
43      '::1::::::::::::::::::1',
44      '::1::::::::::::::::::1',
45      '::1::::::::::::::::::1',
46      '::1::::::::::::::::::1',
47      '::1::::::::::::::::::1',
48      '::1::::::::::::::::::1',
49      '::1::::::::::::::::::1',
50      '::1::::::::::::::::::1',
51      '222::::::::::::::::::222',
52      '223::::::::::::::::::322',
53      '223::::::::::::::::::322',
54      '223::::::::::::::::::322',
55      '222::::::::::::::::::222',
56    ],
57    colors: { '1': 0x4a4f63, '2': 0xe2404f, '3': 0x2b2d3a },
58  },
59  {
60    id: 'night-owl',
61    name: 'Night-Owl Colors',
62    kind: 'palette',
63    palette: { top: 0x4b3d6e, topShade: 0x342a52, inner: 0xe9e2f7, innerShade: 0xc6bce0, accent: 0xc9b6ff },
64  },
65  {
66    id: 'sunrise',
67    name: 'Sunrise Colors',
68    kind: 'palette',
69    palette: { top: 0xf2a65a, topShade: 0xd9843b, inner: 0xfff1dc, innerShade: 0xf0d6b0, accent: 0xff7e5f },
70  },
71  {
72    id: 'star-clip',
73    name: 'Star Clip',
74    kind: 'accessory',
75    slot: 'hair',
76    at: { x: 19, y: 7 },
77    pixels: ['::1::', '11211', ':111:', '1:::1'],
78    colors: { '1': 0xffd54a, '2': 0xfff3b0 },
79  },
80  {
81    id: 'lavender-hair',
82
83    name: 'Lavender Hair',
84    kind: 'palette',
85    palette: { hair: 0xb48ad6, hairShade: 0x8a62ae, hairShine: 0xe6d4f7 },
86  },
87]
88
89export const itemById = (id: unknown): Item | undefined => ITEMS.find(i => i.id === id)
90
achievements/progress.ts 108 lines
1// Counting toward achievements. Each session keeps its own tally under its own
2// key in the shared store; progress merges every session's tally when it's read.
3
4import { ACHIEVEMENTS } from './list'
5import type { Achievement, EventName, Measure, Tally } from './types'
6import { heartsFor, pointsWith } from '../hooks/bond'
7
8
9export const TALLY_PREFIX = 'progress:'
10export const UNLOCKED_KEY = 'unlocked'
11export const WEARING_KEY = 'wearing'
12// Enough days for a year of streaks, without a tally growing forever
13const MAX_DAYS = 400
14// Day-by-day counts kept for the letter and the wrap-up
15const KEEP_DAYS = 14
16
17// A day as the person's own calendar has it
18export function dayOf(now: number): string {
19  const at = new Date(now)
20  return `${at.getFullYear()}-${at.getMonth() + 1}-${at.getDate()}`
21}
22
23// Midnight to 4 in the morning counts as late
24export const isLate = (now: number) => new Date(now).getHours() < 4
25
26export function count(tally: Tally, event: EventName, day: string): Tally {
27  const days = tally.days[event] ?? []
28  const today = tally.byDay?.[day] ?? {}
29  const byDay = { ...tally.byDay, [day]: { ...today, [event]: (today[event] ?? 0) + 1 } }
30  // Only the last two weeks of days
31  const kept = Object.keys(byDay).sort((a, b) => dateOf(b).getTime() - dateOf(a).getTime()).slice(0, KEEP_DAYS)
32  // Everything else in the tally carries over, days together included
33  return {
34    ...tally,
35    counts: { ...tally.counts, [event]: (tally.counts[event] ?? 0) + 1 },
36    days: { ...tally.days, [event]: days.includes(day) ? days : [...days, day].slice(-MAX_DAYS) },
37    byDay: Object.fromEntries(kept.map(d => [d, byDay[d]!])),
38  }
39}
40
41// A day spent with a character
42export function together(tally: Tally, id: string, day: string): Tally {
43  const days = tally.with?.[id] ?? []
44  return days.includes(day) ? tally : { ...tally, with: { ...tally.with, [id]: [...days, day].slice(-MAX_DAYS) } }
45}
46
47// A reason a day counted toward your bond with a character, once a day each
48export function bonded(tally: Tally, id: string, day: string, reason: string): Tally {
49  const days = tally.bond?.[id] ?? {}
50  const reasons = days[day] ?? []
51  if (reasons.includes(reason)) return tally
52  // Only the last year of days
53  const kept = Object.keys(days).sort((a, b) => dateOf(b).getTime() - dateOf(a).getTime()).slice(0, MAX_DAYS)
54  const trimmed = Object.fromEntries(kept.map(d => [d, days[d]!]))
55  return { ...tally, bond: { ...tally.bond, [id]: { ...trimmed, [day]: [...reasons, reason] } } }
56}
57
58// A day key back into a date
59export function dateOf(day: string): Date {
60  const [y = 0, m = 1, d = 1] = day.split('-').map(Number)
61  return new Date(y, m - 1, d)
62}
63
64// A stored value that is a usable tally, whatever an older version wrote
65export function asTally(value: unknown): Tally {
66  const v = value as Partial<Tally> | undefined
67  return {
68    counts: v && typeof v.counts === 'object' && v.counts !== null ? v.counts : {},
69    days: v && typeof v.days === 'object' && v.days !== null ? v.days : {},
70    byDay: v && typeof v.byDay === 'object' && v.byDay !== null ? v.byDay : {},
71    with: v && typeof v.with === 'object' && v.with !== null ? v.with : {},
72    bond: v && typeof v.bond === 'object' && v.bond !== null ? v.bond : {},
73  }
74}
75
76// How far every session together has got on a measure
77export function measure(m: Measure, tallies: readonly Tally[]): number {
78  switch (m.by) {
79    case 'count':
80      return tallies.reduce((sum, t) => sum + (t.counts[m.event] ?? 0), 0)
81    case 'days':
82      return new Set(tallies.flatMap(t => t.days[m.event] ?? [])).size
83    case 'sessionsOnOneDay': {
84      const sessionsOn = new Map<string, number>()
85      for (const t of tallies) for (const day of t.days[m.event] ?? []) sessionsOn.set(day, (sessionsOn.get(day) ?? 0) + 1)
86      return Math.max(0, ...sessionsOn.values())
87    }
88    case 'hearts': {
89      const ids = new Set(tallies.flatMap(t => [...Object.keys(t.with ?? {}), ...Object.keys(t.bond ?? {})]))
90      return Math.max(0, ...[...ids].map(id => heartsFor(pointsWith(tallies, id))))
91    }
92
93  }
94}
95
96export type Progress = { achievement: Achievement; value: number; isDone: boolean }
97
98export function progress(tallies: readonly Tally[], list: readonly Achievement[] = ACHIEVEMENTS): Progress[] {
99  return list.map(achievement => {
100    const value = Math.min(achievement.goal, measure(achievement.measure, tallies))
101    return { achievement, value, isDone: value >= achievement.goal }
102  })
103}
104
105// The achievements newly done: done now, and not in the list already unlocked
106export const newlyDone = (all: readonly Progress[], unlocked: readonly string[]) =>
107  all.filter(p => p.isDone && !unlocked.includes(p.achievement.id))
108
achievements/types.ts 101 lines
1// Achievements and the items they unlock, as data: a new one is a few lines
2// in ./list.ts or ./items.ts, no code
3
4import type { Palette } from '../characters/types'
5import type { Wearing } from '../types'
6
7export type { Wearing }
8
9/**
10 * What the mod records as you work. Achievements are built from these; a new
11 * kind of event is the only thing that needs code (in hooks/register.tsx).
12 */
13export type EventName =
14  | 'prompt' // you sent Claude something
15  | 'lateWork' // ...between midnight and 4 in the morning
16  | 'turnDone' // a turn of Claude's finished
17  | 'commit'
18  | 'push'
19  | 'pullRequest' // opened or merged
20  | 'checkFixed' // tests, types, lint or a build went from failing to passing
21  | 'checkPassed'
22  | 'checkFailed'
23  | 'earlyWork' // ...between 5 and 7 in the morning
24  | 'weekendWork' // ...on a Saturday or Sunday
25  | 'fridayDeploy' // a push on a Friday, from 5 in the evening
26  | 'comeback' // a check went green after failing five runs or more in a row
27  | 'switched' // you switched to another character
28  | 'break' // they took a tea break
29  | 'meal' // they had a meal
30  | 'fileChanged' // Claude edited a file
31  | 'goldHearts' // every heart gold with a character, their tenth moment shared
32
33export type Measure =
34  | {
35      event: EventName
36      /**
37       * count: every time it happened; days: the different days it happened on;
38       * sessionsOnOneDay: the most sessions it happened in on any one day
39       */
40      by: 'count' | 'days' | 'sessionsOnOneDay'
41    }
42  /** The most hearts with any one character */
43  | { by: 'hearts' }
44
45export type Achievement = {
46  id: string
47  name: string
48  /** What it takes, in plain words, shown with the progress */
49  description: string
50  measure: Measure
51  goal: number
52  /** The unit in progress: "commits", "days" */
53  unit: string
54  /** Kept out of /frens achievements until it's earned: a surprise */
55  hidden?: boolean
56
57  /**
58   * The item's id in ./items.ts. Without one, the achievement opens up lines
59   * instead: every version a character keeps back with `unlock` set to its id
60   */
61  unlocks?: string
62
63}
64
65/** Something to wear: a change of colors, or a small thing drawn on */
66export type Item =
67  | {
68      id: string
69      name: string
70      kind: 'palette'
71      /** The colors it changes, over any character's own */
72      palette: Partial<Palette>
73    }
74  | {
75      id: string
76      name: string
77      kind: 'accessory'
78      /** Where it's worn: two in one slot, the later wins */
79      slot?: 'head' | 'neck' | 'hair' | 'held'
80      /** Where its top-left pixel goes on the 32 × 44 portrait */
81      at: { x: number; y: number }
82      /** How many pixels lower they sit while it's on, to make room above a tall hat */
83      sink?: number
84      /** Rows of pixels: '1' to '9' are its own colors, ':' leaves what's there */
85      pixels: string[]
86      colors: Partial<Record<'1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9', number>>
87    }
88
89/** One session's tally, kept under its own key so sessions never collide */
90export type Tally = {
91  counts: Partial<Record<EventName, number>>
92  /** The days each event happened on, as "2026-10-7" */
93  days: Partial<Record<EventName, string[]>>
94  /** How many of each, day by day, for the last two weeks */
95  byDay?: Record<string, Partial<Record<EventName, number>>>
96  /** The days spent with each character, by their id */
97  with?: Record<string, string[]>
98  /** Extra reasons a day counted toward your bond, by character, then day */
99  bond?: Record<string, Record<string, string[]>>
100}
101
hooks/dev.ts 4 lines
1// Tools for working on the mod, not for the people who install it: /frens
2// preview. The public release turns this off (release/export.sh)
3export const DEV = false
4
hooks/diffs.ts 73 lines
1// The changes Claude made this session, kept as unified-diff hunks per file
2// and cut to fit the rows under the buddy
3
4import type { Change } from '../types'
5
6export type Patch = { oldStart: number; oldLines: number; newStart: number; newLines: number; lines: string[] }
7
8const MAX_FILES = 12
9const MAX_HUNKS = 8
10// A Code element holds at most 10000 characters
11const MAX_SOURCE = 9000
12
13const header = (oldStart: number, oldLines: number, newStart: number, newLines: number) =>
14  `@@ -${oldStart},${oldLines} +${newStart},${newLines} @@`
15
16export function toHunks(patches: readonly Patch[]): string[] {
17  return patches.map(p => [header(p.oldStart, p.oldLines, p.newStart, p.newLines), ...p.lines].join('\n'))
18}
19
20// Folds a new edit into the session's changes: its file moves to the front
21export function record(changes: readonly Change[], path: string, patches: readonly Patch[]): Change[] {
22  const lines = patches.flatMap(p => p.lines)
23  const added = lines.filter(l => l.startsWith('+')).length
24  const removed = lines.filter(l => l.startsWith('-')).length
25  const before = changes.find(c => c.path === path)
26  const change: Change = {
27    path,
28    hunks: [...(before?.hunks ?? []), ...toHunks(patches)].slice(-MAX_HUNKS),
29    added: (before?.added ?? 0) + added,
30    removed: (before?.removed ?? 0) + removed,
31  }
32  return [change, ...changes.filter(c => c.path !== path)].slice(0, MAX_FILES)
33}
34
35// The first `rows` lines of one hunk, its header counts redone so it still parses
36function cut(hunk: string, rows: number): string {
37  const [head = '', ...body] = hunk.split('\n')
38  const found = /^@@ -(\d+),?\d* \+(\d+),?\d* @@/.exec(head)
39  const kept = body.slice(0, Math.max(1, rows - 1))
40  if (!found) return [head, ...kept].join('\n')
41  const oldLines = kept.filter(l => !l.startsWith('+')).length
42  const newLines = kept.filter(l => !l.startsWith('-')).length
43  return [header(Number(found[1]), oldLines, Number(found[2]), newLines), ...kept].join('\n')
44}
45
46// The newest hunks of a change that fit in `rows`, one row a line
47export function fit(change: Change, rows: number): { source: string; rows: number } {
48  const picked: string[] = []
49  let used = 0
50  let size = 0
51  for (const hunk of [...change.hunks].reverse()) {
52    const lines = hunk.split('\n').length
53    if (used + lines <= rows && size + hunk.length <= MAX_SOURCE) {
54      picked.unshift(hunk)
55      used += lines
56      size += hunk.length + 1
57    } else {
58      if (picked.length === 0 && rows >= 2) {
59        const part = cut(hunk, rows).slice(0, MAX_SOURCE)
60        picked.unshift(part)
61        used = part.split('\n').length
62      }
63      break
64    }
65  }
66  return { source: picked.join('\n'), rows: used }
67}
68
69// A path as the person reads it: under the project, relative to it
70export function shown(path: string, root: string): string {
71  return root && path.startsWith(`${root}/`) ? path.slice(root.length + 1) : path
72}
73
hooks/sessions.ts 83 lines
1// The buddy's view of your other Claude Code sessions. Each session keeps one record
2// in the mod's store, which every session on this machine shares, and writes
3// only its own, so no two sessions ever write the same key.
4
5import type { Peer } from '../types'
6
7export const PREFIX = 'session:'
8// How often a session reads the others, and writes its heartbeat
9export const POLL_MS = 5_000
10export const HEARTBEAT_MS = 30_000
11// No heartbeat this long and a session is gone (closed, or crashed)
12export const STALE_MS = 3 * 60_000
13// Records this old are swept away by whichever session sees them
14export const FORGET_MS = 24 * 60 * 60_000
15
16export const keyOf = (id: string) => `${PREFIX}${id}`
17
18export const isAlive = (p: Peer, now: number) => now - p.seen < STALE_MS
19
20// A stored value that is a usable record, whatever an older version wrote
21export function asPeer(value: unknown): Peer | undefined {
22  const v = value as Partial<Peer> | undefined
23  if (!v || typeof v.id !== 'string' || typeof v.seen !== 'number') return undefined
24  return {
25    id: v.id,
26    project: typeof v.project === 'string' ? v.project : 'a project',
27    status: v.status === 'working' || v.status === 'done' ? v.status : 'idle',
28    since: typeof v.since === 'number' ? v.since : v.seen,
29    seen: v.seen,
30    summary: typeof v.summary === 'string' ? v.summary : '',
31  }
32}
33
34// The sessions that finished since they last looked
35export function finished(before: ReadonlyMap<string, Peer['status']>, peers: readonly Peer[]): Peer[] {
36  return peers.filter(p => p.status === 'done' && before.get(p.id) === 'working')
37}
38
39// Each session by its project's name, numbered where two share one
40export function names(peers: readonly Peer[]): Map<string, string> {
41  const out = new Map<string, string>()
42  const counts = new Map<string, number>()
43  for (const p of [...peers].sort((a, b) => a.since - b.since)) {
44    const n = (counts.get(p.project) ?? 0) + 1
45    counts.set(p.project, n)
46    out.set(p.id, n === 1 ? p.project : `${p.project} (${n})`)
47  }
48  return out
49}
50
51// How long ago, as people say it
52export function ago(ms: number): string {
53  const minutes = Math.floor(ms / 60_000)
54  if (minutes < 1) return 'just now'
55  if (minutes < 60) return `${minutes}m`
56  const hours = Math.floor(minutes / 60)
57  return `${hours}h${minutes % 60 > 0 ? ` ${minutes % 60}m` : ''}`
58}
59
60// One line about a session, in plain words
61export function line(p: Peer, name: string, now: number): string {
62  const time = ago(now - p.since)
63  if (p.status === 'working') return `● ${name} · working${time === 'just now' ? '' : ` for ${time}`}`
64  if (p.status === 'done') return `✓ ${name} · done${time === 'just now' ? ' just now' : ` ${time} ago`}`
65  return `○ ${name} · resting`
66}
67
68export const projectOf = (root: string) => root.split('/').filter(Boolean).pop() ?? 'a project'
69
70// The chip under their name: a session that just finished first, by name, then
71// how many are working, done and resting
72export function chip(peers: readonly Peer[], fresh: ReadonlySet<string>, label: ReadonlyMap<string, string>): string {
73  const justDone = peers.filter(p => p.status === 'done' && fresh.has(p.id))
74  const count = (status: Peer['status']) => peers.filter(p => p.status === status && !justDone.includes(p)).length
75  const parts = [
76    justDone.length === 1 ? `✓ ${label.get(justDone[0]!.id) ?? justDone[0]!.project} done` : justDone.length > 1 ? `✓ ${justDone.length} just done` : '',
77    count('working') > 0 ? `● ${count('working')} working` : '',
78    count('done') > 0 ? `✓ ${count('done')} done` : '',
79    count('idle') > 0 ? `○ ${count('idle')} resting` : '',
80  ]
81  return parts.filter(Boolean).join(' · ')
82}
83
hooks/world.ts 138 lines
1// The world outside the terminal: the time of day, the season, the day of the
2// week, and the weather where you are
3
4export type PartOfDay = 'dawn' | 'morning' | 'afternoon' | 'evening' | 'night' | 'lateNight'
5export type Season = 'spring' | 'summer' | 'autumn' | 'winter'
6export type DayKind = 'weekday' | 'friday' | 'weekend'
7export type Sky = 'clear' | 'cloudy' | 'fog' | 'drizzle' | 'rain' | 'snow' | 'storm'
8
9export type Weather = {
10  sky: Sky
11  /** Degrees in the local unit */
12  temperature: number
13  unit: 'C' | 'F'
14  /** Where it's from, in words: "Tokyo" */
15  place: string
16  isDay: boolean
17  /** You named the place yourself, so it can be shown; a guess from your IP stays hidden */
18  isChosen?: boolean
19  /** Wind speed in km/h */
20  wind?: number
21}
22
23// Windy enough for gusts to blow past
24export const isWindy = (w: Weather) => (w.wind ?? 0) >= 30
25
26/** Where to ask about the weather for, and how it was found */
27export type Place = { latitude: number; longitude: number; name: string; country: string }
28
29export type WeatherSetting = { mode: 'auto' | 'city' | 'off'; city?: string }
30
31export function partOfDay(hour: number): PartOfDay {
32  if (hour < 5) return 'lateNight'
33  if (hour < 8) return 'dawn'
34  if (hour < 12) return 'morning'
35  if (hour < 17) return 'afternoon'
36  if (hour < 21) return 'evening'
37  return 'night'
38}
39
40// Meteorological seasons, flipped south of the equator
41export function seasonOf(month: number, latitude = 45): Season {
42  const north: Season[] = ['winter', 'winter', 'spring', 'spring', 'spring', 'summer', 'summer', 'summer', 'autumn', 'autumn', 'autumn', 'winter']
43  const season = north[month] ?? 'spring'
44  if (latitude >= 0) return season
45  return ({ winter: 'summer', summer: 'winter', spring: 'autumn', autumn: 'spring' } as const)[season]
46}
47
48export function dayKind(weekday: number): DayKind {
49  if (weekday === 0 || weekday === 6) return 'weekend'
50  return weekday === 5 ? 'friday' : 'weekday'
51}
52
53// WMO weather codes, as Open-Meteo reports them
54export function skyOf(code: number): Sky {
55  if (code <= 1) return 'clear'
56  if (code <= 3) return 'cloudy'
57  if (code === 45 || code === 48) return 'fog'
58  if (code >= 51 && code <= 57) return 'drizzle'
59  if ((code >= 61 && code <= 67) || (code >= 80 && code <= 82)) return 'rain'
60  if ((code >= 71 && code <= 77) || code === 85 || code === 86) return 'snow'
61  if (code >= 95) return 'storm'
62  return 'cloudy'
63}
64
65// Fahrenheit where people use it
66export const unitFor = (country: string): 'C' | 'F' => (['US', 'LR', 'MM', 'BS', 'KY', 'PW', 'FM', 'MH'].includes(country.toUpperCase()) ? 'F' : 'C')
67
68export function glyph(w: Weather): string {
69  switch (w.sky) {
70    case 'clear':
71      return w.isDay ? '☀' : '☾'
72    case 'cloudy':
73      return '☁'
74    case 'fog':
75      return '≈'
76    case 'drizzle':
77    case 'rain':
78      return '☂'
79    case 'snow':
80      return '❄'
81    case 'storm':
82      return '⚡'
83  }
84}
85
86/** One line about the world, for the pane: "☂ 12°C Tokyo · evening". The
87 * season isn't named: it's shown, on them and in the air. The place only when
88 * you chose it: a guess from your IP isn't always right */
89export function describeWorld(w: Weather | undefined, part: PartOfDay): string {
90  const when = part === 'lateNight' ? 'late night' : part
91  if (w === undefined) return when
92  const windy = isWindy(w) ? ' ≋' : ''
93  return `${glyph(w)}${windy} ${Math.round(w.temperature)}°${w.unit}${w.isChosen ? ` ${w.place}` : ''} · ${when}`
94}
95
96/** " in Tokyo" for a place you chose, nothing for a guess */
97export const where = (w: Weather) => (w.isChosen ? ` in ${w.place}` : '')
98
99// The answers the weather and location services give, read defensively
100
101export function readForecast(text: string, place: Place): Weather | undefined {
102  try {
103    const body = JSON.parse(text) as { current?: { temperature_2m?: number; weather_code?: number; is_day?: number; wind_speed_10m?: number } }
104    const c = body.current
105    if (typeof c?.temperature_2m !== 'number' || typeof c.weather_code !== 'number') return undefined
106    const unit = unitFor(place.country)
107    const temperature = unit === 'F' ? c.temperature_2m * 1.8 + 32 : c.temperature_2m
108    return { sky: skyOf(c.weather_code), temperature, unit, place: place.name, isDay: c.is_day !== 0, wind: c.wind_speed_10m }
109  } catch {
110    return undefined
111  }
112}
113
114export function readGeocode(text: string): Place | undefined {
115  try {
116    const first = (JSON.parse(text) as { results?: { latitude: number; longitude: number; name: string; country_code?: string }[] }).results?.[0]
117    if (!first || typeof first.latitude !== 'number') return undefined
118    return { latitude: first.latitude, longitude: first.longitude, name: first.name, country: first.country_code ?? '' }
119  } catch {
120    return undefined
121  }
122}
123
124export function readIpLookup(text: string): Place | undefined {
125  try {
126    const body = JSON.parse(text) as { success?: boolean; latitude?: number; longitude?: number; city?: string; country_code?: string }
127    if (body.success === false || typeof body.latitude !== 'number' || typeof body.longitude !== 'number') return undefined
128    return { latitude: body.latitude, longitude: body.longitude, name: body.city ?? 'here', country: body.country_code ?? '' }
129  } catch {
130    return undefined
131  }
132}
133
134export const forecastUrl = (p: Place) =>
135  `https://api.open-meteo.com/v1/forecast?latitude=${p.latitude.toFixed(1)}&longitude=${p.longitude.toFixed(1)}&current=temperature_2m,weather_code,is_day,wind_speed_10m`
136export const geocodeUrl = (city: string) => `https://geocoding-api.open-meteo.com/v1/search?name=${encodeURIComponent(city)}&count=1`
137export const IP_LOOKUP_URL = 'https://ipwho.is/'
138
hooks/routine.ts 62 lines
1// Their own little routine: a tea break after a long stretch of work, a coffee
2// in the morning, and a bite to eat around mealtimes. Neither ever holds up Claude: work calls their back.
3
4export const TICK_MS = 250
5const ticks = (minutes: number) => (minutes * 60 * 1000) / TICK_MS
6
7// Work this long without a pause and they take a break when things go quiet
8export const BREAK_AFTER = ticks(45)
9export const BREAK_FOR = ticks(5)
10export const SNACK_FOR = ticks(3)
11
12const MEALS: [number, number, string][] = [
13  [8, 9, 'breakfast'],
14  [12, 13, 'lunch'],
15  [18, 19, 'dinner'],
16]
17
18// The meal it's time for, keyed by day so they have each one once
19export function mealtime(now: number): { key: string; name: string } | undefined {
20  const at = new Date(now)
21  const hour = at.getHours()
22  const meal = MEALS.find(([from, to]) => hour >= from && hour < to)
23  if (!meal) return undefined
24  const day = `${at.getFullYear()}-${at.getMonth() + 1}-${at.getDate()}`
25  return { key: `${day}:${meal[2]}`, name: meal[2] }
26}
27
28// One coffee break a morning, at a time of its own each day between half past
29// nine and noon, so it isn't always on the dot. Keyed by day so it's once
30export const COFFEE_FOR = ticks(2)
31const COFFEE_FROM = 9 * 60 + 30
32const COFFEE_UNTIL = 12 * 60
33
34export function coffeeTime(now: number): string | undefined {
35  const at = new Date(now)
36  const day = `${at.getFullYear()}-${at.getMonth() + 1}-${at.getDate()}`
37  let seed = 0
38  for (const ch of day) seed = (seed * 31 + ch.charCodeAt(0)) % 9973
39  const start = COFFEE_FROM + (seed % (COFFEE_UNTIL - COFFEE_FROM - 30))
40  const minute = at.getHours() * 60 + at.getMinutes()
41  return minute >= start && minute < COFFEE_UNTIL ? `${day}:coffee` : undefined
42}
43
44// Every hour and a half or so, while you're around, they sip some water and
45// gently remind you to have some. Nothing to answer, nothing counted
46export const WATER_EVERY = ticks(90)
47export const WATER_FOR = ticks(2)
48// You count as around if you typed or sent something this recently
49export const AROUND = ticks(10)
50
51// An hour without you typing or sending anything, and they say they miss you:
52// once for each time you're away, however long
53export const MISS_AFTER = ticks(60)
54export const MISS_FOR = ticks(10)
55
56// A line to use next: any but the last one, varied by the moment
57export function nextLine(count: number, last: number, frame: number): number {
58  if (count <= 1) return 0
59  const step = 1 + (frame % (count - 1))
60  return ((last < 0 ? 0 : last) + step) % count
61}
62
characters/index.ts 92 lines
1// Every character there is. To add one, write a file like ./aichan.ts and list it here.
2
3import { aichan } from './aichan'
4import { kai } from './kai'
5import { sora } from './sora'
6import type { Character, Line, Lines, Standing, Version, WeatherVerb } from './types'
7import type { Season } from '../hooks/world'
8
9export const CHARACTERS: readonly Character[] = [aichan, kai, sora]
10export const DEFAULT = aichan
11
12export const byId = (id: unknown): Character | undefined => CHARACTERS.find(c => c.id === id)
13
14const MONTHS = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December']
15
16// A birthday as people say it: "3 April"
17export const birthdayOf = (c: Character) => `${c.birthday.day} ${MONTHS[c.birthday.month - 1] ?? ''}`
18
19// How a character reads in a list: "cheerful · woman · she/her · birthday 3 April"
20export const describeCharacter = (c: Character) => `${c.temperament} · ${c.gender} · ${c.pronouns.they}/${c.pronouns.them} · birthday ${birthdayOf(c)}`
21
22// The characters a search finds. Every word typed must match: names, ids and
23// genders as you type them ("ka" finds Kai), pronouns only whole ("he" finds
24// he/him, not she/her)
25export function search(query: string, from: readonly Character[] = CHARACTERS): Character[] {
26  const words = query.toLowerCase().split(/[\s/]+/).filter(Boolean)
27  return from.filter(c => {
28    const named = [c.id, c.name, c.gender].map(x => x.toLowerCase())
29    const pronouns = Object.values(c.pronouns).map(x => x.toLowerCase())
30    return words.every(word => named.some(x => x.startsWith(word)) || pronouns.includes(word))
31  })
32}
33
34export type LineKey = keyof Lines
35export type Say = (key: LineKey, vars?: Record<string, string | number>) => string
36
37/** A newcomer: no hearts, nothing earned */
38export const STRANGER: Standing = { hearts: 0, unlocked: [] }
39
40export const textOf = (v: Version) => (typeof v === 'string' ? v : v.text)
41
42// Whether a version is open to you: any time, once you're close enough, or once earned
43export const isOpen = (v: Version, s: Standing) =>
44  typeof v === 'string' || ((v.hearts ?? 0) <= s.hearts && (v.unlock === undefined || s.unlocked.includes(v.unlock)))
45
46/** Every version of a line, each with its place in the list: the last part of its id */
47export const versionsOf = (raw: Line): readonly Version[] => (Array.isArray(raw) ? raw : [raw as Version])
48
49// The versions open to you, keeping their places
50export function openOf(raw: Line, s: Standing): { text: string; index: number }[] {
51  return versionsOf(raw).flatMap((v, index) => (isOpen(v, s) ? [{ text: textOf(v), index }] : []))
52}
53
54// One of the open versions, picked by a number that moves on: a turn, a frame
55export function pickOpen(raw: Line, s: Standing, seed: number): { text: string; index: number } | undefined {
56  const open = openOf(raw, s)
57  return open.length === 0 ? undefined : open[seed % open.length]
58}
59
60// A character's line, with {name}, their pronouns and the line's own values
61// filled in. A line with versions takes the open ones in turn, starting with
62// the first. Each line said is reported by its id: "kai.failing.2"
63export function voiceOf(c: Character, standing: () => Standing = () => STRANGER, heard?: (id: string) => void): Say {
64  const turns = new Map<LineKey, number>()
65  return (key, vars = {}) => {
66    const turn = turns.get(key) ?? 0
67    turns.set(key, turn + 1)
68    const picked = pickOpen(c.voice.lines[key], standing(), turn)
69    if (picked === undefined) return ''
70    heard?.(`${c.id}.${key}.${picked.index}`)
71    const fill: Record<string, string | number> = { name: c.name, ...c.pronouns, ...vars }
72    return picked.text.replace(/\{(\w+)\}/g, (all, name: string) => String(fill[name] ?? all))
73  }
74}
75
76
77// The words for Claude's spinner: their own, the season's (unless seasons are
78// off), the day's weather, and every one unlocked by an achievement
79export function spinnerWords(
80  c: Character,
81  season: Season | undefined,
82  weather: readonly WeatherVerb[],
83  unlocked: readonly string[],
84): string[] {
85  const seasonal = season ? c.voice.verbs.seasons[season] : []
86  const today = weather.flatMap(kind => c.voice.verbs.weather[kind] ?? [])
87  const earned = unlocked.flatMap(id => c.voice.verbs.achievements[id] ?? [])
88  return [...c.voice.spinner, ...seasonal, ...today, ...earned]
89}
90
91export type { Character } from './types'
92
characters/types.ts 366 lines
1// What a character is: who they are, how they speak, and how they're drawn.
2// One file per character in this folder, listed in ./index.ts.
3
4import type { ArmKind, EyeKind, MouthKind } from '../hooks/sprite'
5import type { Mood } from '../types'
6import type { FavoriteKind } from '../hooks/bond'
7import type { PartOfDay, Season, Sky } from '../hooks/world'
8
9export type Gender = 'woman' | 'man' | 'nonbinary'
10
11/** Filled into lines as {they}, {them}, {their} and {themself} */
12export type Pronouns = { they: string; them: string; their: string; themself: string }
13
14/** 0xRRGGBB colors for the parts of the portrait that differ between characters */
15export type Palette = {
16  hair: number
17  hairShade: number
18  hairShine: number
19  skin: number
20  skinShade: number
21  /** Iris, pupil, and the glow at the bottom of the iris */
22  eyes: number
23  eyesDark: number
24  eyesGlow: number
25  blush: number
26  lips: number
27  /** The outer layer: blazer, hoodie, sweater */
28  top: number
29  topShade: number
30  /** What shows at the neck: blouse, shirt */
31  inner: number
32  innerShade: number
33  /** A tie, a button */
34  accent: number
35  /** Glasses frames */
36  detail: number
37}
38
39export type Look = {
40  /** Their color in the pane: name, speech bubble, headings. Bright enough for dark and light terminals */
41  theme: number
42  palette: Palette
43  /** long: past the shoulders; bob: to the jaw; short: above the ears */
44  hair: 'long' | 'bob' | 'short'
45  /** swept: to one side; parted: down the middle; straight: a blunt fringe */
46  bangs: 'swept' | 'parted' | 'straight'
47  /** The strand of hair that sticks up on top */
48  ahoge: boolean
49  /** narrow: tapering to the chin; square: a broader jaw */
50  face: 'narrow' | 'square'
51  brows: 'thin' | 'thick'
52  /** A flick of lashes at the outer corner of each eye */
53  lashes: boolean
54  facialHair: 'none' | 'stubble'
55  outfit: 'blazer' | 'suit' | 'jacket' | 'hoodie' | 'sweater'
56  glasses: boolean
57  /** Small touches of style */
58  accessories: ('earrings' | 'necklace')[]
59}
60
61/** The face and arms for a moment of a mood */
62export type MoodLook = Partial<{ eyes: EyeKind; mouth: MouthKind; arms: ArmKind; blush: boolean; hop: 0 | 1 }>
63
64/**
65 * How this character's face and arms differ from the usual in a mood, so the
66 * same event looks like them: a cheer, a smug fold of the arms, a small smile.
67 * `frames` take turns every `every` ticks (a quarter second each), over the
68 * rest; `confetti`, `hop`, `tear` and `sweat` false leave those out
69 */
70export type MoodStyle = Omit<MoodLook, 'hop'> &
71  Partial<{ confetti: boolean; hop: boolean; tear: boolean; sweat: boolean }> & {
72    frames?: MoodLook[]
73    every?: number
74  }
75
76
77/**
78 * One version of a line: said any time, or kept back until you're close
79 * (`hearts`: from this many on) or until an achievement unlocks it (`unlock`:
80 * its id in achievements/list.ts)
81 */
82export type Version = string | { text: string; hearts?: number; unlock?: string }
83
84/** A line, or several versions of it, used in turn so the same one doesn't come twice running */
85export type Line = Version | readonly Version[]
86
87/** How close you are and what you've earned, which decides the versions open to you */
88export type Standing = { hearts: number; unlocked: readonly string[] }
89
90/** The temperament, shown when you pick: see docs/sheets */
91export type Temperament = 'cheerful' | 'sassy' | 'quiet'
92
93
94/**
95 * Everything the character says, as templates: {name} and the pronouns are
96 * always there, and some lines get their own {placeholders}. Plain words only,
97 * never code. Diary lines are not here: they're a neutral log.
98 */
99export type Lines = {
100  // You at the prompt
101  typingShort: Line
102  typingLong: Line
103  thinking: Line
104  // What Claude is doing ({lang}: TypeScript, Python, test code…)
105  reading: Line
106  searching: Line
107  web: Line
108  writing: Line
109  runningTests: Line
110  checkingTypes: Line
111  linting: Line
112  building: Line
113  git: Line
114  command: Line
115  helper: Line
116  planning: Line
117  tool: Line
118  toolFailed: Line
119  // Checks ({check}: tests, types, lint, build; {count}: a number; {failing}: "3 failing")
120  greenAgain: Line
121  passing: Line
122  checkPassed: Line
123  downTo: Line
124  stillFailing: Line
125  failing: Line
126  // Git ({done}: "is up" or "merged")
127  pullRequest: Line
128  merged: Line
129  pushed: Line
130  committed: Line
131  // The end of a turn ({lang} of the first file changed)
132  allWins: Line
133  mixed: Line
134  tough: Line
135  niceWork: Line
136  finished: Line
137  stopped: Line
138  error: Line
139  // Her own life ({meal}: breakfast, lunch, dinner)
140  sleep: Line
141  // Waking up when you type or say hi: taken in turn, each with its own little animation
142  /** With a start, embarrassed: "I wasn't asleep!" */
143  wakeStartle: Line
144  /** A big yawn and a stretch */
145  wakeYawn: Line
146  /** Still half in a dream, and telling you about it */
147  wakeDream: Line
148  /** Mumbled in their sleep, before they notice you */
149  sleepTalk: Line
150  /** Right after, realizing you heard */
151  wakeAfterTalk: Line
152  /** After a nap of an hour or more */
153  wakeLong: Line
154  /** Woken at night or late at night */
155  wakeLate: Line
156  // Touching them: a click on the portrait, or the pat button
157  /** A pat on the top of the head */
158  touchHead: Line
159  /** Fiddling with their hair */
160  touchHair: Line
161  /** A poke on the cheek, or anywhere on the face */
162  touchCheek: Line
163  /** Their hand: a high five */
164  touchHands: Line
165  /** What they're holding, when it's none of the ones below: said of anything */
166  touchHeld: Line
167  /** Their mug of tea, on a break */
168  touchTea: Line
169  /** Their morning coffee */
170  touchCoffee: Line
171  /** Their snack: an onigiri */
172  touchSnack: Line
173  /** Their glass of water */
174  touchWater: Line
175  /** A tug at their clothes */
176  touchOutfit: Line
177  /** Something they're wearing ({item}: "headphones", "knit scarf") */
178  touchWorn: Line
179  /** Five in a row: enough */
180  touchTooMuch: Line
181  /** Woken by a touch */
182  touchWake: Line
183  /** A drag across their hair: stroking it */
184  touchStroke: Line
185  /** A drag across a cheek: squishing it */
186  touchSquish: Line
187  // Catching something drifting past with a click
188  catchLeaf: Line
189  catchPetal: Line
190  catchSnowflake: Line
191  catchFirefly: Line
192  // The rare ones: a celebration
193  catchGoldenLeaf: Line
194  catchBlossom: Line
195  catchCrystal: Line
196  catchShootingStar: Line
197  tea: Line
198  meal: Line
199  back: Line
200  /** Now and then, a sip and a gentle reminder; picked from in turn, so they don't repeat */
201  water: string[]
202  /** A morning coffee break: blowing on it, a sip, a happy sigh. Picked from in turn */
203  coffee: string[]
204  /** After an hour without you: a hello, picked so it's never the last one used */
205  missYou: string[]
206  /** When you come back after they said they missed you */
207  welcomeBack: string[]
208  // Your other sessions ({project}; {summary}: that session's last words)
209  peerDone: Line
210  peerToast: Line
211  // Replies to /frens
212  opened: Line
213  closed: Line
214  quiet: Line
215  chatty: Line
216  diffsHidden: Line
217  diffsShown: Line
218  alone: Line
219  scoreUp: Line
220  scoreDown: Line
221  hint: Line
222  switched: Line
223  /** When you switched away from them and came back the same day, taken in turn; versions can be kept back */
224  returned: Version[]
225
226  notifyOn: Line
227  notifyOff: Line
228  /** On their own birthday */
229  birthday: Line
230  /** On the anniversary of the day you met ({years}: "1 year", "2 years") */
231  anniversary: Line
232  /** The anniversary's letter, when no note of their own could be written ({years}; {days}: days together that year, a number) */
233  yearLetter: Line
234  // The day as a loop ({when}: "yesterday"; {summary}: "6 asks · 2 commits")
235  /** The morning letter's greeting */
236  letter: Line
237  /** The letter's word about a win last time, when no note of their own could be written ({when}; {win}: "the tests went green again") */
238  letterWin: Line
239  /** ...about last time, when there's no win to name ({when}) */
240  letterRecap: Line
241  /** ...and when it's your first day, or a fresh start */
242  letterFresh: Line
243  /** Something coming up ({event}; {soon}: "tomorrow" or "in 3 days") */
244  letterSoon: Line
245  /** Signing off the letter */
246  letterSign: Line
247  /** In the evening, once you've wound down after a day of work */
248  wrapUp: Line
249  // Memory ({when}: "last Tuesday"; {memory}: "the tests went green again"; {days}: a number)
250  /** Bringing up a past win */
251  remember: Line
252  /** In the letter, on a run of days in a row */
253  streak: Line
254  /** ...when it's the longest run yet */
255  streakRecord: Line
256  /** A check fails again, and you had a comeback before: once a day ({when}: "last Tuesday"; {memory}: "the tests went green again, after 7 tries") */
257  callback: Line
258  // Achievements ({achievement}: its name; {item}: what it unlocked)
259  unlocked: Line
260  /** An achievement with nothing to wear: it opens up new things for them to say */
261  unlockedLines: Line
262
263  wearing: Line
264  wearingNothing: Line
265}
266
267/** Lines for the time of day, the week, the season and the weather; lists are taken in turn */
268export type WorldLines = {
269  /** The first hello in each part of the day */
270  greet: Record<PartOfDay, string[]>
271  /** In place of a morning hello, on Fridays and weekends */
272  friday: string[]
273  weekend: string[]
274  /** Once, when a new season starts */
275  season: Record<Season, string>
276  /** When the weather changes ({temp}; {where}: " in Tokyo" for a city you set, nothing for a guess) */
277  sky: Record<Sky, string[]>
278}
279
280/** Small talk, by what's going on; each pool is taken in turn. A pool with nothing open stays quiet */
281export type Chatter = {
282  /** Just after a win, or a loss */
283  afterWin: Version[]
284  afterLoss: Version[]
285  /** After a couple of hours of work */
286  longSession: Version[]
287  /** The weather */
288  rain: Version[]
289  hot: Version[]
290  cold: Version[]
291  /** The time of day */
292  morning: Version[]
293  afternoon: Version[]
294  evening: Version[]
295  night: Version[]
296  /** Whatever's on their mind */
297  idle: Version[]
298}
299
300/** The weather spinner words follow: the sky, and hot, cold or windy days */
301export type WeatherVerb = 'clear' | 'cloudy' | 'fog' | 'rain' | 'snow' | 'storm' | 'hot' | 'cold' | 'windy'
302
303/** The moods the status line tells apart */
304export type StatusKey = 'calm' | 'watching' | 'working' | 'win' | 'loss' | 'rest' | 'away' | 'sleep'
305
306/** A heart moment: a little scene where they share something about themselves */
307export type Moment = { title: string; lines: string[] }
308
309/** Something going on in their life, told over months: "learning the guitar" */
310export type Story = { name: string; beats: { after: number; line: string }[] }
311
312export type Character = {
313  /** What /frens character takes, and the file's name */
314  id: string
315  name: string
316  gender: Gender
317  temperament: Temperament
318  pronouns: Pronouns
319  /** They celebrate it, in a party hat */
320  birthday: { month: number; day: number }
321  voice: {
322    /** Who they are, for the model writing their end-of-turn line: "a cheerful …" */
323    persona: string
324    /** How they write: tone, and whether to use kaomoji */
325    style: string
326    lines: Lines
327    /** Words for Claude's spinner while it works ("Ganbatte-ing"), one per turn */
328    spinner: string[]
329    /** More spinner words: in season, for the day's weather, and unlocked by achievements (by achievement id) */
330    verbs: {
331      seasons: Record<Season, string[]>
332      weather: Partial<Record<WeatherVerb, string[]>>
333      achievements: Partial<Record<string, string[]>>
334    }
335    /** Their line under the prompt, by how things are going */
336    status: Record<StatusKey, string>
337    /** What they say about the world around you */
338    world: WorldLines
339    /** Their own way of greeting a special day, by its id in events/calendar.ts */
340    events?: Partial<Record<string, string>>
341    /** Their own lines for a special day's scenes, by its id, one per scene in events/scenes.ts */
342    scenes?: Partial<Record<string, string[]>>
343    /** Small talk now and then, when you're around and they're not busy: the most fitting pool first */
344    chatter: Chatter
345  }
346  look: Look
347  /** Your relationship: a moment at each heart, five, then five more as the
348   * hearts turn gold, and warmer hellos once you're close */
349  bond: {
350    moments: [Moment, Moment, Moment, Moment, Moment, Moment, Moment, Moment, Moment, Moment]
351    /** What they call you once every heart is gold: "partner in crime" */
352    title: string
353    /** Hellos used now and then from three hearts on */
354    warm: string[]
355    /** Things they love you doing: each brings a little bond, once a day, and a line */
356    favorites: { kind: FavoriteKind; name: string; line: string }[]
357    /** Their answer when you say hi, taken in turn */
358    hiBack: Version[]
359  }
360  /** Their own life, going on alongside yours: told a beat at a time, each
361   * once you've spent `after` days together, never more than one a day */
362  story: Story
363  /** Per-mood touches on top of the usual expressions */
364  moods?: Partial<Record<Mood, MoodStyle>>
365}
366