honeytree 1.1.3 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "honeytree",
3
- "version": "1.1.3",
3
+ "version": "1.1.4",
4
4
  "description": "A living pixel art forest that grows while you code",
5
5
  "type": "module",
6
6
  "bin": {
@@ -2,6 +2,9 @@ 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";
@@ -30,6 +33,8 @@ function ForestWatchApp() {
30
33
  const [width, setWidth] = useState(stdout.columns || 80);
31
34
  const [height, setHeight] = useState(stdout.rows || 24);
32
35
 
36
+ const sessionWindow = useMemo(() => [], []);
37
+
33
38
  useInput((input, key) => {
34
39
  if (input === "q" || key.escape || key.ctrl && input === "c") {
35
40
  exit();
@@ -87,6 +92,29 @@ function ForestWatchApp() {
87
92
  async onCommit(commitHash) {
88
93
  const nextState = updateState((draft) => applyCommit(draft, { commitHash }));
89
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
+ }
90
118
  },
91
119
  });
92
120
 
@@ -114,6 +142,17 @@ function ForestWatchApp() {
114
142
  }
115
143
 
116
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
+
117
156
  ensureState();
118
157
  const app = render(h(ForestWatchApp));
119
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
+ };
@@ -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 getEnvironmentSnapshot(date = new Date(), streak = 0) {
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: getSeasonPalette(season),
290
+ palette: getBiomePalette(season, biomeKey),
279
291
  };
280
292
  }
281
293
 
@@ -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
- let species = getTreeSpeciesForCommit(nextTotal);
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
- species = "ancient_oak";
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 type = getGroundElementType(state.total_file_saves);
117
- const sprite = getGroundElementSprite(type);
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 = getAnimalTypeForMinutes(checkpoint);
138
- const sprite = getAnimalSprite(type);
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}`),