A tiny terminal pet that lives on your coding sessions

A tiny terminal pet that lives on your coding sessions.

Long sessions get dull. A pet makes your progress visible: it is happy when your tests pass and sad when a tool fails, so you notice both at a glance.
/pet for the big view.Your pet hatches on first start: a cat, dog, blob or dragon. It lives in one line above your prompt.
| Command | What |
|---|---|
/pet | Big view: art, food / happiness / energy bars, level, age |
/pet name <name> | Name your pet |
/plugin marketplace add Ron-Nefyodov/pet-mod /plugin install pet@pet
Or try it locally: claude --plugin-dir .
npm test # claude plugin test . claude plugin validate .
hooks/register.js 145 lines1import { COMMAND, FRAME_MS, HELP_TEXT, PANE_ID as PET_PANE_ID, parsePetArgs } from './commands.js';
2import { bandSegments, drawBand, drawPane } from './draw.js';
3import { applyEvent, decay, hintOf, moodOf, normalizePet } from './pet.js';
4import { PET_KEY, noticesOf, updatePet } from './pet-store.js';
5import { bashOutputOf, looksLikePassingTests } from './testpass.js';
6
7let ticker = null;
8
9export function register(on) {
10 on('session.start', async ($, e, next) => {
11 const result = await next(e);
12 try {
13 await $.command.register(COMMAND);
14 const now = await $.clock.now();
15 const change = await updatePet({ get: (k) => $.store.get(k), set: (k, v) => $.store.set(k, v) }, now, (p) => applyEvent(p, { type: 'activity' }, now));
16 for (const text of noticesOf(change)) $.ui.toast(text, { timeoutMs: 6000 });
17 $.ui.invalidate('ui.render');
18 } catch {
19 // The pet is best effort; never block a session.
20 }
21 return result;
22 }).catch(($, e, next) => next(e));
23
24 on('prompt.submit', async ($, e, next) => {
25 const result = await next(e);
26 if (e.origin.kind === 'composer') {
27 try {
28 const now = await $.clock.now();
29 await updatePet({ get: (k) => $.store.get(k), set: (k, v) => $.store.set(k, v) }, now, (p) => applyEvent(p, { type: 'activity' }, now));
30 $.ui.invalidate('ui.render');
31 } catch {
32 }
33 }
34 return result;
35 }).catch(($, e, next) => next(e));
36
37 on('turn.complete', async ($, e, next) => {
38 const result = await next(e);
39 try {
40 if (e.reason === 'answer' && e.agentId === undefined) {
41 const now = await $.clock.now();
42 const change = await updatePet({ get: (k) => $.store.get(k), set: (k, v) => $.store.set(k, v) }, now, (p) => applyEvent(p, { type: 'answer' }, now));
43 for (const text of noticesOf(change)) $.ui.toast(text, { timeoutMs: 6000 });
44 $.ui.invalidate('ui.render');
45 }
46 } catch {
47 // best effort
48 }
49 return result;
50 }).catch(($, e, next) => next(e));
51
52 on('tool.call', async ($, e, next) => {
53 const result = await next(e);
54 try {
55 if (result && !result.deny) {
56 const type = result.isError ? 'toolError'
57 : e.tool === 'Bash' && looksLikePassingTests(bashOutputOf(result)) ? 'testsPassed'
58 : 'activity';
59 const now = await $.clock.now();
60 const change = await updatePet({ get: (k) => $.store.get(k), set: (k, v) => $.store.set(k, v) }, now, (p) => applyEvent(p, { type }, now));
61 for (const text of noticesOf(change)) $.ui.toast(text, { timeoutMs: 6000 });
62 if (type !== 'activity') $.ui.invalidate('ui.render');
63 }
64 } catch {
65 // best effort
66 }
67 return result;
68 }).catch(($, e, next) => next(e));
69
70 on('command.run', { command: 'pet' }, async ($, e, next) => {
71 const args = parsePetArgs(e.args);
72 if (args.action === 'help') return { text: HELP_TEXT };
73 const now = await $.clock.now();
74 const io = { get: (k) => $.store.get(k), set: (k, v) => $.store.set(k, v) };
75
76 if (args.action === 'rename') {
77 const change = await updatePet(io, now, (p) => applyEvent(p, { type: 'rename', name: args.name }, now));
78 $.ui.invalidate('ui.render');
79 return { text: `Your ${change.after.species} is now called ${change.after.name}.` };
80 }
81
82 const change = await updatePet(io, now, (p) => applyEvent(p, { type: 'activity' }, now));
83 for (const text of noticesOf(change)) $.ui.toast(text, { timeoutMs: 6000 });
84 await $.ui.open({ id: PET_PANE_ID, title: change.after.name, focus: true, closeOnEscape: true, rows: 14 });
85 if (!ticker) {
86 ticker = $.clock.every(FRAME_MS, async () => {
87 try {
88 const isOpen = (await $.ui.panes()).some((pane) => pane.id === PET_PANE_ID);
89 if (isOpen) {
90 $.ui.invalidate('ui.render');
91 return;
92 }
93 } catch {
94 // fall through and stop
95 }
96 ticker?.cancel();
97 ticker = null;
98 });
99 }
100 $.ui.invalidate('ui.render');
101 return {};
102 }).catch(() => ({ text: '/pet hit an error. Try again.' }));
103
104 on('ui.close', async ($, e, next) => {
105 const result = await next(e);
106 if (e.id === PET_PANE_ID && ticker) {
107 ticker.cancel();
108 ticker = null;
109 }
110 return result;
111 }).catch(($, e, next) => next(e));
112
113 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
114 if (e.surface !== 'terminal' || e.props.hasSurvey) return next(e);
115 try {
116 const stored = normalizePet(await $.store.get(PET_KEY));
117 if (!stored) return next(e);
118 const now = await $.clock.now();
119 const pet = decay(stored, now);
120 return drawBand($.ui.resolve(e), bandSegments(pet, moodOf(stored, now), hintOf(pet), e.props.bodyColumns ?? 80));
121 } catch {
122 return next(e);
123 }
124 });
125
126 on('ui.render', { component: 'Pane', requestId: 'pet' }, async ($, e, next) => {
127 if (e.surface !== 'terminal') return next(e);
128 try {
129 const stored = normalizePet(await $.store.get(PET_KEY));
130 if (!stored) return next(e);
131 const now = await $.clock.now();
132 return drawPane($.ui.resolve(e), {
133 pet: decay(stored, now),
134 mood: moodOf(stored, now),
135 frame: Math.floor(now / FRAME_MS) % 2,
136 now,
137 columns: e.props.bodyColumns,
138 });
139 } catch {
140 return next(e);
141 }
142 });
143
144}
145hooks/commands.js 24 lines1import { cleanName } from './pet.js';
2
3export const PANE_ID = 'pet';
4export const FRAME_MS = 800;
5
6export const COMMAND = {
7 name: 'pet',
8 description: 'Visit your terminal pet',
9 argumentHint: '[name <name>]',
10};
11
12export const HELP_TEXT = 'Usage: /pet opens your pet · /pet name <name> renames it';
13
14export function parsePetArgs(args) {
15 const text = String(args ?? '').trim();
16 if (text === '') return { action: 'open' };
17 const match = /^name(?:\s+([\s\S]*))?$/i.exec(text);
18 if (match) {
19 const name = cleanName(match[1]);
20 return name ? { action: 'rename', name } : { action: 'help' };
21 }
22 return { action: 'help' };
23}
24hooks/draw.js 101 lines1import { ART_WIDTH, artFrame, colorOf, faceOf, moodLine } from './art.js';
2import { ageLabel, levelProgress } from './pet.js';
3
4const LABEL_WIDTH = 10;
5
6export function statBar(value, width) {
7 const ratio = Math.max(0, Math.min(100, value)) / 100;
8 const filled = value > 0 ? Math.max(1, Math.round(ratio * width)) : 0;
9 return '█'.repeat(filled) + '░'.repeat(width - filled);
10}
11
12function moodLabel(mood) {
13 return mood === 'sleeping' ? 'sleeping zZz' : mood;
14}
15
16export function bandSegments(pet, mood, hint, columns) {
17 const segments = [
18 { text: faceOf(pet.species, mood), color: colorOf(pet.species), bold: true },
19 { text: ` ${pet.name}`, bold: true },
20 { text: ` · ${moodLabel(mood)}`, dimColor: true },
21 ];
22 if (hint) segments.push({ text: ` · ${hint}`, color: 'warning' });
23 return fit(segments, Math.max(0, columns));
24}
25
26function fit(segments, columns) {
27 const out = [];
28 let left = columns;
29 for (const segment of segments) {
30 if (left <= 0) break;
31 if (segment.text.length <= left) {
32 out.push(segment);
33 left -= segment.text.length;
34 continue;
35 }
36 out.push({ ...segment, text: left > 1 ? `${segment.text.slice(0, left - 1)}…` : segment.text.slice(0, left) });
37 break;
38 }
39 return out;
40}
41
42export function drawBand({ Box, Text }, segments) {
43 return Box({
44 key: 'pet-band', flexDirection: 'row',
45 children: segments.map((segment) => Text({
46 wrap: 'truncate',
47 ...(segment.color && { color: segment.color }),
48 ...(segment.bold && { bold: true }),
49 ...(segment.dimColor && { dimColor: true }),
50 children: [segment.text],
51 })),
52 });
53}
54
55function statLine(label, value, width) {
56 return `${label.padEnd(LABEL_WIDTH)}${statBar(value, width)} ${String(Math.round(value)).padStart(3)}`;
57}
58
59export function drawPane({ Box, Text }, { pet, mood, frame, now, columns }) {
60 const row = (key, props) => Box({ key, children: [Text(props)] });
61 const width = Math.max(20, (columns ?? 60) - 2);
62 const isWide = width >= ART_WIDTH + 2 + LABEL_WIDTH + 16;
63 const barWidth = Math.max(6, Math.min(20, (isWide ? width - ART_WIDTH - 2 : width) - LABEL_WIDTH - 4));
64 const color = colorOf(pet.species);
65 const progress = levelProgress(pet.xp);
66
67 const art = Box({
68 key: 'pet-art', flexDirection: 'column', width: ART_WIDTH, flexShrink: 0,
69 children: artFrame(pet.species, mood, frame).map((line) => Text({ color, children: [line] })),
70 });
71 const stats = Box({
72 flexDirection: 'column', ...(isWide ? { marginLeft: 2 } : { marginTop: 1 }),
73 children: [
74 row('pet-stat-hunger', { children: [statLine('food', pet.hunger, barWidth)] }),
75 row('pet-stat-happiness', { children: [statLine('happiness', pet.happiness, barWidth)] }),
76 row('pet-stat-energy', { children: [statLine('energy', pet.energy, barWidth)] }),
77 row('pet-level', {
78 color,
79 children: [`${`level ${progress.level}`.padEnd(LABEL_WIDTH)}${statBar((progress.into / progress.need) * 100, barWidth)} ${progress.into}/${progress.need} xp`],
80 }),
81 row('pet-age', { dimColor: true, children: [`${'age'.padEnd(LABEL_WIDTH)}${ageLabel(pet.bornAt, now)}`] }),
82 ],
83 });
84
85 return Box({
86 key: 'pet-pane', flexDirection: 'column', paddingX: 1,
87 children: [
88 Box({
89 key: 'pet-header', flexDirection: 'row',
90 children: [
91 Text({ bold: true, color, children: [pet.name] }),
92 Text({ dimColor: true, children: [` the ${pet.species} · level ${progress.level} · ${moodLabel(mood)}`] }),
93 ],
94 }),
95 Box({ flexDirection: isWide ? 'row' : 'column', marginTop: 1, children: [art, stats] }),
96 Box({ key: 'pet-mood', marginTop: 1, children: [Text({ italic: true, children: [moodLine(pet.name, pet.species, mood)] })] }),
97 row('pet-footer', { dimColor: true, children: ['/pet name <name> · Esc closes'] }),
98 ],
99 });
100}
101hooks/pet.js 155 lines1export const SPECIES = ['cat', 'dog', 'blob', 'dragon'];
2export const MOODS = ['happy', 'ok', 'hungry', 'sad', 'grumpy', 'sleeping'];
3export const SLEEP_AFTER_MS = 30 * 60000;
4
5const NAMES = ['Mochi', 'Pixel', 'Bean', 'Noodle', 'Biscuit', 'Ziggy', 'Pip', 'Tofu', 'Waffles', 'Byte', 'Nugget', 'Sprout'];
6const HOUR = 3600000;
7const AWAKE_PER_HOUR = { hunger: -8, happiness: -5, energy: -6 };
8const ASLEEP_PER_HOUR = { hunger: -3, happiness: -1, energy: 25 };
9const SAD_FOR_MS = 3 * 60000;
10const HAPPY_FOR_MS = 5 * 60000;
11const TESTS_COOLDOWN_MS = 60000;
12const XP_ANSWER = 5;
13const XP_TESTS = 40;
14
15const clamp = (value) => Math.max(0, Math.min(100, value));
16const pick = (list, random) => list[Math.min(list.length - 1, Math.floor(random() * list.length))];
17
18export function hatch(now, random = Math.random) {
19 return {
20 species: pick(SPECIES, random),
21 name: pick(NAMES, random),
22 hunger: 80,
23 happiness: 80,
24 energy: 100,
25 xp: 0,
26 bornAt: now,
27 updatedAt: now,
28 lastActiveAt: now,
29 lastTestsAt: 0,
30 flash: null,
31 };
32}
33
34export function normalizePet(value) {
35 if (!value || typeof value !== 'object' || !SPECIES.includes(value.species)) return null;
36 const num = (v, fallback) => (typeof v === 'number' && Number.isFinite(v) ? v : fallback);
37 const bornAt = num(value.bornAt, 0);
38 return {
39 species: value.species,
40 name: typeof value.name === 'string' && value.name ? value.name : 'Pet',
41 hunger: clamp(num(value.hunger, 50)),
42 happiness: clamp(num(value.happiness, 50)),
43 energy: clamp(num(value.energy, 50)),
44 xp: Math.max(0, num(value.xp, 0)),
45 bornAt,
46 updatedAt: num(value.updatedAt, bornAt),
47 lastActiveAt: num(value.lastActiveAt, bornAt),
48 lastTestsAt: num(value.lastTestsAt, 0),
49 flash: value.flash && MOODS.includes(value.flash.mood) ? { mood: value.flash.mood, until: num(value.flash.until, 0) } : null,
50 };
51}
52
53function drift(pet, rates, ms) {
54 if (ms <= 0) return pet;
55 const hours = ms / HOUR;
56 return {
57 ...pet,
58 hunger: clamp(pet.hunger + rates.hunger * hours),
59 happiness: clamp(pet.happiness + rates.happiness * hours),
60 energy: clamp(pet.energy + rates.energy * hours),
61 };
62}
63
64export function decay(pet, now) {
65 const from = pet.updatedAt;
66 if (!(now > from)) return pet;
67 const sleepStart = pet.lastActiveAt + SLEEP_AFTER_MS;
68 const awakeMs = Math.max(0, Math.min(now, sleepStart) - from);
69 const asleepMs = Math.max(0, now - Math.max(from, sleepStart));
70 return { ...drift(drift(pet, AWAKE_PER_HOUR, awakeMs), ASLEEP_PER_HOUR, asleepMs), updatedAt: now };
71}
72
73function bump(pet, changes) {
74 return {
75 ...pet,
76 hunger: clamp(pet.hunger + (changes.hunger ?? 0)),
77 happiness: clamp(pet.happiness + (changes.happiness ?? 0)),
78 energy: clamp(pet.energy + (changes.energy ?? 0)),
79 xp: pet.xp + (changes.xp ?? 0),
80 };
81}
82
83export function applyEvent(pet, event, now) {
84 const current = { ...decay(pet, now), lastActiveAt: Math.max(pet.lastActiveAt, now) };
85 switch (event.type) {
86 case 'answer':
87 return bump(current, { hunger: 15, happiness: 2, energy: -2, xp: XP_ANSWER });
88 case 'toolError':
89 return { ...bump(current, { happiness: -8 }), flash: { mood: 'sad', until: now + SAD_FOR_MS } };
90 case 'testsPassed':
91 if (now - current.lastTestsAt < TESTS_COOLDOWN_MS) return current;
92 return { ...bump(current, { happiness: 20, xp: XP_TESTS }), lastTestsAt: now, flash: { mood: 'happy', until: now + HAPPY_FOR_MS } };
93 case 'rename':
94 return { ...current, name: event.name };
95 default:
96 return current;
97 }
98}
99
100export function isAsleep(pet, now) {
101 return now - pet.lastActiveAt >= SLEEP_AFTER_MS;
102}
103
104export function moodOf(pet, now) {
105 if (isAsleep(pet, now)) return 'sleeping';
106 const p = decay(pet, now);
107 if (p.flash && now < p.flash.until) return p.flash.mood;
108 if (Math.min(p.hunger, p.happiness, p.energy) < 15) return 'grumpy';
109 if (p.hunger < 35) return 'hungry';
110 if (p.happiness < 35) return 'sad';
111 if (Math.min(p.hunger, p.happiness, p.energy) >= 70) return 'happy';
112 return 'ok';
113}
114
115export function hintOf(pet) {
116 const needs = [
117 { value: pet.hunger, low: 30, hint: 'feed me a task' },
118 { value: pet.happiness, low: 30, hint: 'pass some tests?' },
119 { value: pet.energy, low: 20, hint: 'needs a nap' },
120 ].filter((need) => need.value < need.low);
121 needs.sort((a, b) => a.value - b.value);
122 return needs[0]?.hint ?? '';
123}
124
125const xpForLevel = (level) => 25 * level * (level - 1);
126
127export function levelOf(xp) {
128 let level = 1;
129 while (xpForLevel(level + 1) <= xp) level += 1;
130 return level;
131}
132
133export function levelProgress(xp) {
134 const level = levelOf(xp);
135 const start = xpForLevel(level);
136 return { level, into: xp - start, need: xpForLevel(level + 1) - start };
137}
138
139export function ageLabel(bornAt, now) {
140 const ms = Math.max(0, now - bornAt);
141 if (ms < HOUR) return `${Math.floor(ms / 60000)} min`;
142 if (ms < 24 * HOUR) return `${Math.floor(ms / HOUR)} h`;
143 const days = Math.floor(ms / (24 * HOUR));
144 return `${days} ${days === 1 ? 'day' : 'days'}`;
145}
146
147export function cleanName(raw) {
148 return String(raw ?? '')
149 .replace(/[\u0000-\u001f\u007f]/g, '')
150 .replace(/\s+/g, ' ')
151 .trim()
152 .slice(0, 16)
153 .trim();
154}
155hooks/pet-store.js 27 lines1import { hatch, levelOf, normalizePet } from './pet.js';
2
3export const PET_KEY = 'pet';
4
5let chain = Promise.resolve();
6
7// Serializes read-modify-write so parallel tool calls lose no update.
8export function updatePet(io, now, change, random = Math.random) {
9 const run = chain.then(async () => {
10 const stored = normalizePet(await io.get(PET_KEY));
11 const before = stored ?? hatch(now, random);
12 const after = change(before);
13 await io.set(PET_KEY, after);
14 return { before, after, hatched: !stored };
15 });
16 chain = run.catch(() => undefined);
17 return run;
18}
19
20export function noticesOf({ before, after, hatched }) {
21 const notices = [];
22 if (hatched) notices.push(`An egg hatched! Meet ${after.name} the ${after.species}. Say hi with /pet`);
23 const level = levelOf(after.xp);
24 if (level > levelOf(before.xp)) notices.push(`★ ${after.name} reached level ${level}!`);
25 return notices;
26}
27hooks/testpass.js 33 lines1const FAILURE_PATTERNS = [
2 /\b[1-9]\d*\s+(failed|failing|failures?|errors?|errored)\b/i,
3 /\b[1-9]\d*\s+tests?\s+failed\b/i,
4 /\bfail(?:ures|ed)?\s*[:=]\s*[1-9]/i,
5 /\bFAIL(?:ED)?\b/,
6 /^\s*not ok\b/m,
7 /\bAssertionError\b|\bpanic:|Traceback \(most recent call last\)/,
8];
9
10const PASS_PATTERNS = [
11 /\b[1-9]\d*\s+(passed|passing)\b/i,
12 /\b[1-9]\d*\s+tests?\s+passed\b/i,
13 /^ok\s+\S+\s+(\(cached\)|\d+(\.\d+)?s)/m,
14 /^OK(\s+\(.*\))?\s*$/m,
15 /\btest result: ok\./,
16 /\b[1-9]\d*\s+examples?,\s+0\s+failures\b/,
17];
18
19export function looksLikePassingTests(text) {
20 if (typeof text !== 'string' || text === '') return false;
21 if (FAILURE_PATTERNS.some((pattern) => pattern.test(text))) return false;
22 return PASS_PATTERNS.some((pattern) => pattern.test(text));
23}
24
25export function bashOutputOf(result) {
26 if (!result || result.deny || result.isError) return '';
27 const record = result.result;
28 const streams = record && typeof record === 'object'
29 ? [record.stdout, record.stderr].filter((s) => typeof s === 'string').join('\n')
30 : '';
31 return streams || (typeof result.text === 'string' ? result.text : '');
32}
33hooks/art.js 137 lines1export const ART_WIDTH = 14;
2export const ART_HEIGHT = 5;
3
4const EYES = {
5 happy: '^w^',
6 ok: 'o.o',
7 hungry: 'O~O',
8 sad: ';_;',
9 grumpy: '>_<',
10 sleeping: '-.-',
11};
12
13const SPECIES_ART = {
14 cat: {
15 color: 'warning',
16 face: (eyes) => `=${eyes}=`,
17 frames: [
18 [
19 String.raw` /\_/\ `,
20 String.raw` ( @@@ ) `,
21 String.raw` > ^ < ) `,
22 String.raw` / \ ( `,
23 String.raw`(_|_ _|_)_/ `,
24 ],
25 [
26 String.raw` /\_/\ `,
27 String.raw` ( @@@ ) `,
28 String.raw` > ^ < ( `,
29 String.raw` / \ ) `,
30 String.raw`(_|_ _|_)_\ `,
31 ],
32 ],
33 happy: 'purrs and kneads the keyboard.',
34 sleeping: 'is curled up in a warm loop. zZz',
35 },
36 dog: {
37 color: 'suggestion',
38 face: (eyes) => `U${eyes}U`,
39 frames: [
40 [
41 String.raw` __ __ `,
42 String.raw`/ \____/ \ `,
43 String.raw`\_| @@@ |_/ `,
44 String.raw` | (_) | / `,
45 String.raw` '-------'/ `,
46 ],
47 [
48 String.raw` __ __ `,
49 String.raw`/ \____/ \ `,
50 String.raw`\_| @@@ |_/ `,
51 String.raw` | (_) | \ `,
52 String.raw` '-------'\ `,
53 ],
54 ],
55 happy: 'wags so hard the whole terminal shakes.',
56 sleeping: 'is snoring on the rug. zZz',
57 },
58 blob: {
59 color: 'merged',
60 face: (eyes) => `(${eyes})`,
61 frames: [
62 [
63 String.raw` `,
64 String.raw` .---. `,
65 String.raw` ( @@@ ) `,
66 String.raw` ( ) `,
67 String.raw` '-----' `,
68 ],
69 [
70 String.raw` .-. `,
71 String.raw` .' '. `,
72 String.raw` ( @@@ ) `,
73 String.raw` ( ) `,
74 String.raw` '---' `,
75 ],
76 ],
77 happy: 'jiggles with pure joy.',
78 sleeping: 'has melted into a sleepy puddle. zZz',
79 },
80 dragon: {
81 color: 'success',
82 face: (eyes) => `<${eyes}>`,
83 frames: [
84 [
85 String.raw` __/\__ `,
86 String.raw` /\( @@@ )/\ `,
87 String.raw` \ \ \/\/ / / `,
88 String.raw` / \__~ `,
89 String.raw` ^^ ^^ `,
90 ],
91 [
92 String.raw` /\_ /\ _/\ `,
93 String.raw` \ ( @@@ ) / `,
94 String.raw` \ \/\/ / `,
95 String.raw` / \__~ `,
96 String.raw` ^^ ^^ `,
97 ],
98 ],
99 happy: 'puffs a happy little smoke ring.',
100 sleeping: 'snores tiny sparks. zZz',
101 },
102};
103
104const MOOD_COPY = {
105 ok: 'is hanging out, watching you code.',
106 hungry: "'s tummy rumbles. Got a task?",
107 sad: 'looks a little down. Errors happen.',
108 grumpy: 'is grumpy and wants some attention.',
109};
110
111const SNORE = ['z ', ' Z'];
112
113export function colorOf(species) {
114 return SPECIES_ART[species]?.color ?? 'text';
115}
116
117export function faceOf(species, mood) {
118 return SPECIES_ART[species].face(EYES[mood] ?? EYES.ok);
119}
120
121export function artFrame(species, mood, frameIndex) {
122 const art = SPECIES_ART[species];
123 const index = ((frameIndex % art.frames.length) + art.frames.length) % art.frames.length;
124 const eyes = EYES[mood] ?? EYES.ok;
125 const lines = art.frames[index].map((line) => line.replace('@@@', eyes).padEnd(ART_WIDTH));
126 if (mood === 'sleeping') {
127 lines[0] = lines[0].slice(0, ART_WIDTH - 2) + SNORE[index % SNORE.length];
128 }
129 return lines;
130}
131
132export function moodLine(name, species, mood) {
133 const art = SPECIES_ART[species];
134 const copy = art[mood] ?? MOOD_COPY[mood] ?? MOOD_COPY.ok;
135 return copy.startsWith("'") ? `${name}${copy}` : `${name} ${copy}`;
136}
137