honeytree 1.1.2 → 1.1.4
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 +19 -1
- package/package.json +1 -1
- package/src/commands/watch.js +44 -3
- package/src/core/biomeDetector.js +126 -0
- package/src/core/biomeMigration.js +22 -0
- package/src/core/biomes/aiml.js +36 -0
- package/src/core/biomes/backend.js +35 -0
- package/src/core/biomes/docs.js +35 -0
- package/src/core/biomes/frontend.js +35 -0
- package/src/core/biomes/general.js +34 -0
- package/src/core/biomes/index.js +54 -0
- package/src/core/biomes/infra.js +35 -0
- package/src/core/environment.js +14 -2
- package/src/core/progression.js +32 -10
- package/src/core/sessionDetector.js +92 -0
- package/src/core/sprites.js +1063 -83
- package/src/core/state.js +6 -0
- package/src/renderers/terminal.js +159 -11
package/bin/honeydew.js
CHANGED
|
@@ -1,6 +1,18 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
3
5
|
const command = process.argv[2];
|
|
6
|
+
const invokedAs = path.basename(process.argv[1] || "honeytree");
|
|
7
|
+
|
|
8
|
+
function printUsage() {
|
|
9
|
+
console.error("Usage:");
|
|
10
|
+
console.error(" honeytree show your forest");
|
|
11
|
+
console.error(" honeytree init create or migrate your forest");
|
|
12
|
+
console.error(" honeytree watch open the terminal forest");
|
|
13
|
+
console.error(" honeytree stats show a non-TUI summary");
|
|
14
|
+
console.error(" honeytree export write honeytree-badge.svg and FOREST.md");
|
|
15
|
+
}
|
|
4
16
|
|
|
5
17
|
if (command === "init") {
|
|
6
18
|
const { init } = await import("../src/commands/init.js");
|
|
@@ -11,11 +23,17 @@ if (command === "init") {
|
|
|
11
23
|
} else if (command === "watch" || !command) {
|
|
12
24
|
const { watch } = await import("../src/commands/watch.js");
|
|
13
25
|
await watch();
|
|
26
|
+
} else if (command === "stats") {
|
|
27
|
+
const { stats } = await import("../src/commands/stats.js");
|
|
28
|
+
await stats();
|
|
14
29
|
} else if (command === "plant") {
|
|
15
30
|
const { plant } = await import("../src/commands/plant.js");
|
|
16
31
|
await plant();
|
|
17
32
|
} else {
|
|
18
33
|
console.error(`Unknown command: ${command}`);
|
|
19
|
-
|
|
34
|
+
if (invokedAs === "honeydew") {
|
|
35
|
+
console.error("Run `honeydew watch` or `honeytree init`.");
|
|
36
|
+
}
|
|
37
|
+
printUsage();
|
|
20
38
|
process.exit(1);
|
|
21
39
|
}
|
package/package.json
CHANGED
package/src/commands/watch.js
CHANGED
|
@@ -2,14 +2,18 @@ import React, { useEffect, useMemo, useState } from "react";
|
|
|
2
2
|
import { Box, Text, render, useApp, useInput, useStdout } from "ink";
|
|
3
3
|
|
|
4
4
|
import { applyActiveMinutes, applyCommit, applyFileSave } from "../core/progression.js";
|
|
5
|
+
import { detectBiome } from "../core/biomeDetector.js";
|
|
6
|
+
import { migrateTreesToBiome } from "../core/biomeMigration.js";
|
|
7
|
+
import { classifyDiffStat, classifyFileList, parseDiffStatLine, updateSessionWindow } from "../core/sessionDetector.js";
|
|
5
8
|
import { ensureState, readState, updateState } from "../core/state.js";
|
|
6
9
|
import { renderTerminalFrame } from "../renderers/terminal.js";
|
|
7
10
|
import { createActivityTracker } from "../tracker/activity.js";
|
|
8
11
|
import { startFileTracker } from "../tracker/files.js";
|
|
9
12
|
import { startGitTracker } from "../tracker/git.js";
|
|
10
13
|
|
|
11
|
-
const FRAME_MS =
|
|
12
|
-
const
|
|
14
|
+
const FRAME_MS = 150;
|
|
15
|
+
const STATE_SYNC_MS = 1_000;
|
|
16
|
+
const GIT_POLL_MS = 30_000;
|
|
13
17
|
const h = React.createElement;
|
|
14
18
|
|
|
15
19
|
function buildCommandHint(width) {
|
|
@@ -29,6 +33,8 @@ function ForestWatchApp() {
|
|
|
29
33
|
const [width, setWidth] = useState(stdout.columns || 80);
|
|
30
34
|
const [height, setHeight] = useState(stdout.rows || 24);
|
|
31
35
|
|
|
36
|
+
const sessionWindow = useMemo(() => [], []);
|
|
37
|
+
|
|
32
38
|
useInput((input, key) => {
|
|
33
39
|
if (input === "q" || key.escape || key.ctrl && input === "c") {
|
|
34
40
|
exit();
|
|
@@ -57,7 +63,7 @@ function ForestWatchApp() {
|
|
|
57
63
|
if (latest) {
|
|
58
64
|
setState(latest);
|
|
59
65
|
}
|
|
60
|
-
},
|
|
66
|
+
}, STATE_SYNC_MS);
|
|
61
67
|
return () => clearInterval(syncTimer);
|
|
62
68
|
}, []);
|
|
63
69
|
|
|
@@ -82,9 +88,33 @@ function ForestWatchApp() {
|
|
|
82
88
|
const gitTracker = startGitTracker({
|
|
83
89
|
cwd: process.cwd(),
|
|
84
90
|
lastCommitHash: state.last_commit_hash,
|
|
91
|
+
pollMs: GIT_POLL_MS,
|
|
85
92
|
async onCommit(commitHash) {
|
|
86
93
|
const nextState = updateState((draft) => applyCommit(draft, { commitHash }));
|
|
87
94
|
setState(nextState);
|
|
95
|
+
|
|
96
|
+
// Session detection: classify this commit
|
|
97
|
+
try {
|
|
98
|
+
const git = (await import("simple-git")).default({ baseDir: process.cwd() });
|
|
99
|
+
const diffStat = await git.diff(["--stat", "HEAD~1..HEAD"]);
|
|
100
|
+
const diffFiles = await git.diff(["--name-only", "HEAD~1..HEAD"]);
|
|
101
|
+
const statLine = diffStat.split("\n").filter((l) => l.includes("changed")).pop() || "";
|
|
102
|
+
const parsed = parseDiffStatLine(statLine);
|
|
103
|
+
const fileList = diffFiles.split("\n").filter(Boolean);
|
|
104
|
+
const fileInfo = classifyFileList(fileList);
|
|
105
|
+
const sessionType = classifyDiffStat({ ...parsed, ...fileInfo });
|
|
106
|
+
|
|
107
|
+
sessionWindow.push(sessionType);
|
|
108
|
+
if (sessionWindow.length > 5) sessionWindow.shift();
|
|
109
|
+
|
|
110
|
+
const activeSession = updateSessionWindow(sessionWindow);
|
|
111
|
+
updateState((draft) => {
|
|
112
|
+
draft.activeSession = activeSession;
|
|
113
|
+
return draft;
|
|
114
|
+
});
|
|
115
|
+
} catch {
|
|
116
|
+
// Session detection failure is non-critical
|
|
117
|
+
}
|
|
88
118
|
},
|
|
89
119
|
});
|
|
90
120
|
|
|
@@ -112,6 +142,17 @@ function ForestWatchApp() {
|
|
|
112
142
|
}
|
|
113
143
|
|
|
114
144
|
export async function watch() {
|
|
145
|
+
// Detect and persist biome on startup, migrating trees if biome changed
|
|
146
|
+
updateState((draft) => {
|
|
147
|
+
const previousBiome = draft.biome || "general";
|
|
148
|
+
const detected = detectBiome(process.cwd());
|
|
149
|
+
if (draft.biome !== detected) {
|
|
150
|
+
draft.trees = migrateTreesToBiome(draft.trees, previousBiome, detected);
|
|
151
|
+
draft.biome = detected;
|
|
152
|
+
}
|
|
153
|
+
return draft;
|
|
154
|
+
});
|
|
155
|
+
|
|
115
156
|
ensureState();
|
|
116
157
|
const app = render(h(ForestWatchApp));
|
|
117
158
|
await app.waitUntilExit();
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
const EXTENSION_MAP = {
|
|
5
|
+
frontend: new Set([".tsx", ".jsx", ".vue", ".svelte", ".css", ".scss", ".sass", ".less", ".html", ".ejs"]),
|
|
6
|
+
backend: new Set([".py", ".java", ".go", ".rs", ".rb", ".php", ".cs", ".scala", ".kt", ".ex", ".exs"]),
|
|
7
|
+
aiml: new Set([".ipynb", ".onnx", ".pkl", ".h5", ".safetensors"]),
|
|
8
|
+
infra: new Set([".tf", ".yaml", ".yml", ".toml", ".sh", ".bash", ".nix", ".hcl"]),
|
|
9
|
+
docs: new Set([".md", ".mdx", ".rst", ".txt", ".adoc", ".wiki"]),
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const INFRA_BASENAMES = new Set(["Dockerfile", "Makefile", "docker-compose.yml", "docker-compose.yaml"]);
|
|
13
|
+
|
|
14
|
+
const ML_DEPS = ["torch", "tensorflow", "transformers", "sklearn", "pandas", "numpy"];
|
|
15
|
+
|
|
16
|
+
const THRESHOLD = 0.4;
|
|
17
|
+
|
|
18
|
+
function categorizeFile(filePath) {
|
|
19
|
+
const basename = path.basename(filePath);
|
|
20
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
21
|
+
|
|
22
|
+
if (INFRA_BASENAMES.has(basename)) return "infra";
|
|
23
|
+
|
|
24
|
+
for (const [category, extensions] of Object.entries(EXTENSION_MAP)) {
|
|
25
|
+
if (extensions.has(ext)) return category;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function classifyFiles(filePaths) {
|
|
32
|
+
const counts = { frontend: 0, backend: 0, aiml: 0, infra: 0, docs: 0 };
|
|
33
|
+
let hasNotebook = false;
|
|
34
|
+
|
|
35
|
+
for (const filePath of filePaths) {
|
|
36
|
+
const category = categorizeFile(filePath);
|
|
37
|
+
if (category) {
|
|
38
|
+
counts[category] += 1;
|
|
39
|
+
}
|
|
40
|
+
if (path.extname(filePath).toLowerCase() === ".ipynb") {
|
|
41
|
+
hasNotebook = true;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// AI/ML special case: .py in model/train/data dirs + notebook presence
|
|
46
|
+
if (hasNotebook && counts.backend > 0) {
|
|
47
|
+
counts.aiml += counts.backend;
|
|
48
|
+
counts.backend = 0;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const total = Object.values(counts).reduce((sum, n) => sum + n, 0);
|
|
52
|
+
if (total === 0) return "general";
|
|
53
|
+
|
|
54
|
+
let best = "general";
|
|
55
|
+
let bestPct = 0;
|
|
56
|
+
for (const [category, count] of Object.entries(counts)) {
|
|
57
|
+
const pct = count / total;
|
|
58
|
+
if (pct > bestPct) {
|
|
59
|
+
bestPct = pct;
|
|
60
|
+
best = category;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return bestPct > THRESHOLD ? best : "general";
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function walkDir(dir) {
|
|
68
|
+
const results = [];
|
|
69
|
+
const SKIP_DIRS = new Set(["node_modules", ".git", ".honeytree", ".honeydew", "dist", "build", "__pycache__", ".next", ".vscode"]);
|
|
70
|
+
|
|
71
|
+
function recurse(currentDir) {
|
|
72
|
+
let entries;
|
|
73
|
+
try {
|
|
74
|
+
entries = fs.readdirSync(currentDir, { withFileTypes: true });
|
|
75
|
+
} catch {
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
for (const entry of entries) {
|
|
79
|
+
if (entry.name.startsWith(".") && entry.isDirectory()) continue;
|
|
80
|
+
if (SKIP_DIRS.has(entry.name) && entry.isDirectory()) continue;
|
|
81
|
+
|
|
82
|
+
const fullPath = path.join(currentDir, entry.name);
|
|
83
|
+
if (entry.isDirectory()) {
|
|
84
|
+
recurse(fullPath);
|
|
85
|
+
} else {
|
|
86
|
+
results.push(path.relative(dir, fullPath));
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
recurse(dir);
|
|
92
|
+
return results;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function checkMlDeps(repoDir) {
|
|
96
|
+
const candidates = ["requirements.txt", "pyproject.toml"];
|
|
97
|
+
for (const candidate of candidates) {
|
|
98
|
+
const filePath = path.join(repoDir, candidate);
|
|
99
|
+
try {
|
|
100
|
+
const content = fs.readFileSync(filePath, "utf8");
|
|
101
|
+
if (ML_DEPS.some((dep) => content.includes(dep))) {
|
|
102
|
+
return true;
|
|
103
|
+
}
|
|
104
|
+
} catch {
|
|
105
|
+
// file doesn't exist, skip
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function detectBiome(repoDir = process.cwd()) {
|
|
112
|
+
const files = walkDir(repoDir);
|
|
113
|
+
const hasMlDeps = checkMlDeps(repoDir);
|
|
114
|
+
|
|
115
|
+
if (hasMlDeps) {
|
|
116
|
+
const adjusted = files.map((f) => {
|
|
117
|
+
if (path.extname(f) === ".py") {
|
|
118
|
+
return f.replace(/\.py$/, ".ipynb");
|
|
119
|
+
}
|
|
120
|
+
return f;
|
|
121
|
+
});
|
|
122
|
+
return classifyFiles(adjusted);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return classifyFiles(files);
|
|
126
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { getBiomeConfig } from "./biomes/index.js";
|
|
2
|
+
|
|
3
|
+
function findTierIndex(species, biomeKey) {
|
|
4
|
+
const config = getBiomeConfig(biomeKey);
|
|
5
|
+
return config.trees.findIndex((t) => t.species === species);
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function migrateTreesToBiome(trees, fromBiome, toBiome) {
|
|
9
|
+
if (fromBiome === toBiome) return trees;
|
|
10
|
+
|
|
11
|
+
const toConfig = getBiomeConfig(toBiome);
|
|
12
|
+
|
|
13
|
+
return trees.map((tree) => {
|
|
14
|
+
if (tree.species === "crystal_tree") return tree;
|
|
15
|
+
|
|
16
|
+
const tierIndex = findTierIndex(tree.species, fromBiome);
|
|
17
|
+
if (tierIndex === -1) return tree; // unknown species, leave as-is
|
|
18
|
+
|
|
19
|
+
const newSpecies = toConfig.trees[tierIndex]?.species ?? tree.species;
|
|
20
|
+
return { ...tree, species: newSpecies };
|
|
21
|
+
});
|
|
22
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export const aimlBiome = {
|
|
2
|
+
key: "aiml",
|
|
3
|
+
name: "Bioluminescent Grove",
|
|
4
|
+
|
|
5
|
+
trees: [
|
|
6
|
+
{ species: "spore", minCommits: 1, maxCommits: 2 },
|
|
7
|
+
{ species: "glowstem", minCommits: 3, maxCommits: 8 },
|
|
8
|
+
{ species: "crystal_sapling", minCommits: 9, maxCommits: 18 },
|
|
9
|
+
{ species: "mycelium_tree", minCommits: 19, maxCommits: 30 },
|
|
10
|
+
{ species: "neural_oak", minCommits: 31, maxCommits: 45 },
|
|
11
|
+
{ species: "void_willow", minCommits: 46, maxCommits: 65 },
|
|
12
|
+
{ species: "ancient_node", minCommits: 66, maxCommits: 100 },
|
|
13
|
+
{ species: "crystal_tree", minCommits: 101, maxCommits: Infinity },
|
|
14
|
+
],
|
|
15
|
+
|
|
16
|
+
groundElements: ["glowing_mushroom", "crystal_shard", "spore_pod", "root_cluster"],
|
|
17
|
+
animals: ["firefly", "moth", "chameleon", "fox", "spirit_owl"],
|
|
18
|
+
|
|
19
|
+
palette: {
|
|
20
|
+
leaf: "#0a3a4a", leafDark: "#0a2a30", leafLight: "#1a4a5a",
|
|
21
|
+
trunk: "#3a2a48", trunkDark: "#1a1028", trunkLight: "#4a3a58",
|
|
22
|
+
ground: "#0a1a10", groundDark: "#060e06", groundLight: "#0a2018",
|
|
23
|
+
grass: "#0a1a10", grassLight: "#1a2a18",
|
|
24
|
+
},
|
|
25
|
+
|
|
26
|
+
sky: { top: "#060618", mid: "#0a0a28", bottom: "#0a1020" },
|
|
27
|
+
|
|
28
|
+
ground: {
|
|
29
|
+
grass: ["#0a1a10", "#0a180a", "#081408"],
|
|
30
|
+
dirt: ["#0a0a10", "#080810", "#060608"],
|
|
31
|
+
},
|
|
32
|
+
|
|
33
|
+
particles: ["fireflies", "drifting_spores"],
|
|
34
|
+
bushes: ["spore_cluster", "glow_moss", "mycelium_web"],
|
|
35
|
+
undergrowthDensity: 0.5,
|
|
36
|
+
};
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export const backendBiome = {
|
|
2
|
+
key: "backend",
|
|
3
|
+
name: "Deep Woods",
|
|
4
|
+
|
|
5
|
+
trees: [
|
|
6
|
+
{ species: "seedling", minCommits: 1, maxCommits: 2 },
|
|
7
|
+
{ species: "thin_birch", minCommits: 3, maxCommits: 8 },
|
|
8
|
+
{ species: "tall_pine", minCommits: 9, maxCommits: 18 },
|
|
9
|
+
{ species: "dark_oak", minCommits: 19, maxCommits: 30 },
|
|
10
|
+
{ species: "dense_spruce", minCommits: 31, maxCommits: 45 },
|
|
11
|
+
{ species: "moss_oak", minCommits: 46, maxCommits: 65 },
|
|
12
|
+
{ species: "ancient_pine", minCommits: 66, maxCommits: 100 },
|
|
13
|
+
{ species: "crystal_tree", minCommits: 101, maxCommits: Infinity },
|
|
14
|
+
],
|
|
15
|
+
|
|
16
|
+
groundElements: ["fern", "moss_patch", "old_log", "bracket_fungus"],
|
|
17
|
+
animals: ["beetle", "frog", "rabbit", "owl", "wolf"],
|
|
18
|
+
|
|
19
|
+
palette: {
|
|
20
|
+
leaf: "#1a4a20", leafDark: "#0a3010", leafLight: "#2a5a30",
|
|
21
|
+
ground: "#0f1a0a", groundDark: "#0a1205", groundLight: "#1a2a10",
|
|
22
|
+
grass: "#1a4a1a", grassLight: "#2a5a2a",
|
|
23
|
+
},
|
|
24
|
+
|
|
25
|
+
sky: { top: "#1a2a1a", mid: "#152518", bottom: "#0f1f10" },
|
|
26
|
+
|
|
27
|
+
ground: {
|
|
28
|
+
grass: ["#1a4a1a", "#183a18", "#143014"],
|
|
29
|
+
dirt: ["#2a2018", "#1a1810", "#141008"],
|
|
30
|
+
},
|
|
31
|
+
|
|
32
|
+
particles: ["fog_wisps"],
|
|
33
|
+
bushes: ["fern_cluster", "moss_mound", "root_tangle"],
|
|
34
|
+
undergrowthDensity: 0.7,
|
|
35
|
+
};
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export const docsBiome = {
|
|
2
|
+
key: "docs",
|
|
3
|
+
name: "Garden Path",
|
|
4
|
+
|
|
5
|
+
trees: [
|
|
6
|
+
{ species: "lamp_sprout", minCommits: 1, maxCommits: 2 },
|
|
7
|
+
{ species: "garden_sapling", minCommits: 3, maxCommits: 8 },
|
|
8
|
+
{ species: "lantern_oak", minCommits: 9, maxCommits: 18 },
|
|
9
|
+
{ species: "path_willow", minCommits: 19, maxCommits: 30 },
|
|
10
|
+
{ species: "library_tree", minCommits: 31, maxCommits: 45 },
|
|
11
|
+
{ species: "archive_oak", minCommits: 46, maxCommits: 65 },
|
|
12
|
+
{ species: "ancient_yew", minCommits: 66, maxCommits: 100 },
|
|
13
|
+
{ species: "crystal_tree", minCommits: 101, maxCommits: Infinity },
|
|
14
|
+
],
|
|
15
|
+
|
|
16
|
+
groundElements: ["lantern", "signpost", "stepping_stone", "bookmark_flower"],
|
|
17
|
+
animals: ["firefly", "robin", "hedgehog", "fox", "owl"],
|
|
18
|
+
|
|
19
|
+
palette: {
|
|
20
|
+
leaf: "#2a4a28", leafDark: "#1a3a20", leafLight: "#3a5a38",
|
|
21
|
+
ground: "#2a4a28", groundDark: "#1a3a18", groundLight: "#3a5a38",
|
|
22
|
+
grass: "#2a4a28", grassLight: "#3a5a38",
|
|
23
|
+
},
|
|
24
|
+
|
|
25
|
+
sky: { top: "#0f1028", mid: "#1a2048", bottom: "#1a3838" },
|
|
26
|
+
|
|
27
|
+
ground: {
|
|
28
|
+
grass: ["#2a4a28", "#1a3a18", "#183018"],
|
|
29
|
+
dirt: ["#2a2018", "#1a1810", "#141008"],
|
|
30
|
+
},
|
|
31
|
+
|
|
32
|
+
particles: ["warm_fireflies"],
|
|
33
|
+
bushes: ["garden_shrub", "herb_patch", "ivy_cluster"],
|
|
34
|
+
undergrowthDensity: 0.45,
|
|
35
|
+
};
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export const frontendBiome = {
|
|
2
|
+
key: "frontend",
|
|
3
|
+
name: "Meadow",
|
|
4
|
+
|
|
5
|
+
trees: [
|
|
6
|
+
{ species: "sprout", minCommits: 1, maxCommits: 2 },
|
|
7
|
+
{ species: "young_maple", minCommits: 3, maxCommits: 8 },
|
|
8
|
+
{ species: "wide_oak", minCommits: 9, maxCommits: 18 },
|
|
9
|
+
{ species: "cherry", minCommits: 19, maxCommits: 30 },
|
|
10
|
+
{ species: "magnolia", minCommits: 31, maxCommits: 45 },
|
|
11
|
+
{ species: "wisteria", minCommits: 46, maxCommits: 65 },
|
|
12
|
+
{ species: "ancient_maple", minCommits: 66, maxCommits: 100 },
|
|
13
|
+
{ species: "crystal_tree", minCommits: 101, maxCommits: Infinity },
|
|
14
|
+
],
|
|
15
|
+
|
|
16
|
+
groundElements: ["wildflower", "daisy", "smooth_rock", "clover"],
|
|
17
|
+
animals: ["butterfly", "songbird", "rabbit", "fox", "deer"],
|
|
18
|
+
|
|
19
|
+
palette: {
|
|
20
|
+
leaf: "#4a9a50", leafDark: "#2a7a30", leafLight: "#78c868",
|
|
21
|
+
ground: "#5a9a40", groundDark: "#3a7a28", groundLight: "#7aba58",
|
|
22
|
+
grass: "#5aaa38", grassLight: "#8ad068",
|
|
23
|
+
},
|
|
24
|
+
|
|
25
|
+
sky: { top: "#6699cc", mid: "#88bbdd", bottom: "#c8dde8" },
|
|
26
|
+
|
|
27
|
+
ground: {
|
|
28
|
+
grass: ["#5aaa38", "#4a9a28", "#3a8a20"],
|
|
29
|
+
dirt: ["#6a5530", "#5a4520", "#4a3510"],
|
|
30
|
+
},
|
|
31
|
+
|
|
32
|
+
particles: ["petals", "butterflies"],
|
|
33
|
+
bushes: ["meadow_shrub", "flower_bush", "clover_patch"],
|
|
34
|
+
undergrowthDensity: 0.6,
|
|
35
|
+
};
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
export const generalBiome = {
|
|
2
|
+
key: "general",
|
|
3
|
+
name: "Temperate Forest",
|
|
4
|
+
|
|
5
|
+
trees: [
|
|
6
|
+
{ species: "sapling", minCommits: 1, maxCommits: 2 },
|
|
7
|
+
{ species: "birch", minCommits: 3, maxCommits: 8 },
|
|
8
|
+
{ species: "oak", minCommits: 9, maxCommits: 18 },
|
|
9
|
+
{ species: "cherry", minCommits: 19, maxCommits: 30 },
|
|
10
|
+
{ species: "pine", minCommits: 31, maxCommits: 45 },
|
|
11
|
+
{ species: "willow", minCommits: 46, maxCommits: 65 },
|
|
12
|
+
{ species: "ancient_oak", minCommits: 66, maxCommits: 100 },
|
|
13
|
+
{ species: "crystal_tree", minCommits: 101, maxCommits: Infinity },
|
|
14
|
+
],
|
|
15
|
+
|
|
16
|
+
groundElements: ["flower", "mushroom", "rock", "tall_grass"],
|
|
17
|
+
|
|
18
|
+
animals: ["butterfly", "rabbit", "fox", "deer", "owl"],
|
|
19
|
+
|
|
20
|
+
palette: {}, // empty = no overrides, uses BASE_PALETTE as-is
|
|
21
|
+
|
|
22
|
+
sky: { top: null, mid: null, bottom: null }, // null = use time-of-day defaults
|
|
23
|
+
|
|
24
|
+
ground: {
|
|
25
|
+
grass: ["#69a65d", "#8bcb73"],
|
|
26
|
+
dirt: ["#447055", "#345344"],
|
|
27
|
+
},
|
|
28
|
+
|
|
29
|
+
particles: ["leaves"],
|
|
30
|
+
|
|
31
|
+
bushes: ["tall_grass_bush", "fern_small", "shrub"],
|
|
32
|
+
|
|
33
|
+
undergrowthDensity: 0.4,
|
|
34
|
+
};
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { generalBiome } from "./general.js";
|
|
2
|
+
import { frontendBiome } from "./frontend.js";
|
|
3
|
+
import { backendBiome } from "./backend.js";
|
|
4
|
+
import { aimlBiome } from "./aiml.js";
|
|
5
|
+
import { infraBiome } from "./infra.js";
|
|
6
|
+
import { docsBiome } from "./docs.js";
|
|
7
|
+
|
|
8
|
+
const BIOME_REGISTRY = {
|
|
9
|
+
general: generalBiome,
|
|
10
|
+
frontend: frontendBiome,
|
|
11
|
+
backend: backendBiome,
|
|
12
|
+
aiml: aimlBiome,
|
|
13
|
+
infra: infraBiome,
|
|
14
|
+
docs: docsBiome,
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export const BIOME_KEYS = ["general", "frontend", "backend", "aiml", "infra", "docs"];
|
|
18
|
+
|
|
19
|
+
export function getBiomeConfig(key) {
|
|
20
|
+
return BIOME_REGISTRY[key] ?? BIOME_REGISTRY.general;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function getSpeciesForCommit(totalCommits, biomeKey) {
|
|
24
|
+
const config = getBiomeConfig(biomeKey);
|
|
25
|
+
for (const tier of config.trees) {
|
|
26
|
+
if (totalCommits >= tier.minCommits && totalCommits <= tier.maxCommits) {
|
|
27
|
+
return tier.species;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return config.trees[config.trees.length - 1].species;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function getGroundElementType(totalFileSaves, biomeKey) {
|
|
34
|
+
const config = getBiomeConfig(biomeKey);
|
|
35
|
+
const elements = config.groundElements;
|
|
36
|
+
const tier = Math.max(0, Math.floor(totalFileSaves / 100) - 1);
|
|
37
|
+
return elements[tier % elements.length];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function getAnimalType(totalMinutesCoded, biomeKey) {
|
|
41
|
+
const config = getBiomeConfig(biomeKey);
|
|
42
|
+
const animals = config.animals;
|
|
43
|
+
const thresholds = [480, 240, 120, 60, 30];
|
|
44
|
+
for (let i = 0; i < thresholds.length; i++) {
|
|
45
|
+
if (totalMinutesCoded >= thresholds[i] && i < animals.length) {
|
|
46
|
+
return animals[animals.length - 1 - i];
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return animals[0];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function registerBiome(config) {
|
|
53
|
+
BIOME_REGISTRY[config.key] = config;
|
|
54
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export const infraBiome = {
|
|
2
|
+
key: "infra",
|
|
3
|
+
name: "Tundra",
|
|
4
|
+
|
|
5
|
+
trees: [
|
|
6
|
+
{ species: "frozen_sprout", minCommits: 1, maxCommits: 2 },
|
|
7
|
+
{ species: "bare_sapling", minCommits: 3, maxCommits: 8 },
|
|
8
|
+
{ species: "hardy_pine", minCommits: 9, maxCommits: 18 },
|
|
9
|
+
{ species: "frost_birch", minCommits: 19, maxCommits: 30 },
|
|
10
|
+
{ species: "iron_pine", minCommits: 31, maxCommits: 45 },
|
|
11
|
+
{ species: "stone_oak", minCommits: 46, maxCommits: 65 },
|
|
12
|
+
{ species: "ancient_cedar", minCommits: 66, maxCommits: 100 },
|
|
13
|
+
{ species: "crystal_tree", minCommits: 101, maxCommits: Infinity },
|
|
14
|
+
],
|
|
15
|
+
|
|
16
|
+
groundElements: ["lichen", "frozen_rock", "dead_branch", "ice_shard"],
|
|
17
|
+
animals: ["snowbird", "arctic_hare", "hawk", "mountain_goat", "snowy_owl"],
|
|
18
|
+
|
|
19
|
+
palette: {
|
|
20
|
+
leaf: "#3a5a48", leafDark: "#2a4a38", leafLight: "#4a6a58",
|
|
21
|
+
ground: "#d4dce4", groundDark: "#b8c8d4", groundLight: "#e0e8f0",
|
|
22
|
+
grass: "#d8e0e8", grassLight: "#e8f0f8",
|
|
23
|
+
},
|
|
24
|
+
|
|
25
|
+
sky: { top: "#5a6878", mid: "#8a9aaa", bottom: "#b0c0cc" },
|
|
26
|
+
|
|
27
|
+
ground: {
|
|
28
|
+
grass: ["#d8e0e8", "#c8d4de", "#b8c8d4"],
|
|
29
|
+
dirt: ["#6a6058", "#5a504a", "#4a4038"],
|
|
30
|
+
},
|
|
31
|
+
|
|
32
|
+
particles: ["snowflakes", "wind_gusts"],
|
|
33
|
+
bushes: ["dried_shrub", "snow_mound", "lichen_rock"],
|
|
34
|
+
undergrowthDensity: 0.25,
|
|
35
|
+
};
|
package/src/core/environment.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { getBiomeConfig } from "./biomes/index.js";
|
|
2
|
+
|
|
1
3
|
const MINUTES_PER_DAY = 24 * 60;
|
|
2
4
|
const TRANSITION_MINUTES = 30;
|
|
3
5
|
|
|
@@ -264,7 +266,17 @@ export function getSeasonPalette(season) {
|
|
|
264
266
|
};
|
|
265
267
|
}
|
|
266
268
|
|
|
267
|
-
export function
|
|
269
|
+
export function getBiomePalette(season, biomeKey) {
|
|
270
|
+
const seasonPalette = getSeasonPalette(season);
|
|
271
|
+
if (!biomeKey || biomeKey === "general") {
|
|
272
|
+
return seasonPalette;
|
|
273
|
+
}
|
|
274
|
+
const biomeConfig = getBiomeConfig(biomeKey);
|
|
275
|
+
// Biome overrides go on top of season palette
|
|
276
|
+
return { ...seasonPalette, ...biomeConfig.palette };
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
export function getEnvironmentSnapshot(date = new Date(), streak = 0, biomeKey = null) {
|
|
268
280
|
const season = getSeason(date);
|
|
269
281
|
const sky = getSkyPhase(date);
|
|
270
282
|
const weather = getWeather(streak, date);
|
|
@@ -275,7 +287,7 @@ export function getEnvironmentSnapshot(date = new Date(), streak = 0) {
|
|
|
275
287
|
},
|
|
276
288
|
sky,
|
|
277
289
|
weather,
|
|
278
|
-
palette:
|
|
290
|
+
palette: getBiomePalette(season, biomeKey),
|
|
279
291
|
};
|
|
280
292
|
}
|
|
281
293
|
|
package/src/core/progression.js
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
import {
|
|
2
|
-
getAnimalTypeForMinutes,
|
|
3
2
|
getAnimalSprite,
|
|
4
3
|
getGroundElementSprite,
|
|
5
|
-
getGroundElementType,
|
|
6
|
-
getTreeSpeciesForCommit,
|
|
7
4
|
getTreeSprite,
|
|
8
5
|
} from "./sprites.js";
|
|
6
|
+
import {
|
|
7
|
+
getBiomeConfig,
|
|
8
|
+
getSpeciesForCommit,
|
|
9
|
+
getGroundElementType,
|
|
10
|
+
getAnimalType,
|
|
11
|
+
} from "./biomes/index.js";
|
|
9
12
|
|
|
10
13
|
function hashString(value) {
|
|
11
14
|
let hash = 0;
|
|
@@ -85,12 +88,19 @@ const ROW_SCALE = { back: 0.6, mid: 0.85, front: 1.0 };
|
|
|
85
88
|
|
|
86
89
|
export function applyCommit(state, { commitHash = "", now = new Date() } = {}) {
|
|
87
90
|
const nextTotal = state.total_commits + 1;
|
|
88
|
-
|
|
91
|
+
const biome = state.biome || "general";
|
|
92
|
+
let species = getSpeciesForCommit(nextTotal, biome);
|
|
89
93
|
// Only one crystal tree allowed
|
|
90
94
|
if (species === "crystal_tree" && state.trees.some((t) => t.species === "crystal_tree")) {
|
|
91
|
-
|
|
95
|
+
const config = getBiomeConfig(biome);
|
|
96
|
+
species = config.trees[config.trees.length - 2].species;
|
|
97
|
+
}
|
|
98
|
+
let sprite;
|
|
99
|
+
try {
|
|
100
|
+
sprite = getTreeSprite(species);
|
|
101
|
+
} catch {
|
|
102
|
+
sprite = getTreeSprite("sapling"); // fallback for species without sprites yet
|
|
92
103
|
}
|
|
93
|
-
const sprite = getTreeSprite(species);
|
|
94
104
|
const row = chooseRow(state.trees);
|
|
95
105
|
state.total_commits = nextTotal;
|
|
96
106
|
state.current_streak = updateCommitStreak(state.current_streak, state.last_active_date, now);
|
|
@@ -113,8 +123,14 @@ export function applyCommit(state, { commitHash = "", now = new Date() } = {}) {
|
|
|
113
123
|
export function applyFileSave(state, { now = new Date() } = {}) {
|
|
114
124
|
state.total_file_saves += 1;
|
|
115
125
|
if (state.total_file_saves % 100 === 0) {
|
|
116
|
-
const
|
|
117
|
-
const
|
|
126
|
+
const biome = state.biome || "general";
|
|
127
|
+
const type = getGroundElementType(state.total_file_saves, biome);
|
|
128
|
+
let sprite;
|
|
129
|
+
try {
|
|
130
|
+
sprite = getGroundElementSprite(type);
|
|
131
|
+
} catch {
|
|
132
|
+
sprite = getGroundElementSprite("flower");
|
|
133
|
+
}
|
|
118
134
|
state.ground_elements.push({
|
|
119
135
|
type,
|
|
120
136
|
x_position: placeSprite(
|
|
@@ -131,11 +147,17 @@ export function applyActiveMinutes(state, { minutes = 1, now = new Date() } = {}
|
|
|
131
147
|
const before = state.total_minutes_coded;
|
|
132
148
|
const after = before + minutes;
|
|
133
149
|
state.total_minutes_coded = after;
|
|
150
|
+
const biome = state.biome || "general";
|
|
134
151
|
|
|
135
152
|
let checkpoint = Math.floor(before / 30) * 30 + 30;
|
|
136
153
|
while (checkpoint <= after) {
|
|
137
|
-
const type =
|
|
138
|
-
|
|
154
|
+
const type = getAnimalType(checkpoint, biome);
|
|
155
|
+
let sprite;
|
|
156
|
+
try {
|
|
157
|
+
sprite = getAnimalSprite(type);
|
|
158
|
+
} catch {
|
|
159
|
+
sprite = getAnimalSprite("butterfly");
|
|
160
|
+
}
|
|
139
161
|
state.animals.push({
|
|
140
162
|
type,
|
|
141
163
|
x_position: placeSprite(state.animals, sprite.width, `${type}:${checkpoint}`),
|