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/LICENSE +21 -0
- package/README.md +39 -147
- package/bin/honeydew.js +70 -6
- package/package.json +11 -8
- package/src/animation-keyframes.js +890 -0
- package/src/animation.js +151 -0
- package/src/ascii-tree.js +21 -0
- package/src/auth.js +83 -0
- package/src/components/ForestApp.js +404 -0
- package/src/components/ForestScene.js +26 -0
- package/src/components/LiveTree.js +14 -0
- package/src/components/StatsBar.js +70 -0
- package/src/components/TreeInfoPopup.js +28 -0
- package/src/components/UnlockCelebration.js +40 -0
- package/src/growth.js +32 -0
- package/src/init.js +48 -2
- package/src/plant-real.js +129 -0
- package/src/plant.js +39 -17
- package/src/renderer.js +131 -12
- package/src/rewards.js +119 -0
- package/src/session.js +34 -0
- package/src/sprites.js +72 -6
- package/src/stdin.js +28 -0
- package/src/sync.js +44 -0
- package/src/transcript.js +62 -0
- package/src/turn.js +54 -0
- package/src/varieties.js +30 -0
- package/src/viewer2d.js +24 -0
- package/src/markdown.js +0 -77
- package/src/viewer.js +0 -234
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
|
-
|
|
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
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
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
|
-
|
|
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
|
+
}
|