honeytree 1.2.0 → 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.
@@ -0,0 +1,129 @@
1
+ import { execFile } from "node:child_process";
2
+ import { isLoggedIn, loginWithDevice, getAuth } from "./auth.js";
3
+ import { readForest } from "./state.js";
4
+ import { syncToCloud } from "./sync.js";
5
+ import { asciiTree } from "./ascii-tree.js";
6
+
7
+ const API_URL = process.env.HONEYTREE_API_URL || "https://tryhoney.xyz";
8
+ const POLL_INTERVAL_MS = 4000;
9
+ const POLL_TIMEOUT_MS = 5 * 60 * 1000;
10
+
11
+ // ---- Pure helpers (unit-tested) ----
12
+
13
+ export function formatAvailability({ available, virtualTrees, virtualToNext }) {
14
+ const lines = [];
15
+ lines.push("");
16
+ lines.push(" 50 virtual trees = 1 real tree.");
17
+ if (available >= 1) {
18
+ lines.push(` You have ${available} real tree${available > 1 ? "s" : ""} ready to plant.`);
19
+ } else {
20
+ lines.push(" No real trees ready to plant yet.");
21
+ }
22
+ lines.push(` ${virtualToNext} virtual trees until your next one. (${virtualTrees} planted so far.)`);
23
+ lines.push("");
24
+ return lines;
25
+ }
26
+
27
+ export function findNewCompletedPlantings(baselineIds, currentPlantings) {
28
+ const seen = new Set(baselineIds);
29
+ return (currentPlantings || []).filter((p) => p.status === "completed" && !seen.has(p.id));
30
+ }
31
+
32
+ export function findNewBadgeLabels(baselineSlugs, currentBadges) {
33
+ const seen = new Set(baselineSlugs);
34
+ return (currentBadges || [])
35
+ .filter((b) => b.unlocked && !seen.has(b.slug))
36
+ .map((b) => b.label);
37
+ }
38
+
39
+ // ---- I/O glue ----
40
+
41
+ function openBrowser(url) {
42
+ // Use execFile (no shell) so the URL is passed as a literal argument, never
43
+ // interpolated into a shell command. On Windows, `start` is a cmd builtin.
44
+ const [cmd, args] =
45
+ process.platform === "darwin" ? ["open", [url]] :
46
+ process.platform === "win32" ? ["cmd", ["/c", "start", "", url]] :
47
+ ["xdg-open", [url]];
48
+ execFile(cmd, args, (err) => {
49
+ if (err) console.warn(` (Could not open the browser automatically — visit ${url})`);
50
+ });
51
+ }
52
+
53
+ async function fetchStats() {
54
+ const auth = getAuth();
55
+ if (!auth?.access_token) return null;
56
+ try {
57
+ const res = await fetch(`${API_URL}/api/user/stats`, {
58
+ headers: { Authorization: `Bearer ${auth.access_token}` },
59
+ });
60
+ if (!res.ok) return null;
61
+ return await res.json();
62
+ } catch {
63
+ return null;
64
+ }
65
+ }
66
+
67
+ export async function plant() {
68
+ if (!isLoggedIn()) {
69
+ console.log(" Linking your terminal first...");
70
+ const ok = await loginWithDevice();
71
+ if (!ok) return;
72
+ }
73
+
74
+ // Make sure the server has the latest virtual count.
75
+ const forest = readForest();
76
+ if (forest) await syncToCloud(forest);
77
+
78
+ const stats = await fetchStats();
79
+ if (!stats) {
80
+ console.log(" Could not reach Honeytree. Try again in a moment.");
81
+ return;
82
+ }
83
+
84
+ for (const line of formatAvailability({
85
+ available: stats.available_to_plant ?? 0,
86
+ virtualTrees: stats.virtual_trees ?? 0,
87
+ virtualToNext: stats.virtual_to_next ?? 50,
88
+ })) {
89
+ console.log(line);
90
+ }
91
+
92
+ if ((stats.available_to_plant ?? 0) < 1) {
93
+ return;
94
+ }
95
+
96
+ const baselineIds = (stats.plantings || []).map((p) => p.id);
97
+ const baselineBadgeSlugs = (stats.badges || []).filter((b) => b.unlocked).map((b) => b.slug);
98
+
99
+ const url = `${API_URL}/dashboard?plant=1`;
100
+ console.log(` Opening ${url}`);
101
+ console.log(" Complete your payment in the browser — I'll wait here.");
102
+ openBrowser(url);
103
+
104
+ const deadline = Date.now() + POLL_TIMEOUT_MS;
105
+ while (Date.now() < deadline) {
106
+ await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
107
+ const latest = await fetchStats();
108
+ if (!latest) continue;
109
+ const newlyPlanted = findNewCompletedPlantings(baselineIds, latest.plantings);
110
+ if (newlyPlanted.length === 0) continue;
111
+
112
+ const treesPlanted = newlyPlanted.reduce((s, p) => s + (p.real_trees_planted || 0), 0);
113
+ const newBadges = findNewBadgeLabels(baselineBadgeSlugs, latest.badges);
114
+ const hasBloomer = (latest.badges || []).some((b) => b.slug === "cherry" && b.unlocked);
115
+
116
+ console.log("");
117
+ console.log(asciiTree(hasBloomer));
118
+ console.log(` 🌳 Planted ${treesPlanted} real tree${treesPlanted > 1 ? "s" : ""}!`);
119
+ console.log(` You've now planted ${latest.real_trees_planted} real tree${latest.real_trees_planted > 1 ? "s" : ""} total.`);
120
+ for (const label of newBadges) {
121
+ console.log(` 🏅 ${label} unlocked!`);
122
+ }
123
+ console.log(" A receipt is on its way to your email.");
124
+ console.log("");
125
+ return;
126
+ }
127
+
128
+ console.log(" Still processing — check your dashboard, or run `honeytree plant` again.");
129
+ }
package/src/plant.js CHANGED
@@ -1,7 +1,14 @@
1
- import { getSprite, TREE_TYPES } from "./sprites.js";
1
+ import { getSprite } from "./sprites.js";
2
+ import { getUnlockedVarietyKeys } from "./rewards.js";
3
+ import { unlockedPool, pickSpecies } from "./varieties.js";
2
4
  import { createEmptyForest, readForest, writeForest } from "./state.js";
