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.
- package/README.md +42 -52
- package/bin/honeydew.js +72 -9
- package/package.json +6 -3
- 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 +85 -12
- package/src/rewards.js +119 -0
- package/src/session.js +34 -0
- package/src/sprites.js +70 -5
- 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/camera.js +0 -54
- package/src/diffactions.js +0 -32
- package/src/diffpanel.js +0 -83
- package/src/diffparser.js +0 -44
- package/src/diffwatch.js +0 -52
- package/src/markdown.js +0 -77
- package/src/pointcloud.js +0 -193
- package/src/renderer3d.js +0 -135
- package/src/scanner.js +0 -102
- package/src/viewer.js +0 -461
|
@@ -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
|
|
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
|
|
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
|
-
|
|
105
|
-
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
-
|
|
339
|
-
|
|
340
|
-
|
|
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
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
}
|
|
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
|
+
}
|