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

A friend beside you while you code with Claude.
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.
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.
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.
hooks/register.tsx 3106 lines1import { 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 switchingachievements/list.ts 118 lines1// 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
118achievements/items.ts 90 lines1// 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)
90achievements/progress.ts 108 lines1// 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))
108achievements/types.ts 101 lines1// 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}
101hooks/dev.ts 4 lines1// 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
4hooks/diffs.ts 73 lines1// 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}
73hooks/sessions.ts 83 lines1// 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}
83hooks/world.ts 138 lines1// 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)}¤t=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/'
138hooks/routine.ts 62 lines1// 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}
62characters/index.ts 92 lines1// 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'
92characters/types.ts 366 lines1// 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