honeytree 1.1.6 → 1.2.1

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.
package/src/renderer.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import chalk from "chalk";
2
2
 
3
- import { getSprite, getGroundDetail, TREE_TYPES, GROUND_DETAIL_TYPES } from "./sprites.js";
3
+ import { getSprite, getAncientSprite, getGroundDetail, TREE_TYPES, GROUND_DETAIL_TYPES } from "./sprites.js";
4
4
  import { getVirtualWidth } from "./plant.js";
5
5
 
6
6
  const SKY_ROWS = 4;
@@ -160,6 +160,14 @@ function getTreeYOffset(treeId) {
160
160
  return h % 2; // Returns 0 or 1 (only up, never below ground)
161
161
  }
162
162
 
163
+ function getWindOffset(treeId, windTick) {
164
+ if (windTick == null) return 0;
165
+ const windDirection = Math.floor(windTick / 4) % 2 === 0 ? 1 : -1;
166
+ const treePhase = hash(treeId * 7) % 3;
167
+ const effectiveTick = Math.max(0, windTick - treePhase);
168
+ return (effectiveTick % 2 === 0) ? 0 : windDirection;
169
+ }
170
+
163
171
  function generateStars(width, biome, twinkle = 0) {
164
172
  const stars = [];
165
173
  for (let x = 0; x < width; x += 1) {
@@ -175,14 +183,16 @@ function generateStars(width, biome, twinkle = 0) {
175
183
  return stars;
176
184
  }
177
185
 
178
- function compositeSprite(buffer, sprite, centerX, baseY) {
186
+ function compositeSprite(buffer, sprite, centerX, baseY, canopyShiftX = 0) {
179
187
  const offsetX = centerX - Math.floor(sprite.width / 2);
188
+ const trunkRows = 2;
180
189
  for (let rowIndex = 0; rowIndex < sprite.rows.length; rowIndex += 1) {
181
190
  const targetY = baseY - rowIndex;
182
191
  if (targetY < 0 || targetY >= buffer.length) continue;
183
192
  const row = sprite.rows[rowIndex];
193
+ const shiftX = rowIndex < trunkRows ? 0 : canopyShiftX;
184
194
  for (let columnIndex = 0; columnIndex < row.length; columnIndex += 1) {
185
- const targetX = offsetX + columnIndex;
195
+ const targetX = offsetX + columnIndex + shiftX;
186
196
  if (targetX < 0 || targetX >= buffer[0].length) continue;
187
197
  const [char, color] = row[columnIndex];
188
198
  if (!color) continue;
@@ -292,6 +302,34 @@ function buildStatsLine(forest, biome, viewportX = 0, virtualWidth = 0, termWidt
292
302
  );
293
303
  }
294
304
 
305
+ export function getStatsData(forest, viewportX = 0, virtualWidth = 0, termWidth = 80) {
306
+ const treeCount = forest.trees.length;
307
+ const biome = getBiome(treeCount);
308
+ const milestone = getNextMilestone(treeCount);
309
+ const progress = milestone === 0 ? 0 : treeCount / milestone;
310
+ const streak = forest.streak || 0;
311
+ const wilt = getWiltFactor(forest.lastActiveDate);
312
+ let idleDays = 0;
313
+ if (wilt > 0 && forest.lastActiveDate) {
314
+ const a = new Date(forest.lastActiveDate + "T00:00:00");
315
+ const b = new Date(new Date().toISOString().slice(0, 10) + "T00:00:00");
316
+ idleDays = Math.round((b - a) / (24 * 60 * 60 * 1000));
317
+ }
318
+ return {
319
+ treeCount,
320
+ streak,
321
+ wilt,
322
+ idleDays,
323
+ milestone,
324
+ progress,
325
+ biomeName: biome.label,
326
+ nextTreeType: getNextTreeType(treeCount),
327
+ viewportX,
328
+ virtualWidth,
329
+ termWidth,
330
+ };
331
+ }
332
+
295
333
  export function renderFrame(forest, termWidth = 80, options = {}) {
296
334
  const width = Math.max(40, termWidth);
297
335
  const treeCount = forest.trees.length;
@@ -320,18 +358,86 @@ export function renderFrame(forest, termWidth = 80, options = {}) {
320
358
  }
321
359
  }
322
360
 
361
+ const windTick = options.windTick ?? null;
323
362
  const treeBaseY = groundStart - 1;
363
+ const spriteOverride = options.spriteOverride ?? null;
364
+ const rewards = options.rewards ?? null;
365
+ const hasAncient = rewards && rewards.ancient;
366
+ const hasBloomer = rewards && rewards.cherry;
367
+
324
368
  for (const tree of forest.trees) {
325
369
  const yOffset = getTreeYOffset(tree.id);
326
- compositeSprite(buffer, getSprite(tree.type, tree.growth), tree.x, treeBaseY - yOffset);
370
+ let sprite;
371
+ if (spriteOverride && spriteOverride.treeId === tree.id) {
372
+ sprite = spriteOverride.sprite;
373
+ } else if (hasAncient && hash(tree.id * 37) % 8 === 0) {
374
+ // Ancient: 1 in 8 trees become tall golden trees (still honor heightBonus)
375
+ sprite = getSprite(tree.type, tree.growth, {
376
+ heightBonus: tree.heightBonus || 0,
377
+ variant: "ancient",
378
+ });
379
+ } else {
380
+ sprite = getSprite(tree.type, tree.growth, {
381
+ heightBonus: tree.heightBonus || 0,
382
+ variant: tree.variant || null,
383
+ });
384
+ }
385
+ const canopyShiftX = getWindOffset(tree.id, windTick);
386
+ compositeSprite(buffer, sprite, tree.x, treeBaseY - yOffset, canopyShiftX);
387
+ }
388
+
389
+ // Apply ground overlay (soil reaction during tree birth animation)
390
+ const groundOverlayOpt = options.groundOverlay ?? null;
391
+ if (groundOverlayOpt && groundOverlayOpt.overlays) {
392
+ const groundRow = groundStart; // first ground row
393
+ for (const overlay of groundOverlayOpt.overlays) {
394
+ const ox = groundOverlayOpt.treeX + overlay.dx;
395
+ if (ox >= 0 && ox < virtualWidth && groundRow < buffer.length) {
396
+ buffer[groundRow][ox] = { char: overlay.char, color: overlay.color };
397
+ }
398
+ }
399
+ }
400
+
401
+ // Ground pulse — brighten all ground pixels by 30% during planting flash
402
+ const groundPulse = options.groundPulse ?? false;
403
+ if (groundPulse) {
404
+ for (let rowIndex = 0; rowIndex < GROUND_ROWS; rowIndex += 1) {
405
+ for (let x = 0; x < virtualWidth; x += 1) {
406
+ const cell = buffer[groundStart + rowIndex][x];
407
+ if (cell.color) {
408
+ const c = parseHex(cell.color);
409
+ cell.color = toHex({
410
+ r: Math.min(255, c.r + (255 - c.r) * 0.3),
411
+ g: Math.min(255, c.g + (255 - c.g) * 0.3),
412
+ b: Math.min(255, c.b + (255 - c.b) * 0.3),
413
+ });
414
+ }
415
+ }
416
+ }
327
417
  }
328
418
 
329
419
  renderGroundDetails(buffer, biome, virtualWidth, groundStart);
330
420
 
421
+ // Reward: Bloomer — 30% of canopy █ chars become ✿ in pink
422
+ if (hasBloomer) {
423
+ for (let y = 0; y < groundStart; y++) {
424
+ for (let x = 0; x < virtualWidth; x++) {
425
+ const cell = buffer[y][x];
426
+ if (cell.char === "█" && cell.color && y < groundStart) {
427
+ if (hash(x * 71 + y * 113) % 10 < 3) {
428
+ cell.char = "✿";
429
+ cell.color = "#FFB7C5";
430
+ }
431
+ }
432
+ }
433
+ }
434
+ }
435
+
331
436
  applyFog(buffer, wilt, virtualWidth);
332
437
 
333
438
  // Slice the viewport from the virtual buffer
334
439
  const lines = [];
440
+
335
441
  for (let y = 0; y < SCENE_HEIGHT - SPACER_ROWS - STATS_ROWS - CTA_ROWS; y += 1) {
336
442
  let line = "";
337
443
  for (let x = viewportX; x < viewportX + width; x += 1) {
@@ -346,12 +452,15 @@ export function renderFrame(forest, termWidth = 80, options = {}) {
346
452
  lines.push(line);
347
453
  }
348
454
 
349
- lines.push("");
350
- lines.push(buildStatsLine(forest, biome, viewportX, virtualWidth, width));
351
- lines.push(
352
- chalk.hex("#555555")(" ← → pan · add your forest to your README → ") +
353
- chalk.hex(STATS_ACCENT)("honeytree badge"),
354
- );
455
+ const includeStats = options.includeStats !== false;
456
+ if (includeStats) {
457
+ lines.push("");
458
+ lines.push(buildStatsLine(forest, biome, viewportX, virtualWidth, width));
459
+ lines.push(
460
+ chalk.hex("#555555")(" ← → pan · add your forest to your README → ") +
461
+ chalk.hex(STATS_ACCENT)("honeytree badge"),
462
+ );
463
+ }
355
464
 
356
465
  return lines.join("\n");
357
466
  }
@@ -381,7 +490,12 @@ export function buildScene(forest, width) {
381
490
  const treeBaseY = groundStart - 1;
382
491
  for (const tree of forest.trees) {
383
492
  const yOffset = getTreeYOffset(tree.id);
384
- compositeSprite(buffer, getSprite(tree.type, tree.growth), tree.x, treeBaseY - yOffset);
493
+ compositeSprite(
494
+ buffer,
495
+ getSprite(tree.type, tree.growth, { heightBonus: tree.heightBonus || 0, variant: tree.variant || null }),
496
+ tree.x,
497
+ treeBaseY - yOffset,
498
+ );
385
499
  }
386
500
 
387
501
  renderGroundDetails(buffer, biome, w, groundStart);
@@ -420,7 +534,12 @@ export function renderPlainText(forest, width = 60) {
420
534
  const treeBaseY = groundStart - 1;
421
535
  for (const tree of forest.trees) {
422
536
  const yOffset = getTreeYOffset(tree.id);
423
- compositeSprite(buffer, getSprite(tree.type, tree.growth), tree.x, treeBaseY - yOffset);
537
+ compositeSprite(
538
+ buffer,
539
+ getSprite(tree.type, tree.growth, { heightBonus: tree.heightBonus || 0, variant: tree.variant || null }),
540
+ tree.x,
541
+ treeBaseY - yOffset,
542
+ );
424
543
  }
425
544
 
426
545
  const lines = [];
package/src/rewards.js ADDED
@@ -0,0 +1,119 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import os from "node:os";
4
+ import { VARIETIES } from "./varieties.js";
5
+
6
+ const REWARDS_FILE = path.join(os.homedir(), ".honeydew", "rewards.json");
7
+
8
+ const TIERS = [
9
+ { slug: "cherry", label: "Cherry Blossom", threshold: 1, description: "Cherry blossom trees in your forest" },
10
+ { slug: "pine", label: "Pine", threshold: 5, description: "Evergreen pines in your forest" },
11
+ { slug: "oak", label: "Oak", threshold: 10, description: "Broad oaks in your forest" },
12
+ { slug: "ancient", label: "Ancient", threshold: 25, description: "Rare tall golden ancients" },
13
+ { slug: "mythic", label: "Mythic", threshold: 50, description: "Glowing mythic trees" },
14
+ ];
15
+
16
+ export { TIERS };
17
+
18
+ export function getRewards() {
19
+ try {
20
+ const data = JSON.parse(fs.readFileSync(REWARDS_FILE, "utf8"));
21
+ if (!Array.isArray(data.celebrated)) data.celebrated = [];
22
+ return data;
23
+ } catch {
24
+ return { badges: [], cherry: false, pine: false, oak: false, ancient: false, mythic: false, celebrated: [], username: "" };
25
+ }
26
+ }
27
+
28
+ export function saveRewards(data) {
29
+ const dir = path.dirname(REWARDS_FILE);
30
+ fs.mkdirSync(dir, { recursive: true });
31
+ fs.writeFileSync(REWARDS_FILE, JSON.stringify(data, null, 2));
32
+ }
33
+
34
+ export function hasReward(slug) {
35
+ return getRewards()[slug] === true;
36
+ }
37
+
38
+ // Back-compat alias used by the renderer's blossom branch.
39
+ export function hasCherryBlossom() { return hasReward("cherry"); }
40
+
41
+ // Fetch rewards from server and cache locally
42
+ export async function syncRewards(apiUrl) {
43
+ apiUrl = apiUrl || process.env.HONEYTREE_API_URL || "https://tryhoney.xyz";
44
+ const { getAuth } = await import("./auth.js");
45
+ const auth = getAuth();
46
+ if (!auth || !auth.access_token) return null;
47
+
48
+ try {
49
+ const res = await fetch(`${apiUrl}/api/rewards`, {
50
+ headers: { Authorization: `Bearer ${auth.access_token}` },
51
+ });
52
+
53
+ if (!res.ok) return null;
54
+
55
+ const { rewards } = await res.json();
56
+ const unlocked = rewards.filter((r) => r.unlocked);
57
+ const slugs = unlocked.map((r) => r.slug);
58
+
59
+ const data = {
60
+ badges: unlocked.map((r) => ({ slug: r.slug, label: r.label, description: r.description })),
61
+ cherry: slugs.includes("cherry"),
62
+ pine: slugs.includes("pine"),
63
+ oak: slugs.includes("oak"),
64
+ ancient: slugs.includes("ancient"),
65
+ mythic: slugs.includes("mythic"),
66
+ celebrated: getRewards().celebrated,
67
+ username: auth.username || "",
68
+ };
69
+
70
+ saveRewards(data);
71
+ return data;
72
+ } catch {
73
+ return null;
74
+ }
75
+ }
76
+
77
+ // Print rewards status to console
78
+ export function printRewardsStatus(realTreesPlanted = 0) {
79
+ const rewards = getRewards();
80
+ console.log();
81
+ console.log(" Honeytree Rewards");
82
+ console.log(" ─────────────────");
83
+ for (const tier of TIERS) {
84
+ const unlocked = rewards[tier.slug] === true;
85
+ if (unlocked) {
86
+ console.log(` ✅ ${tier.label} (${tier.threshold} tree${tier.threshold > 1 ? "s" : ""}) — ${tier.description}`);
87
+ } else {
88
+ const remaining = tier.threshold - realTreesPlanted;
89
+ const progress = remaining > 0 ? `${remaining} more real tree${remaining > 1 ? "s" : ""} to go` : "ready to unlock";
90
+ console.log(` 🔒 ${tier.label} (${tier.threshold} tree${tier.threshold > 1 ? "s" : ""}) — ${progress}`);
91
+ }
92
+ }
93
+ console.log();
94
+ }
95
+
96
+ // Unlocked variety keys (always includes "standard"), read from the local cache.
97
+ export function getUnlockedVarietyKeys() {
98
+ const r = getRewards();
99
+ return VARIETIES.filter((v) => v.key === "standard" || r[v.key] === true).map((v) => v.key);
100
+ }
101
+
102
+ // Mark a variety's celebration as shown (idempotent).
103
+ export function markCelebrated(key) {
104
+ const r = getRewards();
105
+ if (!Array.isArray(r.celebrated)) r.celebrated = [];
106
+ if (!r.celebrated.includes(key)) {
107
+ r.celebrated.push(key);
108
+ saveRewards(r);
109
+ }
110
+ }
111
+
112
+ // Unlocked varieties (excluding standard) whose celebration has not been shown.
113
+ export function uncelebratedUnlocked() {
114
+ const r = getRewards();
115
+ const celebrated = new Set(r.celebrated || []);
116
+ return VARIETIES
117
+ .filter((v) => v.key !== "standard" && r[v.key] === true && !celebrated.has(v.key))
118
+ .map((v) => v.key);
119
+ }
package/src/session.js ADDED
@@ -0,0 +1,34 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { getHoneydewDir } from "./state.js";
4
+
5
+ const STALE_MS = 5 * 60 * 1000; // a turn older than this is abandoned
6
+
7
+ function sessionFile() {
8
+ return path.join(getHoneydewDir(), "active-session.json");
9
+ }
10
+
11
+ export function writeActiveSession(data) {
12
+ const dir = getHoneydewDir();
13
+ fs.mkdirSync(dir, { recursive: true });
14
+ fs.writeFileSync(sessionFile(), JSON.stringify(data, null, 2));
15
+ }
16
+
17
+ export function readActiveSession() {
18
+ try {
19
+ return JSON.parse(fs.readFileSync(sessionFile(), "utf8"));
20
+ } catch {
21
+ return null;
22
+ }
23
+ }
24
+
25
+ export function clearActiveSession() {
26
+ try {
27
+ fs.unlinkSync(sessionFile());
28
+ } catch {}
29
+ }
30
+
31
+ export function isStale(session, now = Date.now()) {
32
+ if (!session || typeof session.turnStartedAt !== "number") return true;
33
+ return now - session.turnStartedAt > STALE_MS;
34
+ }
package/src/sprites.js CHANGED
@@ -14,6 +14,30 @@ const COLORS = {
14
14
  cherryBloom: "#f0b7cf",
15
15
  };
16
16
 
17
+ const BLOSSOM_COLORS = {
18
+ petalSoft: "#f7d1e0",
19
+ petalBright: "#ffa6c9",
20
+ petalDeep: "#e87aab",
21
+ branch: "#a67c5b",
22
+ };
23
+
24
+ // Teal palette for "Grove" status unlock
25
+ // Gold palette for "Ancient Forest" tall trees
26
+ export const GOLD_COLORS = {
27
+ canopyTop: "#FFD700",
28
+ canopyMid: "#DAA520",
29
+ canopyDark: "#B8860B",
30
+ trunk: "#8B7355",
31
+ };
32
+
33
+ // Mythic palette — glowing unicode treatment.
34
+ const MYTHIC_COLORS = {
35
+ glow: "#b388ff",
36
+ glowBright: "#e0c3fc",
37
+ core: "#7c4dff",
38
+ trunk: "#5e35b1",
39
+ };
40
+
17
41
  const DETAIL_COLORS = {
18
42
  mushroom: "#c4a882",
19
43
  mushroomCap: "#9e4a3a",
@@ -27,7 +51,7 @@ const DETAIL_COLORS = {
27
51
  bushLight: "#5a8a4a",
28
52
  };
29
53
 
30
- function parse(template, palette) {
54
+ export function parse(template, palette) {
31
55
  const lines = template.trim().split("\n");
32
56
  const width = Math.max(...lines.map((line) => line.length));
33
57
  const rows = lines
@@ -240,8 +264,32 @@ pPPpPPPp
240
264
  { p: COLORS.cherryBloom, P: COLORS.cherryPink, t: COLORS.trunkLight },
241
265
  ),
242
266
  },
267
+ cherry_blossom: {
268
+ seed: parse(` p\n b`, { p: BLOSSOM_COLORS.petalSoft, b: BLOSSOM_COLORS.branch }),
269
+ sapling: parse(` pP\npBp\n b`, { p: BLOSSOM_COLORS.petalSoft, P: BLOSSOM_COLORS.petalBright, B: BLOSSOM_COLORS.petalDeep, b: BLOSSOM_COLORS.branch }),
270
+ young: parse(` PB\n PpBp\npBPpBP\n bb\n bb`, { p: BLOSSOM_COLORS.petalSoft, P: BLOSSOM_COLORS.petalBright, B: BLOSSOM_COLORS.petalDeep, b: BLOSSOM_COLORS.branch }),
271
+ full: parse(` PBp\n pBPPBp\nPBpPBPBP\n pBPPBp\n bb\n bb`, { p: BLOSSOM_COLORS.petalSoft, P: BLOSSOM_COLORS.petalBright, B: BLOSSOM_COLORS.petalDeep, b: BLOSSOM_COLORS.branch }),
272
+ },
273
+ mythic: {
274
+ seed: parse(`\n m\n t\n`, { m: MYTHIC_COLORS.glow, t: MYTHIC_COLORS.trunk }),
275
+ sapling: parse(`\n mm\nmGm\n t\n`, { m: MYTHIC_COLORS.glow, G: MYTHIC_COLORS.glowBright, t: MYTHIC_COLORS.trunk }),
276
+ young: parse(`\n mG\n mGGm\nmGGcGm\n tt\n tt\n`, { m: MYTHIC_COLORS.glow, G: MYTHIC_COLORS.glowBright, c: MYTHIC_COLORS.core, t: MYTHIC_COLORS.trunk }),
277
+ full: parse(`\n mGm\n mGGGGm\nmGGccGGm\n mGGGGm\n tt\n tt\n`, { m: MYTHIC_COLORS.glow, G: MYTHIC_COLORS.glowBright, c: MYTHIC_COLORS.core, t: MYTHIC_COLORS.trunk }),
278
+ },
243
279
  };
244
280
 
281
+ // Tall golden tree for "Ancient Forest" reward (1 in 8 trees)
282
+ const ANCIENT_TREE = {
283
+ seed: parse(` g\n t`, { g: GOLD_COLORS.canopyTop, t: GOLD_COLORS.trunk }),
284
+ sapling: parse(` gg\ngGg\n t`, { g: GOLD_COLORS.canopyTop, G: GOLD_COLORS.canopyMid, t: GOLD_COLORS.trunk }),
285
+ young: parse(` Tg\n TGGg\nTgGGgT\n tt\n tt\n tt`, { g: GOLD_COLORS.canopyMid, G: GOLD_COLORS.canopyDark, T: GOLD_COLORS.canopyTop, t: GOLD_COLORS.trunk }),
286
+ full: parse(` TT\n TGGT\n TgGGGgT\nTgGGGGgT\n TgGGGg\n tt\n tt\n tt`, { g: GOLD_COLORS.canopyMid, G: GOLD_COLORS.canopyDark, T: GOLD_COLORS.canopyTop, t: GOLD_COLORS.trunk }),
287
+ };
288
+
289
+ export function getAncientSprite(growth) {
290
+ return ANCIENT_TREE[getGrowthStage(growth)];
291
+ }
292
+
245
293
  const GROUND_DETAILS = {
246
294
  mushroom: parse(
247
295
  `
@@ -293,10 +341,28 @@ function getGrowthStage(growth) {
293
341
  return "full";
294
342
  }
295
343
 
296
- export function getSprite(type, growth) {
297
- const spriteSet = SPRITES[type];
298
- if (!spriteSet) {
299
- throw new Error(`Unknown tree type: ${type}`);
344
+ // rows are stored bottom-first (row[0] is the ground row). Adding trunk rows at
345
+ // the bottom makes the trunk taller and pushes the canopy up.
346
+ function addTrunkRows(sprite, n) {
347
+ const bottom = sprite.rows[0] || [];
348
+ const trunkRow = bottom.map((cell) => (cell && cell[1] ? ["█", cell[1]] : [" ", null]));
349
+ const extra = Array.from({ length: n }, () => trunkRow.map((c) => [c[0], c[1]]));
350
+ return { rows: [...extra, ...sprite.rows], width: sprite.width };
351
+ }
352
+
353
+ export function getSprite(type, growth, opts = {}) {
354
+ const { heightBonus = 0, variant = null } = opts;
355
+ let sprite;
356
+ if (variant === "ancient") {
357
+ sprite = getAncientSprite(growth);
358
+ } else {
359
+ const spriteSet = SPRITES[type];
360
+ if (!spriteSet) {
361
+ throw new Error(`Unknown tree type: ${type}`);
362
+ }
363
+ sprite = spriteSet[getGrowthStage(growth)];
300
364
  }
301
- return spriteSet[getGrowthStage(growth)];
365
+ if (heightBonus > 0) sprite = addTrunkRows(sprite, heightBonus);
366
+ return sprite;
302
367
  }
368
+
package/src/stdin.js ADDED
@@ -0,0 +1,28 @@
1
+ // Read a hook's JSON payload from stdin. Resolves "" if stdin is a TTY/empty
2
+ // (e.g. when the command is run manually), so callers can fall back gracefully.
3
+ export function readStdin(timeoutMs = 1500) {
4
+ return new Promise((resolve) => {
5
+ if (process.stdin.isTTY) {
6
+ resolve("");
7
+ return;
8
+ }
9
+ let data = "";
10
+ let done = false;
11
+ const finish = () => {
12
+ if (done) return;
13
+ done = true;
14
+ resolve(data);
15
+ };
16
+ const timer = setTimeout(finish, timeoutMs);
17
+ process.stdin.setEncoding("utf8");
18
+ process.stdin.on("data", (c) => (data += c));
19
+ process.stdin.on("end", () => {
20
+ clearTimeout(timer);
21
+ finish();
22
+ });
23
+ process.stdin.on("error", () => {
24
+ clearTimeout(timer);
25
+ finish();
26
+ });
27
+ });
28
+ }
package/src/sync.js ADDED
@@ -0,0 +1,44 @@
1
+ import { getAuth, saveAuth } from "./auth.js";
2
+ import { readForest } from "./state.js";
3
+
4
+ const API_URL = process.env.HONEYTREE_API_URL || "https://tryhoney.xyz";
5
+
6
+ export async function syncToCloud(forest) {
7
+ const auth = getAuth();
8
+ if (!auth || !auth.access_token) return;
9
+
10
+ // Send tree data: type, growth, x position for rendering on the web
11
+ const trees = (forest.trees || []).map((t) => ({
12
+ type: t.type,
13
+ growth: t.growth,
14
+ x: t.x,
15
+ }));
16
+
17
+ try {
18
+ const res = await fetch(`${API_URL}/api/sync`, {
19
+ method: "POST",
20
+ headers: {
21
+ "Content-Type": "application/json",
22
+ Authorization: `Bearer ${auth.access_token}`,
23
+ },
24
+ body: JSON.stringify({
25
+ count: forest.totalPrompts,
26
+ streak: forest.streak || 0,
27
+ trees,
28
+ }),
29
+ });
30
+
31
+ if (res.ok) {
32
+ auth.lastSyncedPrompts = forest.totalPrompts;
33
+ saveAuth(auth);
34
+ }
35
+ } catch {
36
+ // Silently fail — sync is best-effort
37
+ }
38
+ }
39
+
40
+ // Immediate sync (called after login)
41
+ export async function syncNow() {
42
+ const forest = readForest();
43
+ if (forest) await syncToCloud(forest);
44
+ }
@@ -0,0 +1,62 @@
1
+ import fs from "node:fs";
2
+
3
+ function sumOutputTokens(text) {
4
+ let total = 0;
5
+ for (const raw of text.split("\n")) {
6
+ const t = raw.trim();
7
+ if (!t) continue;
8
+ try {
9
+ const obj = JSON.parse(t);
10
+ const ot = obj?.message?.usage?.output_tokens;
11
+ if (typeof ot === "number" && Number.isFinite(ot)) total += ot;
12
+ } catch {
13
+ // ignore malformed line
14
+ }
15
+ }
16
+ return total;
17
+ }
18
+
19
+ function readFrom(filePath, fromOffset, maxBytes = Infinity) {
20
+ let stat;
21
+ try {
22
+ stat = fs.statSync(filePath);
23
+ } catch {
24
+ return null;
25
+ }
26
+ if (stat.size <= fromOffset) return { text: "", size: stat.size };
27
+ const fd = fs.openSync(filePath, "r");
28
+ try {
29
+ const len = Math.min(stat.size - fromOffset, maxBytes);
30
+ const buf = Buffer.alloc(len);
31
+ fs.readSync(fd, buf, 0, len, fromOffset);
32
+ return { text: buf.toString("utf8"), size: stat.size };
33
+ } finally {
34
+ fs.closeSync(fd);
35
+ }
36
+ }
37
+
38
+ // Cap each live tail read so a runaway/unterminated transcript line can't
39
+ // allocate unbounded memory on the 1s viewer interval. The Stop-time full
40
+ // read (readTurnTokens) stays uncapped so a turn is always totalled correctly.
41
+ const MAX_LIVE_READ = 1024 * 1024;
42
+
43
+ // Sum all output tokens from `fromOffset` to EOF (turn is complete at Stop).
44
+ export function readTurnTokens(filePath, fromOffset) {
45
+ const r = readFrom(filePath, fromOffset);
46
+ if (!r) return 0;
47
+ return sumOutputTokens(r.text);
48
+ }
49
+
50
+ // Read only complete (newline-terminated) lines; leave a partial trailing line
51
+ // for the next read by not advancing the offset past it.
52
+ export function readNewTokens(filePath, fromOffset) {
53
+ const r = readFrom(filePath, fromOffset, MAX_LIVE_READ);
54
+ if (!r) return { tokens: 0, newOffset: fromOffset };
55
+ if (!r.text) return { tokens: 0, newOffset: fromOffset };
56
+ const lastNl = r.text.lastIndexOf("\n");
57
+ if (lastNl === -1) return { tokens: 0, newOffset: fromOffset };
58
+ const complete = r.text.slice(0, lastNl + 1);
59
+ const tokens = sumOutputTokens(complete);
60
+ const consumed = Buffer.byteLength(complete, "utf8");
61
+ return { tokens, newOffset: fromOffset + consumed };
62
+ }
package/src/turn.js ADDED
@@ -0,0 +1,54 @@
1
+ import fs from "node:fs";
2
+ import { readForest, createEmptyForest } from "./state.js";
3
+ import { getUnlockedVarietyKeys } from "./rewards.js";
4
+ import { unlockedPool, pickSpecies } from "./varieties.js";
5
+ import { getPlantWidth, findOpenX } from "./plant.js";
6
+ import { writeActiveSession, readActiveSession, clearActiveSession } from "./session.js";
7
+ import { readTurnTokens } from "./transcript.js";
8
+ import { tokensToTree } from "./growth.js";
9
+
10
+ function fileSize(p) {
11
+ try {
12
+ return fs.statSync(p).size;
13
+ } catch {
14
+ return 0;
15
+ }
16
+ }
17
+
18
+ // UserPromptSubmit: mark the start of a turn and pre-choose the tree (type + x)
19
+ // so the live overlay and the committed tree are the same tree.
20
+ export function startTurn(payload) {
21
+ const transcript_path = payload?.transcript_path;
22
+ if (!transcript_path) return;
23
+
24
+ const forest = readForest() ?? createEmptyForest();
25
+ const species = pickSpecies(unlockedPool(getUnlockedVarietyKeys()));
26
+ const type = species.type;
27
+ const variant = species.variant ?? null;
28
+ const width = getPlantWidth(forest);
29
+ const x = findOpenX(forest.trees, type, 1, width);
30
+
31
+ writeActiveSession({
32
+ transcript_path,
33
+ session_id: payload.session_id ?? null,
34
+ turnStartedAt: Date.now(),
35
+ baselineOffset: fileSize(transcript_path),
36
+ type,
37
+ variant,
38
+ x,
39
+ });
40
+ }
41
+
42
+ // Stop: total this turn's output tokens and produce the tree shape, then clear
43
+ // the session. Returns null if there's no matching active session.
44
+ export function computeTickShape(payload) {
45
+ const session = readActiveSession();
46
+ const transcript_path = payload?.transcript_path ?? session?.transcript_path;
47
+ if (!session || !transcript_path || session.transcript_path !== transcript_path) {
48
+ return null;
49
+ }
50
+ const tokens = readTurnTokens(transcript_path, session.baselineOffset || 0);
51
+ const shape = tokensToTree(tokens);
52
+ clearActiveSession();
53
+ return { ...shape, type: session.type, variant: session.variant ?? null, x: session.x, tokens };
54
+ }