From e310af5a43895c480197b05690f3a81a22eb1fb1 Mon Sep 17 00:00:00 2001
From: Christian Johannesen
Date: Wed, 28 Oct 2026 12:42:20 +0100
Subject: [PATCH] Deliver Botanica greenhouse game UI and M5-M11 acceptance
evidence
---
BOTANICA_M5_M11_README.md | 18 +
BOTANICA_M5_M11_START.md | 9 +
README.md | 20 +-
apps/server/src/game-service.ts | 1 +
apps/web/index.html | 2 +-
apps/web/src/components/PlantGameActions.tsx | 102 ++
apps/web/src/components/PlantRecord.tsx | 48 +-
apps/web/src/game.css | 1105 ++++++++++++++++++
apps/web/src/lib/api.ts | 13 +-
apps/web/src/lib/game.tsx | 326 ++++++
apps/web/src/lib/requestId.ts | 12 +
apps/web/src/main.tsx | 305 +++--
apps/web/src/pages/BreedingLab.tsx | 39 +-
apps/web/src/pages/Collection.tsx | 26 +-
apps/web/src/pages/GamePages.tsx | 872 ++++++++++++++
apps/web/src/pages/Lab.tsx | 5 +-
artifacts/m11/breeding-desktop.png | Bin 0 -> 203784 bytes
artifacts/m11/browser-report.json | 116 ++
artifacts/m11/final-data-check.json | 11 +
artifacts/m11/first-discovery.png | Bin 0 -> 188492 bytes
artifacts/m11/greenhouse-1440.png | Bin 0 -> 257157 bytes
artifacts/m11/greenhouse-360.png | Bin 0 -> 163938 bytes
artifacts/m11/greenhouse-desktop.png | Bin 0 -> 154845 bytes
artifacts/m11/greenhouse-mobile-final.png | Bin 0 -> 115312 bytes
artifacts/m11/legacy-after-migration.json | 1 +
artifacts/m11/legacy-baseline.json | 1 +
artifacts/m11/mobile-final.json | 9 +
artifacts/m11/workers-desktop.png | Bin 0 -> 132016 bytes
docs/API_EXAMPLES.md | 82 +-
docs/BOTANICA_M5_M11_AGENT_RULES.md | 44 +
docs/BOTANICA_M5_M11_GAME_DESIGN.md | 93 ++
docs/BOTANICA_M5_M11_IMPLEMENTATION.md | 94 ++
docs/DEPLOYMENT.md | 10 +
docs/GAME_IMPLEMENTATION_NOTES.md | 40 +
docs/PROGRESS.md | 25 +
docs/ROADMAP.md | 19 +-
packages/shared/src/game.ts | 1 +
scripts/verify-game.cjs | 452 +++++++
38 files changed, 3684 insertions(+), 217 deletions(-)
create mode 100644 BOTANICA_M5_M11_README.md
create mode 100644 BOTANICA_M5_M11_START.md
create mode 100644 apps/web/src/components/PlantGameActions.tsx
create mode 100644 apps/web/src/game.css
create mode 100644 apps/web/src/lib/game.tsx
create mode 100644 apps/web/src/lib/requestId.ts
create mode 100644 apps/web/src/pages/GamePages.tsx
create mode 100644 artifacts/m11/breeding-desktop.png
create mode 100644 artifacts/m11/browser-report.json
create mode 100644 artifacts/m11/final-data-check.json
create mode 100644 artifacts/m11/first-discovery.png
create mode 100644 artifacts/m11/greenhouse-1440.png
create mode 100644 artifacts/m11/greenhouse-360.png
create mode 100644 artifacts/m11/greenhouse-desktop.png
create mode 100644 artifacts/m11/greenhouse-mobile-final.png
create mode 100644 artifacts/m11/legacy-after-migration.json
create mode 100644 artifacts/m11/legacy-baseline.json
create mode 100644 artifacts/m11/mobile-final.json
create mode 100644 artifacts/m11/workers-desktop.png
create mode 100644 docs/BOTANICA_M5_M11_AGENT_RULES.md
create mode 100644 docs/BOTANICA_M5_M11_GAME_DESIGN.md
create mode 100644 docs/BOTANICA_M5_M11_IMPLEMENTATION.md
create mode 100644 docs/GAME_IMPLEMENTATION_NOTES.md
create mode 100644 scripts/verify-game.cjs
diff --git a/BOTANICA_M5_M11_README.md b/BOTANICA_M5_M11_README.md
new file mode 100644
index 0000000..b942930
--- /dev/null
+++ b/BOTANICA_M5_M11_README.md
@@ -0,0 +1,18 @@
+# Botanica game expansion pack — M5–M11
+
+**Overlay files onto your EXISTING Botanica repository. Do not start a fresh project.** This is a handoff/spec pack, not a runnable replacement for the current application.
+
+## Contents
+- `docs/BOTANICA_M5_M11_AGENT_RULES.md` — Codex rules and guardrails. **Supplementary rules only: keep the repository’s existing root AGENTS.md unchanged.**
+- `docs/BOTANICA_M5_M11_GAME_DESIGN.md` — complete game design specification.
+- `docs/BOTANICA_M5_M11_IMPLEMENTATION.md` — specific milestones, data model, API outline, acceptance criteria.
+- `BOTANICA_M5_M11_START.md` — single start prompt to paste in Codex.
+
+## Use
+1. Backup/commit existing project. Extract to repository root. Do not overwrite any existing root files. Keep root `AGENTS.md` and follow it alongside this add-on. In case of conflicting instructions, root `AGENTS.md` takes precedence except where you explicitly resolve the conflict.
+2. In Codex, paste the content of `BOTANICA_M5_M11_START.md`, or ask Codex to read and execute it.
+3. Codex should inspect actual source first. The current Forgejo repository could not be fetched directly from this environment; existing code paths and DB table names deliberately are NOT asserted.
+4. Keep current compose and `.env` and any reverse proxy config. Required values: `HOST_PORT=8155`, `PUBLIC_DOMAIN=http://localhost` unless existing project names equivalents; do not overwrite actual production domain `https://botanica.p20.dev` unintentionally.
+
+## Philosophy
+No stop-start ritual at each milestone; batch broad passes and verify critical economics/security once. Build complete playable flows, preserve genetics/renderer and actual live data. No generated demo screenshots or speculative UI in place of functioning code.
diff --git a/BOTANICA_M5_M11_START.md b/BOTANICA_M5_M11_START.md
new file mode 100644
index 0000000..6724be6
--- /dev/null
+++ b/BOTANICA_M5_M11_START.md
@@ -0,0 +1,9 @@
+# Ready-to-send Codex instruction
+
+Implement **Botanica** game mode M5–M11 in the existing repository. Follow the repository root `AGENTS.md` and supplementary `docs/BOTANICA_M5_M11_AGENT_RULES.md`, `docs/BOTANICA_M5_M11_GAME_DESIGN.md`, and `docs/BOTANICA_M5_M11_IMPLEMENTATION.md`. You are authorized to work through all milestones autonomously, without stopping for approval at each step. This is a standard lean idle/incremental management game, not a new research project: favor pragmatic implementation and minimal dependencies, refactor only where necessary, keep tests targeted and run a single final end-to-end smoke/build check.
+
+**First**, briefly inspect the actual repository and map the existing genetics, renderer, schema, API and UI. Preserve what works. Add account isolation and transactions safely, then build a real gameplay home screen, coin economy, shop, plant/harvest system, breeding costs/rewards, worker automation with safe offline catch-up, progression, and game-feeling UI. Keep the advanced genetics lab available. Never reset the database or Docker volumes.
+
+**Product name stays Botanica.** Docker host port is **8155** and public domain default is **http://localhost** (keep existing `.env` names where possible). Work in batches: M5–M7, M8–M10, M11; keep going unless a genuine blocker requires clarification. Update `docs/GAME_IMPLEMENTATION_NOTES.md` with work performed, migrations, tested commands, known issues. Do not claim unexecuted tests succeeded.
+
+Deliver working code, not merely a proposal. Do not add premium currency, real-money payments, multiplayer, separate game engine, Redis, or complex infrastructure. Favor a genuinely fun playable core over feature scaffolding.
diff --git a/README.md b/README.md
index 616e5cd..aeebee4 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
-# Plant Laboratory / Botanica
+# Botanica
-A working local botanical sandbox: edit paired alleles, preview illustrated velvetleaf plants, save founders, breed traceable offspring and explore a persistent collection. M0–M4 are implemented. The game uses simplified fictional genetics.
+An illustrated greenhouse game built on the existing Plant Laboratory. Register with a username and password, grow and collect deterministic SVG plants, sell specimens, expand your glasshouses and fund real genetic crosses. Quests, research and opt-in workers support gradual, offline progress. The game uses simplified fictional genetics.
## Run
@@ -18,14 +18,20 @@ Open http://localhost:8155. Only the web entrypoint is published. It binds to al
## Explore
+- `/`: greenhouse home, seed inventory, growing slots, server timers and harvest.
+- `/shop`, `/quests`, `/research`: server-priced upgrades, twelve one-time goals and three research unlocks.
+- `/workers`: hire and configure gardeners/breeders, opt-in safe auto-sale and bounded offline reports.
+- `/settings`: account, logout and reduced motion. One-use password recovery codes are shown once at signup.
+- `/examples`: the original sandbox records, preserved separately and read-only.
+
- `/lab`: complete diploid allele editor, phenotype and rarity explanations, stage/cosmetic seed controls, manual mutation preview, image exports and save designed founder.
- `/breed`: saved parent pickers, compatibility, server-authoritative breeding, saved child, lineage and journal. Failed requests retry with the same idempotency key.
- `/gallery`: persisted collection with search, trait filters, sorting and 24-item pagination. The keyboard-accessible specimen inspector includes genotype, ancestry, birth records and JSON/SVG/PNG exports.
- `/plants/:id`: directly linkable plant detail and ancestry navigation.
- `/studies`: M2 systematic 12-variation atlas and 100-seed renderer proof.
-- `/about`: fictional genetics, the local prototype and future scope.
+- `/about`: compatibility alias for account/settings.
-Manual golden-vein previews affect image exports only. Saving from the editor creates a new founder without those preview mutations. Bred mutations and rarity outcomes are recorded once on the server. Collection frequency is shown separately from the trait-derived rarity tier.
+Advanced Lab studies are preserved outside the game economy: they cannot be sold or used as game parents. Manual golden-vein previews affect image exports only. Saving from the editor creates a new founder without those preview mutations. Bred mutations and rarity outcomes are recorded once on the server. Collection frequency is shown separately from the trait-derived rarity tier.
## Checks and development
@@ -37,7 +43,7 @@ npm run test:integration
docker compose -f compose.test.yaml down
```
-The regular test command builds pure package dependencies, runs domain/API tests and checks original SVG source synchronization. Integration cases are skipped unless `TEST_DATABASE_URL` is provided. `test:integration` builds a separate `botanica-checks` stack with a fresh tmpfs PostgreSQL database, no published ports and no access to the application's saved data. It tests clean migrations, idempotent 100-plant seed, persistence, concurrency, rollback, ownership, filter/search/sort and pagination. Cleanup removes only the temporary test stack.
+The regular test command builds pure package dependencies, runs domain/API tests and checks original SVG source synchronization. Integration cases are skipped unless `TEST_DATABASE_URL` is provided. `test:integration` builds a separate `botanica-checks` stack with a fresh tmpfs PostgreSQL database, no published ports and no access to the application's saved data. It tests clean migrations, idempotent 100-plant seed, persistence, concurrency, rollback, ownership, filter/search/sort and pagination, plus account recovery, monetary integrity, paid breeding and bounded worker catch-up. Game tests use an additional isolated schema. Cleanup removes only the temporary test stack.
`npm run dev` starts Vite (5173) and API (3000); provide a reachable PostgreSQL `DATABASE_URL` in the host environment. Compose intentionally does not publish its database. Prefer Compose for the complete runtime.
@@ -52,10 +58,10 @@ Production images run as non-root and contain compiled code. nginx handles SPA r
## Browser acceptance and artwork
-`scripts/verify-m3.cjs` and `scripts/verify-m4.cjs` exercise the real application with Playwright/Chrome and write screenshots, exports and measurements to `artifacts/`. Provide a Playwright package through `PLAYWRIGHT_MODULE` and optionally a browser binary through `BROWSER_EXECUTABLE`. Run the M3 script before M4 so the latter can follow its persisted child and parents. Browser checks intentionally retain their labeled founder/offspring as evidence of persistence. M0/M2 artifacts document earlier gates; their historical scripts target their respective earlier UI.
+`scripts/verify-m3.cjs` and `scripts/verify-m4.cjs` exercise the real application with Playwright/Chrome and write screenshots, exports and measurements to `artifacts/`. Provide a Playwright package through `PLAYWRIGHT_MODULE` and optionally a browser binary through `BROWSER_EXECUTABLE`. Those historical scripts target the pre-account sandbox. `scripts/verify-game.cjs` exercises the current account/game UI and preserves labeled QA records without saving passwords or sessions to artifacts. Browser checks intentionally retain their labeled founder/offspring as evidence of persistence. M0/M2 artifacts document earlier gates; their historical scripts target their respective earlier UI.
The pure renderer composes 15 original handcrafted SVG primitives, six palettes and deterministic layout. `npm run assets:write` regenerates primitive sources from typed path definitions; tests reject source drift. [Artwork provenance](assets/README.md) documents CC0 assets. No runtime image services, remote fonts, trackers or paid APIs are used.
See [API examples](docs/API_EXAMPLES.md), [deployment](docs/DEPLOYMENT.md), [progress](docs/PROGRESS.md) and [future roadmap](docs/ROADMAP.md).
-This is a local single-owner demo without authentication. It is ready for further iteration, not public internet launch. Add real authentication, ownership/access controls, throttling and deployment hardening first.
+Accounts use Argon2id, hashed cookie sessions, CSRF protection, ownership-scoped queries and bounded in-process auth throttling. The configured public origin is https://botanica.p20.dev; set `PUBLIC_ORIGINS` explicitly for any additional hostnames. Read [game implementation notes](docs/GAME_IMPLEMENTATION_NOTES.md) for migration, economy and worker semantics.
diff --git a/apps/server/src/game-service.ts b/apps/server/src/game-service.ts
index 5326734..b0a4457 100644
--- a/apps/server/src/game-service.ts
+++ b/apps/server/src/game-service.ts
@@ -1018,6 +1018,7 @@ export class GameService {
seedType: s.seed_type,
plantedAt: s.planted_at?.toISOString() ?? null,
readyAt: s.ready_at?.toISOString() ?? null,
+ ready: !!s.ready_at && s.ready_at <= now,
visualSeed: s.visual_seed,
phenotype: s.genome
? resolvePhenotype("velvetleaf", s.genome)
diff --git a/apps/web/index.html b/apps/web/index.html
index 15b3bf3..b322cab 100644
--- a/apps/web/index.html
+++ b/apps/web/index.html
@@ -1 +1 @@
-Plant Laboratory
+Botanica
diff --git a/apps/web/src/components/PlantGameActions.tsx b/apps/web/src/components/PlantGameActions.tsx
new file mode 100644
index 0000000..a790c7e
--- /dev/null
+++ b/apps/web/src/components/PlantGameActions.tsx
@@ -0,0 +1,102 @@
+import { useState, useEffect } from "react";
+import type { PlantDto } from "@botanica/shared";
+import { useGame, useGameAction, ActionFeedback } from "../lib/game";
+export function PlantGameActions({ plant: p }: { plant: PlantDto }) {
+ const action = useGameAction(),
+ game = useGame(),
+ [confirm, setConfirm] = useState(false),
+ [favorite, setFavorite] = useState(!!p.isFavorite),
+ [protectedPlant, setProtected] = useState(!!p.isProtected);
+ useEffect(() => {
+ setFavorite(!!p.isFavorite);
+ setProtected(!!p.isProtected);
+ }, [p.id, p.isFavorite, p.isProtected]);
+ function flags(nextFavorite: boolean, nextProtected: boolean) {
+ setFavorite(nextFavorite);
+ setProtected(nextProtected);
+ action.mutate(
+ {
+ action: "flags",
+ data: {
+ plantId: p.id,
+ favorite: nextFavorite,
+ protected: nextProtected,
+ },
+ },
+ {
+ onError: () => {
+ setFavorite(!!p.isFavorite);
+ setProtected(!!p.isProtected);
+ },
+ },
+ );
+ }
+ if (!p.gameEligible)
+ return (
+
+ Laboratory study · saved for art and genetics, outside the game economy.
+
+ );
+ const blocked =
+ favorite || protectedPlant || p.rarity.tier === "Mythic" || p.soldAt;
+ return (
+
+
Your specimen
+
+ flags(e.target.checked, protectedPlant)}
+ />
+ Favorite
+
+
+ flags(favorite, e.target.checked)}
+ />
+ Protect from sale
+
+
setConfirm(true)}
+ >
+ {p.soldAt
+ ? "Sold · lineage preserved"
+ : p.rarity.tier === "Mythic"
+ ? "Mythic keepsake · cannot sell"
+ : favorite || protectedPlant
+ ? "Protected from sale"
+ : `Sell specimen · ${p.saleCoins} coins…`}
+
+ {confirm && !p.soldAt && (
+
+
+ Sell {p.name}? It will leave your inventory; its ancestry record
+ remains. The server calculates the coin value from its species and
+ traits.
+
+
+ action.mutate(
+ { action: "sell", data: { plantId: p.id } },
+ { onSuccess: () => setConfirm(false) },
+ )
+ }
+ >
+ Confirm sale
+ {" "}
+
setConfirm(false)}>
+ Keep plant
+
+
+ )}
+
+
+ );
+}
diff --git a/apps/web/src/components/PlantRecord.tsx b/apps/web/src/components/PlantRecord.tsx
index de1cb29..2cfd5a8 100644
--- a/apps/web/src/components/PlantRecord.tsx
+++ b/apps/web/src/components/PlantRecord.tsx
@@ -10,23 +10,36 @@ import { ExportActions } from "./ExportActions";
import { QueryMessage } from "./QueryMessage";
import { locusLabels } from "./GenomeEditor";
import { api } from "../lib/api";
+import { request } from "../lib/api";
+import type { CollectionStats, BreedingEventDto } from "@botanica/shared";
+import { PlantGameActions } from "./PlantGameActions";
export function PlantRecord({
plant: p,
onParent,
+ scope = "owned",
}: {
plant: PlantDto;
onParent?: (id: string) => void;
+ scope?: "owned" | "demo";
}) {
const { stage, background, set } = useInspectorOptions();
const setStage = (stage: GrowthStage) => set({ stage }),
setBackground = (background: Background) => set({ background });
const stats = useQuery({
- queryKey: ["collection-stats"],
- queryFn: api.stats,
+ queryKey: ["collection-stats", scope],
+ queryFn: () =>
+ scope === "demo"
+ ? request("/demo/collection/stats")
+ : api.stats(),
}),
birth = useQuery({
- queryKey: ["birth", p.id],
- queryFn: () => api.birth(p.id),
+ queryKey: ["birth", p.id, scope],
+ queryFn: () =>
+ scope === "demo"
+ ? request<{ event: BreedingEventDto | null }>(
+ "/demo/plants/" + p.id + "/birth",
+ )
+ : api.birth(p.id),
});
useEffect(() => {
setStage("flowering");
@@ -54,6 +67,13 @@ export function PlantRecord({
+ {scope === "owned" ? (
+
+ ) : (
+
+ Read-only reference · original identity and provenance preserved.
+
+ )}
-
-
- Study genome →
-
-
- Use as parent →
-
-
+ {scope === "owned" && (
+
+
+ Study genome →
+
+ {p.gameEligible && !p.soldAt && (
+
+ Use as parent →
+
+ )}
+
+ )}
);
diff --git a/apps/web/src/game.css b/apps/web/src/game.css
new file mode 100644
index 0000000..2aa5447
--- /dev/null
+++ b/apps/web/src/game.css
@@ -0,0 +1,1105 @@
+:root {
+ --forest: #14251c;
+ --moss: #233c2a;
+ --parchment: #f0ead2;
+ --gold: #deb967;
+ --ink: #233c2a;
+}
+.game-shell {
+ display: grid;
+ grid-template-columns: 218px minmax(0, 1fr);
+ min-height: 100vh;
+ background: #f0ead2;
+ color: var(--ink);
+}
+.game-rail {
+ background: var(--forest);
+ color: #eae7d3;
+ padding: 30px 18px;
+ position: sticky;
+ top: 0;
+ height: 100vh;
+ display: flex;
+ flex-direction: column;
+ border-right: 3px solid #3c5540;
+}
+.game-brand {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ font:
+ 30px Georgia,
+ serif;
+ color: var(--gold);
+ padding: 0 5px 28px;
+}
+.game-brand svg {
+ width: 30px;
+ height: 30px;
+}
+.game-brand small {
+ font:
+ 8px Arial,
+ sans-serif;
+ display: block;
+ letter-spacing: 1.6px;
+ margin-top: 8px;
+ color: #9bae98;
+}
+.game-rail nav {
+ display: flex;
+ flex-direction: column;
+ gap: 5px;
+}
+.game-rail nav a {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ padding: 12px;
+ border-radius: 6px;
+ color: #c3ceba;
+ border: 1px solid transparent;
+ font-size: 13px;
+}
+.game-rail nav a.active {
+ color: var(--gold);
+ background: #293f2e;
+ border-color: #53634a;
+}
+.game-rail nav a:hover {
+ background: #293f2e;
+}
+.rail-note {
+ font-size: 12px;
+ color: #bdc8b3;
+ margin: 25px 12px;
+}
+.rail-motto {
+ font:
+ italic 14px/1.7 Georgia,
+ serif;
+ color: #83987f;
+ margin: auto 12px 0;
+}
+.game-body {
+ min-width: 0;
+}
+.game-hud {
+ flex-direction: row;
+ align-items: center;
+ position: sticky;
+ top: 0;
+ z-index: 10;
+ max-width: none;
+ padding: 16px 28px;
+ gap: 24px;
+ background: #f7f2df;
+ border-bottom: 1px solid #c8c3a7;
+ box-shadow: 0 3px 12px #17271b0a;
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: space-between;
+}
+.hud-player {
+ display: flex;
+ gap: 12px;
+ align-items: center;
+}
+.avatar {
+ display: grid;
+ place-items: center;
+ width: 42px;
+ height: 42px;
+ border: 1px solid #beaa70;
+ border-radius: 50%;
+ background: #e6dbb8;
+ font:
+ 22px Georgia,
+ serif;
+}
+.hud-player strong,
+.hud-player span:not(.avatar),
+.hud-seeds span,
+.quest-pin span {
+ display: block;
+}
+.hud-player strong {
+ font-size: 13px;
+}
+.hud-player span:not(.avatar) {
+ font-size: 11px;
+ color: #6b765c;
+ margin: 3px 0;
+}
+.game-hud progress {
+ width: 125px;
+ height: 4px;
+ display: block;
+}
+.game-hud small {
+ font-size: 8px;
+ letter-spacing: 1.5px;
+ color: #7c8064;
+ display: block;
+ margin-bottom: 4px;
+}
+.hud-coins strong {
+ font:
+ 25px Georgia,
+ serif;
+ color: #82702d;
+}
+.hud-seeds strong {
+ font:
+ 20px Georgia,
+ serif;
+}
+.hud-seeds span,
+.quest-pin span {
+ font-size: 10px;
+ color: #71785e;
+ margin-top: 4px;
+}
+.quest-pin {
+ border-left: 1px solid #d7cfaf;
+ padding-left: 20px;
+ max-width: 240px;
+}
+.quest-pin strong {
+ font-size: 12px;
+}
+.game-body main {
+ max-width: 1400px;
+ padding: 28px 30px 45px;
+ outline: none;
+}
+.game-heading {
+ margin: 2px 0 26px;
+}
+.game-heading .eyebrow {
+ margin: 0 0 9px;
+ font-size: 9px;
+}
+.game-heading h1,
+.game-body .lab-heading h1 {
+ font:
+ 36px/1.1 Georgia,
+ serif;
+ letter-spacing: -0.7px;
+ margin: 0 0 9px;
+}
+.game-heading > p:last-child {
+ font-size: 13px;
+ color: #68745c;
+ line-height: 1.6;
+}
+.game-shell h2 {
+ font:
+ 23px Georgia,
+ serif;
+ letter-spacing: -0.3px;
+}
+.game-shell button,
+.game-shell .button-link {
+ font-size: 12px;
+ border-radius: 5px;
+ min-height: 38px;
+ box-shadow: 0 2px 0 #112b1b22;
+}
+.game-shell button:disabled {
+ cursor: not-allowed;
+ opacity: 0.6;
+}
+.game-shell label {
+ font-size: 12px;
+ line-height: 1.5;
+}
+.game-shell select,
+.game-shell input:not([type="checkbox"]) {
+ background: #faf7e9;
+ border: 1px solid #cdcbb4;
+ border-radius: 5px;
+ padding: 9px;
+ max-width: 100%;
+}
+.game-shell select:focus-visible,
+.game-shell input:focus-visible,
+.game-shell button:focus-visible {
+ outline: 3px solid #b18739;
+ outline-offset: 2px;
+}
+.game-shell progress {
+ accent-color: #5d8150;
+ border: 0;
+ background: #dcd7bd;
+ border-radius: 4px;
+ height: 6px;
+ width: 100%;
+}
+.game-shell progress::-webkit-progress-bar {
+ background: #dcd7bd;
+ border-radius: 4px;
+}
+.game-shell progress::-webkit-progress-value {
+ background: #6a8652;
+ border-radius: 4px;
+}
+.greenhouse-toolbar {
+ display: flex;
+ align-items: end;
+ justify-content: space-between;
+ gap: 20px;
+ margin-bottom: 18px;
+}
+.greenhouse-toolbar label {
+ width: 235px;
+ display: grid;
+ gap: 5px;
+}
+.house-tabs {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+}
+.glasshouse {
+ background: #d9dfc7;
+ border: 6px solid #536d4d;
+ border-radius: 65px 65px 10px 10px;
+ box-shadow:
+ 0 5px 0 #354f3c,
+ 0 12px 24px #21371a15;
+ overflow: hidden;
+}
+.glasshouse-roof {
+ height: 90px;
+ background:
+ repeating-linear-gradient(
+ 90deg,
+ transparent 0,
+ transparent calc(25% - 2px),
+ #789174 25%
+ ),
+ linear-gradient(180deg, #e8eddb, #c6d5b7);
+ border-bottom: 4px solid #647f59;
+ display: grid;
+ place-items: center;
+}
+.glasshouse-roof span {
+ background: #3f5d42;
+ border: 1px solid #90a078;
+ border-radius: 4px;
+ color: #eee8ca;
+ padding: 9px 18px;
+ font-size: 9px;
+ letter-spacing: 2.5px;
+}
+.house-equipment {
+ display: flex;
+ justify-content: space-between;
+ padding: 14px 20px;
+ color: #52674c;
+ font-size: 10px;
+ border-bottom: 1px solid #bac7a9;
+}
+.growing-bench {
+ display: grid;
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ gap: 14px;
+ padding: 22px 18px 26px;
+ background: repeating-linear-gradient(
+ 0deg,
+ #afbd9844 0,
+ #afbd9844 1px,
+ transparent 1px,
+ transparent 60px
+ );
+}
+.growing-slot {
+ min-width: 0;
+ background: #f2efd9;
+ border: 1px solid #9ca988;
+ border-radius: 8px;
+ overflow: hidden;
+ box-shadow:
+ 0 4px 0 #80916a,
+ 0 7px 10px #374c2522;
+}
+.slot-top {
+ display: flex;
+ justify-content: space-between;
+ gap: 4px;
+ padding: 11px 10px;
+ font-size: 9px;
+ color: #6d795b;
+ border-bottom: 1px solid #d9dfc5;
+}
+.slot-top span:first-child {
+ font:
+ 12px Georgia,
+ serif;
+ color: #7a8866;
+}
+.slot-art {
+ height: 200px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: radial-gradient(ellipse at bottom, #d7dec0, transparent 70%);
+}
+.slot-art .plant-preview {
+ width: 100% !important;
+ height: 100%;
+}
+.slot-art svg {
+ width: 100%;
+ height: 100%;
+ max-height: 220px;
+}
+.slot-info {
+ padding: 0 10px 12px;
+}
+.slot-info p {
+ font-size: 10px;
+ min-height: 25px;
+ text-align: center;
+ color: #75805f;
+ line-height: 1.4;
+ margin: 8px 0;
+}
+.slot-info button {
+ width: 100%;
+ padding: 8px 5px;
+ min-height: 34px;
+ font-size: 11px;
+}
+.growing-slot.ready {
+ border: 2px solid #b28f36;
+ box-shadow:
+ 0 4px 0 #94804a,
+ 0 0 12px #deb96755;
+}
+.ready .slot-top {
+ color: #846b22;
+}
+.ready .slot-info button {
+ background: #846a2b;
+ color: #fff4cb;
+}
+.game-tip {
+ display: flex;
+ align-items: center;
+ gap: 14px;
+ margin: 28px 0;
+ padding: 16px 18px;
+ background: #e5e3c9;
+ border: 1px solid #c9d0b4;
+ border-radius: 6px;
+ font-size: 12px;
+ line-height: 1.6;
+}
+.game-tip > span {
+ font:
+ 35px Georgia,
+ serif;
+ color: #829062;
+}
+.game-tip p {
+ flex: 1;
+ margin: 0;
+}
+.game-tip a {
+ white-space: nowrap;
+}
+.game-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
+ gap: 18px;
+}
+.game-card {
+ background: #f8f4e3;
+ border: 1px solid #c9cbb0;
+ border-top: 3px solid #6f865a;
+ border-radius: 7px;
+ padding: 22px;
+ box-shadow: 0 3px 0 #ddd8bb;
+ display: flex;
+ flex-direction: column;
+ align-items: start;
+ min-width: 0;
+}
+.game-card h2 {
+ margin: 0 0 10px;
+}
+.game-card > p {
+ font-size: 12px;
+ line-height: 1.7;
+ color: #677256;
+ margin: 0 0 15px;
+}
+.game-card > p.coin-price {
+ color: #87702e;
+ font:
+ 20px Georgia,
+ serif;
+ margin-top: auto;
+ padding-top: 10px;
+}
+.game-card > button {
+ width: 100%;
+ margin-top: 8px;
+}
+.game-card .eyebrow {
+ font-size: 9px;
+ margin-bottom: 14px;
+}
+.item-glyph {
+ font:
+ 34px Georgia,
+ serif;
+ color: #8d9c6a;
+ margin-bottom: 14px;
+}
+.house-select {
+ display: grid;
+ max-width: 300px;
+ gap: 4px;
+ margin-bottom: 20px;
+}
+.completed {
+ opacity: 0.7;
+}
+.game-card form {
+ display: grid;
+ gap: 13px;
+ width: 100%;
+}
+.game-card label {
+ display: grid;
+ gap: 5px;
+ width: 100%;
+}
+.game-card .check-label {
+ display: flex;
+ gap: 8px;
+ align-items: start;
+}
+.check-label input {
+ margin-top: 3px;
+ accent-color: #456d46;
+}
+.game-card .panel-note {
+ font-size: 11px;
+ line-height: 1.7;
+}
+.section-title {
+ margin: 35px 0 20px;
+}
+.work-log {
+ padding: 0;
+ list-style: none;
+ font-size: 12px;
+}
+.work-log li {
+ padding: 14px;
+ border-bottom: 1px solid #d3d2b8;
+ line-height: 1.6;
+}
+.study-notice,
+.pending-banner {
+ font-size: 12px;
+ padding: 15px 18px;
+ background: #e2dfbd;
+ border: 1px solid #c9bd8b;
+ border-radius: 5px;
+ line-height: 1.6;
+}
+.study-notice {
+ margin-bottom: 20px;
+}
+.study-notice a {
+ text-decoration: underline;
+}
+.pending-banner {
+ margin: 15px 30px;
+}
+.settings-card {
+ max-width: 620px;
+ gap: 15px;
+}
+.settings-card > p {
+ font-size: 13px;
+}
+.settings-card a {
+ font-size: 12px;
+}
+.game-footer {
+ max-width: none;
+ padding: 20px 30px;
+ font-size: 10px;
+ display: flex;
+ justify-content: space-between;
+ color: #7d856b;
+ border-top: 1px solid #d5d3b8;
+}
+.mobile-game-nav {
+ display: none;
+}
+.game-body .lab-heading {
+ padding: 20px 0 28px;
+}
+.game-body .study-panel {
+ background: #faf7e8;
+}
+.game-body .render-workbench {
+ border-color: #cbd0b6;
+}
+.game-body .collection-grid {
+ gap: 18px;
+}
+.game-body .lab-heading p {
+ font-size: 13px;
+}
+.game-body .text-link {
+ font-size: 12px;
+}
+.plant-economy-actions {
+ display: grid;
+ gap: 10px;
+ padding: 16px 0;
+ border-bottom: 1px solid #d6dac7;
+ margin-bottom: 18px;
+}
+.plant-economy-actions .check-label {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+.sale-confirm {
+ padding: 12px;
+ background: #ece2c4;
+ border: 1px solid #ccba80;
+}
+.sale-confirm p {
+ font-size: 12px;
+}
+.discovery-reveal {
+ padding: 22px;
+ margin-top: 22px;
+ border: 1px solid #b79f62;
+ background: #f8efce;
+ border-radius: 7px;
+ animation: botanical-reveal 0.4s ease-out;
+}
+.discovery-reveal h2 {
+ margin-top: 0;
+}
+.discovery-reveal p {
+ font-size: 13px;
+}
+.new-discovery {
+ box-shadow: 0 0 0 3px #d9c58a55;
+}
+.report-dock {
+ position: fixed;
+ bottom: 20px;
+ left: 240px;
+ right: 25px;
+ z-index: 30;
+ pointer-events: none;
+}
+.offline-report {
+ pointer-events: auto;
+ max-width: 900px;
+ margin: auto;
+ background: #233c2a;
+ color: #efe9d1;
+ border: 1px solid #c9a75c;
+ border-radius: 10px;
+ padding: 24px;
+ box-shadow: 0 12px 60px #10231755;
+ display: flex;
+ gap: 20px;
+ align-items: start;
+}
+.offline-report .eyebrow {
+ color: #cfbb82;
+ margin-bottom: 10px;
+}
+.offline-report h2 {
+ margin: 0 0 10px;
+}
+.offline-report p {
+ font-size: 12px;
+ line-height: 1.6;
+}
+.offline-report .panel-note {
+ color: #b2c1a3;
+}
+.offline-report button {
+ white-space: nowrap;
+}
+.auth-scene {
+ min-height: 100vh;
+ max-width: none;
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ padding: 0;
+ background: var(--forest);
+ color: var(--ink);
+ align-items: center;
+}
+.auth-botanical {
+ font:
+ clamp(200px, 35vw, 600px) Georgia,
+ serif;
+ color: #7d9966;
+ text-align: center;
+ text-shadow: 0 12px 0 #080f0933;
+}
+.auth-panel {
+ background: #f0ead2;
+ border: 1px solid #a8ab83;
+ padding: 45px;
+ max-width: 500px;
+ margin: 40px 30px 40px 0;
+ border-radius: 12px;
+ box-shadow: 0 15px 40px #0003;
+}
+.auth-panel h1 {
+ font:
+ 64px Georgia,
+ serif;
+ margin-bottom: 25px;
+}
+.auth-panel h2 {
+ font:
+ 25px Georgia,
+ serif;
+}
+.auth-panel p {
+ font-size: 13px;
+ line-height: 1.7;
+}
+.auth-panel .eyebrow {
+ font-size: 9px;
+ line-height: 1.7;
+}
+.auth-panel form {
+ display: grid;
+ gap: 16px;
+ margin: 22px 0;
+}
+.auth-panel label {
+ display: grid;
+ gap: 6px;
+ font-size: 12px;
+}
+.auth-panel input {
+ background: #fbf8e9;
+ border: 1px solid #bdc4a5;
+ border-radius: 5px;
+ padding: 11px;
+ width: 100%;
+ font-size: 15px;
+}
+.auth-panel small {
+ font-size: 11px;
+ line-height: 1.6;
+ color: #687354;
+}
+.auth-tabs {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 7px;
+}
+.auth-tabs button {
+ font-size: 11px;
+ padding: 10px;
+}
+.recovery-codes {
+ display: grid;
+ gap: 10px;
+ margin: 20px 0;
+}
+.recovery-codes code {
+ font-size: 13px;
+ padding: 7px;
+ background: #e1ddc1;
+ overflow-wrap: anywhere;
+}
+.auth-panel > button {
+ font-size: 12px;
+}
+.error-text {
+ overflow-wrap: anywhere;
+}
+.game-shell .saved-notice {
+ font-size: 13px;
+ margin: 15px 0;
+}
+.game-shell .collection-toolbar select {
+ min-width: 0;
+}
+.game-shell .lab-heading h1 {
+ max-width: none;
+}
+@keyframes botanical-reveal {
+ from {
+ opacity: 0;
+ transform: translateY(8px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+@media (min-width: 1500px) {
+ .slot-art {
+ height: 240px;
+ }
+}
+@media (max-width: 1150px) {
+ .game-shell {
+ grid-template-columns: 185px minmax(0, 1fr);
+ }
+ .game-rail {
+ padding: 25px 12px;
+ }
+ .game-brand {
+ font-size: 25px;
+ }
+ .game-brand small {
+ font-size: 7px;
+ }
+ .game-hud {
+ gap: 15px;
+ padding: 14px 20px;
+ }
+ .hud-seeds span {
+ display: none;
+ }
+ .quest-pin {
+ max-width: 170px;
+ padding-left: 12px;
+ }
+ .game-body main {
+ padding: 24px 20px 40px;
+ }
+ .growing-bench {
+ gap: 10px;
+ padding: 15px 12px 22px;
+ }
+ .slot-art {
+ height: 170px;
+ }
+ .report-dock {
+ left: 205px;
+ }
+}
+@media (max-width: 900px) {
+ .growing-bench {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+ .slot-art {
+ height: 220px;
+ }
+ .quest-pin {
+ display: none;
+ }
+ .game-body main {
+ padding: 22px 18px 30px;
+ }
+ .game-grid {
+ grid-template-columns: repeat(auto-fit, minmax(235px, 1fr));
+ }
+ .game-tip {
+ flex-wrap: wrap;
+ }
+ .game-tip a {
+ margin-left: 40px;
+ }
+ .game-rail nav a {
+ padding: 10px;
+ font-size: 12px;
+ }
+ .game-heading h1 {
+ font-size: 31px;
+ }
+ .auth-scene {
+ grid-template-columns: 1fr;
+ }
+ .auth-botanical {
+ display: none;
+ }
+ .auth-panel {
+ margin: 30px auto;
+ max-width: 500px;
+ width: calc(100% - 30px);
+ padding: 30px;
+ }
+}
+@media (max-width: 640px) {
+ .game-shell {
+ display: block;
+ padding-bottom: 70px;
+ }
+ .game-rail {
+ position: static;
+ height: auto;
+ padding: 15px 16px;
+ border: 0;
+ }
+ .game-brand {
+ padding: 0;
+ font-size: 25px;
+ }
+ .game-brand small {
+ font-size: 7px;
+ }
+ .game-rail nav {
+ flex-direction: row;
+ overflow-x: auto;
+ margin-top: 14px;
+ gap: 5px;
+ }
+ .game-rail nav a {
+ white-space: nowrap;
+ padding: 8px 10px;
+ font-size: 11px;
+ }
+ .game-rail nav a svg {
+ display: none;
+ }
+ .rail-motto,
+ .rail-note {
+ display: none;
+ }
+ .game-hud {
+ gap: 10px;
+ padding: 12px 16px;
+ flex-wrap: nowrap;
+ top: 0;
+ }
+ .hud-player {
+ gap: 7px;
+ }
+ .avatar {
+ width: 30px;
+ height: 30px;
+ font-size: 16px;
+ }
+ .hud-player strong {
+ font-size: 11px;
+ }
+ .hud-player span:not(.avatar) {
+ font-size: 9px;
+ }
+ .game-hud .hud-player progress {
+ width: 85px;
+ }
+ .hud-coins strong {
+ font-size: 22px;
+ }
+ .hud-seeds strong {
+ font-size: 19px;
+ }
+ .game-hud small {
+ font-size: 7px;
+ letter-spacing: 0.6px;
+ }
+ .game-body main {
+ padding: 22px 13px 30px;
+ }
+ .game-heading {
+ margin-bottom: 20px;
+ }
+ .game-heading h1 {
+ font-size: 29px;
+ }
+ .game-heading > p:last-child {
+ font-size: 12px;
+ }
+ .greenhouse-toolbar {
+ align-items: stretch;
+ flex-direction: column;
+ gap: 12px;
+ }
+ .greenhouse-toolbar label {
+ width: 100%;
+ grid-template-columns: auto 1fr;
+ align-items: center;
+ }
+ .greenhouse-toolbar select {
+ font-size: 11px;
+ }
+ .house-tabs button {
+ flex: 1;
+ font-size: 10px;
+ }
+ .glasshouse {
+ border-width: 4px;
+ border-radius: 40px 40px 7px 7px;
+ }
+ .glasshouse-roof {
+ height: 65px;
+ }
+ .glasshouse-roof span {
+ font-size: 7px;
+ letter-spacing: 1.5px;
+ padding: 7px 9px;
+ }
+ .house-equipment {
+ font-size: 8px;
+ padding: 12px 8px;
+ gap: 10px;
+ }
+ .growing-bench {
+ gap: 10px;
+ padding: 14px 9px 20px;
+ }
+ .slot-top {
+ font-size: 8px;
+ padding: 9px 7px;
+ }
+ .slot-art {
+ height: 175px;
+ }
+ .slot-info {
+ padding: 0 7px 10px;
+ }
+ .slot-info p {
+ font-size: 9px;
+ min-height: 28px;
+ }
+ .slot-info button {
+ font-size: 10px;
+ padding: 7px 3px;
+ }
+ .game-grid {
+ grid-template-columns: 1fr;
+ }
+ .game-card {
+ padding: 20px;
+ }
+ .game-tip {
+ font-size: 11px;
+ padding: 12px;
+ }
+ .mobile-game-nav {
+ display: flex;
+ position: fixed;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ background: #14251c;
+ color: #c6d1ba;
+ z-index: 25;
+ justify-content: space-around;
+ gap: 0;
+ padding: 8px 6px calc(8px + env(safe-area-inset-bottom));
+ border-top: 2px solid #697b50;
+ }
+ .mobile-game-nav a {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 5px;
+ padding: 4px;
+ font-size: 9px;
+ color: inherit;
+ }
+ .mobile-game-nav a.active {
+ color: #deb967;
+ border: 0;
+ }
+ .game-footer {
+ padding: 15px;
+ font-size: 9px;
+ gap: 15px;
+ }
+ .report-dock {
+ left: 10px;
+ right: 10px;
+ bottom: 80px;
+ }
+ .offline-report {
+ padding: 18px;
+ gap: 10px;
+ max-height: 60vh;
+ overflow: auto;
+ }
+ .offline-report h2 {
+ font-size: 21px;
+ }
+ .offline-report p {
+ font-size: 11px;
+ }
+ .offline-report button {
+ font-size: 10px;
+ padding: 8px;
+ }
+ .pending-banner {
+ margin: 12px 13px;
+ }
+ .auth-panel {
+ padding: 28px 22px;
+ }
+ .auth-panel h1 {
+ font-size: 54px;
+ }
+ .game-body .lab-heading {
+ padding-top: 0;
+ }
+ .game-body .lab-heading h1 {
+ font-size: 30px;
+ }
+ .game-shell .plant-details .study-panel {
+ padding: 20px;
+ }
+ .game-shell .control-pair {
+ grid-template-columns: 1fr;
+ }
+}
+@media (prefers-reduced-motion: reduce) {
+ *,
+ *::before,
+ *::after {
+ animation: none !important;
+ transition: none !important;
+ scroll-behavior: auto !important;
+ }
+}
+html[data-motion="reduced"] *,
+html[data-motion="reduced"] *::before,
+html[data-motion="reduced"] *::after {
+ animation: none !important;
+ transition: none !important;
+ scroll-behavior: auto !important;
+}
+/* Keep the next goal visible in compact HUDs as well. */
+@media (max-width: 900px) {
+ .game-hud {
+ flex-wrap: wrap;
+ }
+ .quest-pin {
+ display: flex;
+ flex-basis: 100%;
+ align-items: center;
+ max-width: none;
+ gap: 10px;
+ padding: 8px 0 0;
+ border-left: 0;
+ border-top: 1px solid #ddd6b9;
+ }
+ .quest-pin small {
+ margin: 0;
+ font-size: 7px;
+ }
+ .quest-pin strong {
+ font-size: 11px;
+ }
+ .quest-pin span {
+ margin: 0 0 0 auto;
+ font-size: 9px;
+ }
+}
diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts
index 654597f..dc5fdb7 100644
--- a/apps/web/src/lib/api.ts
+++ b/apps/web/src/lib/api.ts
@@ -17,6 +17,10 @@ export class RequestError extends Error {
super(message);
}
}
+let csrfToken = "";
+export function setCsrf(token: string) {
+ csrfToken = token;
+}
export async function request
(
url: string,
options: RequestInit = {},
@@ -25,7 +29,12 @@ export async function request(
try {
response = await fetch("/api/v1" + url, {
...options,
- headers: { "Content-Type": "application/json", ...options.headers },
+ credentials: "same-origin",
+ headers: {
+ "Content-Type": "application/json",
+ "X-CSRF-Token": csrfToken,
+ ...options.headers,
+ },
});
} catch {
throw new RequestError(
@@ -86,5 +95,5 @@ export async function loadAllParents() {
items.push(...page.items);
cursor = page.nextCursor;
} while (cursor && items.length < 1000);
- return items;
+ return items.filter((p) => p.gameEligible && !p.soldAt);
}
diff --git a/apps/web/src/lib/game.tsx b/apps/web/src/lib/game.tsx
new file mode 100644
index 0000000..fa1e1d7
--- /dev/null
+++ b/apps/web/src/lib/game.tsx
@@ -0,0 +1,326 @@
+import {
+ createContext,
+ useContext,
+ useEffect,
+ useRef,
+ useState,
+ type ReactNode,
+} from "react";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import type { GameAction, GameState, OfflineSummary } from "@botanica/shared";
+import { request, RequestError, setCsrf } from "./api";
+import { newRequestId } from "./requestId";
+const Context = createContext(null);
+export function useGame() {
+ const game = useContext(Context);
+ if (!game) throw new Error("Game state unavailable");
+ return game;
+}
+export function useGameAction() {
+ const client = useQueryClient(),
+ keys = useRef(new Map());
+ return useMutation({
+ mutationFn: async (input: {
+ action: GameAction["action"];
+ data: Record;
+ }) => {
+ const fingerprint = JSON.stringify(input),
+ id = keys.current.get(fingerprint) ?? newRequestId();
+ keys.current.set(fingerprint, id);
+ const result = await request<{
+ message: string;
+ plantId?: string;
+ discovered?: boolean;
+ }>("/game/action", {
+ method: "POST",
+ body: JSON.stringify({ ...input, requestId: id }),
+ });
+ keys.current.delete(fingerprint);
+ return result;
+ },
+ onSuccess: () => client.invalidateQueries(),
+ onError: () => {
+ void client.invalidateQueries({ queryKey: ["game"] });
+ },
+ });
+}
+export function ActionFeedback({
+ action,
+}: {
+ action: ReturnType;
+}) {
+ return (
+ <>
+ {action.error && (
+
+ {action.error.message}
+
+ )}
+ {action.data && (
+
+ {action.data.message}
+
+ )}
+ >
+ );
+}
+function Auth() {
+ const [mode, setMode] = useState("login"),
+ [username, setUsername] = useState(""),
+ [password, setPassword] = useState(""),
+ [code, setCode] = useState(""),
+ [codes, setCodes] = useState(null),
+ client = useQueryClient();
+ const auth = useMutation({
+ mutationFn: () =>
+ request<{ csrfToken: string; recoveryCodes?: string[] }>(
+ "/auth/" + mode,
+ {
+ method: "POST",
+ body: JSON.stringify({
+ username,
+ password,
+ ...(mode === "recover" ? { recoveryCode: code } : {}),
+ }),
+ },
+ ),
+ onSuccess: (result) => {
+ setCsrf(result.csrfToken);
+ setPassword("");
+ if (result.recoveryCodes) setCodes(result.recoveryCodes);
+ else location.assign("/");
+ },
+ });
+ return (
+
+
+ ❦
+
+
+ A little greenhouse. Endless possibility.
+ Botanica
+ {codes ? (
+ <>
+ Keep your recovery codes
+
+ Save these five one-use codes somewhere private. They are shown
+ only once. Use one to reset a forgotten password; no email is
+ required.
+
+
+ {codes.map((c) => (
+ {c}
+ ))}
+
+ {
+ setCodes(null);
+ location.assign("/");
+ }}
+ >
+ I've saved my codes — enter greenhouse
+
+ >
+ ) : (
+ <>
+
+ {[
+ ["login", "Sign in"],
+ ["register", "Create account"],
+ ["recover", "Recover account"],
+ ].map(([id, label]) => (
+ {
+ setMode(id);
+ auth.reset();
+ }}
+ >
+ {label}
+
+ ))}
+
+
+
+ Start with 120 coins, three seeds and your own glasshouse. No
+ email, no daily streaks. Grow at your own pace.
+
+ >
+ )}
+
+
+ );
+}
+function Report({
+ report,
+ onDismiss,
+}: {
+ report: OfflineSummary;
+ onDismiss: () => void;
+}) {
+ return (
+
+
+
While you were away
+
Your greenhouse kept growing.
+
+ {report.harvested} harvested · {report.sold} sold · {report.breeds}{" "}
+ crosses · {report.seedsUsed} seeds used ·{" "}
+ {report.coinDelta >= 0 ? "+" : ""}
+ {report.coinDelta} coins
+
+
+ {new Date(report.from).toLocaleString()} →{" "}
+ {new Date(report.to).toLocaleString()} · {report.events} worker
+ intervals · {report.blocked} intervals waiting for resources or a
+ ready crop.
+
+ {!!report.notable.length && (
+
+ {report.notable.length} notable discoveries are waiting in your
+ collection.
+
+ )}
+
+
+ Dismiss
+
+
+ );
+}
+export function GameProvider({ children }: { children: ReactNode }) {
+ const me = useQuery({
+ queryKey: ["me"],
+ queryFn: async () => {
+ const value = await request<{ csrfToken: string }>("/me");
+ setCsrf(value.csrfToken);
+ return value;
+ },
+ retry: false,
+ }),
+ client = useQueryClient(),
+ [report, setReport] = useState(null);
+ const game = useQuery({
+ queryKey: ["game"],
+ queryFn: async () => {
+ const state = await request("/game/state");
+ setCsrf(state.player.csrfToken);
+ return state;
+ },
+ enabled: me.isSuccess,
+ refetchInterval: 15000,
+ retry: false,
+ });
+ useEffect(() => {
+ if (game.data?.report) setReport(game.data.report);
+ }, [game.data]);
+ useEffect(() => {
+ if (game.error instanceof RequestError && game.error.status === 401)
+ void client.invalidateQueries({ queryKey: ["me"] });
+ }, [game.error, client]);
+ if (me.isPending)
+ return (
+
+ Opening the greenhouse…
+
+ );
+ if (me.error) {
+ if (me.error instanceof RequestError && me.error.status === 401)
+ return ;
+ return (
+
+ {me.error.message}
+ void me.refetch()}>Reconnect
+
+ );
+ }
+ if (!game.data)
+ return (
+
+
+ {game.error
+ ? "The greenhouse is unavailable."
+ : "Preparing your growing bench…"}
+
+ {game.error && {game.error.message}
}
+ void game.refetch()}>Reconnect
+
+ );
+ return (
+
+ {children}
+ {report && (
+
+ setReport(null)} />
+
+ )}
+
+ );
+}
diff --git a/apps/web/src/lib/requestId.ts b/apps/web/src/lib/requestId.ts
new file mode 100644
index 0000000..20b0e53
--- /dev/null
+++ b/apps/web/src/lib/requestId.ts
@@ -0,0 +1,12 @@
+// getRandomValues also works on the configured plain-HTTP LAN origin.
+// randomUUID is restricted to secure contexts in browsers.
+export function newRequestId(): string {
+ if (typeof crypto.randomUUID === "function") return crypto.randomUUID();
+ const bytes = crypto.getRandomValues(new Uint8Array(16));
+ bytes[6] = (bytes[6] & 15) | 64;
+ bytes[8] = (bytes[8] & 63) | 128;
+ const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join(
+ "",
+ );
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
+}
diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx
index dc72dcb..fcff255 100644
--- a/apps/web/src/main.tsx
+++ b/apps/web/src/main.tsx
@@ -3,7 +3,6 @@ import { createRoot } from "react-dom/client";
import {
BrowserRouter,
NavLink,
- Navigate,
Route,
Routes,
Link,
@@ -12,156 +11,208 @@ import {
import {
QueryClient,
QueryClientProvider,
- useQuery,
+ useQueryClient,
} from "@tanstack/react-query";
-import { Leaf } from "lucide-react";
+import {
+ Leaf,
+ House,
+ ShoppingBasket,
+ Flower2,
+ GitBranch,
+ Users,
+ BookOpen,
+ FlaskConical,
+ NotebookPen,
+ Settings as SettingsIcon,
+} from "lucide-react";
import { Lab } from "./pages/Lab";
import { BreedingLab } from "./pages/BreedingLab";
import { Collection } from "./pages/Collection";
import { PlantDetails } from "./pages/PlantDetails";
import { RenderGallery } from "./pages/RenderGallery";
-import { BotanicalPlate } from "./components/BotanicalPlate";
+import {
+ Greenhouse,
+ Shop,
+ Quests,
+ Research,
+ Workers,
+ Settings,
+ Examples,
+ ReferenceDetails,
+} from "./pages/GamePages";
import { AppErrorBoundary } from "./components/AppErrorBoundary";
+import { GameProvider, useGame } from "./lib/game";
import "./styles.css";
+import "./game.css";
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: 1, refetchOnWindowFocus: false } },
});
-function Health() {
- const result = useQuery({
- queryKey: ["health"],
- queryFn: async () => {
- const r = await fetch("/health");
- if (!r.ok) throw new Error("Unavailable");
- return r.json() as Promise<{ status: string }>;
- },
- refetchInterval: 30000,
- retry: 1,
- });
- return (
-
-
- {result.isPending
- ? "Connecting to laboratory"
- : result.isError
- ? "Laboratory connection unavailable"
- : "Laboratory online"}
-
- );
-}
-
-function About() {
- return (
- <>
-
-
-
Field notes / Plant Laboratory
-
Botany meets a little imagination.
-
- An illustrated breeding sandbox inspired by Mendelian genetics. The
- plants are fictional; the joy of discovering a new combination is
- yours.
-
-
- Begin a botanical study →
-
-
-
-
- AN ORIGINAL BOTANICAL STUDY
-
-
-
-
- Paired traits
-
- Each of six loci carries two alleles. Dominance resolves their
- expression. A child inherits one allele from each saved parent, with
- small, recorded chances of mutation. This simplified game model is
- not a scientifically accurate botany simulator.
-
-
-
- A traceable collection
-
- Saved plants retain their genomes, versions, cosmetic seeds and
- ancestry. Rarity is a score derived from traits, separate from how
- often a trait occurs in your collection. Demonstration specimens are
- curated examples, clearly labeled.
-
-
-
- A first chapter
-
- Plant Laboratory is Botanica's first working module. Greenhouse
- progression, resources, research and accounts are future work. This
- local sandbox has no authentication and is not ready for public
- internet use.
-
-
-
- >
- );
-}
+const links = [
+ ["/", "Greenhouse", House],
+ ["/shop", "Shop", ShoppingBasket],
+ ["/gallery", "Collection", Flower2],
+ ["/breed", "Breeding", GitBranch],
+ ["/workers", "Workers", Users],
+ ["/research", "Research", BookOpen],
+ ["/quests", "Quests", NotebookPen],
+ ["/lab", "Advanced Lab", FlaskConical],
+ ["/settings", "Account", SettingsIcon],
+] as const;
function App() {
- const { pathname } = useLocation();
+ const game = useGame(),
+ client = useQueryClient(),
+ { pathname } = useLocation(),
+ quest = game.quests.find((q) => !q.claimed);
useEffect(() => {
document.getElementById("main")?.focus({ preventScroll: true });
window.scrollTo({ top: 0 });
}, [pathname]);
+ useEffect(() => {
+ document.documentElement.dataset.motion =
+ localStorage.getItem("botanica-motion") ?? "system";
+ }, []);
return (
- <>
+
Skip to content
-
-
-
-
-
+
+
+
- Plant LaboratoryBOTANICA / PLANT LABORATORY
+ BotanicaTHE GREENHOUSE JOURNAL
-
+
- {[
- ["lab", "Laboratory"],
- ["breed", "Breeding"],
- ["gallery", "Collection"],
- ["about", "Field notes"],
- ].map(([key, label]) => (
-
- {label}
+ {links.map(([to, label, Icon]) => (
+
+
+ {label}
))}
-
-
-
- } />
- } />
- } />
- } />
- } />
- } />
- } />
-
- Page not found
-
- Return to the laboratory →
-
-
- }
- />
-
-
-
- Rooted in curiosity. Grown with care.
-
-
- >
+
+ ❦ Reference herbarium
+
+
+ Rooted in curiosity.
+
+ Grown with care.
+
+
+
+
+
+
+ {game.player.username.slice(0, 1).toUpperCase()}
+
+
+
{game.player.username}
+
+ Level {game.player.level} · {game.player.xp} XP
+
+
+
+
+
+ COIN PURSE
+ ◈ {game.player.coins.toLocaleString()}
+
+
+ SEED POUCH
+
+ ❧ {Object.values(game.seeds).reduce((a, b) => a + b, 0)}
+
+
+ {game.seeds.starter} starter · {game.seeds.uncommon} uncommon ·{" "}
+ {game.seeds.rare} rare
+
+
+
+ NEXT IN YOUR NOTEBOOK
+ {quest?.name ?? "A notebook full of memories"}
+
+ {quest
+ ? quest.progress +
+ " / " +
+ quest.target +
+ " · " +
+ (quest.progress >= quest.target
+ ? "Reward ready →"
+ : "Keep growing →")
+ : "All goals complete"}
+
+
+
+ {game.pendingWork && (
+
+ Your team has more offline work to process.{" "}
+
+ void client.invalidateQueries({ queryKey: ["game"] })
+ }
+ >
+ Process next batch
+
+
+ )}
+
+
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+ } />
+
+
+ Advanced studies are a creative sandbox. Saved studies keep
+ their genomes and artwork, but cannot be sold or used for
+ game rewards. Grow game plants →
+
+
+ >
+ }
+ />
+ } />
+ } />
+ } />
+ } />
+ } />
+
+ Path not found
+ Return to your greenhouse →
+
+ }
+ />
+
+
+
+ Botanica · No rush. Something good is growing.
+ Botanical studies
+
+
+
+ {links.slice(0, 5).map(([to, label, Icon]) => (
+
+
+ {label}
+
+ ))}
+
+
);
}
createRoot(document.getElementById("root")!).render(
@@ -169,7 +220,9 @@ createRoot(document.getElementById("root")!).render(
-
+
+
+
diff --git a/apps/web/src/pages/BreedingLab.tsx b/apps/web/src/pages/BreedingLab.tsx
index c73ed40..4600c20 100644
--- a/apps/web/src/pages/BreedingLab.tsx
+++ b/apps/web/src/pages/BreedingLab.tsx
@@ -6,6 +6,8 @@ import { PlantPreview } from "../components/PlantPreview";
import { QueryMessage } from "../components/QueryMessage";
import { api, loadAllParents } from "../lib/api";
import type { PlantDto } from "@botanica/shared";
+import { useGame } from "../lib/game";
+import { newRequestId } from "../lib/requestId";
function Parent({
label,
id,
@@ -22,7 +24,11 @@ function Parent({
{label}
- onChange(e.target.value)}>
+ onChange(e.target.value)}
+ >
Choose a saved plant
{plants.map((p) => (
@@ -58,6 +64,7 @@ function Parent({
);
}
export function BreedingLab() {
+ const game = useGame();
const [search] = useSearchParams();
const parents = useQuery({ queryKey: ["parents"], queryFn: loadAllParents }),
[a, setA] = useState(search.get("parent") ?? ""),
@@ -72,6 +79,7 @@ export function BreedingLab() {
const cross = useMutation({
mutationFn: api.cross,
onSuccess: () => {
+ void client.invalidateQueries({ queryKey: ["game"] });
void client.invalidateQueries({ queryKey: ["parents"] });
void client.invalidateQueries({ queryKey: ["plants"] });
void client.invalidateQueries({ queryKey: ["collection-stats"] });
@@ -109,7 +117,7 @@ export function BreedingLab() {
function breed() {
const pair = a + ":" + b;
if (!key.current || key.current.pair !== pair)
- key.current = { pair, id: crypto.randomUUID() };
+ key.current = { pair, id: newRequestId() };
cross.mutate({
parentAId: a,
parentBId: b,
@@ -128,7 +136,7 @@ export function BreedingLab() {
- Design a parent →
+ Advanced genetics →
{parents.isPending ? (
@@ -141,9 +149,12 @@ export function BreedingLab() {
) : !list.length ? (
No saved parents yet
-
Design and save two plants in the laboratory.
-
- Open laboratory →
+
+ Grow and harvest two plants in your greenhouse. Breeding unlocks at
+ level 3 and costs {game.breedCost} coins; both parents are kept.
+
+
+ Open greenhouse →
) : (
@@ -167,9 +178,23 @@ export function BreedingLab() {
{message}
+
+ {game.player.level < 3
+ ? "Reach level 3 to unlock breeding."
+ : game.player.coins < game.breedCost
+ ? "Not enough coins for this cross."
+ : `${game.breedCost} coins per cross · instant offspring · parents kept.`}
+
{cross.isPending
? "Crossing…"
diff --git a/apps/web/src/pages/Collection.tsx b/apps/web/src/pages/Collection.tsx
index f4e70a3..f9fcc3b 100644
--- a/apps/web/src/pages/Collection.tsx
+++ b/apps/web/src/pages/Collection.tsx
@@ -68,8 +68,8 @@ export function Collection() {
: "Your saved plants, their inherited traits and the stories behind them."}
-
- Create a plant →
+
+ Grow a plant →
@@ -176,7 +176,10 @@ export function Collection() {
>
{p.rarity.tier}
- Gen {p.generation}
+
+ {p.isFavorite ? "★ " : ""}
+ {p.isProtected ? "Protected · " : ""}Gen {p.generation}
+
{p.phenotype.leafPattern}
- {p.creationSource === "demo"
- ? "Demo study"
- : p.creationSource === "bred"
- ? "Bred offspring"
- : "Designed founder"}
+ {p.firstDiscovery
+ ? "First discovery"
+ : p.gameEligible && p.creationSource !== "bred"
+ ? "Greenhouse grown"
+ : p.creationSource === "demo"
+ ? "Demo study"
+ : p.creationSource === "bred"
+ ? "Bred offspring"
+ : "Designed founder"}
@@ -208,7 +215,8 @@ export function Collection() {
No plants match these filters
Try a different trait or clear your search to see the whole
- collection.
+ collection. Harvest a crop in your greenhouse to add your first
+ specimen.
Show all plants
diff --git a/apps/web/src/pages/GamePages.tsx b/apps/web/src/pages/GamePages.tsx
new file mode 100644
index 0000000..2609320
--- /dev/null
+++ b/apps/web/src/pages/GamePages.tsx
@@ -0,0 +1,872 @@
+import { useEffect, useState, useMemo, useRef } from "react";
+import { Link, useNavigate, useParams } from "react-router";
+import { useQuery, useQueryClient } from "@tanstack/react-query";
+import type { GameWorker, SeedType, WorkerPolicy } from "@botanica/shared";
+import { useGame, useGameAction, ActionFeedback } from "../lib/game";
+import { loadAllParents, request } from "../lib/api";
+import { PlantPreview } from "../components/PlantPreview";
+import { PlantRecord } from "../components/PlantRecord";
+export function GameHeading({ title, note }: { title: string; note: string }) {
+ return (
+
+
Botanica / grow at your own pace
+
{title}
+
{note}
+
+ );
+}
+export function Greenhouse() {
+ const client = useQueryClient(),
+ lastReadyCheck = useRef(0);
+ const game = useGame(),
+ [selected, setSelected] = useState(""),
+ [seed, setSeed] = useState("starter"),
+ [now, setNow] = useState(Date.now()),
+ action = useGameAction();
+ useEffect(() => {
+ const id = setInterval(() => setNow(Date.now()), 1000);
+ return () => clearInterval(id);
+ }, []);
+ const house =
+ game.greenhouses.find((h) => h.id === selected) ?? game.greenhouses[0],
+ offset = useMemo(
+ () => new Date(game.serverNow).getTime() - Date.now(),
+ [game.serverNow],
+ );
+ useEffect(() => {
+ if (now - lastReadyCheck.current < 1000) return;
+ if (
+ house.plants.some(
+ (slot) =>
+ slot.readyAt &&
+ !slot.ready &&
+ new Date(slot.readyAt).getTime() <= now + offset,
+ )
+ ) {
+ lastReadyCheck.current = now;
+ void client.invalidateQueries({ queryKey: ["game"] });
+ }
+ }, [now, offset, house, client]);
+ return (
+ <>
+
+
+
+ {game.greenhouses.map((h) => (
+ setSelected(h.id)}
+ >
+ {h.name}
+
+ ))}
+
+
+ Plant with
+ setSeed(e.target.value as SeedType)}
+ >
+ {game.seedInfo.map((s) => (
+
+ {s.name} · {game.seeds[s.id]} available
+
+ ))}
+
+
+
+
+
+ BOTANICA · {house.name.toUpperCase()}
+
+
+ {house.slots} growing slots
+
+ {house.lamp ? "☀ Grow lamp · 10% faster" : "Natural light"} ·{" "}
+ {house.irrigation ? "Irrigation installed" : "Hand tended"}
+
+
+
+ {house.plants.map((slot) => {
+ const remaining = slot.readyAt
+ ? Math.max(
+ 0,
+ Math.ceil(
+ (new Date(slot.readyAt).getTime() - now - offset) / 1000,
+ ),
+ )
+ : 0,
+ ready = slot.ready,
+ progress =
+ slot.readyAt && slot.plantedAt
+ ? Math.min(
+ 100,
+ Math.max(
+ 0,
+ (100 *
+ (now + offset - new Date(slot.plantedAt).getTime())) /
+ (new Date(slot.readyAt).getTime() -
+ new Date(slot.plantedAt).getTime()),
+ ),
+ )
+ : 0;
+ return (
+
+
+ № {String(slot.slotNo + 1).padStart(2, "0")}
+
+ {ready
+ ? "Ready to harvest"
+ : slot.seedType
+ ? "Growing"
+ : "Open soil"}
+
+
+
+ {slot.phenotype ? (
+
+ ) : (
+
+
+
+
+
+
+
+ )}
+
+
+ {slot.seedType ? (
+ <>
+
+
+ {ready
+ ? "Your next discovery is waiting."
+ : `${Math.floor(remaining / 60)}m ${remaining % 60}s remaining`}
+
+
+ action.mutate({
+ action: "harvest",
+ data: { slotId: slot.id },
+ })
+ }
+ >
+ {ready ? "Harvest plant" : "Taking root…"}
+
+ >
+ ) : (
+ <>
+
+ {game.seeds[seed]
+ ? "A little room for possibility."
+ : "No seeds of this type. Try starter or visit the shop."}
+
+
+ action.mutate({
+ action: "plant",
+ data: { slotId: slot.id, seedType: seed },
+ })
+ }
+ >
+ Plant {seed} seed
+
+ >
+ )}
+
+
+ );
+ })}
+
+
+
+ {action.data?.plantId && (
+
+
+ {action.data.discovered
+ ? "A first of its kind."
+ : "A new leaf in your collection."}
+
+
+ Your specimen is safe in the collection. Each harvest recycles one
+ starter seed.
+
+
+ Meet your plant →
+
+
+ )}
+
+
❦
+
+ Keep a favorite or sell a specimen to grow your coin purse. Crops
+ never wilt while you're away.
+
+
+ Your collection →
+
+
+ >
+ );
+}
+export function Shop() {
+ const game = useGame(),
+ action = useGameAction(),
+ [houseId, setHouse] = useState(game.greenhouses[0].id),
+ house = game.greenhouses.find((h) => h.id === houseId)!;
+ return (
+ <>
+
+
+ Equip greenhouse
+ setHouse(e.target.value)}
+ >
+ {game.greenhouses.map((h) => (
+
+ {h.name}
+
+ ))}
+
+
+
+ {game.shop.map((item) => {
+ const cost =
+ item.id === "slot"
+ ? (house.nextSlotCost ?? item.cost)
+ : item.cost,
+ reason =
+ item.disabledReason ??
+ (item.id === "slot" && house.slots >= 12
+ ? "All 12 slots installed"
+ : (item.id === "lamp" && house.lamp) ||
+ (item.id === "irrigation" && house.irrigation)
+ ? "Already installed"
+ : game.player.coins < cost
+ ? "Not enough coins"
+ : game.pendingWork
+ ? "Finish offline work first"
+ : null);
+ return (
+
+
+ {item.id.startsWith("seed")
+ ? "❧"
+ : item.id === "greenhouse"
+ ? "⌂"
+ : "✧"}
+
+ {item.name}
+ {item.description}
+ ◈ {cost} coins
+
+ action.mutate({
+ action: "purchase",
+ data: { itemId: item.id, houseId },
+ })
+ }
+ >
+ {reason ?? "Purchase"}
+
+
+ );
+ })}
+
+
+
+ Seed packs use fixed founder genomes, described above; no random rarity
+ roll. Starter seeds are guaranteed back at every harvest.
+
+ >
+ );
+}
+export function Quests() {
+ const game = useGame(),
+ action = useGameAction();
+ return (
+ <>
+
+
+ {game.quests.map((q) => (
+
+ {q.claimed ? "Completed" : "Field goal"}
+ {q.name}
+ {q.description}
+
+
+ {q.progress} / {q.target} · ◈ {q.coins} · {q.xp} XP
+
+
+ action.mutate({ action: "claim", data: { questId: q.id } })
+ }
+ >
+ {q.claimed
+ ? "Reward collected"
+ : q.progress < q.target
+ ? "In progress"
+ : "Claim reward"}
+
+
+ ))}
+
+
+ >
+ );
+}
+export function Research() {
+ const game = useGame(),
+ action = useGameAction();
+ return (
+ <>
+
+
+ {game.research.map((item) => (
+
+
+ Level {item.level} ·{" "}
+ {item.owned ? "Learned" : "Botanical research"}
+
+ {item.name}
+ {item.description}
+ ◈ {item.cost} coins
+
+ action.mutate({
+ action: "research",
+ data: { researchId: item.id },
+ })
+ }
+ >
+ {item.disabledReason ??
+ (game.player.coins < item.cost
+ ? "Not enough coins"
+ : "Research")}
+
+
+ ))}
+
+
+ >
+ );
+}
+function Assignment({ worker }: { worker: GameWorker }) {
+ const game = useGame(),
+ action = useGameAction(),
+ parents = useQuery({ queryKey: ["parents"], queryFn: loadAllParents }),
+ [house, setHouse] = useState(worker.greenhouseId ?? game.greenhouses[0].id),
+ [enabled, setEnabled] = useState(worker.enabled),
+ [policy, setPolicy] = useState({
+ mode: worker.kind === "gardener" ? "repeat_crop" : "repeat_cross",
+ seedType: "starter",
+ autoSell: false,
+ reserveCoins: 100,
+ ...(worker.policy as Partial),
+ });
+ const update = (patch: Partial) =>
+ setPolicy({ ...policy, ...patch });
+ return (
+
+
+ {worker.enabled ? "Working" : "Paused"} · one interval /{" "}
+ {worker.intervalSeconds}s
+
+
+ {worker.kind === "gardener" ? "Junior gardener" : "Breeding specialist"}
+
+
+
+
+ );
+}
+export function Workers() {
+ const game = useGame(),
+ action = useGameAction();
+ return (
+ <>
+
+
+ {(["gardener", "breeder"] as const).map((kind) => {
+ const level = kind === "gardener" ? 5 : 7,
+ reason =
+ game.player.level < level
+ ? "Unlocks at level " + level
+ : game.workers.length >= 4
+ ? "Team full"
+ : game.player.coins < game.workerCosts[kind]
+ ? "Not enough coins"
+ : null;
+ return (
+
+
+ {kind === "gardener"
+ ? "Junior gardener"
+ : "Breeding specialist"}
+
+
+ {kind === "gardener"
+ ? "Tends one ready or empty slot every 120 seconds (96 with irrigation). Uses seed inventory; recycles starter seeds."
+ : "Creates one real cross every 300 seconds, subject to your coin reserve and parent eligibility."}
+
+
+ ◈ {game.workerCosts[kind]} coins · no wages
+
+
+ action.mutate({ action: "hire", data: { kind } })
+ }
+ >
+ {reason ?? "Hire " + kind}
+
+
+ );
+ })}
+
+
+
+ Your greenhouse team · {game.workers.length}/4
+
+ {!game.workers.length && (
+
+ No employees yet. Growing, selling and completing quests will fund
+ your first gardener.
+
+ )}
+
+ {game.workers.map((w) => (
+
+ ))}
+
+
+ Automation starts paused. Auto-sale starts off and only sells duplicate
+ Common harvests: never favorites, protected plants, first discoveries,
+ rare plants or your last specimen of a species. Catch-up processes at
+ most 100 intervals and 24 hours per calculation. Unprocessed time stays
+ available; blocked intervals are recorded, without retroactive spending
+ of newly acquired resources.
+
+ Recent work
+ {game.recentRuns.length ? (
+
+ {game.recentRuns.map((run) => (
+
+ {new Date(run.summary.to).toLocaleString()} ·{" "}
+ {run.summary.harvested} harvested · {run.summary.sold} sold ·{" "}
+ {run.summary.breeds} crosses · {run.summary.coinDelta} coins ·{" "}
+ {run.summary.blocked} waiting intervals
+
+ ))}
+
+ ) : (
+ The work journal will fill after your team starts tending.
+ )}
+ >
+ );
+}
+export function Settings() {
+ const game = useGame(),
+ [error, setError] = useState(""),
+ [motion, setMotion] = useState(
+ localStorage.getItem("botanica-motion") ?? "system",
+ );
+ useEffect(() => {
+ document.documentElement.dataset.motion = motion;
+ localStorage.setItem("botanica-motion", motion);
+ }, [motion]);
+ return (
+ <>
+
+
+ Comfort & account
+
+ Animations
+ setMotion(e.target.value)}
+ >
+ Follow device preference
+ Reduce motion
+
+
+
+ Recovery codes were shown once when you joined. Keep them private;
+ each code resets your password once and signs out other sessions. Use
+ “Recover account” on the sign-in screen.
+
+ {
+ void request("/auth/logout", { method: "POST", body: "{}" })
+ .then(() => location.assign("/"))
+ .catch((e) => setError(e.message));
+ }}
+ >
+ Sign out
+
+ {error && {error}
}
+
+ Fictional botany, deterministic genetics. No tracking, paid currency
+ or login streaks.
+
+
+ Renderer study gallery →
+
+
+ >
+ );
+}
+export function Examples() {
+ const [cursor, setCursor] = useState(null);
+ const result = useQuery({
+ queryKey: ["demo", cursor],
+ queryFn: () =>
+ request<
+ import("@botanica/shared").PageDto
+ >(
+ "/demo/plants?limit=100" +
+ (cursor ? "&cursor=" + encodeURIComponent(cursor) : ""),
+ ),
+ });
+ return (
+ <>
+
+ {result.error && {result.error.message}
}
+
+ {result.data?.items.map((p) => (
+
+
+ {p.name}
+
+ {p.rarity.tier} · {p.phenotype.pigment} ·{" "}
+ {p.phenotype.leafPattern}
+
+
+ Inspect reference →
+
+
+ ))}
+
+
+ {cursor && (
+ setCursor(null)}>First references
+ )}
+ {result.data?.nextCursor && (
+ setCursor(result.data!.nextCursor!)}>
+ More references →
+
+ )}
+
+ >
+ );
+}
+export function ReferenceDetails() {
+ const { id } = useParams(),
+ navigate = useNavigate(),
+ plant = useQuery({
+ queryKey: ["demo-plant", id],
+ queryFn: () =>
+ request("/demo/plants/" + id),
+ });
+ return (
+ <>
+
+
+ ← Reference herbarium
+
+ {plant.error && {plant.error.message}
}
+ {plant.data && (
+ navigate("/examples/" + parent)}
+ />
+ )}
+ >
+ );
+}
diff --git a/apps/web/src/pages/Lab.tsx b/apps/web/src/pages/Lab.tsx
index e296e1e..61017ed 100644
--- a/apps/web/src/pages/Lab.tsx
+++ b/apps/web/src/pages/Lab.tsx
@@ -57,8 +57,9 @@ export function Lab() {
The plant laboratory
Grow something unexpected.
- Edit paired alleles and see their expression. Save a designed
- founder, then cross saved plants in the breeding lab.
+ Edit paired alleles and see their expression. Save a creative study
+ or inspect a grown plant. Game breeding uses mature greenhouse
+ specimens.
diff --git a/artifacts/m11/breeding-desktop.png b/artifacts/m11/breeding-desktop.png
new file mode 100644
index 0000000000000000000000000000000000000000..a1eb005a672b12eef679247242c7634ee04fe150
GIT binary patch
literal 203784
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
z