@readme/cli 0.0.26

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (144) hide show
  1. package/README.md +55 -0
  2. package/bin/readme.js +8 -0
  3. package/package.json +58 -0
  4. package/src/bootstrap.js +97 -0
  5. package/src/cli.js +189 -0
  6. package/src/commands/dev.js +119 -0
  7. package/src/commands/eyes.js +37 -0
  8. package/src/commands/import.js +2565 -0
  9. package/src/commands/lint.js +70 -0
  10. package/src/commands/oas-sync.js +364 -0
  11. package/src/commands/oas-validate.js +208 -0
  12. package/src/commands/play.js +17 -0
  13. package/src/commands/pretty.js +133 -0
  14. package/src/commands/setup.js +256 -0
  15. package/src/commands/versions.js +81 -0
  16. package/src/dev/.next/app-build-manifest.json +20 -0
  17. package/src/dev/.next/build-manifest.json +31 -0
  18. package/src/dev/.next/cache/.rscinfo +1 -0
  19. package/src/dev/.next/cache/next-devtools-config.json +1 -0
  20. package/src/dev/.next/cache/webpack/client-development/0.pack.gz +0 -0
  21. package/src/dev/.next/cache/webpack/client-development/1.pack.gz +0 -0
  22. package/src/dev/.next/cache/webpack/client-development/10.pack.gz +0 -0
  23. package/src/dev/.next/cache/webpack/client-development/11.pack.gz +0 -0
  24. package/src/dev/.next/cache/webpack/client-development/2.pack.gz +0 -0
  25. package/src/dev/.next/cache/webpack/client-development/3.pack.gz +0 -0
  26. package/src/dev/.next/cache/webpack/client-development/3.pack.gz_ +0 -0
  27. package/src/dev/.next/cache/webpack/client-development/4.pack.gz +0 -0
  28. package/src/dev/.next/cache/webpack/client-development/5.pack.gz +0 -0
  29. package/src/dev/.next/cache/webpack/client-development/5.pack.gz_ +0 -0
  30. package/src/dev/.next/cache/webpack/client-development/6.pack.gz +0 -0
  31. package/src/dev/.next/cache/webpack/client-development/7.pack.gz +0 -0
  32. package/src/dev/.next/cache/webpack/client-development/7.pack.gz_ +0 -0
  33. package/src/dev/.next/cache/webpack/client-development/8.pack.gz +0 -0
  34. package/src/dev/.next/cache/webpack/client-development/9.pack.gz +0 -0
  35. package/src/dev/.next/cache/webpack/client-development/index.pack.gz.old +0 -0
  36. package/src/dev/.next/cache/webpack/client-development-fallback/0.pack.gz +0 -0
  37. package/src/dev/.next/cache/webpack/client-development-fallback/1.pack.gz +0 -0
  38. package/src/dev/.next/cache/webpack/client-development-fallback/index.pack.gz +0 -0
  39. package/src/dev/.next/cache/webpack/client-development-fallback/index.pack.gz.old +0 -0
  40. package/src/dev/.next/cache/webpack/edge-server-development/0.pack.gz +0 -0
  41. package/src/dev/.next/cache/webpack/edge-server-development/1.pack.gz +0 -0
  42. package/src/dev/.next/cache/webpack/edge-server-development/index.pack.gz +0 -0
  43. package/src/dev/.next/cache/webpack/edge-server-development/index.pack.gz.old +0 -0
  44. package/src/dev/.next/cache/webpack/server-development/0.pack.gz +0 -0
  45. package/src/dev/.next/cache/webpack/server-development/1.pack.gz +0 -0
  46. package/src/dev/.next/cache/webpack/server-development/10.pack.gz +0 -0
  47. package/src/dev/.next/cache/webpack/server-development/11.pack.gz +0 -0
  48. package/src/dev/.next/cache/webpack/server-development/12.pack.gz +0 -0
  49. package/src/dev/.next/cache/webpack/server-development/13.pack.gz +0 -0
  50. package/src/dev/.next/cache/webpack/server-development/14.pack.gz +0 -0
  51. package/src/dev/.next/cache/webpack/server-development/15.pack.gz +0 -0
  52. package/src/dev/.next/cache/webpack/server-development/2.pack.gz +0 -0
  53. package/src/dev/.next/cache/webpack/server-development/2.pack.gz_ +0 -0
  54. package/src/dev/.next/cache/webpack/server-development/3.pack.gz +0 -0
  55. package/src/dev/.next/cache/webpack/server-development/3.pack.gz_ +0 -0
  56. package/src/dev/.next/cache/webpack/server-development/4.pack.gz +0 -0
  57. package/src/dev/.next/cache/webpack/server-development/5.pack.gz +0 -0
  58. package/src/dev/.next/cache/webpack/server-development/6.pack.gz +0 -0
  59. package/src/dev/.next/cache/webpack/server-development/6.pack.gz_ +0 -0
  60. package/src/dev/.next/cache/webpack/server-development/7.pack.gz +0 -0
  61. package/src/dev/.next/cache/webpack/server-development/7.pack.gz_ +0 -0
  62. package/src/dev/.next/cache/webpack/server-development/8.pack.gz +0 -0
  63. package/src/dev/.next/cache/webpack/server-development/9.pack.gz +0 -0
  64. package/src/dev/.next/cache/webpack/server-development/9.pack.gz_ +0 -0
  65. package/src/dev/.next/cache/webpack/server-development/index.pack.gz +0 -0
  66. package/src/dev/.next/cache/webpack/server-development/index.pack.gz.old +0 -0
  67. package/src/dev/.next/package.json +1 -0
  68. package/src/dev/.next/prerender-manifest.json +11 -0
  69. package/src/dev/.next/react-loadable-manifest.json +1 -0
  70. package/src/dev/.next/routes-manifest.json +1 -0
  71. package/src/dev/.next/server/app/[...slug]/page.js +360 -0
  72. package/src/dev/.next/server/app/[...slug]/page_client-reference-manifest.js +1 -0
  73. package/src/dev/.next/server/app/page.js +349 -0
  74. package/src/dev/.next/server/app/page_client-reference-manifest.js +1 -0
  75. package/src/dev/.next/server/app-paths-manifest.json +3 -0
  76. package/src/dev/.next/server/edge-runtime-webpack.js +1151 -0
  77. package/src/dev/.next/server/interception-route-rewrite-manifest.js +1 -0
  78. package/src/dev/.next/server/middleware-build-manifest.js +33 -0
  79. package/src/dev/.next/server/middleware-manifest.json +32 -0
  80. package/src/dev/.next/server/middleware-react-loadable-manifest.js +1 -0
  81. package/src/dev/.next/server/middleware.js +1113 -0
  82. package/src/dev/.next/server/next-font-manifest.js +1 -0
  83. package/src/dev/.next/server/next-font-manifest.json +1 -0
  84. package/src/dev/.next/server/pages-manifest.json +5 -0
  85. package/src/dev/.next/server/server-reference-manifest.js +1 -0
  86. package/src/dev/.next/server/server-reference-manifest.json +5 -0
  87. package/src/dev/.next/server/static/webpack/633457081244afec._.hot-update.json +1 -0
  88. package/src/dev/.next/server/vendor-chunks/@readme.js +25 -0
  89. package/src/dev/.next/server/vendor-chunks/@swc.js +55 -0
  90. package/src/dev/.next/server/vendor-chunks/next.js +3659 -0
  91. package/src/dev/.next/server/webpack-runtime.js +209 -0
  92. package/src/dev/.next/static/chunks/app/[...slug]/loading.js +28 -0
  93. package/src/dev/.next/static/chunks/app/[...slug]/page.js +28 -0
  94. package/src/dev/.next/static/chunks/app/layout.js +171 -0
  95. package/src/dev/.next/static/chunks/app/page.js +28 -0
  96. package/src/dev/.next/static/chunks/app-pages-internals.js +182 -0
  97. package/src/dev/.next/static/chunks/main-app.js +1882 -0
  98. package/src/dev/.next/static/chunks/polyfills.js +1 -0
  99. package/src/dev/.next/static/chunks/webpack.js +1393 -0
  100. package/src/dev/.next/static/css/app/layout.css +559 -0
  101. package/src/dev/.next/static/development/_buildManifest.js +1 -0
  102. package/src/dev/.next/static/development/_ssgManifest.js +1 -0
  103. package/src/dev/.next/static/webpack/633457081244afec._.hot-update.json +1 -0
  104. package/src/dev/.next/static/webpack/ec52a3fce0f78db0.webpack.hot-update.json +1 -0
  105. package/src/dev/.next/static/webpack/webpack.ec52a3fce0f78db0.hot-update.js +12 -0
  106. package/src/dev/.next/trace +21 -0
  107. package/src/dev/.next/types/app/[...slug]/page.ts +84 -0
  108. package/src/dev/.next/types/app/layout.ts +84 -0
  109. package/src/dev/.next/types/app/page.ts +84 -0
  110. package/src/dev/.next/types/cache-life.d.ts +141 -0
  111. package/src/dev/.next/types/package.json +1 -0
  112. package/src/dev/.next/types/routes.d.ts +55 -0
  113. package/src/dev/app/Sidebar.js +149 -0
  114. package/src/dev/app/[...slug]/loading.js +16 -0
  115. package/src/dev/app/[...slug]/page.js +43 -0
  116. package/src/dev/app/globals.css +167 -0
  117. package/src/dev/app/layout.js +73 -0
  118. package/src/dev/app/page.js +19 -0
  119. package/src/dev/lib/docs.js +337 -0
  120. package/src/dev/middleware.js +7 -0
  121. package/src/dev/next.config.mjs +22 -0
  122. package/src/index.js +12 -0
  123. package/src/prompts/index.js +352 -0
  124. package/src/utils/claude.js +15 -0
  125. package/src/utils/eyes.js +365 -0
  126. package/src/utils/git.js +143 -0
  127. package/src/utils/lint.js +99 -0
  128. package/src/utils/reporter.js +319 -0
  129. package/src/utils/setup-templates.js +323 -0
  130. package/src/utils/styles.js +50 -0
  131. package/src/utils/tamagotchi.js +1139 -0
  132. package/src/utils/tips.js +90 -0
  133. package/src/validators/components.js +230 -0
  134. package/src/validators/content.js +53 -0
  135. package/src/validators/duplicates.js +45 -0
  136. package/src/validators/frontmatter.js +247 -0
  137. package/src/validators/links.js +68 -0
  138. package/src/validators/nesting.js +50 -0
  139. package/src/validators/numbering.js +136 -0
  140. package/src/validators/oas-reference.js +126 -0
  141. package/src/validators/oas-schema.js +106 -0
  142. package/src/validators/ordering.js +121 -0
  143. package/src/validators/recipes.js +143 -0
  144. package/vendor/TOOLS.md +19 -0
