honeytree 1.2.7 → 1.3.0
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/bin/honeydew.js +2 -5
- package/package.json +1 -1
- package/src/auth.js +46 -0
- package/src/plant.js +2 -2
- package/src/rewards.js +34 -7
- package/src/sync.js +8 -9
- package/src/turn.js +2 -2
- package/src/varieties.js +11 -0
package/bin/honeydew.js
CHANGED
|
@@ -64,15 +64,12 @@ if (command === "init") {
|
|
|
64
64
|
console.log(" Done.");
|
|
65
65
|
} else if (command === "rewards") {
|
|
66
66
|
const { isLoggedIn } = await import("../src/auth.js");
|
|
67
|
-
const { syncRewards, printRewardsStatus } = await import("../src/rewards.js");
|
|
68
|
-
const { readForest } = await import("../src/state.js");
|
|
67
|
+
const { syncRewards, printRewardsStatus, getRewards } = await import("../src/rewards.js");
|
|
69
68
|
if (isLoggedIn()) {
|
|
70
69
|
console.log(" Fetching rewards...");
|
|
71
70
|
await syncRewards();
|
|
72
71
|
}
|
|
73
|
-
|
|
74
|
-
const realTrees = 0; // local doesn't know real trees; printRewardsStatus uses cached data
|
|
75
|
-
printRewardsStatus(realTrees);
|
|
72
|
+
printRewardsStatus(getRewards().realTrees || 0);
|
|
76
73
|
} else if (command === "status") {
|
|
77
74
|
const { getAuth } = await import("../src/auth.js");
|
|
78
75
|
const auth = getAuth();
|
package/package.json
CHANGED
package/src/auth.js
CHANGED
|
@@ -29,6 +29,51 @@ export function isLoggedIn() {
|
|
|
29
29
|
return !!(auth && auth.access_token);
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
// Supabase access tokens live ~1 hour. Trade the stored refresh token for a
|
|
33
|
+
// fresh pair so syncs keep working between logins.
|
|
34
|
+
export async function refreshAuth(apiUrl) {
|
|
35
|
+
apiUrl = apiUrl || process.env.HONEYTREE_API_URL || "https://www.tryhoney.xyz";
|
|
36
|
+
const auth = getAuth();
|
|
37
|
+
if (!auth || !auth.refresh_token) return null;
|
|
38
|
+
try {
|
|
39
|
+
const res = await fetch(`${apiUrl}/api/auth/refresh`, {
|
|
40
|
+
method: "POST",
|
|
41
|
+
headers: { "Content-Type": "application/json" },
|
|
42
|
+
body: JSON.stringify({ refresh_token: auth.refresh_token }),
|
|
43
|
+
});
|
|
44
|
+
if (!res.ok) return null;
|
|
45
|
+
const data = await res.json();
|
|
46
|
+
if (!data.access_token) return null;
|
|
47
|
+
const next = {
|
|
48
|
+
...auth,
|
|
49
|
+
access_token: data.access_token,
|
|
50
|
+
refresh_token: data.refresh_token || auth.refresh_token,
|
|
51
|
+
};
|
|
52
|
+
saveAuth(next);
|
|
53
|
+
return next;
|
|
54
|
+
} catch {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Bearer fetch that retries once through a token refresh on 401.
|
|
60
|
+
// Returns null when not logged in.
|
|
61
|
+
export async function authedFetch(url, options = {}) {
|
|
62
|
+
const auth = getAuth();
|
|
63
|
+
if (!auth || !auth.access_token) return null;
|
|
64
|
+
const doFetch = (token) =>
|
|
65
|
+
fetch(url, {
|
|
66
|
+
...options,
|
|
67
|
+
headers: { ...(options.headers || {}), Authorization: `Bearer ${token}` },
|
|
68
|
+
});
|
|
69
|
+
let res = await doFetch(auth.access_token);
|
|
70
|
+
if (res.status === 401) {
|
|
71
|
+
const refreshed = await refreshAuth();
|
|
72
|
+
if (refreshed) res = await doFetch(refreshed.access_token);
|
|
73
|
+
}
|
|
74
|
+
return res;
|
|
75
|
+
}
|
|
76
|
+
|
|
32
77
|
export async function loginWithDevice(apiUrl = process.env.HONEYTREE_API_URL || "https://www.tryhoney.xyz") {
|
|
33
78
|
const res = await fetch(`${apiUrl}/api/auth/device`, { method: "POST" });
|
|
34
79
|
const contentType = res.headers.get("content-type") || "";
|
|
@@ -74,6 +119,7 @@ export async function loginWithDevice(apiUrl = process.env.HONEYTREE_API_URL ||
|
|
|
74
119
|
if (data.status === "complete") {
|
|
75
120
|
saveAuth({
|
|
76
121
|
access_token: data.access_token,
|
|
122
|
+
refresh_token: data.refresh_token || null,
|
|
77
123
|
user_id: data.user.id,
|
|
78
124
|
username: data.user.username,
|
|
79
125
|
});
|
package/src/plant.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { getSprite } from "./sprites.js";
|
|
2
2
|
import { getUnlockedVarietyKeys } from "./rewards.js";
|
|
3
|
-
import {
|
|
3
|
+
import { eraSpecies, pickSpecies } from "./varieties.js";
|
|
4
4
|
import { createEmptyForest, readForest, writeForest } from "./state.js";
|
|
5
5
|
import { findBadgeFile, writeBadgeSVG } from "./badge.js";
|
|
6
6
|
import { migrateLayout } from "./migrate.js";
|
|
@@ -107,7 +107,7 @@ export async function tick(shape = null) {
|
|
|
107
107
|
let type = shape?.type;
|
|
108
108
|
let variant = shape?.variant ?? null;
|
|
109
109
|
if (!type) {
|
|
110
|
-
const species = pickSpecies(
|
|
110
|
+
const species = pickSpecies(eraSpecies(getUnlockedVarietyKeys()));
|
|
111
111
|
type = species.type;
|
|
112
112
|
variant = species.variant ?? null;
|
|
113
113
|
}
|
package/src/rewards.js
CHANGED
|
@@ -46,16 +46,19 @@ export async function syncRewards(apiUrl) {
|
|
|
46
46
|
if (!auth || !auth.access_token) return null;
|
|
47
47
|
|
|
48
48
|
try {
|
|
49
|
-
const
|
|
50
|
-
|
|
51
|
-
});
|
|
49
|
+
const { authedFetch } = await import("./auth.js");
|
|
50
|
+
// authedFetch refreshes the 1-hour access token and retries once on 401.
|
|
51
|
+
const res = await authedFetch(`${apiUrl}/api/rewards`);
|
|
52
|
+
if (!res || !res.ok) return null;
|
|
52
53
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
const { rewards } = await res.json();
|
|
54
|
+
const { rewards, real_trees_planted } = await res.json();
|
|
56
55
|
const unlocked = rewards.filter((r) => r.unlocked);
|
|
57
56
|
const slugs = unlocked.map((r) => r.slug);
|
|
58
57
|
|
|
58
|
+
const prev = getRewards();
|
|
59
|
+
const prevReal = typeof prev.realTrees === "number" ? prev.realTrees : null;
|
|
60
|
+
const serverReal = typeof real_trees_planted === "number" ? real_trees_planted : 0;
|
|
61
|
+
|
|
59
62
|
const data = {
|
|
60
63
|
badges: unlocked.map((r) => ({ slug: r.slug, label: r.label, description: r.description })),
|
|
61
64
|
cherry: slugs.includes("cherry"),
|
|
@@ -63,17 +66,41 @@ export async function syncRewards(apiUrl) {
|
|
|
63
66
|
oak: slugs.includes("oak"),
|
|
64
67
|
ancient: slugs.includes("ancient"),
|
|
65
68
|
mythic: slugs.includes("mythic"),
|
|
66
|
-
celebrated:
|
|
69
|
+
celebrated: prev.celebrated,
|
|
70
|
+
realTrees: serverReal,
|
|
67
71
|
username: auth.username || "",
|
|
68
72
|
};
|
|
69
73
|
|
|
70
74
|
saveRewards(data);
|
|
75
|
+
|
|
76
|
+
// A real tree was paid for since we last looked: the old forest is cleared
|
|
77
|
+
// and a fresh era begins (all new trees take the latest unlocked variety).
|
|
78
|
+
// The cumulative prompt counter survives, so "virtual trees grown" on the
|
|
79
|
+
// site keeps its total. First sync after upgrade only sets the baseline.
|
|
80
|
+
if (prevReal !== null && serverReal > prevReal) {
|
|
81
|
+
await wipeForestForNewEra();
|
|
82
|
+
}
|
|
83
|
+
|
|
71
84
|
return data;
|
|
72
85
|
} catch {
|
|
73
86
|
return null;
|
|
74
87
|
}
|
|
75
88
|
}
|
|
76
89
|
|
|
90
|
+
// Clear all trees locally and push the empty forest up so the dashboard
|
|
91
|
+
// mirrors the reset. totalPrompts/streak are kept.
|
|
92
|
+
async function wipeForestForNewEra() {
|
|
93
|
+
const { readForest, writeForest } = await import("./state.js");
|
|
94
|
+
const forest = readForest();
|
|
95
|
+
if (!forest) return;
|
|
96
|
+
forest.trees = [];
|
|
97
|
+
writeForest(forest);
|
|
98
|
+
try {
|
|
99
|
+
const { syncToCloud } = await import("./sync.js");
|
|
100
|
+
await syncToCloud(forest);
|
|
101
|
+
} catch {}
|
|
102
|
+
}
|
|
103
|
+
|
|
77
104
|
// Print rewards status to console
|
|
78
105
|
export function printRewardsStatus(realTreesPlanted = 0) {
|
|
79
106
|
const rewards = getRewards();
|
package/src/sync.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { getAuth, saveAuth } from "./auth.js";
|
|
1
|
+
import { getAuth, saveAuth, authedFetch } from "./auth.js";
|
|
2
2
|
import { readForest } from "./state.js";
|
|
3
3
|
|
|
4
4
|
const API_URL = process.env.HONEYTREE_API_URL || "https://www.tryhoney.xyz";
|
|
@@ -18,12 +18,10 @@ export async function syncToCloud(forest) {
|
|
|
18
18
|
}));
|
|
19
19
|
|
|
20
20
|
try {
|
|
21
|
-
|
|
21
|
+
// authedFetch refreshes the access token and retries once on 401.
|
|
22
|
+
const res = await authedFetch(`${API_URL}/api/sync`, {
|
|
22
23
|
method: "POST",
|
|
23
|
-
headers: {
|
|
24
|
-
"Content-Type": "application/json",
|
|
25
|
-
Authorization: `Bearer ${auth.access_token}`,
|
|
26
|
-
},
|
|
24
|
+
headers: { "Content-Type": "application/json" },
|
|
27
25
|
body: JSON.stringify({
|
|
28
26
|
count: forest.totalPrompts,
|
|
29
27
|
streak: forest.streak || 0,
|
|
@@ -31,9 +29,10 @@ export async function syncToCloud(forest) {
|
|
|
31
29
|
}),
|
|
32
30
|
});
|
|
33
31
|
|
|
34
|
-
if (res.ok) {
|
|
35
|
-
|
|
36
|
-
|
|
32
|
+
if (res && res.ok) {
|
|
33
|
+
const fresh = getAuth() || auth;
|
|
34
|
+
fresh.lastSyncedPrompts = forest.totalPrompts;
|
|
35
|
+
saveAuth(fresh);
|
|
37
36
|
}
|
|
38
37
|
} catch {
|
|
39
38
|
// Silently fail — sync is best-effort
|
package/src/turn.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import { readForest, createEmptyForest } from "./state.js";
|
|
3
3
|
import { getUnlockedVarietyKeys } from "./rewards.js";
|
|
4
|
-
import {
|
|
4
|
+
import { eraSpecies, pickSpecies } from "./varieties.js";
|
|
5
5
|
import { getPlantWidth, findOpenX } from "./plant.js";
|
|
6
6
|
import { writeActiveSession, readActiveSession, clearActiveSession } from "./session.js";
|
|
7
7
|
import { readTurnTokens } from "./transcript.js";
|
|
@@ -22,7 +22,7 @@ export function startTurn(payload) {
|
|
|
22
22
|
if (!transcript_path) return;
|
|
23
23
|
|
|
24
24
|
const forest = readForest() ?? createEmptyForest();
|
|
25
|
-
const species = pickSpecies(
|
|
25
|
+
const species = pickSpecies(eraSpecies(getUnlockedVarietyKeys()));
|
|
26
26
|
const type = species.type;
|
|
27
27
|
const variant = species.variant ?? null;
|
|
28
28
|
const width = getPlantWidth(forest);
|
package/src/varieties.js
CHANGED
|
@@ -11,6 +11,17 @@ export const VARIETIES = [
|
|
|
11
11
|
{ key: "mythic", threshold: 10, label: "Mythic", species: [{ type: "mythic" }] },
|
|
12
12
|
];
|
|
13
13
|
|
|
14
|
+
// The forest grows one variety at a time: each real-tree payment wipes the
|
|
15
|
+
// forest and starts a new era where every tree takes the LATEST unlocked
|
|
16
|
+
// variety. Standard (birch/willow) before any unlock.
|
|
17
|
+
export function eraSpecies(unlockedKeys = []) {
|
|
18
|
+
const keys = new Set(unlockedKeys);
|
|
19
|
+
for (let i = VARIETIES.length - 1; i >= 1; i--) {
|
|
20
|
+
if (keys.has(VARIETIES[i].key)) return VARIETIES[i].species;
|
|
21
|
+
}
|
|
22
|
+
return VARIETIES[0].species;
|
|
23
|
+
}
|
|
24
|
+
|
|
14
25
|
// Build the species pool from the set of unlocked variety keys.
|
|
15
26
|
// `standard` is always included.
|
|
16
27
|
export function unlockedPool(unlockedKeys = []) {
|