3
5
  import { findBadgeFile, writeBadgeSVG } from "./badge.js";
4
6
  import { migrateLayout } from "./migrate.js";
7
+ import { isLoggedIn } from "./auth.js";
8
+ import { syncToCloud } from "./sync.js";
9
+ import fs from "node:fs";
10
+ import path from "node:path";
11
+ import os from "node:os";
5
12
 
6
13
  const MIN_GAP = 6;
7
14
  const DEFAULT_WIDTH = 80;
@@ -11,7 +18,7 @@ export function getVirtualWidth(treeCount, termWidth) {
11
18
  return Math.max(termWidth, treeCount * TREE_SPACING);
12
19
  }
13
20
 
14
- function getPlantWidth(forest) {
21
+ export function getPlantWidth(forest) {
15
22
  const termWidth = forest.viewerWidth && forest.viewerWidth > 40
16
23
  ? forest.viewerWidth
17
24
  : DEFAULT_WIDTH;
@@ -19,10 +26,6 @@ function getPlantWidth(forest) {
19
26
  return getVirtualWidth(treeCount, termWidth);
20
27
  }
21
28
 
22
- function randomItem(items) {
23
- return items[Math.floor(Math.random() * items.length)];
24
- }
25
-
26
29
  function randomGrowth() {
27
30
  return Math.round((0.3 + Math.random() * 0.7) * 100) / 100;
28
31
  }
@@ -35,7 +38,7 @@ function occupiedRanges(trees) {
35
38
  });
36
39
  }
37
40
 