@@ -0,0 +1,1139 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import os from 'node:os';
4
+ import readline from 'node:readline';
5
+ import chalk from 'chalk';
6
+ import { eyes, expressions, palettes, setPalette } from './eyes.js';
7
+
8
+ // ── Data directory (XDG spec) ───────────────────────────
9
+
10
+ function getDataDir() {
11
+ const base = process.env.XDG_DATA_HOME || path.join(os.homedir(), '.local', 'share');
12
+ return path.join(base, 'readme');
13
+ }
14
+
15
+ function getSavePath() {
16
+ return path.join(getDataDir(), 'eyes.json');
17
+ }
18
+
19
+ // ── State management ────────────────────────────────────
20
+
21
+ const MAX_STAT = 10;
22
+ const DECAY_INTERVAL_MS = 1000 * 60 * 5; // stats decay every 5 minutes of real time
23
+
24
+ const ALL_TRICKS = ['hide and seek', 'spin', 'fetch', 'bow', 'chameleon', 'dance', 'owl impression', 'writes an OAS file'];
25
+
26
+ function newPet(name = 'Eyes', color = 'blue') {
27
+ return {
28
+ name,
29
+ color,
30
+ born: Date.now(),
31
+ lastVisit: Date.now(),
32
+ hunger: 8, // 0 = starving, 10 = full
33
+ happiness: 8, // 0 = miserable, 10 = ecstatic
34
+ energy: 8, // 0 = exhausted, 10 = wide awake
35
+ sleeping: false,
36
+ age: 0, // total real-time minutes alive
37
+ tricks: [], // learned trick names
38
+ };
39
+ }
40
+
41
+ export function loadPet() {
42
+ try {
43
+ const data = JSON.parse(fs.readFileSync(getSavePath(), 'utf8'));
44
+ return data;
45
+ } catch {
46
+ return null;
47
+ }
48
+ }
49
+
50
+ function savePet(pet) {
51
+ const dir = getDataDir();
52
+ fs.mkdirSync(dir, { recursive: true });
53
+ fs.writeFileSync(getSavePath(), JSON.stringify(pet, null, 2));
54
+ }
55
+
56
+ export function applyDecay(pet) {
57
+ const now = Date.now();
58
+ const elapsed = now - pet.lastVisit;
59
+ const ticks = Math.floor(elapsed / DECAY_INTERVAL_MS);
60
+
61
+ if (ticks > 0) {
62
+ pet.hunger = Math.max(0, pet.hunger - ticks);
63
+ pet.happiness = Math.max(0, pet.happiness - Math.floor(ticks * 0.7));
64
+ pet.energy = Math.min(MAX_STAT, pet.energy + Math.floor(ticks * 0.3)); // rests while you're away
65
+ pet.lastVisit = now;
66
+ }
67
+
68
+ // Update age in minutes
69
+ pet.age = Math.floor((now - pet.born) / 60000);
70
+ return pet;
71
+ }
72
+
73
+ // ── Actions ─────────────────────────────────────────────
74
+
75
+ // Returns "does a spin" vs "writes an OAS file" depending on the trick name
76
+ function trickPhrase(trick) {
77
+ // Tricks with custom phrasing
78
+ const custom = { 'writes an OAS file': 'writes an OAS file', 'hide and seek': 'plays hide and seek' };
79
+ if (custom[trick]) return custom[trick];
80
+ const vowels = 'aeiou';
81
+ const article = vowels.includes(trick[0].toLowerCase()) ? 'an' : 'a';
82
+ return `does ${article} ${trick}`;
83
+ }
84
+
85
+ const ok = (message, extra = {}) => ({ message, ok: true, ...extra });
86
+ const fail = (message) => ({ message, ok: false });
87
+
88
+ function feed(pet) {
89
+ if (pet.hunger >= MAX_STAT) return fail('Already full!');
90
+ pet.sleeping = false;
91
+ pet.hunger = Math.min(MAX_STAT, pet.hunger + 3);
92
+ pet.energy = Math.min(MAX_STAT, pet.energy + 1);
93
+ return ok(`${pet.name} munches happily!`);
94
+ }
95
+
96
+ function play(pet) {
97
+ if (pet.energy <= 0) return fail('Too tired to play...');
98
+ pet.sleeping = false;
99
+ pet.happiness = Math.min(MAX_STAT, pet.happiness + 3);
100
+ pet.hunger = Math.max(0, pet.hunger - 1);
101
+ pet.energy = Math.max(0, pet.energy - 2);
102
+ return ok(`${pet.name} bounces around!`);
103
+ }
104
+
105
+ function nap(pet) {
106
+ if (pet.sleeping) {
107
+ pet.sleeping = false;
108
+ return ok(`${pet.name} wakes up! Good morning!`);
109
+ }
110
+ if (pet.energy >= MAX_STAT) return fail('Not sleepy!');
111
+ pet.sleeping = true;
112
+ pet.energy = Math.min(MAX_STAT, pet.energy + 4);
113
+ pet.happiness = Math.min(MAX_STAT, pet.happiness + 1);
114
+ return ok(`${pet.name} curls up for a nap... zzz`);
115
+ }
116
+
117
+ function petAction(pet) {
118
+ if (pet.energy <= 1) return fail(`${pet.name} is too tired for pets...`);
119
+ pet.sleeping = false;
120
+ pet.happiness = Math.min(MAX_STAT, pet.happiness + 2);
121
+ return ok(`${pet.name} loves the attention!`);
122
+ }
123
+
124
+ function teach(pet) {
125
+ if (pet.energy <= 1) return fail('Too tired to learn right now...');
126
+ pet.sleeping = false;
127
+
128
+ const known = (pet.tricks || []).filter(t => ALL_TRICKS.includes(t));
129
+ pet.tricks = known; // prune stale tricks from old saves
130
+ const unknown = ALL_TRICKS.filter(t => !known.includes(t));
131
+
132
+ pet.energy = Math.max(0, pet.energy - 1);
133
+ pet.hunger = Math.max(0, pet.hunger - 1);
134
+
135
+ if (unknown.length === 0) {
136
+ const trick = known[Math.floor(Math.random() * known.length)];
137
+ pet.happiness = Math.min(MAX_STAT, pet.happiness + 1);
138
+ return ok(`${pet.name} ${trickPhrase(trick)}!`, { trick });
139
+ }
140
+
141
+ const learnChance = 0.3 + (pet.happiness / MAX_STAT) * 0.4;
142
+ if (Math.random() < learnChance) {
143
+ const trick = unknown[Math.floor(Math.random() * unknown.length)];
144
+ pet.tricks = [...known, trick];
145
+ pet.happiness = Math.min(MAX_STAT, pet.happiness + 2);
146
+ return ok(`${pet.name} learned ${trick}! (${pet.tricks.length}/${ALL_TRICKS.length} tricks)`, { trick });
147
+ }
148
+
149
+ const reactions = [
150
+ `${pet.name} tilts their head... not quite`,
151
+ `${pet.name} got distracted. Try again!`,
152
+ `Almost! ${pet.name} is getting the hang of it`,
153
+ `${pet.name} tries their best but needs more practice`,
154
+ ];
155
+ pet.happiness = Math.min(MAX_STAT, pet.happiness + 1);
156
+ return ok(reactions[Math.floor(Math.random() * reactions.length)]);
157
+ }
158
+
159
+ // ── Expression based on mood ────────────────────────────
160
+
161
+ function getMood(pet) {
162
+ if (pet.sleeping) return 'sleeping';
163
+ if (pet.energy <= 1) return 'tired';
164
+ if (pet.hunger <= 1) return 'sad';
165
+ if (pet.happiness >= 8 && pet.hunger >= 6) return 'happy';
166
+ if (pet.happiness <= 2) return 'sad';
167
+ return 'normal';
168
+ }
169
+
170
+ function getExpression(mood) {
171
+ switch (mood) {
172
+ case 'sleeping': return 'closed';
173
+ case 'tired': return 'squint';
174
+ case 'sad': return 'squint';
175
+ case 'happy': return 'right';
176
+ default: return 'right';
177
+ }
178
+ }
179
+
180
+ /**
181
+ * Get a greeting message and expression for the CLI header.
182
+ * Returns { greeting, expression } based on current pet state.
183
+ */
184
+ export function getPetHeader(pet) {
185
+ const name = pet.name;
186
+
187
+ if (pet.sleeping) {
188
+ return { greeting: `${name} is sleeping... zzz`, expression: 'closed' };
189
+ }
190
+
191
+ const expression = Math.random() < 0.5 ? 'left' : 'right';
192
+ return { greeting: null, expression };
193
+ }
194
+
195
+ // Animated expression sequence based on mood
196
+ function getAnimFrames(mood) {
197
+ switch (mood) {
198
+ case 'sleeping':
199
+ return {
200
+ frames: ['closed', 'closed', 'closed', 'closed', 'squint', 'closed', 'closed', 'closed', 'squint', 'closed'],
201
+ durations: [2000, 1000, 2000, 500, 100, 800, 1500, 500, 100, 1000],
202
+ };
203
+ case 'tired':
204
+ return {
205
+ frames: ['half-blink', 'half-blink', 'half-blink-left', 'half-blink', 'half-blink', 'half-blink-right', 'squint', 'half-blink', 'half-blink-left', 'half-blink', 'half-blink', 'half-blink-right', 'closed', 'half-blink', 'half-blink'],
206
+ durations: [800, 600, 500, 1000, 400, 500, 150, 800, 500, 600, 800, 500, 200, 300, 1200],
207
+ };
208
+ case 'sad':
209
+ return {
210
+ frames: ['left', 'left', 'right', 'right', 'left', 'half-blink', 'squint', 'half-blink', 'left', 'left', 'right', 'left'],
211
+ durations: [800, 400, 200, 800, 600, 80, 400, 80, 600, 400, 300, 800],
212
+ };
213
+ case 'happy':
214
+ return {
215
+ frames: ['right', 'right', 'right:up', 'right:up', 'right', 'left', 'right', 'half-blink', 'squint', 'closed', 'squint', 'half-blink', 'right', 'right'],
216
+ durations: [600, 300, 150, 150, 300, 400, 400, 50, 50, 50, 50, 50, 300, 800],
217
+ };
218
+ default:
219
+ return {
220
+ frames: ['right', 'right', 'right', 'left', 'left', 'right', 'half-blink', 'squint', 'closed', 'squint', 'half-blink', 'right', 'right'],
221
+ durations: [1200, 200, 400, 120, 600, 120, 50, 50, 50, 50, 50, 200, 1000],
222
+ };
223
+ }
224
+ }
225
+
226
+ // ── Rendering ───────────────────────────────────────────
227
+
228
+ function statBar(value, max, color) {
229
+ const filled = Math.round((value / max) * 10);
230
+ const empty = 10 - filled;
231
+ return color('█'.repeat(filled)) + chalk.gray('░'.repeat(empty));
232
+ }
233
+
234
+ function formatAge(minutes) {
235
+ if (minutes < 60) return `${minutes}m`;
236
+ const hours = Math.floor(minutes / 60);
237
+ if (hours < 24) return `${hours}h ${minutes % 60}m`;
238
+ const days = Math.floor(hours / 24);
239
+ return `${days}d ${hours % 24}h`;
240
+ }
241
+
242
+ const BOX_WIDTH = 53;
243
+
244
+ function boxVisLen(s) {
245
+ const stripped = s.replace(/\x1b\[[0-9;]*m/g, '');
246
+ let len = 0;
247
+ for (const ch of stripped) {
248
+ const code = ch.codePointAt(0);
249
+ len += code >= 0x1F000 ? 2 : 1;
250
+ }
251
+ return len;
252
+ }
253
+
254
+ function boxLine(content = '', bg = null) {
255
+ const inner = BOX_WIDTH - 2;
256
+ const padding = Math.max(0, inner - boxVisLen(content));
257
+ const filled = content + ' '.repeat(padding);
258
+ return chalk.dim('│') + (bg ? chalk.bgHex(bg)(filled) : filled) + chalk.dim('│');
259
+ }
260
+
261
+ function boxTop() {
262
+ return chalk.dim('┌' + '─'.repeat(BOX_WIDTH - 2) + '┐');
263
+ }
264
+
265
+ function boxBottom() {
266
+ return chalk.dim('└' + '─'.repeat(BOX_WIDTH - 2) + '┘');
267
+ }
268
+
269
+ function boxDivider() {
270
+ return chalk.dim('├' + '─'.repeat(BOX_WIDTH - 2) + '┤');
271
+ }
272
+
273
+ const STATUS_LEFT = 22; // inner width of left column
274
+ const STATUS_RIGHT = BOX_WIDTH - 2 - STATUS_LEFT - 1; // -2 borders, -1 middle divider
275
+
276
+ function statusRow(leftContent, rightContent) {
277
+ const lVis = boxVisLen(leftContent);
278
+ const rVis = boxVisLen(rightContent);
279
+ const lPad = Math.max(0, STATUS_LEFT - lVis);
280
+ const rPad = Math.max(0, STATUS_RIGHT - rVis);
281
+ return leftContent + ' '.repeat(lPad) + chalk.dim('│') + rightContent + ' '.repeat(rPad);
282
+ }
283
+
284
+ function statusDivider() {
285
+ return chalk.dim('─'.repeat(STATUS_LEFT) + '┼' + '─'.repeat(STATUS_RIGHT));
286
+ }
287
+
288
+ function renderStatus(pet) {
289
+ const mood = getMood(pet);
290
+ const moodEmoji = { sleeping: 'sleeping', tired: 'tired', sad: 'lonely', happy: 'happy!', normal: 'content' }[mood];
291
+
292
+ const lines = [
293
+ statusRow(
294
+ ` ${chalk.bold(pet.name)}`,
295
+ ` ${chalk.dim('vitals')}`,
296
+ ),
297
+ statusRow(
298
+ ` ${chalk.dim('age:')} ${formatAge(pet.age)}`,
299
+ ` ${chalk.hex('#ff6b6b')('hunger')} ${statBar(pet.hunger, MAX_STAT, chalk.hex('#ff6b6b'))} ${String(pet.hunger).padStart(2)}/${MAX_STAT}`,
300
+ ),
301
+ statusRow(
302
+ ` ${chalk.dim('mood:')} ${moodEmoji}`,
303
+ ` ${chalk.hex('#ffd93d')('happy')} ${statBar(pet.happiness, MAX_STAT, chalk.hex('#ffd93d'))} ${String(pet.happiness).padStart(2)}/${MAX_STAT}`,
304
+ ),
305
+ statusRow(
306
+ ` ${chalk.dim('tricks:')} ${(pet.tricks || []).filter(t => ALL_TRICKS.includes(t)).length}/${ALL_TRICKS.length}`,
307
+ ` ${chalk.hex('#6bcb77')('energy')} ${statBar(pet.energy, MAX_STAT, chalk.hex('#6bcb77'))} ${String(pet.energy).padStart(2)}/${MAX_STAT}`,
308
+ ),
309
+ ];
310
+ return lines;
311
+ }
312
+
313
+ function renderActions(pet, activeKey = null) {
314
+ const knownTricks = (pet.tricks || []).filter(t => ALL_TRICKS.includes(t));
315
+ const allLearned = knownTricks.length >= ALL_TRICKS.length;
316
+ const items = [
317
+ { key: 'f', label: 'feed' },
318
+ { key: 'p', label: 'play' },
319
+ { key: pet.sleeping ? 'w' : 's', label: pet.sleeping ? 'wake' : 'sleep' },
320
+ { key: 'h', label: 'pet' },
321
+ { key: 't', label: allLearned ? 'trick' : 'teach' },
322
+ ];
323
+ const parts = items.map(({ key, label }) => {
324
+ const text = `[${key}] ${label}`;
325
+ return key === activeKey ? chalk.hex('#63D2FF')(text) : chalk.white(text);
326
+ });
327
+ return [` ${parts.join(' ')}`];
328
+ }
329
+
330
+ // ── Reset ───────────────────────────────────────────────
331
+
332
+ export function resetPet() {
333
+ const savePath = getSavePath();
334
+ try {
335
+ fs.unlinkSync(savePath);
336
+ console.log(chalk.dim(' Save data cleared. A new friend will hatch next time!'));
337
+ } catch {
338
+ console.log(chalk.dim(' No save data found.'));
339
+ }
340
+ }
341
+
342
+ // ── Game loop ───────────────────────────────────────────
343
+
344
+ async function askSetup() {
345
+ const { printEyes } = await import('./eyes.js');
346
+
347
+ // Show the default eyes
348
+ console.log('');
349
+ printEyes('right', ' ');
350
+ console.log('');
351
+
352
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
353
+ const ask = (q) => new Promise((resolve) => {
354
+ rl.question(q, (answer) => resolve(answer.trim()));
355
+ });
356
+
357
+ // Ask name
358
+ const name = (await ask(chalk.hex('#63D2FF')(' A new friend hatched! What will you name them? '))) || 'Eyes';
359
+
360
+ // Show color options
361
+ console.log('');
362
+ const colorKeys = Object.keys(palettes).filter(k => !palettes[k].hidden);
363
+ for (let i = 0; i < colorKeys.length; i++) {
364
+ const p = palettes[colorKeys[i]];
365
+ console.log(` ${chalk.hex(p.body)('██')} ${chalk.bold(`${i + 1}.`)} ${p.name}`);
366
+ }
367
+ console.log('');
368
+
369
+ const colorChoice = await ask(chalk.hex('#63D2FF')(` Pick a color (1-${colorKeys.length}): `));
370
+ const colorIndex = parseInt(colorChoice, 10) - 1;
371
+ const color = colorKeys[colorIndex] || 'blue';
372
+
373
+ rl.close();
374
+ return { name, color };
375
+ }
376
+
377
+ export async function startGame() {
378
+ let petState = loadPet();
379
+ let isNew = false;
380
+
381
+ if (!petState) {
382
+ isNew = true;
383
+ const { name, color } = await askSetup();
384
+ petState = newPet(name, color);
385
+ savePet(petState);
386
+ }
387
+
388
+ // Apply saved color palette
389
+ if (petState.color) setPalette(petState.color);
390
+
391
+ petState = applyDecay(petState);
392
+ savePet(petState);
393
+
394
+ // Set up raw stdin for keypresses
395
+ if (!process.stdin.isTTY) {
396
+ console.log('Tamagotchi requires an interactive terminal.');
397
+ return;
398
+ }
399
+
400
+ process.stdin.setRawMode(true);
401
+ process.stdin.resume();
402
+ process.stdin.setEncoding('utf8');
403
+
404
+ // Hide cursor
405
+ process.stdout.write('\x1b[?25l');
406
+
407
+ let message = isNew
408
+ ? chalk.hex('#63D2FF')(`${petState.name} hatched! Take good care of them.`)
409
+ : chalk.hex('#63D2FF')(`Welcome back! ${petState.name} missed you.`);
410
+ let messageTimeout = null;
411
+ let activeAction = null;
412
+ let stopped = false;
413
+ let animFrame = 0;
414
+ let currentAnim = getAnimFrames(getMood(petState));
415
+
416
+ function resolveFrame(frameName) {
417
+ const isUp = frameName.endsWith(':up');
418
+ const isDown = frameName.endsWith(':down');
419
+ const expr = isUp ? frameName.slice(0, -3) : isDown ? frameName.slice(0, -5) : frameName;
420
+ const px = expressions[expr];
421
+ if (!px) return eyes('right');
422
+ const normal = eyes(expr);
423
+ const empty = ' '.repeat(7);
424
+ if (isUp) {
425
+ // Shift down: empty top, trim bottom
426
+ return [empty, ...normal.slice(0, -1)];
427
+ }
428
+ if (isDown) {
429
+ // Shift up: trim top, empty bottom
430
+ return [...normal.slice(1), empty];
431
+ }
432
+ return normal;
433
+ }
434
+
435
+ const INNER = BOX_WIDTH - 2;
436
+ const ICON_WIDTH = 14; // 7 pixels × 2 chars
437
+ const PAD_LEFT = Math.floor((INNER - ICON_WIDTH) / 2);
438
+ const PAD_RIGHT = INNER - ICON_WIDTH - PAD_LEFT;
439
+
440
+ // Measure visible length of an ANSI string (accounts for double-width emoji)
441
+ function visLen(s) {
442
+ const stripped = s.replace(/\x1b\[[0-9;]*m/g, '');
443
+ let len = 0;
444
+ for (const ch of stripped) {
445
+ const code = ch.codePointAt(0);
446
+ // Emoji (surrogate pairs / high codepoints) are 2 cols, most symbols are 1
447
+ len += code >= 0x1F000 ? 2 : 1;
448
+ }
449
+ return len;
450
+ }
451
+
452
+ // Pad a string (possibly with ANSI) to a fixed visible width
453
+ function padRight(s, width) {
454
+ const diff = width - visLen(s);
455
+ return diff > 0 ? s + ' '.repeat(diff) : s;
456
+ }
457
+
458
+ function draw() {
459
+ const mood = getMood(petState);
460
+ currentAnim = getAnimFrames(mood);
461
+ const frameName = frameOverride || currentAnim.frames[animFrame % currentAnim.frames.length];
462
+ let iconLines = iconFlipped ? resolveFrame(frameName).slice().reverse() : resolveFrame(frameName);
463
+ if (iconShift > 0) {
464
+ const emptyLine = ' '.repeat(ICON_WIDTH);
465
+ for (let i = 0; i < iconShift; i++) iconLines = [emptyLine, ...iconLines.slice(0, -1)];
466
+ }
467
+
468
+ process.stdout.write('\x1b[H\x1b[J');
469
+
470
+ // Gather overlays
471
+ const foodLines = getFoodLines();
472
+ const isSleeping = petState.sleeping;
473
+ const sleepStars = isSleeping ? getSleepScene(animFrame) : null;
474
+ const hearts = getHeartOverlay();
475
+ const sparkles = getPlayOverlay();
476
+
477
+ // Build scene rows: [left area] [icon centered] [right area]
478
+ const effLeft = PAD_LEFT + iconHShift;
479
+ const effRight = PAD_RIGHT - iconHShift;
480
+ const sceneRows = iconLines.map((iconLine, r) => {
481
+ let leftStr = ' '.repeat(effLeft);
482
+ let rightStr = ' '.repeat(effRight);
483
+
484
+ // Pick the active overlay (only one can be active at a time)
485
+ if (heartFrame >= 0 && hearts.left) {
486
+ leftStr = padRight(hearts.left[r] || '', PAD_LEFT);
487
+ rightStr = padRight(hearts.right[r] || '', PAD_RIGHT);
488
+ } else if (playFrame >= 0 && sparkles.left) {
489
+ leftStr = padRight(sparkles.left[r] || '', PAD_LEFT);
490
+ rightStr = padRight(sparkles.right[r] || '', PAD_RIGHT);
491
+ } else if (feedFrame >= 0 && foodLines && r < foodLines.length) {
492
+ rightStr = padRight(foodLines[r] || '', effRight);
493
+ } else if (sleepStars && sleepStars.left && r < sleepStars.left.length) {
494
+ leftStr = padRight(sleepStars.left[r] || '', PAD_LEFT);
495
+ rightStr = padRight(sleepStars.right[r] || '', PAD_RIGHT);
496
+ } else if (trickSceneOverlay && (trickSceneOverlay.right || trickSceneOverlay[r])) {
497
+ if (trickSceneOverlay.left) leftStr = padRight(trickSceneOverlay.left[r] || '', effLeft);
498
+ if (trickSceneOverlay.right) rightStr = padRight(trickSceneOverlay.right[r] || '', effRight);
499
+ if (!trickSceneOverlay.left && !trickSceneOverlay.right) rightStr = padRight(' ' + (trickSceneOverlay[r] || ''), effRight);
500
+ }
501
+
502
+ return leftStr + iconLine + rightStr;
503
+ });
504
+
505
+ // Status + actions
506
+ const statusLines = renderStatus(petState);
507
+ const actionLines = renderActions(petState, activeAction);
508
+
509
+ // Petting hand overlay — two rows: palm + fingers, swaying
510
+ function getPetHand() {
511
+ if (petFrame < 0) return [];
512
+ const offsets = [0, 1, 2, 3, 2, 1, 0, -1, -2, -1, 0, 1, 2];
513
+ const offset = offsets[petFrame % offsets.length];
514
+ const pos = PAD_LEFT + 2 + offset;
515
+ const pad = ' '.repeat(Math.max(0, pos));
516
+ const skin = chalk.hex('#FFCC88');
517
+ return [
518
+ pad + skin(' ╷╷╷╷'),
519
+ pad + skin('╭┴┴┴┴╮'),
520
+ ];
521
+ }
522
+
523
+ // Render with box border
524
+ const footer = chalk.dim('[r] reset [q] quit');
525
+ const footerVis = '[r] reset [q] quit'.length;
526
+ console.log('');
527
+ console.log(' ' + ' '.repeat(BOX_WIDTH - 2 - footerVis + 1) + footer);
528
+ console.log(' ' + boxTop());
529
+ const sceneBg = petState.sleeping ? '#05051a' : null;
530
+ const handLines = getPetHand();
531
+ if (handLines.length) {
532
+ // Hand takes 2 rows, so skip the empty top row and trim the last scene row
533
+ console.log(' ' + boxLine(handLines[0], sceneBg));
534
+ console.log(' ' + boxLine(handLines[1], sceneBg));
535
+ for (let i = 0; i < sceneRows.length - 1; i++) {
536
+ console.log(' ' + boxLine(sceneRows[i], sceneBg));
537
+ }
538
+ } else {
539
+ console.log(' ' + boxLine('', sceneBg));
540
+ for (const row of sceneRows) {
541
+ console.log(' ' + boxLine(row, sceneBg));
542
+ }
543
+ }
544
+ // Message centered between scene and vitals
545
+ const inner = BOX_WIDTH - 2;
546
+ let displayMsg = message || '';
547
+ if (boxVisLen(displayMsg) > inner) {
548
+ // Strip ANSI, truncate, re-apply no color (plain truncation)
549
+ const plain = displayMsg.replace(/\x1b\[[0-9;]*m/g, '');
550
+ displayMsg = plain.slice(0, inner - 1) + '…';
551
+ }
552
+ const msgVis = boxVisLen(displayMsg);
553
+ const msgPadL = Math.max(0, Math.floor((inner - msgVis) / 2));
554
+ const msgPadR = Math.max(0, inner - msgVis - msgPadL);
555
+ console.log(' ' + boxDivider());
556
+ console.log(' ' + chalk.dim('│') + ' '.repeat(msgPadL) + displayMsg + ' '.repeat(msgPadR) + chalk.dim('│'));
557
+ console.log(' ' + chalk.dim('├' + '─'.repeat(STATUS_LEFT) + '┬' + '─'.repeat(STATUS_RIGHT) + '┤'));
558
+ for (const line of statusLines) {
559
+ console.log(' ' + boxLine(line));
560
+ }
561
+ console.log(' ' + chalk.dim('├' + '─'.repeat(STATUS_LEFT) + '┴' + '─'.repeat(STATUS_RIGHT) + '┤'));
562
+ for (const line of actionLines) {
563
+ console.log(' ' + boxLine(line));
564
+ }
565
+ console.log(' ' + boxBottom());
566
+ if (confirmingReset) {
567
+ console.log('');
568
+ console.log(' ' + chalk.hex('#ff6b6b')(`Reset ${petState.name}? They'll be gone forever!`) + chalk.dim(' [y] yes [n] no'));
569
+ } else {
570
+ console.log('');
571
+ }
572
+ }
573
+
574
+ function clearMessage(delay = 3000) {
575
+ if (messageTimeout) clearTimeout(messageTimeout);
576
+ messageTimeout = setTimeout(() => {
577
+ message = '';
578
+ draw();
579
+ }, delay);
580
+ }
581
+
582
+ // ── Sleep scene ──────────────────────────────────────
583
+ function getSleepScene(frame) {
584
+ // Stars on both sides of the centered icon
585
+ function starChar(seed) {
586
+ const t = (frame + seed) % 6;
587
+ if (t < 2) return chalk.hex('#FFD700')('·');
588
+ if (t < 4) return chalk.hex('#FFF8DC')('*');
589
+ return chalk.dim('·');
590
+ }
591
+
592
+ const leftGrid = [
593
+ [{ col: 3, seed: 0 }, { col: PAD_LEFT - 4, seed: 8 }],
594
+ [{ col: 7, seed: 5 }],
595
+ [{ col: 2, seed: 2 }, { col: PAD_LEFT - 3, seed: 9 }],
596
+ ];
597
+
598
+ const rightGrid = [
599
+ [{ col: 2, seed: 3 }, { col: Math.min(10, PAD_RIGHT - 3), seed: 6 }],
600
+ [{ col: 5, seed: 1 }, { col: Math.min(12, PAD_RIGHT - 4), seed: 10 }],
601
+ [{ col: 1, seed: 7 }, { col: Math.min(8, PAD_RIGHT - 2), seed: 4 }],
602
+ ];
603
+
604
+ const leftLines = leftGrid.map(row => {
605
+ const cells = new Array(PAD_LEFT).fill(' ');
606
+ for (const star of row) {
607
+ if (star.col >= 0 && star.col < PAD_LEFT) cells[star.col] = starChar(star.seed);
608
+ }
609
+ return cells.join('');
610
+ });
611
+
612
+ const rightLines = rightGrid.map((row, ri) => {
613
+ const cells = new Array(PAD_RIGHT).fill(' ');
614
+ for (const star of row) {
615
+ if (star.col >= 0 && star.col < PAD_RIGHT) cells[star.col] = starChar(star.seed);
616
+ }
617
+ // Moon on first row, right side (emoji is 2 chars wide, so use col and blank the next)
618
+ if (ri === 0) {
619
+ const moonCol = Math.min(PAD_RIGHT - 3, 13);
620
+ cells[moonCol] = '🌙';
621
+ if (moonCol + 1 < PAD_RIGHT) cells[moonCol + 1] = '';
622
+ }
623
+ return cells.join('');
624
+ });
625
+
626
+ return { left: leftLines, right: rightLines };
627
+ }
628
+
629
+ let feedAnimating = false;
630
+ let feedFrame = -1;
631
+ const allFood = ['🍕', '🌮', '🍎', '🧀', '🍪', '🥐', '🍣', '🍩', '🍔', '🌯', '🥨', '🍇', '🥕', '🍰'];
632
+ let feedEmojis = [];
633
+
634
+ function getFoodLines() {
635
+ if (feedFrame < 0) return null;
636
+ // 3 food items scrolling right-to-left across the full right area
637
+ const maxPos = PAD_RIGHT;
638
+ const lines = [];
639
+ for (let row = 0; row < 3; row++) {
640
+ const stagger = row * 3;
641
+ const pos = feedFrame - stagger;
642
+ if (pos >= 0 && pos < maxPos) {
643
+ const spaces = Math.max(0, maxPos - pos - 2);
644
+ lines.push(' '.repeat(spaces) + feedEmojis[row]);
645
+ } else {
646
+ lines.push('');
647
+ }
648
+ }
649
+ return lines;
650
+ }
651
+
652
+ async function animateFeed() {
653
+ feedAnimating = true;
654
+ // Pick 3 random foods
655
+ feedEmojis = [];
656
+ const pool = [...allFood];
657
+ for (let i = 0; i < 3; i++) {
658
+ const idx = Math.floor(Math.random() * pool.length);
659
+ feedEmojis.push(pool.splice(idx, 1)[0]);
660
+ }
661
+ const totalFrames = PAD_RIGHT + 8; // enough for last food to cross fully
662
+ for (feedFrame = 0; feedFrame <= totalFrames; feedFrame++) {
663
+ draw();
664
+ await new Promise(r => setTimeout(r, 100));
665
+ }
666
+ feedFrame = -1;
667
+ draw();
668
+ feedAnimating = false;
669
+ }
670
+
671
+ let petAnimating = false;
672
+ let petFrame = -1;
673
+ let heartFrame = -1;
674
+
675
+ // Hearts float upward: 3 hearts at staggered positions
676
+ function getHeartOverlay() {
677
+ if (heartFrame < 0) return { left: null, right: null };
678
+ const hearts = [
679
+ { startFrame: 0, side: 'right', startRow: 2, offset: 2 },
680
+ { startFrame: 1, side: 'left', startRow: 2, offset: 2 },
681
+ { startFrame: 3, side: 'right', startRow: 2, offset: 6 },
682
+ { startFrame: 4, side: 'left', startRow: 1, offset: 5 },
683
+ { startFrame: 6, side: 'right', startRow: 2, offset: 10 },
684
+ { startFrame: 7, side: 'left', startRow: 2, offset: 9 },
685
+ ];
686
+ const leftLines = ['', '', '', ''];
687
+ const rightLines = ['', '', '', ''];
688
+ for (const h of hearts) {
689
+ const age = heartFrame - h.startFrame;
690
+ if (age < 0 || age > 4) continue;
691
+ const row = h.startRow - age;
692
+ if (row < 0 || row > 3) continue;
693
+ const heart = age < 2 ? chalk.hex('#ff6b9d')('♥') : chalk.hex('#ff6b9d').dim('♥');
694
+ if (h.side === 'left') {
695
+ leftLines[row] = ' '.repeat(Math.max(0, PAD_LEFT - h.offset - 1)) + heart + ' '.repeat(h.offset);
696
+ } else {
697
+ rightLines[row] = ' '.repeat(h.offset) + heart;
698
+ }
699
+ }
700
+ return { left: leftLines, right: rightLines };
701
+ }
702
+
703
+ async function animatePet() {
704
+ petAnimating = true;
705
+ // Phase 1: petting hand sways
706
+ for (petFrame = 0; petFrame <= 10; petFrame++) {
707
+ draw();
708
+ await new Promise(r => setTimeout(r, 120));
709
+ }
710
+ petFrame = -1;
711
+ // Phase 2: hearts float up
712
+ for (heartFrame = 0; heartFrame <= 12; heartFrame++) {
713
+ draw();
714
+ await new Promise(r => setTimeout(r, 120));
715
+ }
716
+ heartFrame = -1;
717
+ draw();
718
+ petAnimating = false;
719
+ }
720
+
721
+ let playAnimating = false;
722
+ let playFrame = -1;
723
+ const confettiColors = ['#FF6B6B', '#FFD93D', '#6BCB77', '#4D96FF', '#FF6BD6', '#63D2FF'];
724
+
725
+ function getPlayOverlay() {
726
+ if (playFrame < 0) return { left: null, right: null };
727
+ // Build cell arrays for left and right, then render with chalk
728
+ const leftCells = Array.from({ length: 4 }, () => new Array(PAD_LEFT).fill(null));
729
+ const rightCells = Array.from({ length: 4 }, () => new Array(PAD_RIGHT).fill(null));
730
+ const spots = [
731
+ { r: 0, side: 'left', seed: 0, col: 3 },
732
+ { r: 0, side: 'right', seed: 2, col: 4 },
733
+ { r: 0, side: 'left', seed: 7, col: 12 },
734
+ { r: 0, side: 'right', seed: 8, col: 12 },
735
+ { r: 1, side: 'left', seed: 1, col: 5 },
736
+ { r: 1, side: 'right', seed: 4, col: 7 },
737
+ { r: 1, side: 'left', seed: 6, col: 14 },
738
+ { r: 1, side: 'right', seed: 9, col: 14 },
739
+ { r: 2, side: 'left', seed: 3, col: 2 },
740
+ { r: 2, side: 'right', seed: 5, col: 3 },
741
+ { r: 2, side: 'right', seed: 10, col: 10 },
742
+ ];
743
+ for (const s of spots) {
744
+ const visible = (playFrame + s.seed) % 3 !== 0;
745
+ if (!visible) continue;
746
+ const color = confettiColors[(playFrame + s.seed) % confettiColors.length];
747
+ const ch = (playFrame + s.seed) % 2 === 0 ? '✦' : '·';
748
+ const cells = s.side === 'left' ? leftCells[s.r] : rightCells[s.r];
749
+ if (s.col < cells.length) cells[s.col] = chalk.hex(color)(ch);
750
+ }
751
+ const toStr = (cells) => cells.map(c => c || ' ').join('');
752
+ return {
753
+ left: leftCells.map(toStr),
754
+ right: rightCells.map(toStr),
755
+ };
756
+ }
757
+
758
+ async function animatePlay() {
759
+ playAnimating = true;
760
+ const bounceFrames = ['right:up', 'right:up', 'right', 'right', 'right:up', 'right:up', 'right', 'right', 'right:up', 'right:up', 'right', 'right', 'right'];
761
+ for (playFrame = 0; playFrame <= 12; playFrame++) {
762
+ frameOverride = bounceFrames[playFrame % bounceFrames.length];
763
+ draw();
764
+ await new Promise(r => setTimeout(r, 180));
765
+ }
766
+ playFrame = -1;
767
+ frameOverride = null;
768
+ draw();
769
+ playAnimating = false;
770
+ }
771
+
772
+ let teachAnimating = false;
773
+
774
+ async function animateTrick(name) {
775
+ const wait = (ms) => new Promise(r => setTimeout(r, ms));
776
+ const setFrame = async (f, ms) => { frameOverride = f; draw(); await wait(ms); };
777
+
778
+ switch (name) {
779
+ case 'hide and seek': {
780
+ // sink down progressively
781
+ for (const shift of [0, 1, 2, 3]) {
782
+ iconShift = shift;
783
+ await setFrame('right', 180);
784
+ }
785
+ // peek — bob up slightly then back down
786
+ iconShift = 2; await setFrame('right', 200);
787
+ iconShift = 3; await setFrame('right', 300);
788
+ iconShift = 2; await setFrame('right', 150);
789
+ iconShift = 3; await setFrame('right', 400);
790
+ // come back up
791
+ for (const shift of [2, 1, 0]) {
792
+ iconShift = shift;
793
+ await setFrame('right', 180);
794
+ }
795
+ break;
796
+ }
797
+ case 'spin': {
798
+ for (const f of ['left', 'up-left', 'up-right', 'right', 'up-right', 'up-left', 'left', 'up-left', 'right'])
799
+ await setFrame(f, 110);
800
+ break;
801
+ }
802
+ case 'fetch': {
803
+ iconHShift = -8;
804
+ const method = chalk.hex('#63D2FF')('POST');
805
+ const pending = chalk.dim('○') + ' ' + method + ' /api/pets';
806
+ const d = chalk.dim;
807
+ const json = [
808
+ d(' "id": 1,'),
809
+ d(' "name": "Max",'),
810
+ d(' "type": "dog",'),
811
+ d(' "breed": "husky",'),
812
+ d(' "age": 3,'),
813
+ d(' "tag": "friendly"'),
814
+ ];
815
+
816
+ // show request
817
+ trickSceneOverlay = [pending, '', '', ''];
818
+ await setFrame('up-right', 300);
819
+
820
+ // loading dots
821
+ for (const dots of [' ·', ' · ·', ' · · ·', ' · · · ·', ' · · ·', ' · ·', ' · · ·', ' · · · ·']) {
822
+ trickSceneOverlay = [pending, chalk.dim(dots), '', ''];
823
+ await setFrame('up-right', 180);
824
+ }
825
+
826
+ // json scrolls in line by line (pending pinned at top, 3 visible body rows)
827
+ const body = [d('{'), ...json, d('}')];
828
+ for (let i = 0; i < body.length; i++) {
829
+ const win = body.slice(Math.max(0, i - 2), i + 1);
830
+ while (win.length < 3) win.push('');
831
+ trickSceneOverlay = [pending, win[0], win[1], win[2]];
832
+ const expr = i % 2 === 0 ? 'up-left' : 'up-right';
833
+ await setFrame(expr, 200);
834
+ }
835
+
836
+ // 200 OK — hold on last 3 body lines
837
+ const tail = body.slice(-3);
838
+ trickSceneOverlay = [chalk.hex('#6bcb77')('● 200 OK'), tail[0], tail[1], tail[2]];
839
+ await setFrame('right', 700);
840
+
841
+ trickSceneOverlay = null;
842
+ break;
843
+ }
844
+ case 'bow': {
845
+ for (const [f, ms] of [['right', 200], ['right:up', 300], ['right:up', 300], ['squint', 500], ['right:up', 300], ['right', 200]])
846
+ await setFrame(f, ms);
847
+ break;
848
+ }
849
+ case 'chameleon': {
850
+ const colorKeys = Object.keys(palettes).filter(k => !palettes[k].hidden);
851
+ for (const color of [...colorKeys, ...colorKeys, petState.color]) {
852
+ setPalette(color);
853
+ await setFrame('right', 200);
854
+ }
855
+ break;
856
+ }
857
+ case 'dance': {
858
+ const notes = ['♩', '♪', '♫', '♬'];
859
+ const nc = chalk.hex('#ffd93d');
860
+ const place = (width, col, note) => ' '.repeat(Math.min(col, width - 1)) + nc(note) + ' '.repeat(Math.max(0, width - col - 1));
861
+ // Each frame: scattered note positions on left and right, shifting each beat
862
+ const frames = ['left:up', 'left', 'right:up', 'right', 'left:up', 'left', 'right:up', 'right', 'right:up', 'right'];
863
+ const spots = [
864
+ { lr: [14, 3], ll: [2, 11], rr: [4, 15], rl: [8, 1] },
865
+ { lr: [6, 16], ll: [9, 4], rr: [12, 2], rl: [14, 7] },
866
+ ];
867
+ for (let i = 0; i < frames.length; i++) {
868
+ const s = spots[i % 2];
869
+ const ni = (k) => notes[(i + k) % notes.length];
870
+ trickSceneOverlay = {
871
+ left: [place(PAD_LEFT, s.ll[0], ni(0)), place(PAD_LEFT, s.ll[1], ni(2)), place(PAD_LEFT, s.lr[0], ni(1)), place(PAD_LEFT, s.lr[1], ni(3))],
872
+ right: [place(PAD_RIGHT, s.rr[0], ni(2)), place(PAD_RIGHT, s.rl[0], ni(0)), place(PAD_RIGHT, s.rr[1], ni(3)), place(PAD_RIGHT, s.rl[1], ni(1))],
873
+ };
874
+ await setFrame(frames[i], 130);
875
+ }
876
+ trickSceneOverlay = null;
877
+ break;
878
+ }
879
+ case 'owl impression': {
880
+ setPalette('owl');
881
+ const hoot = chalk.hex('#ffd93d');
882
+ for (const [f, ms] of [['left', 200], ['right', 200], ['left', 200], ['right', 200]])
883
+ await setFrame(f, ms);
884
+ const rpad = () => ' '.repeat(2 + Math.floor(Math.random() * 8));
885
+ trickSceneOverlay = { right: [rpad() + hoot('hoot!'), '', '', ''] };
886
+ await setFrame('up-right', 500);
887
+ trickSceneOverlay = { right: [rpad() + hoot('hoot!'), rpad() + hoot('hoot!'), '', ''] };
888
+ await setFrame('up-left', 600);
889
+ trickSceneOverlay = null;
890
+ await setFrame('right', 300);
891
+ setPalette(petState.color);
892
+ break;
893
+ }
894
+ case 'writes an OAS file': {
895
+ iconHShift = -8;
896
+ const c = chalk.dim;
897
+ const groups = [
898
+ [c('openapi: 3.0.0'), c('info:'), c(' title: Petstore'), c(' version: 1.0.0')],
899
+ [c(' contact:'), c(' name: Petstore'), c(' url: petstore'), c(' license: MIT')],
900
+ [c('paths:'), c(' /pets:'), c(' get: listPets'), c(' post: createPet')],
901
+ [c(' /pets/{petId}:'), c(' get: getPetById'), c(' put: updatePet'), c(' delete: delPet')],
902
+ [c('components:'), c(' schemas:'), c(' Pet:'), c(' type: object')],
903
+ [c(' properties:'), c(' id: integer'), c(' name: string'), c(' tag: string')],
904
+ ];
905
+ const exprs = ['up-right', 'up-left', 'up-right', 'up-left', 'up-right', 'up-left'];
906
+ for (let i = 0; i < groups.length; i++) {
907
+ trickSceneOverlay = groups[i];
908
+ await setFrame(exprs[i], 450);
909
+ }
910
+ trickSceneOverlay = null;
911
+ break;
912
+ }
913
+ default: {
914
+ for (const f of ['right:up', 'right', 'right:up', 'right'])
915
+ await setFrame(f, 150);
916
+ }
917
+ }
918
+ frameOverride = null;
919
+ iconFlipped = false;
920
+ iconShift = 0;
921
+ iconHShift = 0;
922
+ trickSceneOverlay = null;
923
+ }
924
+
925
+ async function animateTeach(trickName) {
926
+ teachAnimating = true;
927
+ // Thinking phase
928
+ for (const expr of ['right', 'up-left', 'up-right', 'up-left', 'right']) {
929
+ frameOverride = expr;
930
+ draw();
931
+ await new Promise(r => setTimeout(r, 300));
932
+ }
933
+ frameOverride = null;
934
+
935
+ if (trickName) {
936
+ await animateTrick(trickName);
937
+ } else {
938
+ // Didn't learn: squint then back
939
+ frameOverride = 'squint';
940
+ draw();
941
+ await new Promise(r => setTimeout(r, 400));
942
+ frameOverride = null;
943
+ }
944
+ draw();
945
+ teachAnimating = false;
946
+ }
947
+
948
+ let frameOverride = null;
949
+ let iconFlipped = false;
950
+ let iconShift = 0; // rows to shift icon downward (for hiding)
951
+ let iconHShift = 0; // columns to shift icon left (negative = left, increases right area)
952
+ let trickSceneOverlay = null; // array of strings per scene row (right side)
953
+
954
+ async function animateSleep(falling) {
955
+ // falling = true: eyes close. false: eyes open.
956
+ const sequence = falling
957
+ ? ['right', 'half-blink', 'squint', 'closed']
958
+ : ['closed', 'squint', 'half-blink', 'right'];
959
+ for (const expr of sequence) {
960
+ frameOverride = expr;
961
+ draw();
962
+ await new Promise(r => setTimeout(r, 120));
963
+ }
964
+ frameOverride = null;
965
+ }
966
+
967
+ function doPerformTrick(name) {
968
+ if (actionBusy) return;
969
+ message = `${petState.name} ${trickPhrase(name)}!`;
970
+ actionBusy = true;
971
+ animateTrick(name).then(() => {
972
+ draw();
973
+ actionBusy = false;
974
+ clearMessage();
975
+ });
976
+ }
977
+
978
+ const actionKeyMap = new Map([[feed, 'f'], [play, 'p'], [petAction, 'h'], [teach, 't']]);
979
+ function getnapKey() { return petState.sleeping ? 'w' : 's'; }
980
+
981
+ function doAction(actionFn) {
982
+ if (actionBusy) return;
983
+ const tricksBefore = (petState.tricks || []).length;
984
+ const result = actionFn(petState);
985
+ message = result.ok ? result.message : chalk.hex('#ff9540')(result.message);
986
+ petState.lastVisit = Date.now();
987
+ savePet(petState);
988
+ animFrame = 0;
989
+ actionBusy = true;
990
+ activeAction = actionFn === nap ? getnapKey() : (actionKeyMap.get(actionFn) || null);
991
+
992
+ function done() {
993
+ activeAction = null;
994
+ actionBusy = false;
995
+ clearMessage();
996
+ }
997
+
998
+ if (!result.ok) {
999
+ draw();
1000
+ clearMessage();
1001
+ done();
1002
+ } else if (actionFn === nap) {
1003
+ // Only nap/wake gets the eye open/close animation
1004
+ animateSleep(petState.sleeping).then(() => { draw(); done(); });
1005
+ } else if (actionFn === feed) {
1006
+ animateFeed().then(done);
1007
+ } else if (actionFn === petAction) {
1008
+ animatePet().then(done);
1009
+ } else if (actionFn === play) {
1010
+ animatePlay().then(done);
1011
+ } else if (actionFn === teach) {
1012
+ const tricksAfter = (petState.tricks || []).length;
1013
+ const learnedTrick = tricksAfter > tricksBefore
1014
+ ? petState.tricks[petState.tricks.length - 1]
1015
+ : (result.trick || null);
1016
+ const learned = tricksAfter > tricksBefore;
1017
+ animateTeach(learnedTrick).then(() => {
1018
+ activeAction = null;
1019
+ actionBusy = false;
1020
+ clearMessage(learned ? 6000 : 3000);
1021
+ });
1022
+ } else {
1023
+ draw();
1024
+ done();
1025
+ }
1026
+ }
1027
+
1028
+ function cleanup() {
1029
+ stopped = true;
1030
+ process.stdout.write('\x1b[?25h'); // show cursor
1031
+ process.stdin.setRawMode(false);
1032
+ process.stdin.pause();
1033
+ savePet(petState);
1034
+ }
1035
+
1036
+ // Input handler
1037
+ process.stdin.on('data', (key) => {
1038
+ if (stopped) return;
1039
+
1040
+ if (confirmingReset) {
1041
+ if (key === 'y' || key === 'Y') {
1042
+ cleanup();
1043
+ const savePath = getSavePath();
1044
+ try { fs.unlinkSync(savePath); } catch {}
1045
+ console.log('');
1046
+ console.log(` ${chalk.dim('Save data cleared. A new friend will hatch next time!')}`);
1047
+ console.log('');
1048
+ process.exit();
1049
+ } else {
1050
+ confirmingReset = false;
1051
+ message = chalk.dim('Reset cancelled.');
1052
+ draw();
1053
+ clearMessage();
1054
+ }
1055
+ return;
1056
+ }
1057
+
1058
+ switch (key) {
1059
+ case 'f':
1060
+ doAction(feed);
1061
+ break;
1062
+ case 'p':
1063
+ doAction(play);
1064
+ break;
1065
+ case 's':
1066
+ case 'w':
1067
+ doAction(nap);
1068
+ break;
1069
+ case 'h':
1070
+ doAction(petAction);
1071
+ break;
1072
+ case 't':
1073
+ doAction(teach);
1074
+ break;
1075
+ case '1': case '2': case '3': case '4':
1076
+ case '5': case '6': case '7': case '8': {
1077
+ const idx = parseInt(key) - 1;
1078
+ const trickName = ALL_TRICKS[idx];
1079
+ const known = (petState.tricks || []).filter(t => ALL_TRICKS.includes(t));
1080
+ if (trickName && known.includes(trickName)) {
1081
+ doPerformTrick(trickName);
1082
+ } else if (trickName) {
1083
+ message = chalk.dim(`${petState.name} hasn't learned ${trickName} yet`);
1084
+ draw();
1085
+ clearMessage();
1086
+ }
1087
+ break;
1088
+ }
1089
+ case 'r':
1090
+ confirmingReset = true;
1091
+ message = '';
1092
+ draw();
1093
+ break;
1094
+ case 'q':
1095
+ case '\u0003': // Ctrl+C
1096
+ message = chalk.dim(`${petState.name} waves goodbye!`);
1097
+ draw();
1098
+ cleanup();
1099
+ process.exit();
1100
+ break;
1101
+ default:
1102
+ break;
1103
+ }
1104
+ });
1105
+
1106
+ let confirmingReset = false;
1107
+ let actionBusy = false;
1108
+
1109
+ // Animation + decay loop
1110
+ async function gameLoop() {
1111
+ while (!stopped) {
1112
+ if (!actionBusy) {
1113
+ draw();
1114
+ const duration = currentAnim.durations[animFrame % currentAnim.durations.length];
1115
+ await new Promise(r => setTimeout(r, duration));
1116
+ animFrame++;
1117
+ } else {
1118
+ await new Promise(r => setTimeout(r, 100));
1119
+ }
1120
+
1121
+ // Apply passive decay every loop cycle
1122
+ const now = Date.now();
1123
+ const elapsed = now - petState.lastVisit;
1124
+ if (elapsed >= DECAY_INTERVAL_MS) {
1125
+ const moodBefore = getMood(petState);
1126
+ petState = applyDecay(petState);
1127
+ savePet(petState);
1128
+ const moodAfter = getMood(petState);
1129
+ if (moodBefore !== moodAfter) {
1130
+ if (moodAfter === 'tired') message = chalk.hex('#63D2FF')(`${petState.name} is getting sleepy...`);
1131
+ else if (moodAfter === 'sad' && petState.hunger <= 1) message = chalk.hex('#ff6b6b')(`${petState.name} is hungry!`);
1132
+ }
1133
+ }
1134
+ }
1135
+ }
1136
+
1137
+
1138
+ await gameLoop();
1139
+ }