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
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import React, { useMemo } from "react";
|
|
2
|
+
import { Box, Text } from "ink";
|
|
3
|
+
|
|
4
|
+
import { getStatsData } from "../renderer.js";
|
|
5
|
+
import { getVirtualWidth } from "../plant.js";
|
|
6
|
+
|
|
7
|
+
const h = React.createElement;
|
|
8
|
+
|
|
9
|
+
const STATS_ACCENT = "#f5a50b";
|
|
10
|
+
const STATS_TEXT = "#8e8a84";
|
|
11
|
+
const STATS_WARN = "#c4653a";
|
|
12
|
+
const STREAK_COLOR = "#e8a33a";
|
|
13
|
+
const BAR_FILL = "#6cb95e";
|
|
14
|
+
const BAR_EMPTY = "#3d3d3d";
|
|
15
|
+
|
|
16
|
+
export default function StatsBar({ forest, viewportX, termWidth, rewards }) {
|
|
17
|
+
const vw = getVirtualWidth(forest.trees.length, termWidth);
|
|
18
|
+
const stats = useMemo(
|
|
19
|
+
() => getStatsData(forest, viewportX, vw, termWidth),
|
|
20
|
+
[forest, viewportX, vw, termWidth],
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
const barWidth = 12;
|
|
24
|
+
const filledWidth = Math.max(0, Math.min(barWidth, Math.round(stats.progress * barWidth)));
|
|
25
|
+
|
|
26
|
+
// Minimap
|
|
27
|
+
let minimap = null;
|
|
28
|
+
if (stats.virtualWidth > stats.termWidth) {
|
|
29
|
+
const mapWidth = 12;
|
|
30
|
+
const viewFraction = stats.termWidth / stats.virtualWidth;
|
|
31
|
+
const thumbWidth = Math.max(1, Math.round(viewFraction * mapWidth));
|
|
32
|
+
const maxOffset = stats.virtualWidth - stats.termWidth;
|
|
33
|
+
const thumbPos = maxOffset > 0
|
|
34
|
+
? Math.round((stats.viewportX / maxOffset) * (mapWidth - thumbWidth))
|
|
35
|
+
: 0;
|
|
36
|
+
const mapBar =
|
|
37
|
+
"─".repeat(thumbPos) +
|
|
38
|
+
"═".repeat(thumbWidth) +
|
|
39
|
+
"─".repeat(mapWidth - thumbPos - thumbWidth);
|
|
40
|
+
minimap = h(Text, null,
|
|
41
|
+
h(Text, { color: STATS_TEXT }, " ["),
|
|
42
|
+
h(Text, { color: BAR_FILL }, mapBar),
|
|
43
|
+
h(Text, { color: STATS_TEXT }, "]"),
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const streakSegment = stats.wilt > 0
|
|
48
|
+
? h(Text, { color: STATS_WARN }, `wilting (${stats.idleDays}d idle)`)
|
|
49
|
+
: stats.streak > 0
|
|
50
|
+
? h(Text, { color: STREAK_COLOR }, `${stats.streak}-day streak`)
|
|
51
|
+
: h(Text, { color: STATS_TEXT }, "no streak");
|
|
52
|
+
|
|
53
|
+
return h(Box, { flexDirection: "column" },
|
|
54
|
+
h(Box, null,
|
|
55
|
+
h(Text, { color: STATS_ACCENT }, " honeytree"),
|
|
56
|
+
h(Text, { color: STATS_TEXT }, ` · ${stats.treeCount} tree${stats.treeCount === 1 ? "" : "s"} · `),
|
|
57
|
+
streakSegment,
|
|
58
|
+
h(Text, { color: STATS_TEXT }, " · "),
|
|
59
|
+
h(Text, { color: BAR_FILL }, "█".repeat(filledWidth)),
|
|
60
|
+
h(Text, { color: BAR_EMPTY }, "░".repeat(barWidth - filledWidth)),
|
|
61
|
+
h(Text, { color: STATS_TEXT }, ` next: ${stats.nextTreeType}`),
|
|
62
|
+
h(Text, { color: "#555555" }, ` [${stats.biomeName}]`),
|
|
63
|
+
minimap,
|
|
64
|
+
),
|
|
65
|
+
h(Box, null,
|
|
66
|
+
h(Text, { color: "#555555" }, " ← → pan · ↑↓ select tree · enter info · q quit · "),
|
|
67
|
+
h(Text, { color: STATS_ACCENT }, "honeytree badge"),
|
|
68
|
+
),
|
|
69
|
+
);
|
|
70
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { Box, Text } from "ink";
|
|
3
|
+
|
|
4
|
+
const h = React.createElement;
|
|
5
|
+
|
|
6
|
+
export default function TreeInfoPopup({ tree }) {
|
|
7
|
+
if (!tree) return null;
|
|
8
|
+
|
|
9
|
+
const growthPct = Math.round(tree.growth * 100);
|
|
10
|
+
const plantedDate = tree.plantedAt
|
|
11
|
+
? new Date(tree.plantedAt).toLocaleDateString()
|
|
12
|
+
: "unknown";
|
|
13
|
+
|
|
14
|
+
let age = "";
|
|
15
|
+
if (tree.plantedAt) {
|
|
16
|
+
const days = Math.floor((Date.now() - new Date(tree.plantedAt).getTime()) / (24 * 60 * 60 * 1000));
|
|
17
|
+
age = days === 0 ? "today" : days === 1 ? "1 day" : `${days} days`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return h(Box, { flexDirection: "column", borderStyle: "round", borderColor: "#f5a50b", paddingX: 1 },
|
|
21
|
+
h(Text, { bold: true, color: "#f5a50b" }, ` ${tree.type} `),
|
|
22
|
+
h(Text, { color: "#8e8a84" }, ` Growth: ${growthPct}%`),
|
|
23
|
+
h(Text, { color: "#8e8a84" }, ` Planted: ${plantedDate}`),
|
|
24
|
+
age ? h(Text, { color: "#8e8a84" }, ` Age: ${age}`) : null,
|
|
25
|
+
h(Text, { color: "#8e8a84" }, ` ID: ${tree.id}`),
|
|
26
|
+
h(Text, { dimColor: true }, ` [esc] close`),
|
|
27
|
+
);
|
|
28
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { Box, Text, useInput } from "ink";
|
|
3
|
+
import { VARIETIES } from "../varieties.js";
|
|
4
|
+
|
|
5
|
+
const h = React.createElement;
|
|
6
|
+
|
|
7
|
+
// ASCII reveal art per variety (screenshot-worthy, terminal-safe).
|
|
8
|
+
const ART = {
|
|
9
|
+
cherry: " .::.\n.:(@@):.\n :(@@): \n || ",
|
|
10
|
+
pine: " ^\n /^\\\n /^^^\\\n /^^^^^\\\n ||| ",
|
|
11
|
+
oak: " ___\n (###)\n (#####)\n (###)\n ||| ",
|
|
12
|
+
ancient: " *___*\n (#####)\n(#######)\n (#####)\n ||||| ",
|
|
13
|
+
mythic: " *. .*\n .*###*.\n*#######*\n .*###*.\n ||||| ",
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export function celebrationFor(key) {
|
|
17
|
+
const v = VARIETIES.find((x) => x.key === key);
|
|
18
|
+
return {
|
|
19
|
+
label: v ? v.label : key,
|
|
20
|
+
art: ART[key] || " ###\n (###)\n |||",
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Full-screen celebration. Calls onDismiss() when the user presses any key.
|
|
25
|
+
export default function UnlockCelebration({ varietyKey, onDismiss }) {
|
|
26
|
+
const { label, art } = celebrationFor(varietyKey);
|
|
27
|
+
useInput(() => onDismiss());
|
|
28
|
+
|
|
29
|
+
return h(
|
|
30
|
+
Box,
|
|
31
|
+
{ flexDirection: "column", alignItems: "center", justifyContent: "center", height: 18, width: "100%" },
|
|
32
|
+
h(Text, { color: "#f5a50b", bold: true }, "✦ NEW TREE VARIETY UNLOCKED ✦"),
|
|
33
|
+
h(Box, { height: 1 }),
|
|
34
|
+
h(Text, { color: "#b388ff" }, art),
|
|
35
|
+
h(Box, { height: 1 }),
|
|
36
|
+
h(Text, { bold: true }, label),
|
|
37
|
+
h(Box, { height: 1 }),
|
|
38
|
+
h(Text, { dimColor: true }, "Screenshot this — then press any key to enter your forest")
|
|
39
|
+
);
|
|
40
|
+
}
|
package/src/growth.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// Pure mapping from a turn's output-token count to a tree's shape.
|
|
2
|
+
// Tunable: change these to reshape the forest's "honesty" curve.
|
|
3
|
+
export const GROWTH = {
|
|
4
|
+
SAPLING_MAX: 250,
|
|
5
|
+
FULL_TOKENS: 1500,
|
|
6
|
+
MONSTER_TOKENS: 4000,
|
|
7
|
+
MAX_HEIGHT_BONUS: 4,
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export function tokensToTree(tokens) {
|
|
11
|
+
const n = Number.isFinite(tokens) && tokens > 0 ? tokens : 0;
|
|
12
|
+
|
|
13
|
+
let growth;
|
|
14
|
+
if (n < GROWTH.SAPLING_MAX) {
|
|
15
|
+
growth = 0.15 + (n / GROWTH.SAPLING_MAX) * 0.2; // 0.15 .. 0.35
|
|
16
|
+
} else if (n < GROWTH.FULL_TOKENS) {
|
|
17
|
+
const r = (n - GROWTH.SAPLING_MAX) / (GROWTH.FULL_TOKENS - GROWTH.SAPLING_MAX);
|
|
18
|
+
growth = 0.35 + r * 0.65; // 0.35 .. 1.0
|
|
19
|
+
} else {
|
|
20
|
+
growth = 1;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
let heightBonus = 0;
|
|
24
|
+
if (n > GROWTH.FULL_TOKENS) {
|
|
25
|
+
const over =
|
|
26
|
+
(n - GROWTH.FULL_TOKENS) / (GROWTH.MONSTER_TOKENS - GROWTH.FULL_TOKENS);
|
|
27
|
+
heightBonus = Math.min(GROWTH.MAX_HEIGHT_BONUS, Math.max(1, Math.ceil(over * GROWTH.MAX_HEIGHT_BONUS)));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Variant (species/look) is owned by the variety system, not token count.
|
|
31
|
+
return { growth: Math.min(1, Math.round(growth * 100) / 100), heightBonus, variant: null };
|
|
32
|
+
}
|
package/src/init.js
CHANGED
|
@@ -20,7 +20,17 @@ const HONEYDEW_STOP_HOOK = {
|
|
|
20
20
|
hooks: [
|
|
21
21
|
{
|
|
22
22
|
type: "command",
|
|
23
|
-
command: "honeytree
|
|
23
|
+
command: "honeytree __tick",
|
|
24
|
+
},
|
|
25
|
+
],
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const HONEYDEW_SESSION_HOOK = {
|
|
29
|
+
matcher: "",
|
|
30
|
+
hooks: [
|
|
31
|
+
{
|
|
32
|
+
type: "command",
|
|
33
|
+
command: "honeytree __session",
|
|
24
34
|
},
|
|
25
35
|
],
|
|
26
36
|
};
|
|
@@ -28,11 +38,34 @@ const HONEYDEW_STOP_HOOK = {
|
|
|
28
38
|
function hasHoneydewHook(settings) {
|
|
29
39
|
return (
|
|
30
40
|
settings?.hooks?.Stop?.some((entry) =>
|
|
31
|
-
entry?.hooks?.some((hook) => hook?.command === "honeytree
|
|
41
|
+
entry?.hooks?.some((hook) => hook?.command === "honeytree __tick"),
|
|
32
42
|
) ?? false
|
|
33
43
|
);
|
|
34
44
|
}
|
|
35
45
|
|
|
46
|
+
function hasSessionHook(settings) {
|
|
47
|
+
return (
|
|
48
|
+
settings?.hooks?.UserPromptSubmit?.some((entry) =>
|
|
49
|
+
entry?.hooks?.some((hook) => hook?.command === "honeytree __session"),
|
|
50
|
+
) ?? false
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Rewrite any legacy `honeytree plant` Stop hook to the new hidden `__tick`.
|
|
55
|
+
// Returns true if anything changed.
|
|
56
|
+
function migrateLegacyHook(settings) {
|
|
57
|
+
let changed = false;
|
|
58
|
+
for (const entry of settings?.hooks?.Stop ?? []) {
|
|
59
|
+
for (const hook of entry?.hooks ?? []) {
|
|
60
|
+
if (hook?.command === "honeytree plant") {
|
|
61
|
+
hook.command = "honeytree __tick";
|
|
62
|
+
changed = true;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return changed;
|
|
67
|
+
}
|
|
68
|
+
|
|
36
69
|
export async function init() {
|
|
37
70
|
const honeydewDir = getHoneydewDir();
|
|
38
71
|
fs.mkdirSync(honeydewDir, { recursive: true });
|
|
@@ -72,6 +105,12 @@ export async function init() {
|
|
|
72
105
|
settings.hooks ??= {};
|
|
73
106
|
settings.hooks.Stop ??= [];
|
|
74
107
|
|
|
108
|
+
const migrated = migrateLegacyHook(settings);
|
|
109
|
+
if (migrated) {
|
|
110
|
+
fs.writeFileSync(settingsPath, `${JSON.stringify(settings, null, 2)}\n`);
|
|
111
|
+
console.log(`Migrated legacy honeytree hook to __tick in ${settingsPath}`);
|
|
112
|
+
}
|
|
113
|
+
|
|
75
114
|
if (hasHoneydewHook(settings)) {
|
|
76
115
|
console.log(`Claude Code hook already configured in ${settingsPath}`);
|
|
77
116
|
} else {
|
|
@@ -80,6 +119,13 @@ export async function init() {
|
|
|
80
119
|
console.log(`Added honeytree Stop hook to ${settingsPath}`);
|
|
81
120
|
}
|
|
82
121
|
|
|
122
|
+
settings.hooks.UserPromptSubmit ??= [];
|
|
123
|
+
if (!hasSessionHook(settings)) {
|
|
124
|
+
settings.hooks.UserPromptSubmit.push(HONEYDEW_SESSION_HOOK);
|
|
125
|
+
fs.writeFileSync(settingsPath, `${JSON.stringify(settings, null, 2)}\n`);
|
|
126
|
+
console.log(`Added honeytree UserPromptSubmit hook to ${settingsPath}`);
|
|
127
|
+
}
|
|
128
|
+
|
|
83
129
|
console.log("");
|
|
84
130
|
console.log("Setup complete.");
|
|
85
131
|
console.log("Run `honeytree` in a separate terminal to watch the forest grow.");
|
|
@@ -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
|
}
|