38
- function findOpenX(trees, type, growth, width) {
41
+ export function findOpenX(trees, type, growth, width) {
39
42
  const sprite = getSprite(type, growth);
40
43
  const half = Math.floor(sprite.width / 2);
41
44
  const margin = half + 1;
@@ -68,7 +71,7 @@ function daysBetween(dateA, dateB) {
68
71
  return Math.round(Math.abs(b - a) / (24 * 60 * 60 * 1000));
69
72
  }
70
73
 
71
- export async function plant() {
74
+ export async function tick(shape = null) {
72
75
  const forest = readForest() ?? createEmptyForest();
73
76
  const width = getPlantWidth(forest);
74
77
 
@@ -101,17 +104,21 @@ export async function plant() {
101
104
  tree.growth = nudgeGrowth(tree.growth);
102
105
  }
103
106
 
104
- const type = randomItem(TREE_TYPES);
105
- const growth = randomGrowth();
107
+ let type = shape?.type;
108
+ let variant = shape?.variant ?? null;
109
+ if (!type) {
110
+ const species = pickSpecies(unlockedPool(getUnlockedVarietyKeys()));
111
+ type = species.type;
112
+ variant = species.variant ?? null;
113
+ }
114
+ const growth = typeof shape?.growth === "number" ? shape.growth : randomGrowth();
106
115
  const nextId = forest.trees.reduce((max, tree) => Math.max(max, tree.id), 0) + 1;
116
+ const x = typeof shape?.x === "number" ? shape.x : findOpenX(forest.trees, type, growth, width);
107
117
 
108
- forest.trees.push({
109
- id: nextId,
110
- type,
111
- growth,
112
- x: findOpenX(forest.trees, type, growth, width),
113
- plantedAt: new Date().toISOString(),
114
- });
118
+ const tree = { id: nextId, type, growth, x, plantedAt: new Date().toISOString() };
119
+ if (shape?.heightBonus) tree.heightBonus = shape.heightBonus;
120
+ if (variant) tree.variant = variant;
121
+ forest.trees.push(tree);
115
122
  forest.totalPrompts += 1;
116
123
 
117
124
  writeForest(forest);
@@ -121,4 +128,19 @@ export async function plant() {
121
128
  const badgePath = findBadgeFile();
122
129
  if (badgePath) writeBadgeSVG(forest, badgePath);
123
130
  } catch {}
131
+
132
+ // Cloud sync every 10 prompts (fire-and-forget)
133
+ if (isLoggedIn() && forest.totalPrompts % 10 === 0) {
134
+ syncToCloud(forest).catch(() => {});
135
+ }
136
+
137
+ // Milestone check at every 50 prompts — unlocks 1 real tree planting
138
+ if (isLoggedIn() && forest.totalPrompts > 0 && forest.totalPrompts % 50 === 0) {
139
+ const milestoneFile = path.join(os.homedir(), ".honeydew", "milestone.json");
140
+ fs.mkdirSync(path.dirname(milestoneFile), { recursive: true });
141
+ fs.writeFileSync(
142
+ milestoneFile,
143
+ JSON.stringify({ totalPrompts: forest.totalPrompts, timestamp: Date.now() })
144
+ );
145
+ }
124
146
  }
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;
@@ -302,6 +302,34 @@ function buildStatsLine(forest, biome, viewportX = 0, virtualWidth = 0, termWidt
302
302
  );
303
303
  }
304
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
+
305
333
  export function renderFrame(forest, termWidth = 80, options = {}) {
306
334
  const width = Math.max(40, termWidth);
307
335
  const treeCount = forest.trees.length;
@@ -333,11 +361,27 @@ export function renderFrame(forest, termWidth = 80, options = {}) {
333
361
  const windTick = options.windTick ?? null;
334
362
  const treeBaseY = groundStart - 1;
335
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
+
336
368
  for (const tree of forest.trees) {
337
369
  const yOffset = getTreeYOffset(tree.id);
338
- const sprite = (spriteOverride && spriteOverride.treeId === tree.id)
339
- ? spriteOverride.sprite
340
- : getSprite(tree.type, tree.growth);
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
+ }
341
385
  const canopyShiftX = getWindOffset(tree.id, windTick);
342
386
  compositeSprite(buffer, sprite, tree.x, treeBaseY - yOffset, canopyShiftX);
343
387
  }
@@ -374,10 +418,26 @@ export function renderFrame(forest, termWidth = 80, options = {}) {
374
418
 
375
419
  renderGroundDetails(buffer, biome, virtualWidth, groundStart);
376
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
+
377
436
  applyFog(buffer, wilt, virtualWidth);
378
437
 
379
438
  // Slice the viewport from the virtual buffer
380
439
  const lines = [];
440
+
381
441
  for (let y = 0; y < SCENE_HEIGHT - SPACER_ROWS - STATS_ROWS - CTA_ROWS; y += 1) {
382
442
  let line = "";
383
443
  for (let x = viewportX; x < viewportX + width; x += 1) {
@@ -392,12 +452,15 @@ export function renderFrame(forest, termWidth = 80, options = {}) {
392
452
  lines.push(line);
393
453
  }
394
454
 
395
- lines.push("");
396
- lines.push(buildStatsLine(forest, biome, viewportX, virtualWidth, width));
397
- lines.push(
398
- chalk.hex("#555555")(" ← → pan · add your forest to your README → ") +
399
- chalk.hex(STATS_ACCENT)("honeytree badge"),
400
- );
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
+ }
401
464
 
402
465
  return lines.join("\n");
403
466
  }
@@ -427,7 +490,12 @@ export function buildScene(forest, width) {
427
490
  const treeBaseY = groundStart - 1;
428
491
  for (const tree of forest.trees) {
429
492
  const yOffset = getTreeYOffset(tree.id);
430
- 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
+ );
431
499
  }
432
500
 
433
501
  renderGroundDetails(buffer, biome, w, groundStart);
@@ -466,7 +534,12 @@ export function renderPlainText(forest, width = 60) {
466
534
  const treeBaseY = groundStart - 1;
467
535
  for (const tree of forest.trees) {
468
536
  const yOffset = getTreeYOffset(tree.id);
469
- 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
+ );
470
543
  }
471
544
 
472
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",
@@ -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
+ },
279
+ };
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 }),
243
287
  };
244
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,11 +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
  }
303
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
+ }