Compare commits
2 commits
c9b935f842
...
e310af5a43
| Author | SHA1 | Date | |
|---|---|---|---|
| e310af5a43 | |||
| 8296386c7d |
|
|
@ -4,3 +4,6 @@ POSTGRES_DB=botanica
|
||||||
POSTGRES_USER=botanica
|
POSTGRES_USER=botanica
|
||||||
POSTGRES_PASSWORD=change-me-before-exposing
|
POSTGRES_PASSWORD=change-me-before-exposing
|
||||||
NODE_ENV=development
|
NODE_ENV=development
|
||||||
|
# Optional comma-separated exact browser origins for authenticated POST requests.
|
||||||
|
# PUBLIC_DOMAIN remains an origin without a port. Add Vite's origin for npm run dev.
|
||||||
|
# PUBLIC_ORIGINS=http://localhost:8155,http://localhost:5173,http://127.0.0.1:8155,http://192.168.1.197:8155,https://botanica.p20.dev
|
||||||
|
|
|
||||||
18
BOTANICA_M5_M11_README.md
Normal file
|
|
@ -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.
|
||||||
9
BOTANICA_M5_M11_START.md
Normal file
|
|
@ -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.
|
||||||
20
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
|
## Run
|
||||||
|
|
||||||
|
|
@ -18,14 +18,20 @@ Open http://localhost:8155. Only the web entrypoint is published. It binds to al
|
||||||
|
|
||||||
## Explore
|
## 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.
|
- `/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.
|
- `/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.
|
- `/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.
|
- `/plants/:id`: directly linkable plant detail and ancestry navigation.
|
||||||
- `/studies`: M2 systematic 12-variation atlas and 100-seed renderer proof.
|
- `/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
|
## Checks and development
|
||||||
|
|
||||||
|
|
@ -37,7 +43,7 @@ npm run test:integration
|
||||||
docker compose -f compose.test.yaml down
|
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.
|
`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
|
## 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.
|
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).
|
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.
|
||||||
|
|
|
||||||
28
apps/server/migrations/0003_game_foundation.sql
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
ALTER TABLE players ADD COLUMN normalized_username text UNIQUE;
|
||||||
|
ALTER TABLE players ADD COLUMN password_hash text;
|
||||||
|
ALTER TABLE players ADD COLUMN coin_balance integer NOT NULL DEFAULT 0 CHECK(coin_balance BETWEEN 0 AND 1000000000);
|
||||||
|
ALTER TABLE players ADD COLUMN xp integer NOT NULL DEFAULT 0 CHECK(xp>=0);
|
||||||
|
ALTER TABLE players ADD COLUMN game_stats jsonb NOT NULL DEFAULT '{}'::jsonb;
|
||||||
|
ALTER TABLE plants ADD COLUMN game_eligible boolean NOT NULL DEFAULT false;
|
||||||
|
ALTER TABLE plants ADD COLUMN is_favorite boolean NOT NULL DEFAULT false;
|
||||||
|
ALTER TABLE plants ADD COLUMN is_protected boolean NOT NULL DEFAULT false;
|
||||||
|
ALTER TABLE plants ADD COLUMN first_discovery boolean NOT NULL DEFAULT false;
|
||||||
|
ALTER TABLE plants ADD COLUMN sold_at timestamptz;
|
||||||
|
ALTER TABLE breeding_events DROP CONSTRAINT breeding_events_idempotency_key_key;
|
||||||
|
ALTER TABLE breeding_events ADD CONSTRAINT breeding_owner_request UNIQUE(owner_id,idempotency_key);
|
||||||
|
CREATE TABLE sessions(token_hash text PRIMARY KEY,player_id uuid NOT NULL REFERENCES players(id),expires_at timestamptz NOT NULL);
|
||||||
|
CREATE INDEX sessions_owner ON sessions(player_id);
|
||||||
|
CREATE TABLE recovery_codes(player_id uuid REFERENCES players(id),code_hash text PRIMARY KEY,used_at timestamptz);
|
||||||
|
CREATE TABLE inventory_seeds(player_id uuid REFERENCES players(id),seed_type text NOT NULL,quantity integer NOT NULL CHECK(quantity>=0),PRIMARY KEY(player_id,seed_type));
|
||||||
|
CREATE TABLE greenhouses(id uuid PRIMARY KEY,player_id uuid NOT NULL REFERENCES players(id),name text NOT NULL,slots integer NOT NULL DEFAULT 4 CHECK(slots BETWEEN 4 AND 12),lamp boolean NOT NULL DEFAULT false,irrigation boolean NOT NULL DEFAULT false);
|
||||||
|
CREATE INDEX greenhouse_owner ON greenhouses(player_id);
|
||||||
|
CREATE TABLE plant_slots(id uuid PRIMARY KEY,greenhouse_id uuid NOT NULL REFERENCES greenhouses(id),slot_no integer NOT NULL,seed_type text,genome jsonb,visual_seed integer,planted_at timestamptz,ready_at timestamptz,growth_rules_version integer NOT NULL DEFAULT 1,UNIQUE(greenhouse_id,slot_no));
|
||||||
|
CREATE TABLE game_receipts(player_id uuid REFERENCES players(id),request_id uuid NOT NULL,operation text NOT NULL,body_hash text NOT NULL,response jsonb NOT NULL,created_at timestamptz NOT NULL DEFAULT now(),PRIMARY KEY(player_id,request_id));
|
||||||
|
CREATE TABLE coin_ledger(id uuid PRIMARY KEY,player_id uuid NOT NULL REFERENCES players(id),amount integer NOT NULL,balance_after integer NOT NULL CHECK(balance_after>=0),reason text NOT NULL,reference text NOT NULL,created_at timestamptz NOT NULL DEFAULT now());
|
||||||
|
CREATE INDEX ledger_owner_created ON coin_ledger(player_id,created_at DESC);
|
||||||
|
CREATE TABLE discoveries(player_id uuid REFERENCES players(id),signature text NOT NULL,plant_id uuid REFERENCES plants(id),created_at timestamptz NOT NULL DEFAULT now(),PRIMARY KEY(player_id,signature));
|
||||||
|
CREATE TABLE player_quests(player_id uuid REFERENCES players(id),quest_id text NOT NULL,claimed_at timestamptz NOT NULL DEFAULT now(),PRIMARY KEY(player_id,quest_id));
|
||||||
|
CREATE TABLE player_research(player_id uuid REFERENCES players(id),research_id text NOT NULL,created_at timestamptz NOT NULL DEFAULT now(),PRIMARY KEY(player_id,research_id));
|
||||||
|
CREATE TABLE workers(id uuid PRIMARY KEY,player_id uuid NOT NULL REFERENCES players(id),kind text NOT NULL CHECK(kind IN ('gardener','breeder')),greenhouse_id uuid REFERENCES greenhouses(id),enabled boolean NOT NULL DEFAULT false,policy jsonb NOT NULL DEFAULT '{}'::jsonb,last_processed_at timestamptz NOT NULL DEFAULT now());
|
||||||
|
CREATE INDEX worker_owner ON workers(player_id);
|
||||||
|
CREATE TABLE worker_runs(id uuid PRIMARY KEY,player_id uuid NOT NULL REFERENCES players(id),worker_id uuid NOT NULL REFERENCES workers(id),interval_start timestamptz NOT NULL,interval_end timestamptz NOT NULL,summary jsonb NOT NULL,created_at timestamptz NOT NULL DEFAULT now(),UNIQUE(worker_id,interval_start,interval_end));
|
||||||
|
|
@ -11,13 +11,14 @@
|
||||||
"db:seed": "tsx src/seed.ts"
|
"db:seed": "tsx src/seed.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"fastify": "^5.12.7",
|
|
||||||
"zod": "^4.0.0",
|
|
||||||
"drizzle-orm": "^0.45.4",
|
|
||||||
"pg": "^8.16.3",
|
|
||||||
"@botanica/genetics": "0.0.0",
|
"@botanica/genetics": "0.0.0",
|
||||||
|
"@botanica/renderer": "0.0.0",
|
||||||
"@botanica/shared": "0.0.0",
|
"@botanica/shared": "0.0.0",
|
||||||
"@botanica/renderer": "0.0.0"
|
"@node-rs/argon2": "^2.2.2",
|
||||||
|
"drizzle-orm": "^0.45.4",
|
||||||
|
"fastify": "^5.12.7",
|
||||||
|
"pg": "^8.16.3",
|
||||||
|
"zod": "^4.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/pg": "^8.15.5",
|
"@types/pg": "^8.15.5",
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,11 @@
|
||||||
|
import {
|
||||||
|
registerAuth,
|
||||||
|
authenticate,
|
||||||
|
checkCsrf,
|
||||||
|
type Identity,
|
||||||
|
} from "./auth.js";
|
||||||
|
import { registerGameRoutes } from "./game-routes.js";
|
||||||
|
import type { FastifyRequest } from "fastify";
|
||||||
import type { Pool } from "pg";
|
import type { Pool } from "pg";
|
||||||
import { ApiError } from "./errors.js";
|
import { ApiError } from "./errors.js";
|
||||||
import { registerPlantRoutes } from "./plant-routes.js";
|
import { registerPlantRoutes } from "./plant-routes.js";
|
||||||
|
|
@ -10,8 +18,21 @@ export function createApp(
|
||||||
checkDatabase: () => Promise<unknown>,
|
checkDatabase: () => Promise<unknown>,
|
||||||
logger = false,
|
logger = false,
|
||||||
pool?: Pool,
|
pool?: Pool,
|
||||||
|
identityForTest?: (request: FastifyRequest) => Promise<Identity>,
|
||||||
) {
|
) {
|
||||||
const app = Fastify({ logger, bodyLimit: 65536 });
|
const app = Fastify({
|
||||||
|
logger: logger
|
||||||
|
? {
|
||||||
|
redact: [
|
||||||
|
"req.headers.cookie",
|
||||||
|
"req.headers.authorization",
|
||||||
|
"req.headers.x-csrf-token",
|
||||||
|
"res.headers.set-cookie",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
: false,
|
||||||
|
bodyLimit: 65536,
|
||||||
|
});
|
||||||
app.get("/health", async (_request, reply) => {
|
app.get("/health", async (_request, reply) => {
|
||||||
try {
|
try {
|
||||||
await checkDatabase();
|
await checkDatabase();
|
||||||
|
|
@ -27,7 +48,33 @@ export function createApp(
|
||||||
});
|
});
|
||||||
registerGeneticsRoutes(app);
|
registerGeneticsRoutes(app);
|
||||||
registerPreviewRoute(app);
|
registerPreviewRoute(app);
|
||||||
if (pool) registerPlantRoutes(app, pool);
|
if (pool) {
|
||||||
|
registerAuth(app, pool);
|
||||||
|
app.addHook("preHandler", async (request) => {
|
||||||
|
const path = request.url.split("?")[0];
|
||||||
|
if (
|
||||||
|
!path.startsWith("/api/v1/") ||
|
||||||
|
path.startsWith("/api/v1/auth/") ||
|
||||||
|
[
|
||||||
|
"/api/v1/me",
|
||||||
|
"/api/v1/catalog/species",
|
||||||
|
"/api/v1/phenotype/resolve",
|
||||||
|
"/api/v1/preview",
|
||||||
|
].includes(path)
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
request.identity = identityForTest
|
||||||
|
? await identityForTest(request)
|
||||||
|
: await authenticate(pool, request);
|
||||||
|
if (
|
||||||
|
!["GET", "HEAD", "OPTIONS"].includes(request.method) &&
|
||||||
|
!identityForTest
|
||||||
|
)
|
||||||
|
checkCsrf(request, request.identity);
|
||||||
|
});
|
||||||
|
registerPlantRoutes(app, pool);
|
||||||
|
registerGameRoutes(app, pool);
|
||||||
|
}
|
||||||
app.setNotFoundHandler((_request, reply) =>
|
app.setNotFoundHandler((_request, reply) =>
|
||||||
reply
|
reply
|
||||||
.code(404)
|
.code(404)
|
||||||
|
|
|
||||||
303
apps/server/src/auth.ts
Normal file
|
|
@ -0,0 +1,303 @@
|
||||||
|
import {
|
||||||
|
randomBytes,
|
||||||
|
randomUUID,
|
||||||
|
createHash,
|
||||||
|
timingSafeEqual,
|
||||||
|
} from "node:crypto";
|
||||||
|
import { hash, verify, Algorithm } from "@node-rs/argon2";
|
||||||
|
import type { Pool } from "pg";
|
||||||
|
import type { FastifyInstance, FastifyRequest, FastifyReply } from "fastify";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { ApiError } from "./errors.js";
|
||||||
|
import { GAME, levelFor } from "./game-config.js";
|
||||||
|
export const digest = (value: string) =>
|
||||||
|
createHash("sha256").update(value).digest("hex");
|
||||||
|
declare module "fastify" {
|
||||||
|
interface FastifyRequest {
|
||||||
|
identity?: Identity;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export interface Identity {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
csrfToken: string;
|
||||||
|
}
|
||||||
|
const credentials = z.strictObject({
|
||||||
|
username: z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.regex(/^[a-zA-Z0-9_]{3,24}$/, "Use 3–24 letters, numbers or underscores."),
|
||||||
|
password: z.string().min(10).max(128),
|
||||||
|
});
|
||||||
|
export function allowedOrigin(request: FastifyRequest) {
|
||||||
|
const origin = request.headers.origin;
|
||||||
|
if (!origin) return false;
|
||||||
|
const defaults = [
|
||||||
|
"http://localhost:8155",
|
||||||
|
"http://127.0.0.1:8155",
|
||||||
|
"http://192.168.1.197:8155",
|
||||||
|
"https://botanica.p20.dev",
|
||||||
|
];
|
||||||
|
return (
|
||||||
|
process.env.PUBLIC_ORIGINS?.split(",").map((s) => s.trim()) ?? defaults
|
||||||
|
).includes(origin);
|
||||||
|
}
|
||||||
|
function cookie(
|
||||||
|
reply: FastifyReply,
|
||||||
|
request: FastifyRequest,
|
||||||
|
value: string,
|
||||||
|
maxAge = 2592000,
|
||||||
|
) {
|
||||||
|
const secure =
|
||||||
|
request.headers.origin?.startsWith("https://") ||
|
||||||
|
request.headers["x-forwarded-proto"] === "https";
|
||||||
|
reply.header(
|
||||||
|
"set-cookie",
|
||||||
|
`botanica_session=${value}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${maxAge}${secure ? "; Secure" : ""}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
export function registerAuth(app: FastifyInstance, pool: Pool) {
|
||||||
|
const attempts = new Map<string, { count: number; until: number }>();
|
||||||
|
function throttle(key: string, limit: number) {
|
||||||
|
const now = Date.now(),
|
||||||
|
previous = attempts.get(key);
|
||||||
|
if (previous && previous.until > now) {
|
||||||
|
if (++previous.count <= limit) return;
|
||||||
|
} else {
|
||||||
|
if (attempts.size >= 4096)
|
||||||
|
for (const [id, entry] of attempts)
|
||||||
|
if (entry.until <= now) attempts.delete(id);
|
||||||
|
if (attempts.size < 4096) {
|
||||||
|
attempts.set(key, { count: 1, until: now + 900000 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new ApiError(
|
||||||
|
429,
|
||||||
|
"AUTH_THROTTLED",
|
||||||
|
"Too many attempts. Try again in 15 minutes.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
app.addHook("preHandler", async (request) => {
|
||||||
|
if (request.url.startsWith("/api/v1/auth/") && request.method === "POST") {
|
||||||
|
if (!allowedOrigin(request))
|
||||||
|
throw new ApiError(
|
||||||
|
403,
|
||||||
|
"ORIGIN_REJECTED",
|
||||||
|
"Use this application’s own origin.",
|
||||||
|
);
|
||||||
|
const username =
|
||||||
|
typeof request.body === "object" &&
|
||||||
|
request.body !== null &&
|
||||||
|
"username" in request.body &&
|
||||||
|
typeof request.body.username === "string"
|
||||||
|
? request.body.username.toLowerCase().slice(0, 24)
|
||||||
|
: "";
|
||||||
|
throttle("ip:" + request.ip, 100);
|
||||||
|
throttle(request.ip + ":" + username, 20);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
async function startSession(
|
||||||
|
id: string,
|
||||||
|
reply: FastifyReply,
|
||||||
|
request: FastifyRequest,
|
||||||
|
) {
|
||||||
|
const token = randomBytes(32).toString("hex");
|
||||||
|
await pool.query(
|
||||||
|
"INSERT INTO sessions(token_hash,player_id,expires_at) VALUES($1,$2,now()+interval '30 days')",
|
||||||
|
[digest(token), id],
|
||||||
|
);
|
||||||
|
cookie(reply, request, token);
|
||||||
|
return { csrfToken: digest("csrf:" + token) };
|
||||||
|
}
|
||||||
|
app.post("/api/v1/auth/register", async (request, reply) => {
|
||||||
|
const input = credentials.parse(request.body),
|
||||||
|
passwordHash = await hash(input.password, {
|
||||||
|
algorithm: Algorithm.Argon2id,
|
||||||
|
memoryCost: 19456,
|
||||||
|
timeCost: 2,
|
||||||
|
parallelism: 1,
|
||||||
|
}),
|
||||||
|
id = randomUUID(),
|
||||||
|
client = await pool.connect(),
|
||||||
|
codes = Array.from({ length: 5 }, () => randomBytes(12).toString("hex"));
|
||||||
|
try {
|
||||||
|
await client.query("BEGIN");
|
||||||
|
const inserted = await client.query(
|
||||||
|
"INSERT INTO players(id,display_name,normalized_username,password_hash,coin_balance) VALUES($1,$2,$3,$4,$5) ON CONFLICT(normalized_username) DO NOTHING RETURNING id",
|
||||||
|
[
|
||||||
|
id,
|
||||||
|
input.username,
|
||||||
|
input.username.toLowerCase(),
|
||||||
|
passwordHash,
|
||||||
|
GAME.starterCoins,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
if (!inserted.rowCount)
|
||||||
|
throw new ApiError(
|
||||||
|
409,
|
||||||
|
"USERNAME_TAKEN",
|
||||||
|
"That username is already in use.",
|
||||||
|
);
|
||||||
|
await client.query(
|
||||||
|
"INSERT INTO inventory_seeds VALUES($1,'starter',$2)",
|
||||||
|
[id, GAME.starterSeeds],
|
||||||
|
);
|
||||||
|
const house = randomUUID();
|
||||||
|
await client.query(
|
||||||
|
"INSERT INTO greenhouses(id,player_id,name) VALUES($1,$2,$3)",
|
||||||
|
[house, id, "Little Glasshouse"],
|
||||||
|
);
|
||||||
|
for (let i = 0; i < 4; i++)
|
||||||
|
await client.query(
|
||||||
|
"INSERT INTO plant_slots(id,greenhouse_id,slot_no) VALUES($1,$2,$3)",
|
||||||
|
[randomUUID(), house, i],
|
||||||
|
);
|
||||||
|
for (const code of codes)
|
||||||
|
await client.query(
|
||||||
|
"INSERT INTO recovery_codes(player_id,code_hash) VALUES($1,$2)",
|
||||||
|
[id, digest(code)],
|
||||||
|
);
|
||||||
|
await client.query(
|
||||||
|
"INSERT INTO coin_ledger(id,player_id,amount,balance_after,reason,reference) VALUES($1,$2::uuid,$3,$3,'welcome',$2::uuid::text)",
|
||||||
|
[randomUUID(), id, GAME.starterCoins],
|
||||||
|
);
|
||||||
|
await client.query("COMMIT");
|
||||||
|
return reply.code(201).send({
|
||||||
|
...(await startSession(id, reply, request)),
|
||||||
|
recoveryCodes: codes,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
await client.query("ROLLBACK");
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
app.post("/api/v1/auth/login", async (request, reply) => {
|
||||||
|
const input = credentials.parse(request.body),
|
||||||
|
row = (
|
||||||
|
await pool.query(
|
||||||
|
"SELECT id,password_hash FROM players WHERE normalized_username=$1",
|
||||||
|
[input.username.toLowerCase()],
|
||||||
|
)
|
||||||
|
).rows[0];
|
||||||
|
if (!row || !(await verify(row.password_hash, input.password)))
|
||||||
|
throw new ApiError(
|
||||||
|
401,
|
||||||
|
"INVALID_LOGIN",
|
||||||
|
"Username or password is incorrect.",
|
||||||
|
);
|
||||||
|
return startSession(row.id, reply, request);
|
||||||
|
});
|
||||||
|
app.post("/api/v1/auth/recover", async (request, reply) => {
|
||||||
|
const input = credentials
|
||||||
|
.extend({ recoveryCode: z.string().regex(/^[a-f0-9]{24}$/) })
|
||||||
|
.parse(request.body),
|
||||||
|
passwordHash = await hash(input.password, {
|
||||||
|
algorithm: Algorithm.Argon2id,
|
||||||
|
memoryCost: 19456,
|
||||||
|
timeCost: 2,
|
||||||
|
parallelism: 1,
|
||||||
|
}),
|
||||||
|
client = await pool.connect();
|
||||||
|
try {
|
||||||
|
await client.query("BEGIN");
|
||||||
|
const row = (
|
||||||
|
await client.query(
|
||||||
|
"UPDATE recovery_codes c SET used_at=now() FROM players p WHERE c.player_id=p.id AND p.normalized_username=$1 AND c.code_hash=$2 AND c.used_at IS NULL RETURNING p.id",
|
||||||
|
[input.username.toLowerCase(), digest(input.recoveryCode)],
|
||||||
|
)
|
||||||
|
).rows[0];
|
||||||
|
if (!row)
|
||||||
|
throw new ApiError(
|
||||||
|
401,
|
||||||
|
"INVALID_RECOVERY",
|
||||||
|
"Username or recovery code is incorrect or already used.",
|
||||||
|
);
|
||||||
|
await client.query("UPDATE players SET password_hash=$2 WHERE id=$1", [
|
||||||
|
row.id,
|
||||||
|
passwordHash,
|
||||||
|
]);
|
||||||
|
await client.query("DELETE FROM sessions WHERE player_id=$1", [row.id]);
|
||||||
|
await client.query("COMMIT");
|
||||||
|
return startSession(row.id, reply, request);
|
||||||
|
} catch (error) {
|
||||||
|
await client.query("ROLLBACK");
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
app.post("/api/v1/auth/logout", async (request, reply) => {
|
||||||
|
const identity = await authenticate(pool, request);
|
||||||
|
checkCsrf(request, identity);
|
||||||
|
await pool.query("DELETE FROM sessions WHERE token_hash=$1", [
|
||||||
|
digest(sessionToken(request)),
|
||||||
|
]);
|
||||||
|
cookie(reply, request, "", 0);
|
||||||
|
return { ok: true };
|
||||||
|
});
|
||||||
|
app.get("/api/v1/me", async (request) => {
|
||||||
|
const identity = await authenticate(pool, request),
|
||||||
|
row = (
|
||||||
|
await pool.query("SELECT coin_balance,xp FROM players WHERE id=$1", [
|
||||||
|
identity.id,
|
||||||
|
])
|
||||||
|
).rows[0];
|
||||||
|
return {
|
||||||
|
...identity,
|
||||||
|
coins: row.coin_balance,
|
||||||
|
xp: row.xp,
|
||||||
|
...levelFor(row.xp),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function sessionToken(request: FastifyRequest) {
|
||||||
|
return (
|
||||||
|
request.headers.cookie
|
||||||
|
?.split(";")
|
||||||
|
.map((c) => c.trim())
|
||||||
|
.find((c) => c.startsWith("botanica_session="))
|
||||||
|
?.slice(17) ?? ""
|
||||||
|
);
|
||||||
|
}
|
||||||
|
export async function authenticate(
|
||||||
|
pool: Pool,
|
||||||
|
request: FastifyRequest,
|
||||||
|
): Promise<Identity> {
|
||||||
|
const token = sessionToken(request);
|
||||||
|
if (!/^[a-f0-9]{64}$/.test(token))
|
||||||
|
throw new ApiError(401, "AUTH_REQUIRED", "Sign in to your greenhouse.");
|
||||||
|
const row = (
|
||||||
|
await pool.query(
|
||||||
|
"SELECT p.id,p.display_name FROM sessions s JOIN players p ON p.id=s.player_id WHERE s.token_hash=$1 AND s.expires_at>now()",
|
||||||
|
[digest(token)],
|
||||||
|
)
|
||||||
|
).rows[0];
|
||||||
|
if (!row)
|
||||||
|
throw new ApiError(
|
||||||
|
401,
|
||||||
|
"AUTH_REQUIRED",
|
||||||
|
"Your session expired. Please sign in.",
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
username: row.display_name,
|
||||||
|
csrfToken: digest("csrf:" + token),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
export function checkCsrf(request: FastifyRequest, identity: Identity) {
|
||||||
|
const token = request.headers["x-csrf-token"];
|
||||||
|
if (
|
||||||
|
!allowedOrigin(request) ||
|
||||||
|
typeof token !== "string" ||
|
||||||
|
!/^[a-f0-9]{64}$/.test(token) ||
|
||||||
|
!timingSafeEqual(Buffer.from(token), Buffer.from(identity.csrfToken))
|
||||||
|
)
|
||||||
|
throw new ApiError(
|
||||||
|
403,
|
||||||
|
"CSRF_REJECTED",
|
||||||
|
"Refresh the page before trying this action again.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -44,7 +44,7 @@ export async function listCollection(
|
||||||
trait("pigment") +
|
trait("pigment") +
|
||||||
" AS resolved_pigment, " +
|
" AS resolved_pigment, " +
|
||||||
trait("leafPattern") +
|
trait("leafPattern") +
|
||||||
" AS resolved_pattern FROM plants p WHERE owner_id=$1) ";
|
" AS resolved_pattern FROM plants p WHERE owner_id=$1 AND sold_at IS NULL) ";
|
||||||
const conditions: string[] = [];
|
const conditions: string[] = [];
|
||||||
for (const [column, value] of [
|
for (const [column, value] of [
|
||||||
["species_id", query.species],
|
["species_id", query.species],
|
||||||
|
|
@ -159,7 +159,7 @@ export async function collectionStats(
|
||||||
rarity_tier: NonNullable<keyof CollectionStats["byRarity"]>;
|
rarity_tier: NonNullable<keyof CollectionStats["byRarity"]>;
|
||||||
count: string;
|
count: string;
|
||||||
}>(
|
}>(
|
||||||
"SELECT rarity_tier,count(*) FROM plants WHERE owner_id=$1 GROUP BY rarity_tier",
|
"SELECT rarity_tier,count(*) FROM plants WHERE owner_id=$1 AND sold_at IS NULL GROUP BY rarity_tier",
|
||||||
[ownerId],
|
[ownerId],
|
||||||
)
|
)
|
||||||
).rows;
|
).rows;
|
||||||
|
|
|
||||||
235
apps/server/src/game-config.ts
Normal file
|
|
@ -0,0 +1,235 @@
|
||||||
|
import { genomeSchema, type Genome } from "@botanica/genetics";
|
||||||
|
import type { SeedType } from "@botanica/shared";
|
||||||
|
export const GAME = {
|
||||||
|
starterCoins: 120,
|
||||||
|
starterSeeds: 3,
|
||||||
|
breedCost: 75,
|
||||||
|
firstGrowSeconds: 20,
|
||||||
|
maxWorkerEvents: 100,
|
||||||
|
catchupHours: 24,
|
||||||
|
workerCosts: { gardener: 1500, breeder: 5000 },
|
||||||
|
workerIntervals: { gardener: 120, breeder: 300 },
|
||||||
|
} as const;
|
||||||
|
export const SLOT_PRICES = [
|
||||||
|
100, 250, 500, 750, 1000, 1250, 1500, 2000,
|
||||||
|
] as const;
|
||||||
|
export const nextSlotCost = (slots: number): number | null =>
|
||||||
|
SLOT_PRICES[slots - 4] ?? null;
|
||||||
|
export const SEEDS: Record<
|
||||||
|
SeedType,
|
||||||
|
{
|
||||||
|
name: string;
|
||||||
|
cost: number;
|
||||||
|
level: number;
|
||||||
|
seconds: number;
|
||||||
|
description: string;
|
||||||
|
genome: Genome;
|
||||||
|
}
|
||||||
|
> = {
|
||||||
|
starter: {
|
||||||
|
name: "Starter seed",
|
||||||
|
cost: 10,
|
||||||
|
level: 1,
|
||||||
|
seconds: 120,
|
||||||
|
description:
|
||||||
|
"Known purple, lobed, solid foliage. Heterozygous traits make interesting parents. Every harvest returns one starter seed.",
|
||||||
|
genome: genomeSchema.parse({
|
||||||
|
pigment: ["P", "G"],
|
||||||
|
leafShape: ["L", "O"],
|
||||||
|
leafPattern: ["S", "S"],
|
||||||
|
growthHabit: ["T", "B"],
|
||||||
|
flowerColor: ["B", "W"],
|
||||||
|
flowerDensity: ["H", "M"],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
uncommon: {
|
||||||
|
name: "Variegated seed",
|
||||||
|
cost: 75,
|
||||||
|
level: 3,
|
||||||
|
seconds: 300,
|
||||||
|
description:
|
||||||
|
"Guaranteed variegated foliage with purple pigment. No rarity dice roll.",
|
||||||
|
genome: genomeSchema.parse({
|
||||||
|
pigment: ["P", "G"],
|
||||||
|
leafShape: ["L", "O"],
|
||||||
|
leafPattern: ["V", "S"],
|
||||||
|
growthHabit: ["B", "C"],
|
||||||
|
flowerColor: ["R", "W"],
|
||||||
|
flowerDensity: ["H", "M"],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
rare: {
|
||||||
|
name: "Heirloom seed",
|
||||||
|
cost: 250,
|
||||||
|
level: 7,
|
||||||
|
seconds: 720,
|
||||||
|
description:
|
||||||
|
"Known green, oval, solid foliage. Rare classification from two recessive traits.",
|
||||||
|
genome: genomeSchema.parse({
|
||||||
|
pigment: ["G", "G"],
|
||||||
|
leafShape: ["O", "O"],
|
||||||
|
leafPattern: ["S", "S"],
|
||||||
|
growthHabit: ["T", "T"],
|
||||||
|
flowerColor: ["B", "B"],
|
||||||
|
flowerDensity: ["H", "H"],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const thresholds = [0, 60, 160, 300, 500, 800, 1200, 1700, 2300, 3000];
|
||||||
|
export function levelFor(xp: number) {
|
||||||
|
let level = 1;
|
||||||
|
while (level < 10 && xp >= thresholds[level]) level++;
|
||||||
|
return {
|
||||||
|
level,
|
||||||
|
nextLevelXp: thresholds[level] ?? thresholds[9] + (level - 9) * 1000,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
export const QUESTS = [
|
||||||
|
{
|
||||||
|
id: "grow",
|
||||||
|
name: "First green",
|
||||||
|
description: "Harvest your first plant.",
|
||||||
|
stat: "harvested",
|
||||||
|
target: 1,
|
||||||
|
coins: 50,
|
||||||
|
xp: 40,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "sell",
|
||||||
|
name: "A little seed money",
|
||||||
|
description: "Sell one grown specimen.",
|
||||||
|
stat: "sold",
|
||||||
|
target: 1,
|
||||||
|
coins: 50,
|
||||||
|
xp: 60,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "slot",
|
||||||
|
name: "Room to grow",
|
||||||
|
description: "Purchase a slot expansion.",
|
||||||
|
stat: "slotsBought",
|
||||||
|
target: 1,
|
||||||
|
coins: 100,
|
||||||
|
xp: 80,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "cross",
|
||||||
|
name: "Family resemblance",
|
||||||
|
description: "Make your first genetic cross.",
|
||||||
|
stat: "crosses",
|
||||||
|
target: 1,
|
||||||
|
coins: 200,
|
||||||
|
xp: 100,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "keep",
|
||||||
|
name: "A living cabinet",
|
||||||
|
description: "Keep five mature game plants.",
|
||||||
|
stat: "kept",
|
||||||
|
target: 5,
|
||||||
|
coins: 100,
|
||||||
|
xp: 100,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "harvest10",
|
||||||
|
name: "Green fingers",
|
||||||
|
description: "Harvest ten plants.",
|
||||||
|
stat: "harvested",
|
||||||
|
target: 10,
|
||||||
|
coins: 300,
|
||||||
|
xp: 200,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "sell10",
|
||||||
|
name: "Market gardener",
|
||||||
|
description: "Sell ten plants.",
|
||||||
|
stat: "sold",
|
||||||
|
target: 10,
|
||||||
|
coins: 300,
|
||||||
|
xp: 150,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "discover",
|
||||||
|
name: "A curious eye",
|
||||||
|
description: "Discover five phenotype signatures.",
|
||||||
|
stat: "discoveries",
|
||||||
|
target: 5,
|
||||||
|
coins: 400,
|
||||||
|
xp: 200,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "plant20",
|
||||||
|
name: "A growing habit",
|
||||||
|
description: "Plant twenty seeds.",
|
||||||
|
stat: "planted",
|
||||||
|
target: 20,
|
||||||
|
coins: 400,
|
||||||
|
xp: 250,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "house",
|
||||||
|
name: "Under new glass",
|
||||||
|
description: "Open your second greenhouse.",
|
||||||
|
stat: "houses",
|
||||||
|
target: 2,
|
||||||
|
coins: 500,
|
||||||
|
xp: 300,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "worker",
|
||||||
|
name: "A helping hand",
|
||||||
|
description: "Hire a worker.",
|
||||||
|
stat: "workers",
|
||||||
|
target: 1,
|
||||||
|
coins: 250,
|
||||||
|
xp: 200,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "rare",
|
||||||
|
name: "Something special",
|
||||||
|
description: "Keep a Rare or higher game plant.",
|
||||||
|
stat: "rare",
|
||||||
|
target: 1,
|
||||||
|
coins: 250,
|
||||||
|
xp: 200,
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
export const RESEARCH = [
|
||||||
|
{
|
||||||
|
id: "compost",
|
||||||
|
name: "Living compost",
|
||||||
|
description: "+10 XP on each harvest.",
|
||||||
|
cost: 200,
|
||||||
|
level: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "botany",
|
||||||
|
name: "Botanical notes",
|
||||||
|
description: "+10 XP on each first discovery.",
|
||||||
|
cost: 400,
|
||||||
|
level: 3,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "selection",
|
||||||
|
name: "Selective breeding",
|
||||||
|
description:
|
||||||
|
"Unlock target-trait worker recipes. Matching offspring are protected; odds are never changed.",
|
||||||
|
cost: 1500,
|
||||||
|
level: 10,
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
export function saleValue(tier: string, score: number) {
|
||||||
|
const factor: Record<string, number> = {
|
||||||
|
Common: 1,
|
||||||
|
Uncommon: 2.5,
|
||||||
|
Rare: 7,
|
||||||
|
Epic: 22,
|
||||||
|
Legendary: 65,
|
||||||
|
};
|
||||||
|
return tier === "Mythic"
|
||||||
|
? null
|
||||||
|
: Math.min(
|
||||||
|
2000,
|
||||||
|
Math.round(25 * (factor[tier] ?? 0) * (1 + Math.min(100, score) / 250)),
|
||||||
|
);
|
||||||
|
}
|
||||||
32
apps/server/src/game-routes.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
import type { FastifyInstance } from "fastify";
|
||||||
|
import type { Pool } from "pg";
|
||||||
|
import { gameActionSchema, plantListQuerySchema } from "@botanica/shared";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { GameService } from "./game-service.js";
|
||||||
|
import { PlantRepository } from "./plants.js";
|
||||||
|
import { DEMO_PLAYER_ID } from "./constants.js";
|
||||||
|
export function registerGameRoutes(app: FastifyInstance, pool: Pool) {
|
||||||
|
const game = new GameService(pool);
|
||||||
|
app.get("/api/v1/game/state", (request) => game.state(request.identity!));
|
||||||
|
app.post("/api/v1/game/action", (request) =>
|
||||||
|
game.action(request.identity!.id, gameActionSchema.parse(request.body)),
|
||||||
|
);
|
||||||
|
app.get("/api/v1/demo/plants", (request) =>
|
||||||
|
new PlantRepository(pool, DEMO_PLAYER_ID).listPlants(
|
||||||
|
plantListQuerySchema.parse(request.query),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
app.get("/api/v1/demo/collection/stats", () =>
|
||||||
|
new PlantRepository(pool, DEMO_PLAYER_ID).stats(),
|
||||||
|
);
|
||||||
|
app.get("/api/v1/demo/plants/:id/birth", (request) =>
|
||||||
|
new PlantRepository(pool, DEMO_PLAYER_ID).birthEvent(
|
||||||
|
z.object({ id: z.uuid() }).parse(request.params).id,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
app.get("/api/v1/demo/plants/:id", (request) =>
|
||||||
|
new PlantRepository(pool, DEMO_PLAYER_ID).getPlant(
|
||||||
|
z.object({ id: z.uuid() }).parse(request.params).id,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
1077
apps/server/src/game-service.ts
Normal file
467
apps/server/src/game.integration.test.ts
Normal file
|
|
@ -0,0 +1,467 @@
|
||||||
|
import { beforeAll, afterAll, describe, it, expect } from "vitest";
|
||||||
|
import pg from "pg";
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { createApp } from "./app.js";
|
||||||
|
import { migrate } from "./migrations.js";
|
||||||
|
import { seedDatabase } from "./seed-data.js";
|
||||||
|
import { GameService } from "./game-service.js";
|
||||||
|
import { digest, type Identity } from "./auth.js";
|
||||||
|
import type { GameAction, GameState } from "@botanica/shared";
|
||||||
|
import { saleValue } from "./game-config.js";
|
||||||
|
import { DEMO_PLAYER_ID } from "./constants.js";
|
||||||
|
|
||||||
|
// Separate schema on the disposable Compose test database, never the application database.
|
||||||
|
const url = process.env.TEST_DATABASE_URL,
|
||||||
|
schema = "game_" + randomUUID().replaceAll("-", "");
|
||||||
|
describe.skipIf(!url)(
|
||||||
|
"Game ownership, monetary integrity and offline acceptance",
|
||||||
|
() => {
|
||||||
|
const pool = new pg.Pool({
|
||||||
|
connectionString: url,
|
||||||
|
options: "-c search_path=" + schema,
|
||||||
|
});
|
||||||
|
const app = createApp(() => pool.query("SELECT 1"), false, pool);
|
||||||
|
let now = new Date("2026-01-01T00:00:00Z");
|
||||||
|
const game = new GameService(pool, () => now);
|
||||||
|
const advance = (seconds: number) => {
|
||||||
|
now = new Date(now.getTime() + seconds * 1000);
|
||||||
|
};
|
||||||
|
type Account = {
|
||||||
|
identity: Identity;
|
||||||
|
cookie: string;
|
||||||
|
csrf: string;
|
||||||
|
codes: string[];
|
||||||
|
username: string;
|
||||||
|
};
|
||||||
|
async function account(): Promise<Account> {
|
||||||
|
const username = "test_" + randomUUID().slice(0, 8),
|
||||||
|
r = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/v1/auth/register",
|
||||||
|
headers: { origin: "http://localhost:8155" },
|
||||||
|
payload: { username, password: "fixture-password-123" },
|
||||||
|
});
|
||||||
|
expect(r.statusCode, r.body).toBe(201);
|
||||||
|
const cookie = String(r.headers["set-cookie"]).split(";")[0],
|
||||||
|
csrf = r.json().csrfToken,
|
||||||
|
me = await app.inject({ url: "/api/v1/me", headers: { cookie } });
|
||||||
|
expect(me.statusCode).toBe(200);
|
||||||
|
return {
|
||||||
|
identity: me.json(),
|
||||||
|
cookie,
|
||||||
|
csrf,
|
||||||
|
codes: r.json().recoveryCodes,
|
||||||
|
username,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const act = (
|
||||||
|
a: Account,
|
||||||
|
action: GameAction["action"],
|
||||||
|
data: Record<string, unknown>,
|
||||||
|
requestId = randomUUID(),
|
||||||
|
) => game.action(a.identity.id, { action, data, requestId });
|
||||||
|
const httpAction = (
|
||||||
|
a: Account,
|
||||||
|
action: GameAction["action"],
|
||||||
|
data: Record<string, unknown>,
|
||||||
|
) =>
|
||||||
|
app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/v1/game/action",
|
||||||
|
headers: {
|
||||||
|
cookie: a.cookie,
|
||||||
|
origin: "http://localhost:8155",
|
||||||
|
"x-csrf-token": a.csrf,
|
||||||
|
},
|
||||||
|
payload: { action, data, requestId: randomUUID() },
|
||||||
|
});
|
||||||
|
async function state(a: Account) {
|
||||||
|
return game.state(a.identity);
|
||||||
|
}
|
||||||
|
async function grow(a: Account, count = 1) {
|
||||||
|
const s = await state(a),
|
||||||
|
slots = s.greenhouses[0].plants.slice(0, count);
|
||||||
|
for (const slot of slots)
|
||||||
|
await act(a, "plant", { slotId: slot.id, seedType: "starter" });
|
||||||
|
advance(121);
|
||||||
|
const result = [];
|
||||||
|
for (const slot of slots)
|
||||||
|
result.push(await act(a, "harvest", { slotId: slot.id }));
|
||||||
|
return result as { plantId: string }[];
|
||||||
|
}
|
||||||
|
beforeAll(async () => {
|
||||||
|
const admin = new pg.Pool({ connectionString: url });
|
||||||
|
try {
|
||||||
|
await admin.query("CREATE SCHEMA " + schema);
|
||||||
|
} finally {
|
||||||
|
await admin.end();
|
||||||
|
}
|
||||||
|
await migrate(pool);
|
||||||
|
await seedDatabase(pool);
|
||||||
|
});
|
||||||
|
afterAll(async () => {
|
||||||
|
await app.close();
|
||||||
|
await pool.end();
|
||||||
|
});
|
||||||
|
it("authenticates with hashed credentials, CSRF and one-use recovery; bootstraps isolated accounts", async () => {
|
||||||
|
const a = await account(),
|
||||||
|
b = await account(),
|
||||||
|
s = await state(a);
|
||||||
|
expect(s.player.coins).toBe(120);
|
||||||
|
expect(s.seeds.starter).toBe(3);
|
||||||
|
expect(s.greenhouses[0].plants).toHaveLength(4);
|
||||||
|
expect((await app.inject("/api/v1/plants")).statusCode).toBe(401);
|
||||||
|
expect((await app.inject("/health")).statusCode).toBe(200);
|
||||||
|
const row = (
|
||||||
|
await pool.query("SELECT password_hash FROM players WHERE id=$1", [
|
||||||
|
a.identity.id,
|
||||||
|
])
|
||||||
|
).rows[0];
|
||||||
|
expect(row.password_hash).toMatch(/^\$argon2id\$/);
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await pool.query(
|
||||||
|
"SELECT token_hash FROM sessions WHERE player_id=$1",
|
||||||
|
[a.identity.id],
|
||||||
|
)
|
||||||
|
).rows[0].token_hash,
|
||||||
|
).toBe(digest(a.cookie.split("=")[1]));
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await pool.query(
|
||||||
|
"SELECT code_hash FROM recovery_codes WHERE player_id=$1",
|
||||||
|
[a.identity.id],
|
||||||
|
)
|
||||||
|
).rows.map((r) => r.code_hash),
|
||||||
|
).not.toContain(a.codes[0]);
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/v1/game/action",
|
||||||
|
headers: { cookie: a.cookie, origin: "http://localhost:8155" },
|
||||||
|
payload: {
|
||||||
|
action: "purchase",
|
||||||
|
requestId: randomUUID(),
|
||||||
|
data: { itemId: "seed_starter" },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
).statusCode,
|
||||||
|
).toBe(403);
|
||||||
|
const [plant] = await grow(a);
|
||||||
|
for (const path of [
|
||||||
|
"/plants/" + plant.plantId,
|
||||||
|
"/plants/" + plant.plantId + "/birth",
|
||||||
|
])
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await app.inject({
|
||||||
|
url: "/api/v1" + path,
|
||||||
|
headers: { cookie: b.cookie },
|
||||||
|
})
|
||||||
|
).statusCode,
|
||||||
|
).toBe(404);
|
||||||
|
expect(
|
||||||
|
(await httpAction(b, "sell", { plantId: plant.plantId })).statusCode,
|
||||||
|
).toBe(409);
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await httpAction(b, "flags", {
|
||||||
|
plantId: plant.plantId,
|
||||||
|
favorite: true,
|
||||||
|
protected: true,
|
||||||
|
})
|
||||||
|
).statusCode,
|
||||||
|
).toBe(404);
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await httpAction(b, "plant", {
|
||||||
|
slotId: s.greenhouses[0].plants[0].id,
|
||||||
|
seedType: "starter",
|
||||||
|
})
|
||||||
|
).statusCode,
|
||||||
|
).toBe(404);
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await app.inject({
|
||||||
|
url: "/api/v1/plants",
|
||||||
|
headers: { cookie: b.cookie },
|
||||||
|
})
|
||||||
|
).json().total,
|
||||||
|
).toBe(0);
|
||||||
|
expect(
|
||||||
|
Number(
|
||||||
|
(
|
||||||
|
await pool.query("SELECT count(*) FROM plants WHERE owner_id=$1", [
|
||||||
|
DEMO_PLAYER_ID,
|
||||||
|
])
|
||||||
|
).rows[0].count,
|
||||||
|
),
|
||||||
|
).toBe(100);
|
||||||
|
const recover = {
|
||||||
|
username: a.username,
|
||||||
|
password: "replacement-password-123",
|
||||||
|
recoveryCode: a.codes[0],
|
||||||
|
},
|
||||||
|
headers = { origin: "http://localhost:8155" };
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/v1/auth/recover",
|
||||||
|
headers,
|
||||||
|
payload: recover,
|
||||||
|
})
|
||||||
|
).statusCode,
|
||||||
|
).toBe(200);
|
||||||
|
expect(
|
||||||
|
(await app.inject({ url: "/api/v1/me", headers: { cookie: a.cookie } }))
|
||||||
|
.statusCode,
|
||||||
|
).toBe(401);
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/v1/auth/recover",
|
||||||
|
headers,
|
||||||
|
payload: recover,
|
||||||
|
})
|
||||||
|
).statusCode,
|
||||||
|
).toBe(401);
|
||||||
|
}, 20000);
|
||||||
|
it("serializes competing purchases, rejects overspending and replays each receipt without a second debit", async () => {
|
||||||
|
const a = await account();
|
||||||
|
await pool.query("UPDATE players SET coin_balance=10 WHERE id=$1", [
|
||||||
|
a.identity.id,
|
||||||
|
]);
|
||||||
|
const results = await Promise.allSettled([
|
||||||
|
act(a, "purchase", { itemId: "seed_starter" }),
|
||||||
|
act(a, "purchase", { itemId: "seed_starter" }),
|
||||||
|
]);
|
||||||
|
expect(results.filter((r) => r.status === "fulfilled")).toHaveLength(1);
|
||||||
|
expect((await state(a)).player.coins).toBe(0);
|
||||||
|
expect((await state(a)).seeds.starter).toBe(4);
|
||||||
|
await pool.query("UPDATE players SET coin_balance=20 WHERE id=$1", [
|
||||||
|
a.identity.id,
|
||||||
|
]);
|
||||||
|
const key = randomUUID(),
|
||||||
|
input = { itemId: "seed_starter" };
|
||||||
|
const retried = await Promise.all([
|
||||||
|
act(a, "purchase", input, key),
|
||||||
|
act(a, "purchase", input, key),
|
||||||
|
]);
|
||||||
|
expect(retried[0]).toEqual(retried[1]);
|
||||||
|
expect((await state(a)).player.coins).toBe(10);
|
||||||
|
expect((await state(a)).seeds.starter).toBe(5);
|
||||||
|
await expect(
|
||||||
|
act(a, "purchase", { itemId: "seed_uncommon" }, key),
|
||||||
|
).rejects.toMatchObject({ code: "IDEMPOTENCY_CONFLICT" });
|
||||||
|
});
|
||||||
|
it("harvests and sells once under concurrent requests, recycles seeds and protects favorites", async () => {
|
||||||
|
const a = await account(),
|
||||||
|
slot = (await state(a)).greenhouses[0].plants[0];
|
||||||
|
await act(a, "plant", { slotId: slot.id, seedType: "starter" });
|
||||||
|
await expect(
|
||||||
|
act(a, "harvest", { slotId: slot.id }),
|
||||||
|
).rejects.toMatchObject({ code: "NOT_READY" });
|
||||||
|
advance(21);
|
||||||
|
const results = await Promise.allSettled([
|
||||||
|
act(a, "harvest", { slotId: slot.id }),
|
||||||
|
act(a, "harvest", { slotId: slot.id }),
|
||||||
|
]);
|
||||||
|
expect(results.filter((r) => r.status === "fulfilled")).toHaveLength(1);
|
||||||
|
const harvested = results.find(
|
||||||
|
(r) => r.status === "fulfilled",
|
||||||
|
) as PromiseFulfilledResult<{ plantId: string }>;
|
||||||
|
expect((await state(a)).seeds.starter).toBe(3);
|
||||||
|
expect((await state(a)).player.coins).toBe(130); // only first-discovery reward, no harvest sale payout
|
||||||
|
const plantId = harvested.value.plantId;
|
||||||
|
await act(a, "flags", { plantId, favorite: true, protected: true });
|
||||||
|
await expect(act(a, "sell", { plantId })).rejects.toMatchObject({
|
||||||
|
code: "PROTECTED_PLANT",
|
||||||
|
});
|
||||||
|
await act(a, "flags", { plantId, favorite: false, protected: false });
|
||||||
|
const sales = await Promise.allSettled([
|
||||||
|
act(a, "sell", { plantId }),
|
||||||
|
act(a, "sell", { plantId }),
|
||||||
|
]);
|
||||||
|
expect(sales.filter((r) => r.status === "fulfilled")).toHaveLength(1);
|
||||||
|
const plant = (
|
||||||
|
await pool.query("SELECT * FROM plants WHERE id=$1", [plantId])
|
||||||
|
).rows[0];
|
||||||
|
expect(plant.sold_at).toBeTruthy();
|
||||||
|
expect((await state(a)).player.coins).toBe(
|
||||||
|
130 + saleValue(plant.rarity_tier, plant.rarity_score)!,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await pool.query(
|
||||||
|
"SELECT count(*) FROM coin_ledger WHERE player_id=$1 AND reason='sale'",
|
||||||
|
[a.identity.id],
|
||||||
|
)
|
||||||
|
).rows[0].count,
|
||||||
|
).toBe("1");
|
||||||
|
});
|
||||||
|
it("charges a real inherited cross once, preserves lineage and rejects foreign or designed parents", async () => {
|
||||||
|
const a = await account(),
|
||||||
|
b = await account(),
|
||||||
|
[left, right] = await grow(a, 2),
|
||||||
|
[foreign] = await grow(b);
|
||||||
|
await pool.query("UPDATE players SET xp=160 WHERE id=$1", [
|
||||||
|
a.identity.id,
|
||||||
|
]);
|
||||||
|
const input = {
|
||||||
|
parentAId: left.plantId,
|
||||||
|
parentBId: right.plantId,
|
||||||
|
idempotencyKey: randomUUID(),
|
||||||
|
},
|
||||||
|
before = (await state(a)).player.coins;
|
||||||
|
const [first, retry] = (await Promise.all([
|
||||||
|
game.cross(a.identity.id, input),
|
||||||
|
game.cross(a.identity.id, input),
|
||||||
|
])) as [
|
||||||
|
{ plant: { id: string; parents: string[]; gameEligible: boolean } },
|
||||||
|
unknown,
|
||||||
|
];
|
||||||
|
expect(first).toEqual(retry);
|
||||||
|
expect(first.plant.parents).toEqual([left.plantId, right.plantId]);
|
||||||
|
expect(first.plant.gameEligible).toBe(true);
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await pool.query(
|
||||||
|
"SELECT amount FROM coin_ledger WHERE player_id=$1 AND reason='breeding'",
|
||||||
|
[a.identity.id],
|
||||||
|
)
|
||||||
|
).rows,
|
||||||
|
).toEqual([{ amount: -75 }]);
|
||||||
|
expect((await state(a)).player.coins).toBeGreaterThanOrEqual(before - 75);
|
||||||
|
await expect(
|
||||||
|
game.cross(a.identity.id, {
|
||||||
|
...input,
|
||||||
|
parentBId: foreign.plantId,
|
||||||
|
idempotencyKey: randomUUID(),
|
||||||
|
}),
|
||||||
|
).rejects.toMatchObject({ code: "PARENT_UNAVAILABLE" });
|
||||||
|
const designed = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/v1/plants",
|
||||||
|
headers: {
|
||||||
|
cookie: a.cookie,
|
||||||
|
origin: "http://localhost:8155",
|
||||||
|
"x-csrf-token": a.csrf,
|
||||||
|
},
|
||||||
|
payload: {
|
||||||
|
name: "Study",
|
||||||
|
speciesId: "velvetleaf",
|
||||||
|
genome: (
|
||||||
|
await pool.query("SELECT genome FROM plants WHERE id=$1", [
|
||||||
|
left.plantId,
|
||||||
|
])
|
||||||
|
).rows[0].genome,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(designed.statusCode).toBe(201);
|
||||||
|
await expect(
|
||||||
|
game.cross(a.identity.id, {
|
||||||
|
...input,
|
||||||
|
parentBId: designed.json().id,
|
||||||
|
idempotencyKey: randomUUID(),
|
||||||
|
}),
|
||||||
|
).rejects.toMatchObject({ code: "PARENT_UNAVAILABLE" });
|
||||||
|
await expect(
|
||||||
|
act(a, "sell", { plantId: designed.json().id }),
|
||||||
|
).rejects.toMatchObject({ code: "STUDY_ONLY" });
|
||||||
|
});
|
||||||
|
it("reconciles a finite hour for gardeners and breeders once; protects keepsakes and retains capped backlog", async () => {
|
||||||
|
const a = await account(),
|
||||||
|
b = await account(),
|
||||||
|
parents = await grow(a, 2); // controlled test fixtures unlock late game without waiting through the whole progression
|
||||||
|
await pool.query(
|
||||||
|
"UPDATE players SET xp=3000,coin_balance=10000 WHERE id=$1",
|
||||||
|
[a.identity.id],
|
||||||
|
);
|
||||||
|
await act(a, "flags", {
|
||||||
|
plantId: parents[0].plantId,
|
||||||
|
favorite: true,
|
||||||
|
protected: true,
|
||||||
|
});
|
||||||
|
await act(a, "hire", { kind: "gardener" });
|
||||||
|
await act(a, "hire", { kind: "breeder" });
|
||||||
|
let s = await state(a);
|
||||||
|
expect(s.workers.every((w) => !w.enabled)).toBe(true);
|
||||||
|
const gardener = s.workers.find((w) => w.kind === "gardener")!,
|
||||||
|
breeder = s.workers.find((w) => w.kind === "breeder")!,
|
||||||
|
house = s.greenhouses[0].id;
|
||||||
|
const policy = {
|
||||||
|
mode: "repeat_crop",
|
||||||
|
seedType: "starter",
|
||||||
|
autoSell: true,
|
||||||
|
reserveCoins: 100,
|
||||||
|
};
|
||||||
|
await act(a, "assign", {
|
||||||
|
workerId: gardener.id,
|
||||||
|
greenhouseId: house,
|
||||||
|
enabled: true,
|
||||||
|
policy,
|
||||||
|
});
|
||||||
|
await act(a, "assign", {
|
||||||
|
workerId: breeder.id,
|
||||||
|
greenhouseId: house,
|
||||||
|
enabled: true,
|
||||||
|
policy: {
|
||||||
|
...policy,
|
||||||
|
mode: "repeat_cross",
|
||||||
|
autoSell: false,
|
||||||
|
parentAId: parents[0].plantId,
|
||||||
|
parentBId: parents[1].plantId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await httpAction(b, "assign", {
|
||||||
|
workerId: gardener.id,
|
||||||
|
greenhouseId: house,
|
||||||
|
enabled: true,
|
||||||
|
policy,
|
||||||
|
})
|
||||||
|
).statusCode,
|
||||||
|
).toBe(404);
|
||||||
|
advance(3600);
|
||||||
|
const summaries = await Promise.all([
|
||||||
|
game.reconcile(a.identity.id),
|
||||||
|
game.reconcile(a.identity.id),
|
||||||
|
]);
|
||||||
|
expect(summaries.filter(Boolean)).toHaveLength(1);
|
||||||
|
const report = summaries.find(Boolean)!;
|
||||||
|
expect(report.events).toBe(42);
|
||||||
|
expect(report.harvested).toBeGreaterThan(0);
|
||||||
|
expect(report.breeds).toBe(12);
|
||||||
|
expect(report.seedsUsed).toBeGreaterThanOrEqual(report.harvested);
|
||||||
|
s = await state(a);
|
||||||
|
expect(s.report).toBeNull();
|
||||||
|
expect(s.player.coins).toBeGreaterThanOrEqual(0);
|
||||||
|
const keepsake = (
|
||||||
|
await pool.query("SELECT sold_at FROM plants WHERE id=$1", [
|
||||||
|
parents[0].plantId,
|
||||||
|
])
|
||||||
|
).rows[0];
|
||||||
|
expect(keepsake.sold_at).toBeNull();
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await pool.query(
|
||||||
|
"SELECT count(*) FROM plants WHERE owner_id=$1 AND sold_at IS NOT NULL AND (is_favorite OR is_protected OR first_discovery OR rarity_tier!='Common')",
|
||||||
|
[a.identity.id],
|
||||||
|
)
|
||||||
|
).rows[0].count,
|
||||||
|
).toBe("0");
|
||||||
|
expect(s.recentRuns.reduce((n, r) => n + r.summary.events, 0)).toBe(42);
|
||||||
|
expect(s.recentRuns.reduce((n, r) => n + r.summary.coinDelta, 0)).toBe(
|
||||||
|
report.coinDelta,
|
||||||
|
);
|
||||||
|
advance(48 * 3600);
|
||||||
|
const capped = await game.reconcile(a.identity.id);
|
||||||
|
expect(capped!.events).toBe(100);
|
||||||
|
expect((await game.state(a.identity)).pendingWork).toBe(true);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
@ -11,7 +11,16 @@ describe.skipIf(!databaseUrl)(
|
||||||
"PostgreSQL persistence and transaction acceptance",
|
"PostgreSQL persistence and transaction acceptance",
|
||||||
() => {
|
() => {
|
||||||
const pool = new pg.Pool({ connectionString: databaseUrl });
|
const pool = new pg.Pool({ connectionString: databaseUrl });
|
||||||
const app = createApp(() => pool.query("SELECT 1"), false, pool);
|
const app = createApp(
|
||||||
|
() => pool.query("SELECT 1"),
|
||||||
|
false,
|
||||||
|
pool,
|
||||||
|
async () => ({
|
||||||
|
id: DEMO_PLAYER_ID,
|
||||||
|
username: "test-sandbox",
|
||||||
|
csrfToken: "test-only",
|
||||||
|
}),
|
||||||
|
);
|
||||||
let childId = "";
|
let childId = "";
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
await migrate(pool);
|
await migrate(pool);
|
||||||
|
|
@ -29,7 +38,7 @@ describe.skipIf(!databaseUrl)(
|
||||||
(await pool.query("SELECT count(*) FROM schema_migrations")).rows[0]
|
(await pool.query("SELECT count(*) FROM schema_migrations")).rows[0]
|
||||||
.count,
|
.count,
|
||||||
),
|
),
|
||||||
).toBe(2);
|
).toBe(3);
|
||||||
expect(
|
expect(
|
||||||
Number(
|
Number(
|
||||||
(await pool.query("SELECT count(*) FROM players")).rows[0].count,
|
(await pool.query("SELECT count(*) FROM players")).rows[0].count,
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import { GameService } from "./game-service.js";
|
||||||
|
import { DEMO_PLAYER_ID } from "./constants.js";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import type { FastifyInstance } from "fastify";
|
import type { FastifyInstance } from "fastify";
|
||||||
import type { Pool } from "pg";
|
import type { Pool } from "pg";
|
||||||
|
|
@ -9,28 +11,40 @@ import {
|
||||||
} from "@botanica/shared";
|
} from "@botanica/shared";
|
||||||
import { PlantRepository } from "./plants.js";
|
import { PlantRepository } from "./plants.js";
|
||||||
export function registerPlantRoutes(app: FastifyInstance, pool: Pool) {
|
export function registerPlantRoutes(app: FastifyInstance, pool: Pool) {
|
||||||
const repository = new PlantRepository(pool);
|
const repository = (request: import("fastify").FastifyRequest) =>
|
||||||
|
new PlantRepository(pool, request.identity!.id);
|
||||||
app.get("/api/v1/plants", (request) =>
|
app.get("/api/v1/plants", (request) =>
|
||||||
repository.listPlants(plantListQuerySchema.parse(request.query)),
|
repository(request).listPlants(plantListQuerySchema.parse(request.query)),
|
||||||
);
|
);
|
||||||
app.get("/api/v1/collection/stats", () => repository.stats());
|
app.get("/api/v1/collection/stats", (request) => repository(request).stats());
|
||||||
app.get("/api/v1/plants/:id/birth", (request) =>
|
app.get("/api/v1/plants/:id/birth", (request) =>
|
||||||
repository.birthEvent(z.object({ id: z.uuid() }).parse(request.params).id),
|
repository(request).birthEvent(
|
||||||
|
z.object({ id: z.uuid() }).parse(request.params).id,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
app.get("/api/v1/plants/:id", (request) =>
|
app.get("/api/v1/plants/:id", (request) =>
|
||||||
repository.getPlant(z.object({ id: z.uuid() }).parse(request.params).id),
|
repository(request).getPlant(
|
||||||
|
z.object({ id: z.uuid() }).parse(request.params).id,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
app.post("/api/v1/plants", async (request, reply) =>
|
app.post("/api/v1/plants", async (request, reply) =>
|
||||||
reply
|
reply
|
||||||
.code(201)
|
.code(201)
|
||||||
.send(
|
.send(
|
||||||
await repository.createPlant(createPlantSchema.parse(request.body)),
|
await repository(request).createPlant(
|
||||||
|
createPlantSchema.parse(request.body),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
app.post("/api/v1/breeding", (request) =>
|
app.post("/api/v1/breeding", (request) =>
|
||||||
repository.cross(breedingRequestSchema.parse(request.body)),
|
request.identity!.id === DEMO_PLAYER_ID
|
||||||
|
? repository(request).cross(breedingRequestSchema.parse(request.body))
|
||||||
|
: new GameService(pool).cross(
|
||||||
|
request.identity!.id,
|
||||||
|
breedingRequestSchema.parse(request.body),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
app.get("/api/v1/breeding", (request) =>
|
app.get("/api/v1/breeding", (request) =>
|
||||||
repository.listEvents(pageQuerySchema.parse(request.query)),
|
repository(request).listEvents(pageQuerySchema.parse(request.query)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ import type {
|
||||||
} from "@botanica/shared";
|
} from "@botanica/shared";
|
||||||
import { DEMO_PLAYER_ID } from "./constants.js";
|
import { DEMO_PLAYER_ID } from "./constants.js";
|
||||||
import { ApiError } from "./errors.js";
|
import { ApiError } from "./errors.js";
|
||||||
|
import { saleValue } from "./game-config.js";
|
||||||
export const generateSeed = () => randomBytes(4).readInt32BE();
|
export const generateSeed = () => randomBytes(4).readInt32BE();
|
||||||
export interface PlantRow extends QueryResultRow {
|
export interface PlantRow extends QueryResultRow {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -39,6 +40,11 @@ export interface PlantRow extends QueryResultRow {
|
||||||
created_at: Date;
|
created_at: Date;
|
||||||
cursor_time?: string;
|
cursor_time?: string;
|
||||||
sort_name?: string;
|
sort_name?: string;
|
||||||
|
game_eligible: boolean;
|
||||||
|
first_discovery: boolean;
|
||||||
|
is_favorite: boolean;
|
||||||
|
is_protected: boolean;
|
||||||
|
sold_at: Date | null;
|
||||||
creation_source: PlantDto["creationSource"];
|
creation_source: PlantDto["creationSource"];
|
||||||
rarity_policy_version: 1;
|
rarity_policy_version: 1;
|
||||||
rarity_score: number;
|
rarity_score: number;
|
||||||
|
|
@ -81,6 +87,14 @@ function plantDto(row: PlantRow): PlantDto {
|
||||||
: [],
|
: [],
|
||||||
createdAt: row.created_at.toISOString(),
|
createdAt: row.created_at.toISOString(),
|
||||||
creationSource: row.creation_source,
|
creationSource: row.creation_source,
|
||||||
|
gameEligible: row.game_eligible ?? false,
|
||||||
|
firstDiscovery: row.first_discovery ?? false,
|
||||||
|
saleCoins: row.game_eligible
|
||||||
|
? saleValue(row.rarity_tier, row.rarity_score)
|
||||||
|
: null,
|
||||||
|
isFavorite: row.is_favorite ?? false,
|
||||||
|
isProtected: row.is_protected ?? false,
|
||||||
|
soldAt: row.sold_at?.toISOString() ?? null,
|
||||||
rarity: {
|
rarity: {
|
||||||
score: row.rarity_score,
|
score: row.rarity_score,
|
||||||
tier: row.rarity_tier,
|
tier: row.rarity_tier,
|
||||||
|
|
@ -255,10 +269,13 @@ export class PlantRepository {
|
||||||
total,
|
total,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
async cross(input: BreedingRequest): Promise<BreedingResponse> {
|
async cross(
|
||||||
const client = await this.pool.connect();
|
input: BreedingRequest,
|
||||||
|
transaction?: PoolClient,
|
||||||
|
): Promise<BreedingResponse> {
|
||||||
|
const client = transaction ?? (await this.pool.connect());
|
||||||
try {
|
try {
|
||||||
await client.query("BEGIN");
|
if (!transaction) await client.query("BEGIN");
|
||||||
await client.query(
|
await client.query(
|
||||||
"SELECT pg_advisory_xact_lock(hashtextextended($1,0))",
|
"SELECT pg_advisory_xact_lock(hashtextextended($1,0))",
|
||||||
[this.ownerId + ":" + input.idempotencyKey],
|
[this.ownerId + ":" + input.idempotencyKey],
|
||||||
|
|
@ -280,7 +297,7 @@ export class PlantRepository {
|
||||||
"This request key already belongs to another parent pair.",
|
"This request key already belongs to another parent pair.",
|
||||||
);
|
);
|
||||||
const plant = await this.getPlant(existing.child_id, client);
|
const plant = await this.getPlant(existing.child_id, client);
|
||||||
await client.query("COMMIT");
|
if (!transaction) await client.query("COMMIT");
|
||||||
return { plant, event: eventDto(existing) };
|
return { plant, event: eventDto(existing) };
|
||||||
}
|
}
|
||||||
if (input.parentAId === input.parentBId)
|
if (input.parentAId === input.parentBId)
|
||||||
|
|
@ -292,7 +309,7 @@ export class PlantRepository {
|
||||||
// Read owned rows before interpreting their versioned genomes.
|
// Read owned rows before interpreting their versioned genomes.
|
||||||
const rows = (
|
const rows = (
|
||||||
await client.query<PlantRow>(
|
await client.query<PlantRow>(
|
||||||
"SELECT * FROM plants WHERE owner_id=$1 AND id=ANY($2::uuid[]) FOR SHARE",
|
"SELECT * FROM plants WHERE owner_id=$1 AND sold_at IS NULL AND id=ANY($2::uuid[]) FOR SHARE",
|
||||||
[this.ownerId, [input.parentAId, input.parentBId]],
|
[this.ownerId, [input.parentAId, input.parentBId]],
|
||||||
)
|
)
|
||||||
).rows;
|
).rows;
|
||||||
|
|
@ -302,7 +319,7 @@ export class PlantRepository {
|
||||||
throw new ApiError(
|
throw new ApiError(
|
||||||
404,
|
404,
|
||||||
"PLANT_NOT_FOUND",
|
"PLANT_NOT_FOUND",
|
||||||
"One or both parents are not in this sandbox.",
|
"One or both parents are unavailable in your collection.",
|
||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
if (!canBreed(a.species_id, b.species_id).compatible)
|
if (!canBreed(a.species_id, b.species_id).compatible)
|
||||||
|
|
@ -362,13 +379,13 @@ export class PlantRepository {
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
).rows[0];
|
).rows[0];
|
||||||
await client.query("COMMIT");
|
if (!transaction) await client.query("COMMIT");
|
||||||
return { plant, event: eventDto(event) };
|
return { plant, event: eventDto(event) };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await client.query("ROLLBACK");
|
if (!transaction) await client.query("ROLLBACK");
|
||||||
throw error;
|
throw error;
|
||||||
} finally {
|
} finally {
|
||||||
client.release();
|
if (!transaction) client.release();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import {
|
||||||
pgTable,
|
pgTable,
|
||||||
text,
|
text,
|
||||||
integer,
|
integer,
|
||||||
|
boolean,
|
||||||
uuid,
|
uuid,
|
||||||
jsonb,
|
jsonb,
|
||||||
timestamp,
|
timestamp,
|
||||||
|
|
@ -19,6 +20,11 @@ export const species = pgTable("species", {
|
||||||
export const players = pgTable("players", {
|
export const players = pgTable("players", {
|
||||||
id: uuid().primaryKey(),
|
id: uuid().primaryKey(),
|
||||||
displayName: text("display_name").notNull(),
|
displayName: text("display_name").notNull(),
|
||||||
|
normalizedUsername: text("normalized_username").unique(),
|
||||||
|
passwordHash: text("password_hash"),
|
||||||
|
coinBalance: integer("coin_balance").notNull().default(0),
|
||||||
|
xp: integer("xp").notNull().default(0),
|
||||||
|
gameStats: jsonb("game_stats").notNull().default({}),
|
||||||
createdAt: timestamp("created_at", { withTimezone: true })
|
createdAt: timestamp("created_at", { withTimezone: true })
|
||||||
.defaultNow()
|
.defaultNow()
|
||||||
.notNull(),
|
.notNull(),
|
||||||
|
|
@ -30,6 +36,11 @@ export const plants = pgTable(
|
||||||
name: text().notNull().default("Velvetleaf"),
|
name: text().notNull().default("Velvetleaf"),
|
||||||
creationSource: text("creation_source").notNull().default("designed"),
|
creationSource: text("creation_source").notNull().default("designed"),
|
||||||
rarityReasons: jsonb("rarity_reasons").notNull().default([]),
|
rarityReasons: jsonb("rarity_reasons").notNull().default([]),
|
||||||
|
gameEligible: boolean("game_eligible").notNull().default(false),
|
||||||
|
isFavorite: boolean("is_favorite").notNull().default(false),
|
||||||
|
isProtected: boolean("is_protected").notNull().default(false),
|
||||||
|
firstDiscovery: boolean("first_discovery").notNull().default(false),
|
||||||
|
soldAt: timestamp("sold_at", { withTimezone: true }),
|
||||||
ownerId: uuid("owner_id")
|
ownerId: uuid("owner_id")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => players.id),
|
.references(() => players.id),
|
||||||
|
|
@ -82,7 +93,7 @@ export const breedingEvents = pgTable(
|
||||||
},
|
},
|
||||||
(t) => [
|
(t) => [
|
||||||
unique().on(t.childId),
|
unique().on(t.childId),
|
||||||
unique().on(t.idempotencyKey),
|
unique("breeding_owner_request").on(t.ownerId, t.idempotencyKey),
|
||||||
index("breeding_owner_created").on(t.ownerId, t.createdAt.desc()),
|
index("breeding_owner_created").on(t.ownerId, t.createdAt.desc()),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1 +1 @@
|
||||||
<!doctype html><html lang="en"><head><meta charset="UTF-8"/><meta name="viewport" content="width=device-width, initial-scale=1.0"/><meta name="theme-color" content="#20392d"/><meta name="description" content="Plant Laboratory — a botanical breeding sandbox."/><title>Plant Laboratory</title></head><body><div id="root"></div><script type="module" src="/src/main.tsx"></script></body></html>
|
<!doctype html><html lang="en"><head><meta charset="UTF-8"/><meta name="viewport" content="width=device-width, initial-scale=1.0"/><meta name="theme-color" content="#14251c"/><meta name="description" content="Botanica — your own illustrated greenhouse. Grow, discover and breed a living collection."/><title>Botanica</title></head><body><div id="root"></div><script type="module" src="/src/main.tsx"></script></body></html>
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,10 @@ server {
|
||||||
add_header X-Content-Type-Options nosniff always;
|
add_header X-Content-Type-Options nosniff always;
|
||||||
add_header Referrer-Policy same-origin always;
|
add_header Referrer-Policy same-origin always;
|
||||||
location = /health { proxy_pass http://api:3000/health; }
|
location = /health { proxy_pass http://api:3000/health; }
|
||||||
location /api/ { proxy_pass http://api:3000; }
|
location /api/ {
|
||||||
|
proxy_set_header Host $http_host;
|
||||||
|
proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;
|
||||||
|
proxy_pass http://api:3000;
|
||||||
|
}
|
||||||
location / { try_files $uri $uri/ /index.html; }
|
location / { try_files $uri $uri/ /index.html; }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
102
apps/web/src/components/PlantGameActions.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<p className="study-notice">
|
||||||
|
Laboratory study · saved for art and genetics, outside the game economy.
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
const blocked =
|
||||||
|
favorite || protectedPlant || p.rarity.tier === "Mythic" || p.soldAt;
|
||||||
|
return (
|
||||||
|
<div className="plant-economy-actions">
|
||||||
|
<h3>Your specimen</h3>
|
||||||
|
<label className="check-label">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={favorite}
|
||||||
|
disabled={action.isPending || !!p.soldAt || game.pendingWork}
|
||||||
|
onChange={(e) => flags(e.target.checked, protectedPlant)}
|
||||||
|
/>
|
||||||
|
Favorite
|
||||||
|
</label>
|
||||||
|
<label className="check-label">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={protectedPlant}
|
||||||
|
disabled={action.isPending || !!p.soldAt || game.pendingWork}
|
||||||
|
onChange={(e) => flags(favorite, e.target.checked)}
|
||||||
|
/>
|
||||||
|
Protect from sale
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
className="secondary"
|
||||||
|
disabled={!!blocked || action.isPending || game.pendingWork}
|
||||||
|
onClick={() => 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…`}
|
||||||
|
</button>
|
||||||
|
{confirm && !p.soldAt && (
|
||||||
|
<div className="sale-confirm">
|
||||||
|
<p>
|
||||||
|
Sell {p.name}? It will leave your inventory; its ancestry record
|
||||||
|
remains. The server calculates the coin value from its species and
|
||||||
|
traits.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
disabled={action.isPending}
|
||||||
|
onClick={() =>
|
||||||
|
action.mutate(
|
||||||
|
{ action: "sell", data: { plantId: p.id } },
|
||||||
|
{ onSuccess: () => setConfirm(false) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Confirm sale
|
||||||
|
</button>{" "}
|
||||||
|
<button className="secondary" onClick={() => setConfirm(false)}>
|
||||||
|
Keep plant
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<ActionFeedback action={action} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -10,23 +10,36 @@ import { ExportActions } from "./ExportActions";
|
||||||
import { QueryMessage } from "./QueryMessage";
|
import { QueryMessage } from "./QueryMessage";
|
||||||
import { locusLabels } from "./GenomeEditor";
|
import { locusLabels } from "./GenomeEditor";
|
||||||
import { api } from "../lib/api";
|
import { api } from "../lib/api";
|
||||||
|
import { request } from "../lib/api";
|
||||||
|
import type { CollectionStats, BreedingEventDto } from "@botanica/shared";
|
||||||
|
import { PlantGameActions } from "./PlantGameActions";
|
||||||
export function PlantRecord({
|
export function PlantRecord({
|
||||||
plant: p,
|
plant: p,
|
||||||
onParent,
|
onParent,
|
||||||
|
scope = "owned",
|
||||||
}: {
|
}: {
|
||||||
plant: PlantDto;
|
plant: PlantDto;
|
||||||
onParent?: (id: string) => void;
|
onParent?: (id: string) => void;
|
||||||
|
scope?: "owned" | "demo";
|
||||||
}) {
|
}) {
|
||||||
const { stage, background, set } = useInspectorOptions();
|
const { stage, background, set } = useInspectorOptions();
|
||||||
const setStage = (stage: GrowthStage) => set({ stage }),
|
const setStage = (stage: GrowthStage) => set({ stage }),
|
||||||
setBackground = (background: Background) => set({ background });
|
setBackground = (background: Background) => set({ background });
|
||||||
const stats = useQuery({
|
const stats = useQuery({
|
||||||
queryKey: ["collection-stats"],
|
queryKey: ["collection-stats", scope],
|
||||||
queryFn: api.stats,
|
queryFn: () =>
|
||||||
|
scope === "demo"
|
||||||
|
? request<CollectionStats>("/demo/collection/stats")
|
||||||
|
: api.stats(),
|
||||||
}),
|
}),
|
||||||
birth = useQuery({
|
birth = useQuery({
|
||||||
queryKey: ["birth", p.id],
|
queryKey: ["birth", p.id, scope],
|
||||||
queryFn: () => api.birth(p.id),
|
queryFn: () =>
|
||||||
|
scope === "demo"
|
||||||
|
? request<{ event: BreedingEventDto | null }>(
|
||||||
|
"/demo/plants/" + p.id + "/birth",
|
||||||
|
)
|
||||||
|
: api.birth(p.id),
|
||||||
});
|
});
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setStage("flowering");
|
setStage("flowering");
|
||||||
|
|
@ -54,6 +67,13 @@ export function PlantRecord({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<aside className="study-panel">
|
<aside className="study-panel">
|
||||||
|
{scope === "owned" ? (
|
||||||
|
<PlantGameActions plant={p} />
|
||||||
|
) : (
|
||||||
|
<p className="study-notice">
|
||||||
|
Read-only reference · original identity and provenance preserved.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
<div className="detail-badges">
|
<div className="detail-badges">
|
||||||
<span
|
<span
|
||||||
className={"rarity-badge rarity-" + p.rarity.tier.toLowerCase()}
|
className={"rarity-badge rarity-" + p.rarity.tier.toLowerCase()}
|
||||||
|
|
@ -198,14 +218,18 @@ export function PlantRecord({
|
||||||
background={background}
|
background={background}
|
||||||
plant={p}
|
plant={p}
|
||||||
/>
|
/>
|
||||||
|
{scope === "owned" && (
|
||||||
<div className="detail-links">
|
<div className="detail-links">
|
||||||
<Link className="text-link" to={"/lab?plant=" + p.id}>
|
<Link className="text-link" to={"/lab?plant=" + p.id}>
|
||||||
Study genome →
|
Study genome →
|
||||||
</Link>
|
</Link>
|
||||||
|
{p.gameEligible && !p.soldAt && (
|
||||||
<Link className="text-link" to={"/breed?parent=" + p.id}>
|
<Link className="text-link" to={"/breed?parent=" + p.id}>
|
||||||
Use as parent →
|
Use as parent →
|
||||||
</Link>
|
</Link>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</aside>
|
</aside>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
1105
apps/web/src/game.css
Normal file
|
|
@ -17,6 +17,10 @@ export class RequestError extends Error {
|
||||||
super(message);
|
super(message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
let csrfToken = "";
|
||||||
|
export function setCsrf(token: string) {
|
||||||
|
csrfToken = token;
|
||||||
|
}
|
||||||
export async function request<T>(
|
export async function request<T>(
|
||||||
url: string,
|
url: string,
|
||||||
options: RequestInit = {},
|
options: RequestInit = {},
|
||||||
|
|
@ -25,7 +29,12 @@ export async function request<T>(
|
||||||
try {
|
try {
|
||||||
response = await fetch("/api/v1" + url, {
|
response = await fetch("/api/v1" + url, {
|
||||||
...options,
|
...options,
|
||||||
headers: { "Content-Type": "application/json", ...options.headers },
|
credentials: "same-origin",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"X-CSRF-Token": csrfToken,
|
||||||
|
...options.headers,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
throw new RequestError(
|
throw new RequestError(
|
||||||
|
|
@ -86,5 +95,5 @@ export async function loadAllParents() {
|
||||||
items.push(...page.items);
|
items.push(...page.items);
|
||||||
cursor = page.nextCursor;
|
cursor = page.nextCursor;
|
||||||
} while (cursor && items.length < 1000);
|
} while (cursor && items.length < 1000);
|
||||||
return items;
|
return items.filter((p) => p.gameEligible && !p.soldAt);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
326
apps/web/src/lib/game.tsx
Normal file
|
|
@ -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<GameState | null>(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<string, string>());
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (input: {
|
||||||
|
action: GameAction["action"];
|
||||||
|
data: Record<string, unknown>;
|
||||||
|
}) => {
|
||||||
|
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<typeof useGameAction>;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{action.error && (
|
||||||
|
<p role="alert" className="error-text">
|
||||||
|
{action.error.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{action.data && (
|
||||||
|
<p role="status" className="saved-notice">
|
||||||
|
{action.data.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
function Auth() {
|
||||||
|
const [mode, setMode] = useState("login"),
|
||||||
|
[username, setUsername] = useState(""),
|
||||||
|
[password, setPassword] = useState(""),
|
||||||
|
[code, setCode] = useState(""),
|
||||||
|
[codes, setCodes] = useState<string[] | null>(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 (
|
||||||
|
<main className="auth-scene">
|
||||||
|
<div className="auth-botanical" aria-hidden="true">
|
||||||
|
❦
|
||||||
|
</div>
|
||||||
|
<section className="auth-panel">
|
||||||
|
<p className="eyebrow">A little greenhouse. Endless possibility.</p>
|
||||||
|
<h1>Botanica</h1>
|
||||||
|
{codes ? (
|
||||||
|
<>
|
||||||
|
<h2>Keep your recovery codes</h2>
|
||||||
|
<p>
|
||||||
|
Save these five one-use codes somewhere private. They are shown
|
||||||
|
only once. Use one to reset a forgotten password; no email is
|
||||||
|
required.
|
||||||
|
</p>
|
||||||
|
<div className="recovery-codes">
|
||||||
|
{codes.map((c) => (
|
||||||
|
<code key={c}>{c}</code>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setCodes(null);
|
||||||
|
location.assign("/");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
I've saved my codes — enter greenhouse
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="auth-tabs">
|
||||||
|
{[
|
||||||
|
["login", "Sign in"],
|
||||||
|
["register", "Create account"],
|
||||||
|
["recover", "Recover account"],
|
||||||
|
].map(([id, label]) => (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={mode === id ? "" : "secondary"}
|
||||||
|
key={id}
|
||||||
|
onClick={() => {
|
||||||
|
setMode(id);
|
||||||
|
auth.reset();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<form
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
auth.mutate();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<label>
|
||||||
|
Username
|
||||||
|
<input
|
||||||
|
autoComplete="username"
|
||||||
|
required
|
||||||
|
pattern="[a-zA-Z0-9_]{3,24}"
|
||||||
|
minLength={3}
|
||||||
|
maxLength={24}
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
{mode === "recover" ? "New password" : "Password"}
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
autoComplete={
|
||||||
|
mode === "login" ? "current-password" : "new-password"
|
||||||
|
}
|
||||||
|
required
|
||||||
|
minLength={10}
|
||||||
|
maxLength={128}
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{mode === "recover" && (
|
||||||
|
<label>
|
||||||
|
One-use recovery code
|
||||||
|
<input
|
||||||
|
required
|
||||||
|
pattern="[a-f0-9]{24}"
|
||||||
|
value={code}
|
||||||
|
onChange={(e) => setCode(e.target.value.trim())}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<small>
|
||||||
|
Username: 3–24 letters, numbers or underscores. Password: at
|
||||||
|
least 10 characters.
|
||||||
|
</small>
|
||||||
|
<button disabled={auth.isPending}>
|
||||||
|
{auth.isPending
|
||||||
|
? "Opening the gate…"
|
||||||
|
: mode === "login"
|
||||||
|
? "Enter greenhouse"
|
||||||
|
: mode === "register"
|
||||||
|
? "Start growing"
|
||||||
|
: "Reset password"}
|
||||||
|
</button>
|
||||||
|
{auth.error && (
|
||||||
|
<p role="alert" className="error-text">
|
||||||
|
{auth.error.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
<p className="panel-note">
|
||||||
|
Start with 120 coins, three seeds and your own glasshouse. No
|
||||||
|
email, no daily streaks. Grow at your own pace.
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
function Report({
|
||||||
|
report,
|
||||||
|
onDismiss,
|
||||||
|
}: {
|
||||||
|
report: OfflineSummary;
|
||||||
|
onDismiss: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<section className="offline-report" aria-label="Offline progress">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">While you were away</p>
|
||||||
|
<h2>Your greenhouse kept growing.</h2>
|
||||||
|
<p>
|
||||||
|
{report.harvested} harvested · {report.sold} sold · {report.breeds}{" "}
|
||||||
|
crosses · {report.seedsUsed} seeds used ·{" "}
|
||||||
|
{report.coinDelta >= 0 ? "+" : ""}
|
||||||
|
{report.coinDelta} coins
|
||||||
|
</p>
|
||||||
|
<p className="panel-note">
|
||||||
|
{new Date(report.from).toLocaleString()} →{" "}
|
||||||
|
{new Date(report.to).toLocaleString()} · {report.events} worker
|
||||||
|
intervals · {report.blocked} intervals waiting for resources or a
|
||||||
|
ready crop.
|
||||||
|
</p>
|
||||||
|
{!!report.notable.length && (
|
||||||
|
<p>
|
||||||
|
{report.notable.length} notable discoveries are waiting in your
|
||||||
|
collection.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="secondary"
|
||||||
|
onClick={onDismiss}
|
||||||
|
aria-label="Dismiss offline report"
|
||||||
|
>
|
||||||
|
Dismiss
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
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<OfflineSummary | null>(null);
|
||||||
|
const game = useQuery({
|
||||||
|
queryKey: ["game"],
|
||||||
|
queryFn: async () => {
|
||||||
|
const state = await request<GameState>("/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 (
|
||||||
|
<main className="empty">
|
||||||
|
<h1>Opening the greenhouse…</h1>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
if (me.error) {
|
||||||
|
if (me.error instanceof RequestError && me.error.status === 401)
|
||||||
|
return <Auth />;
|
||||||
|
return (
|
||||||
|
<main className="empty">
|
||||||
|
<p role="alert">{me.error.message}</p>
|
||||||
|
<button onClick={() => void me.refetch()}>Reconnect</button>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!game.data)
|
||||||
|
return (
|
||||||
|
<main className="empty">
|
||||||
|
<h2>
|
||||||
|
{game.error
|
||||||
|
? "The greenhouse is unavailable."
|
||||||
|
: "Preparing your growing bench…"}
|
||||||
|
</h2>
|
||||||
|
{game.error && <p role="alert">{game.error.message}</p>}
|
||||||
|
<button onClick={() => void game.refetch()}>Reconnect</button>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<Context.Provider value={game.data}>
|
||||||
|
{children}
|
||||||
|
{report && (
|
||||||
|
<div className="report-dock">
|
||||||
|
<Report report={report} onDismiss={() => setReport(null)} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Context.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
12
apps/web/src/lib/requestId.ts
Normal file
|
|
@ -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)}`;
|
||||||
|
}
|
||||||
|
|
@ -3,7 +3,6 @@ import { createRoot } from "react-dom/client";
|
||||||
import {
|
import {
|
||||||
BrowserRouter,
|
BrowserRouter,
|
||||||
NavLink,
|
NavLink,
|
||||||
Navigate,
|
|
||||||
Route,
|
Route,
|
||||||
Routes,
|
Routes,
|
||||||
Link,
|
Link,
|
||||||
|
|
@ -12,156 +11,208 @@ import {
|
||||||
import {
|
import {
|
||||||
QueryClient,
|
QueryClient,
|
||||||
QueryClientProvider,
|
QueryClientProvider,
|
||||||
useQuery,
|
useQueryClient,
|
||||||
} from "@tanstack/react-query";
|
} 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 { Lab } from "./pages/Lab";
|
||||||
import { BreedingLab } from "./pages/BreedingLab";
|
import { BreedingLab } from "./pages/BreedingLab";
|
||||||
import { Collection } from "./pages/Collection";
|
import { Collection } from "./pages/Collection";
|
||||||
import { PlantDetails } from "./pages/PlantDetails";
|
import { PlantDetails } from "./pages/PlantDetails";
|
||||||
import { RenderGallery } from "./pages/RenderGallery";
|
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 { AppErrorBoundary } from "./components/AppErrorBoundary";
|
||||||
|
import { GameProvider, useGame } from "./lib/game";
|
||||||
import "./styles.css";
|
import "./styles.css";
|
||||||
|
import "./game.css";
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
defaultOptions: { queries: { retry: 1, refetchOnWindowFocus: false } },
|
defaultOptions: { queries: { retry: 1, refetchOnWindowFocus: false } },
|
||||||
});
|
});
|
||||||
function Health() {
|
const links = [
|
||||||
const result = useQuery({
|
["/", "Greenhouse", House],
|
||||||
queryKey: ["health"],
|
["/shop", "Shop", ShoppingBasket],
|
||||||
queryFn: async () => {
|
["/gallery", "Collection", Flower2],
|
||||||
const r = await fetch("/health");
|
["/breed", "Breeding", GitBranch],
|
||||||
if (!r.ok) throw new Error("Unavailable");
|
["/workers", "Workers", Users],
|
||||||
return r.json() as Promise<{ status: string }>;
|
["/research", "Research", BookOpen],
|
||||||
},
|
["/quests", "Quests", NotebookPen],
|
||||||
refetchInterval: 30000,
|
["/lab", "Advanced Lab", FlaskConical],
|
||||||
retry: 1,
|
["/settings", "Account", SettingsIcon],
|
||||||
});
|
] as const;
|
||||||
return (
|
|
||||||
<span className="health" role="status">
|
|
||||||
<span className={result.isSuccess ? "dot online" : "dot"} />
|
|
||||||
{result.isPending
|
|
||||||
? "Connecting to laboratory"
|
|
||||||
: result.isError
|
|
||||||
? "Laboratory connection unavailable"
|
|
||||||
: "Laboratory online"}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function About() {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<section className="hero">
|
|
||||||
<div className="intro">
|
|
||||||
<p className="eyebrow">Field notes / Plant Laboratory</p>
|
|
||||||
<h1>Botany meets a little imagination.</h1>
|
|
||||||
<p className="description">
|
|
||||||
An illustrated breeding sandbox inspired by Mendelian genetics. The
|
|
||||||
plants are fictional; the joy of discovering a new combination is
|
|
||||||
yours.
|
|
||||||
</p>
|
|
||||||
<Link className="text-link" to="/lab">
|
|
||||||
Begin a botanical study →
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
<figure>
|
|
||||||
<BotanicalPlate />
|
|
||||||
<figcaption>AN ORIGINAL BOTANICAL STUDY</figcaption>
|
|
||||||
</figure>
|
|
||||||
</section>
|
|
||||||
<section className="about-notes">
|
|
||||||
<article>
|
|
||||||
<h2>Paired traits</h2>
|
|
||||||
<p>
|
|
||||||
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.
|
|
||||||
</p>
|
|
||||||
</article>
|
|
||||||
<article>
|
|
||||||
<h2>A traceable collection</h2>
|
|
||||||
<p>
|
|
||||||
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.
|
|
||||||
</p>
|
|
||||||
</article>
|
|
||||||
<article>
|
|
||||||
<h2>A first chapter</h2>
|
|
||||||
<p>
|
|
||||||
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.
|
|
||||||
</p>
|
|
||||||
</article>
|
|
||||||
</section>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
function App() {
|
function App() {
|
||||||
const { pathname } = useLocation();
|
const game = useGame(),
|
||||||
|
client = useQueryClient(),
|
||||||
|
{ pathname } = useLocation(),
|
||||||
|
quest = game.quests.find((q) => !q.claimed);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.getElementById("main")?.focus({ preventScroll: true });
|
document.getElementById("main")?.focus({ preventScroll: true });
|
||||||
window.scrollTo({ top: 0 });
|
window.scrollTo({ top: 0 });
|
||||||
}, [pathname]);
|
}, [pathname]);
|
||||||
|
useEffect(() => {
|
||||||
|
document.documentElement.dataset.motion =
|
||||||
|
localStorage.getItem("botanica-motion") ?? "system";
|
||||||
|
}, []);
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="game-shell">
|
||||||
<a href="#main" className="skip">
|
<a href="#main" className="skip">
|
||||||
Skip to content
|
Skip to content
|
||||||
</a>
|
</a>
|
||||||
<header>
|
<aside className="game-rail">
|
||||||
<NavLink to="/lab" className="brand" aria-label="Plant Laboratory home">
|
<Link className="game-brand" to="/">
|
||||||
<span className="brand-icon">
|
<Leaf />
|
||||||
<Leaf size={25} />
|
|
||||||
</span>
|
|
||||||
<span>
|
<span>
|
||||||
Plant Laboratory<small>BOTANICA / PLANT LABORATORY</small>
|
Botanica<small>THE GREENHOUSE JOURNAL</small>
|
||||||
</span>
|
</span>
|
||||||
</NavLink>
|
</Link>
|
||||||
<nav aria-label="Main navigation">
|
<nav aria-label="Main navigation">
|
||||||
{[
|
{links.map(([to, label, Icon]) => (
|
||||||
["lab", "Laboratory"],
|
<NavLink key={to} to={to} end={to === "/"}>
|
||||||
["breed", "Breeding"],
|
<Icon size={19} />
|
||||||
["gallery", "Collection"],
|
<span>{label}</span>
|
||||||
["about", "Field notes"],
|
|
||||||
].map(([key, label]) => (
|
|
||||||
<NavLink key={key} to={"/" + key}>
|
|
||||||
{label}
|
|
||||||
</NavLink>
|
</NavLink>
|
||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
|
<Link to="/examples" className="rail-note">
|
||||||
|
❦ Reference herbarium
|
||||||
|
</Link>
|
||||||
|
<p className="rail-motto">
|
||||||
|
Rooted in curiosity.
|
||||||
|
<br />
|
||||||
|
Grown with care.
|
||||||
|
</p>
|
||||||
|
</aside>
|
||||||
|
<div className="game-body">
|
||||||
|
<header className="game-hud">
|
||||||
|
<div className="hud-player">
|
||||||
|
<span className="avatar">
|
||||||
|
{game.player.username.slice(0, 1).toUpperCase()}
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<strong>{game.player.username}</strong>
|
||||||
|
<span>
|
||||||
|
Level {game.player.level} · {game.player.xp} XP
|
||||||
|
</span>
|
||||||
|
<progress
|
||||||
|
aria-label="Level experience"
|
||||||
|
value={game.player.xp}
|
||||||
|
max={game.player.nextLevelXp || game.player.xp || 1}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="hud-coins">
|
||||||
|
<small>COIN PURSE</small>
|
||||||
|
<strong>◈ {game.player.coins.toLocaleString()}</strong>
|
||||||
|
</div>
|
||||||
|
<div className="hud-seeds">
|
||||||
|
<small>SEED POUCH</small>
|
||||||
|
<strong>
|
||||||
|
❧ {Object.values(game.seeds).reduce((a, b) => a + b, 0)}
|
||||||
|
</strong>
|
||||||
|
<span>
|
||||||
|
{game.seeds.starter} starter · {game.seeds.uncommon} uncommon ·{" "}
|
||||||
|
{game.seeds.rare} rare
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Link to="/quests" className="quest-pin">
|
||||||
|
<small>NEXT IN YOUR NOTEBOOK</small>
|
||||||
|
<strong>{quest?.name ?? "A notebook full of memories"}</strong>
|
||||||
|
<span>
|
||||||
|
{quest
|
||||||
|
? quest.progress +
|
||||||
|
" / " +
|
||||||
|
quest.target +
|
||||||
|
" · " +
|
||||||
|
(quest.progress >= quest.target
|
||||||
|
? "Reward ready →"
|
||||||
|
: "Keep growing →")
|
||||||
|
: "All goals complete"}
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
</header>
|
</header>
|
||||||
|
{game.pendingWork && (
|
||||||
|
<div className="pending-banner" role="status">
|
||||||
|
Your team has more offline work to process.{" "}
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
void client.invalidateQueries({ queryKey: ["game"] })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Process next batch
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<main id="main" tabIndex={-1}>
|
<main id="main" tabIndex={-1}>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Navigate to="/lab" replace />} />
|
<Route path="/" element={<Greenhouse />} />
|
||||||
<Route path="/lab" element={<Lab />} />
|
<Route path="/shop" element={<Shop />} />
|
||||||
<Route path="/breed" element={<BreedingLab />} />
|
|
||||||
<Route path="/gallery" element={<Collection />} />
|
<Route path="/gallery" element={<Collection />} />
|
||||||
|
<Route path="/breed" element={<BreedingLab />} />
|
||||||
|
<Route path="/workers" element={<Workers />} />
|
||||||
|
<Route path="/research" element={<Research />} />
|
||||||
|
<Route path="/quests" element={<Quests />} />
|
||||||
|
<Route path="/settings" element={<Settings />} />
|
||||||
|
<Route
|
||||||
|
path="/lab"
|
||||||
|
element={
|
||||||
|
<>
|
||||||
|
<div className="study-notice">
|
||||||
|
Advanced studies are a creative sandbox. Saved studies keep
|
||||||
|
their genomes and artwork, but cannot be sold or used for
|
||||||
|
game rewards. <Link to="/">Grow game plants →</Link>
|
||||||
|
</div>
|
||||||
|
<Lab />
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Route path="/plants/:id" element={<PlantDetails />} />
|
<Route path="/plants/:id" element={<PlantDetails />} />
|
||||||
<Route path="/studies" element={<RenderGallery />} />
|
<Route path="/studies" element={<RenderGallery />} />
|
||||||
<Route path="/about" element={<About />} />
|
<Route path="/examples" element={<Examples />} />
|
||||||
|
<Route path="/examples/:id" element={<ReferenceDetails />} />
|
||||||
|
<Route path="/about" element={<Settings />} />
|
||||||
<Route
|
<Route
|
||||||
path="*"
|
path="*"
|
||||||
element={
|
element={
|
||||||
<section className="empty">
|
<section className="empty">
|
||||||
<h1>Page not found</h1>
|
<h1>Path not found</h1>
|
||||||
<Link className="text-link" to="/lab">
|
<Link to="/">Return to your greenhouse →</Link>
|
||||||
Return to the laboratory →
|
|
||||||
</Link>
|
|
||||||
</section>
|
</section>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</Routes>
|
</Routes>
|
||||||
</main>
|
</main>
|
||||||
<footer>
|
<footer className="game-footer">
|
||||||
<span>Rooted in curiosity. Grown with care.</span>
|
Botanica · No rush. Something good is growing.
|
||||||
<Health />
|
<Link to="/studies">Botanical studies</Link>
|
||||||
</footer>
|
</footer>
|
||||||
</>
|
</div>
|
||||||
|
<nav className="mobile-game-nav" aria-label="Quick navigation">
|
||||||
|
{links.slice(0, 5).map(([to, label, Icon]) => (
|
||||||
|
<NavLink key={to} to={to} end={to === "/"}>
|
||||||
|
<Icon size={20} />
|
||||||
|
<span>{label}</span>
|
||||||
|
</NavLink>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
createRoot(document.getElementById("root")!).render(
|
createRoot(document.getElementById("root")!).render(
|
||||||
|
|
@ -169,7 +220,9 @@ createRoot(document.getElementById("root")!).render(
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<AppErrorBoundary>
|
<AppErrorBoundary>
|
||||||
|
<GameProvider>
|
||||||
<App />
|
<App />
|
||||||
|
</GameProvider>
|
||||||
</AppErrorBoundary>
|
</AppErrorBoundary>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,8 @@ import { PlantPreview } from "../components/PlantPreview";
|
||||||
import { QueryMessage } from "../components/QueryMessage";
|
import { QueryMessage } from "../components/QueryMessage";
|
||||||
import { api, loadAllParents } from "../lib/api";
|
import { api, loadAllParents } from "../lib/api";
|
||||||
import type { PlantDto } from "@botanica/shared";
|
import type { PlantDto } from "@botanica/shared";
|
||||||
|
import { useGame } from "../lib/game";
|
||||||
|
import { newRequestId } from "../lib/requestId";
|
||||||
function Parent({
|
function Parent({
|
||||||
label,
|
label,
|
||||||
id,
|
id,
|
||||||
|
|
@ -22,7 +24,11 @@ function Parent({
|
||||||
<section className="parent-card">
|
<section className="parent-card">
|
||||||
<label>
|
<label>
|
||||||
{label}
|
{label}
|
||||||
<select value={id} onChange={(e) => onChange(e.target.value)}>
|
<select
|
||||||
|
aria-label={label}
|
||||||
|
value={id}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
>
|
||||||
<option value="">Choose a saved plant</option>
|
<option value="">Choose a saved plant</option>
|
||||||
{plants.map((p) => (
|
{plants.map((p) => (
|
||||||
<option key={p.id} value={p.id}>
|
<option key={p.id} value={p.id}>
|
||||||
|
|
@ -58,6 +64,7 @@ function Parent({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
export function BreedingLab() {
|
export function BreedingLab() {
|
||||||
|
const game = useGame();
|
||||||
const [search] = useSearchParams();
|
const [search] = useSearchParams();
|
||||||
const parents = useQuery({ queryKey: ["parents"], queryFn: loadAllParents }),
|
const parents = useQuery({ queryKey: ["parents"], queryFn: loadAllParents }),
|
||||||
[a, setA] = useState(search.get("parent") ?? ""),
|
[a, setA] = useState(search.get("parent") ?? ""),
|
||||||
|
|
@ -72,6 +79,7 @@ export function BreedingLab() {
|
||||||
const cross = useMutation({
|
const cross = useMutation({
|
||||||
mutationFn: api.cross,
|
mutationFn: api.cross,
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
|
void client.invalidateQueries({ queryKey: ["game"] });
|
||||||
void client.invalidateQueries({ queryKey: ["parents"] });
|
void client.invalidateQueries({ queryKey: ["parents"] });
|
||||||
void client.invalidateQueries({ queryKey: ["plants"] });
|
void client.invalidateQueries({ queryKey: ["plants"] });
|
||||||
void client.invalidateQueries({ queryKey: ["collection-stats"] });
|
void client.invalidateQueries({ queryKey: ["collection-stats"] });
|
||||||
|
|
@ -109,7 +117,7 @@ export function BreedingLab() {
|
||||||
function breed() {
|
function breed() {
|
||||||
const pair = a + ":" + b;
|
const pair = a + ":" + b;
|
||||||
if (!key.current || key.current.pair !== pair)
|
if (!key.current || key.current.pair !== pair)
|
||||||
key.current = { pair, id: crypto.randomUUID() };
|
key.current = { pair, id: newRequestId() };
|
||||||
cross.mutate({
|
cross.mutate({
|
||||||
parentAId: a,
|
parentAId: a,
|
||||||
parentBId: b,
|
parentBId: b,
|
||||||
|
|
@ -128,7 +136,7 @@ export function BreedingLab() {
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Link className="button-link secondary" to="/lab">
|
<Link className="button-link secondary" to="/lab">
|
||||||
Design a parent →
|
Advanced genetics →
|
||||||
</Link>
|
</Link>
|
||||||
</section>
|
</section>
|
||||||
{parents.isPending ? (
|
{parents.isPending ? (
|
||||||
|
|
@ -141,9 +149,12 @@ export function BreedingLab() {
|
||||||
) : !list.length ? (
|
) : !list.length ? (
|
||||||
<div className="empty">
|
<div className="empty">
|
||||||
<h2>No saved parents yet</h2>
|
<h2>No saved parents yet</h2>
|
||||||
<p>Design and save two plants in the laboratory.</p>
|
<p>
|
||||||
<Link className="text-link" to="/lab">
|
Grow and harvest two plants in your greenhouse. Breeding unlocks at
|
||||||
Open laboratory →
|
level 3 and costs {game.breedCost} coins; both parents are kept.
|
||||||
|
</p>
|
||||||
|
<Link className="text-link" to="/">
|
||||||
|
Open greenhouse →
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -167,9 +178,23 @@ export function BreedingLab() {
|
||||||
</div>
|
</div>
|
||||||
<section className="cross-controls">
|
<section className="cross-controls">
|
||||||
<p role="status">{message}</p>
|
<p role="status">{message}</p>
|
||||||
|
<p>
|
||||||
|
{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.`}
|
||||||
|
</p>
|
||||||
<button
|
<button
|
||||||
onClick={breed}
|
onClick={breed}
|
||||||
disabled={!compatible || cross.isPending || !!cross.data}
|
disabled={
|
||||||
|
!compatible ||
|
||||||
|
cross.isPending ||
|
||||||
|
!!cross.data ||
|
||||||
|
game.player.level < 3 ||
|
||||||
|
game.player.coins < game.breedCost ||
|
||||||
|
game.pendingWork
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{cross.isPending
|
{cross.isPending
|
||||||
? "Crossing…"
|
? "Crossing…"
|
||||||
|
|
|
||||||
|
|
@ -68,8 +68,8 @@ export function Collection() {
|
||||||
: "Your saved plants, their inherited traits and the stories behind them."}
|
: "Your saved plants, their inherited traits and the stories behind them."}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Link className="button-link secondary" to="/lab">
|
<Link className="button-link secondary" to="/">
|
||||||
Create a plant →
|
Grow a plant →
|
||||||
</Link>
|
</Link>
|
||||||
</section>
|
</section>
|
||||||
<section className="collection-filters" aria-label="Collection filters">
|
<section className="collection-filters" aria-label="Collection filters">
|
||||||
|
|
@ -176,7 +176,10 @@ export function Collection() {
|
||||||
>
|
>
|
||||||
{p.rarity.tier}
|
{p.rarity.tier}
|
||||||
</span>
|
</span>
|
||||||
<span>Gen {p.generation}</span>
|
<span>
|
||||||
|
{p.isFavorite ? "★ " : ""}
|
||||||
|
{p.isProtected ? "Protected · " : ""}Gen {p.generation}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<PlantPreview
|
<PlantPreview
|
||||||
phenotype={p.phenotype}
|
phenotype={p.phenotype}
|
||||||
|
|
@ -192,7 +195,11 @@ export function Collection() {
|
||||||
<div className="collection-card-footer">
|
<div className="collection-card-footer">
|
||||||
<span>{p.phenotype.leafPattern}</span>
|
<span>{p.phenotype.leafPattern}</span>
|
||||||
<span>
|
<span>
|
||||||
{p.creationSource === "demo"
|
{p.firstDiscovery
|
||||||
|
? "First discovery"
|
||||||
|
: p.gameEligible && p.creationSource !== "bred"
|
||||||
|
? "Greenhouse grown"
|
||||||
|
: p.creationSource === "demo"
|
||||||
? "Demo study"
|
? "Demo study"
|
||||||
: p.creationSource === "bred"
|
: p.creationSource === "bred"
|
||||||
? "Bred offspring"
|
? "Bred offspring"
|
||||||
|
|
@ -208,7 +215,8 @@ export function Collection() {
|
||||||
<h2>No plants match these filters</h2>
|
<h2>No plants match these filters</h2>
|
||||||
<p>
|
<p>
|
||||||
Try a different trait or clear your search to see the whole
|
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.
|
||||||
</p>
|
</p>
|
||||||
<button className="secondary" onClick={clear}>
|
<button className="secondary" onClick={clear}>
|
||||||
Show all plants
|
Show all plants
|
||||||
|
|
|
||||||
872
apps/web/src/pages/GamePages.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<div className="game-heading">
|
||||||
|
<p className="eyebrow">Botanica / grow at your own pace</p>
|
||||||
|
<h1>{title}</h1>
|
||||||
|
<p>{note}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
export function Greenhouse() {
|
||||||
|
const client = useQueryClient(),
|
||||||
|
lastReadyCheck = useRef(0);
|
||||||
|
const game = useGame(),
|
||||||
|
[selected, setSelected] = useState(""),
|
||||||
|
[seed, setSeed] = useState<SeedType>("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 (
|
||||||
|
<>
|
||||||
|
<GameHeading
|
||||||
|
title="A good day to grow."
|
||||||
|
note="Plant a seed. Make a discovery. Build a greenhouse that's yours."
|
||||||
|
/>
|
||||||
|
<div className="greenhouse-toolbar">
|
||||||
|
<div className="house-tabs">
|
||||||
|
{game.greenhouses.map((h) => (
|
||||||
|
<button
|
||||||
|
key={h.id}
|
||||||
|
className={house.id === h.id ? "" : "secondary"}
|
||||||
|
onClick={() => setSelected(h.id)}
|
||||||
|
>
|
||||||
|
{h.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<label>
|
||||||
|
Plant with
|
||||||
|
<select
|
||||||
|
aria-label="Plant with"
|
||||||
|
value={seed}
|
||||||
|
onChange={(e) => setSeed(e.target.value as SeedType)}
|
||||||
|
>
|
||||||
|
{game.seedInfo.map((s) => (
|
||||||
|
<option key={s.id} value={s.id}>
|
||||||
|
{s.name} · {game.seeds[s.id]} available
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="glasshouse">
|
||||||
|
<div className="glasshouse-roof" aria-hidden="true">
|
||||||
|
<span>BOTANICA · {house.name.toUpperCase()}</span>
|
||||||
|
</div>
|
||||||
|
<div className="house-equipment">
|
||||||
|
<span>{house.slots} growing slots</span>
|
||||||
|
<span>
|
||||||
|
{house.lamp ? "☀ Grow lamp · 10% faster" : "Natural light"} ·{" "}
|
||||||
|
{house.irrigation ? "Irrigation installed" : "Hand tended"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="growing-bench">
|
||||||
|
{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 (
|
||||||
|
<article
|
||||||
|
key={slot.id}
|
||||||
|
className={
|
||||||
|
"growing-slot " +
|
||||||
|
(ready ? "ready" : slot.seedType ? "growing" : "empty-slot")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="slot-top">
|
||||||
|
<span>№ {String(slot.slotNo + 1).padStart(2, "0")}</span>
|
||||||
|
<span>
|
||||||
|
{ready
|
||||||
|
? "Ready to harvest"
|
||||||
|
: slot.seedType
|
||||||
|
? "Growing"
|
||||||
|
: "Open soil"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="slot-art">
|
||||||
|
{slot.phenotype ? (
|
||||||
|
<PlantPreview
|
||||||
|
phenotype={slot.phenotype}
|
||||||
|
visualSeed={slot.visualSeed!}
|
||||||
|
stage={
|
||||||
|
ready
|
||||||
|
? "flowering"
|
||||||
|
: progress < 40
|
||||||
|
? "seedling"
|
||||||
|
: "juvenile"
|
||||||
|
}
|
||||||
|
size={512}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 200 200"
|
||||||
|
role="img"
|
||||||
|
aria-label="Empty terracotta growing pot"
|
||||||
|
>
|
||||||
|
<ellipse
|
||||||
|
cx="100"
|
||||||
|
cy="171"
|
||||||
|
rx="65"
|
||||||
|
ry="10"
|
||||||
|
fill="#162c20"
|
||||||
|
opacity=".2"
|
||||||
|
/>
|
||||||
|
<path d="M54 91h92l-13 69q-33 14-66 0Z" fill="#b87550" />
|
||||||
|
<path
|
||||||
|
d="M51 86q49-19 98 0v17q-49 19-98 0Z"
|
||||||
|
fill="#d59a70"
|
||||||
|
/>
|
||||||
|
<ellipse
|
||||||
|
cx="100"
|
||||||
|
cy="86"
|
||||||
|
rx="43"
|
||||||
|
ry="10"
|
||||||
|
fill="#493f2d"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M100 70V43m-13 14 13 13 13-13"
|
||||||
|
fill="none"
|
||||||
|
stroke="#839272"
|
||||||
|
strokeWidth="3"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="slot-info">
|
||||||
|
{slot.seedType ? (
|
||||||
|
<>
|
||||||
|
<progress
|
||||||
|
max={100}
|
||||||
|
value={progress}
|
||||||
|
aria-label={"Growth of slot " + (slot.slotNo + 1)}
|
||||||
|
/>
|
||||||
|
<p>
|
||||||
|
{ready
|
||||||
|
? "Your next discovery is waiting."
|
||||||
|
: `${Math.floor(remaining / 60)}m ${remaining % 60}s remaining`}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
disabled={
|
||||||
|
action.isPending || !ready || game.pendingWork
|
||||||
|
}
|
||||||
|
onClick={() =>
|
||||||
|
action.mutate({
|
||||||
|
action: "harvest",
|
||||||
|
data: { slotId: slot.id },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{ready ? "Harvest plant" : "Taking root…"}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p>
|
||||||
|
{game.seeds[seed]
|
||||||
|
? "A little room for possibility."
|
||||||
|
: "No seeds of this type. Try starter or visit the shop."}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
disabled={
|
||||||
|
action.isPending ||
|
||||||
|
!game.seeds[seed] ||
|
||||||
|
game.pendingWork
|
||||||
|
}
|
||||||
|
onClick={() =>
|
||||||
|
action.mutate({
|
||||||
|
action: "plant",
|
||||||
|
data: { slotId: slot.id, seedType: seed },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Plant {seed} seed
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ActionFeedback action={action} />
|
||||||
|
{action.data?.plantId && (
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
"discovery-reveal " +
|
||||||
|
(action.data.discovered ? "new-discovery" : "")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<h2>
|
||||||
|
{action.data.discovered
|
||||||
|
? "A first of its kind."
|
||||||
|
: "A new leaf in your collection."}
|
||||||
|
</h2>
|
||||||
|
<p>
|
||||||
|
Your specimen is safe in the collection. Each harvest recycles one
|
||||||
|
starter seed.
|
||||||
|
</p>
|
||||||
|
<Link className="button-link" to={"/plants/" + action.data.plantId}>
|
||||||
|
Meet your plant →
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="game-tip">
|
||||||
|
<span>❦</span>
|
||||||
|
<p>
|
||||||
|
Keep a favorite or sell a specimen to grow your coin purse. Crops
|
||||||
|
never wilt while you're away.
|
||||||
|
</p>
|
||||||
|
<Link className="text-link" to="/gallery">
|
||||||
|
Your collection →
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
export function Shop() {
|
||||||
|
const game = useGame(),
|
||||||
|
action = useGameAction(),
|
||||||
|
[houseId, setHouse] = useState(game.greenhouses[0].id),
|
||||||
|
house = game.greenhouses.find((h) => h.id === houseId)!;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<GameHeading
|
||||||
|
title="The potting shop"
|
||||||
|
note="Known seeds, useful tools, a little more room to grow. All prices are in coins."
|
||||||
|
/>
|
||||||
|
<label className="house-select">
|
||||||
|
Equip greenhouse
|
||||||
|
<select
|
||||||
|
aria-label="Equip greenhouse"
|
||||||
|
value={houseId}
|
||||||
|
onChange={(e) => setHouse(e.target.value)}
|
||||||
|
>
|
||||||
|
{game.greenhouses.map((h) => (
|
||||||
|
<option key={h.id} value={h.id}>
|
||||||
|
{h.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<div className="game-grid">
|
||||||
|
{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 (
|
||||||
|
<article className="game-card" key={item.id}>
|
||||||
|
<span className="item-glyph" aria-hidden="true">
|
||||||
|
{item.id.startsWith("seed")
|
||||||
|
? "❧"
|
||||||
|
: item.id === "greenhouse"
|
||||||
|
? "⌂"
|
||||||
|
: "✧"}
|
||||||
|
</span>
|
||||||
|
<h2>{item.name}</h2>
|
||||||
|
<p>{item.description}</p>
|
||||||
|
<p className="coin-price">◈ {cost} coins</p>
|
||||||
|
<button
|
||||||
|
disabled={!!reason || action.isPending}
|
||||||
|
onClick={() =>
|
||||||
|
action.mutate({
|
||||||
|
action: "purchase",
|
||||||
|
data: { itemId: item.id, houseId },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{reason ?? "Purchase"}
|
||||||
|
</button>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<ActionFeedback action={action} />
|
||||||
|
<p className="panel-note">
|
||||||
|
Seed packs use fixed founder genomes, described above; no random rarity
|
||||||
|
roll. Starter seeds are guaranteed back at every harvest.
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
export function Quests() {
|
||||||
|
const game = useGame(),
|
||||||
|
action = useGameAction();
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<GameHeading
|
||||||
|
title="A growing notebook"
|
||||||
|
note="Small accomplishments, lasting rewards. Claim them whenever you like."
|
||||||
|
/>
|
||||||
|
<div className="game-grid">
|
||||||
|
{game.quests.map((q) => (
|
||||||
|
<article
|
||||||
|
key={q.id}
|
||||||
|
className={"game-card " + (q.claimed ? "completed" : "")}
|
||||||
|
>
|
||||||
|
<p className="eyebrow">{q.claimed ? "Completed" : "Field goal"}</p>
|
||||||
|
<h2>{q.name}</h2>
|
||||||
|
<p>{q.description}</p>
|
||||||
|
<progress value={q.progress} max={q.target} aria-label={q.name} />
|
||||||
|
<p>
|
||||||
|
{q.progress} / {q.target} · ◈ {q.coins} · {q.xp} XP
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
disabled={
|
||||||
|
q.claimed ||
|
||||||
|
q.progress < q.target ||
|
||||||
|
action.isPending ||
|
||||||
|
game.pendingWork
|
||||||
|
}
|
||||||
|
onClick={() =>
|
||||||
|
action.mutate({ action: "claim", data: { questId: q.id } })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{q.claimed
|
||||||
|
? "Reward collected"
|
||||||
|
: q.progress < q.target
|
||||||
|
? "In progress"
|
||||||
|
: "Claim reward"}
|
||||||
|
</button>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<ActionFeedback action={action} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
export function Research() {
|
||||||
|
const game = useGame(),
|
||||||
|
action = useGameAction();
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<GameHeading
|
||||||
|
title="The botanist's desk"
|
||||||
|
note="A few thoughtful improvements. Research lasts for your whole collection."
|
||||||
|
/>
|
||||||
|
<div className="game-grid">
|
||||||
|
{game.research.map((item) => (
|
||||||
|
<article key={item.id} className="game-card">
|
||||||
|
<p className="eyebrow">
|
||||||
|
Level {item.level} ·{" "}
|
||||||
|
{item.owned ? "Learned" : "Botanical research"}
|
||||||
|
</p>
|
||||||
|
<h2>{item.name}</h2>
|
||||||
|
<p>{item.description}</p>
|
||||||
|
<p className="coin-price">◈ {item.cost} coins</p>
|
||||||
|
<button
|
||||||
|
disabled={
|
||||||
|
!!item.disabledReason ||
|
||||||
|
game.player.coins < item.cost ||
|
||||||
|
action.isPending ||
|
||||||
|
game.pendingWork
|
||||||
|
}
|
||||||
|
onClick={() =>
|
||||||
|
action.mutate({
|
||||||
|
action: "research",
|
||||||
|
data: { researchId: item.id },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{item.disabledReason ??
|
||||||
|
(game.player.coins < item.cost
|
||||||
|
? "Not enough coins"
|
||||||
|
: "Research")}
|
||||||
|
</button>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<ActionFeedback action={action} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
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<WorkerPolicy>({
|
||||||
|
mode: worker.kind === "gardener" ? "repeat_crop" : "repeat_cross",
|
||||||
|
seedType: "starter",
|
||||||
|
autoSell: false,
|
||||||
|
reserveCoins: 100,
|
||||||
|
...(worker.policy as Partial<WorkerPolicy>),
|
||||||
|
});
|
||||||
|
const update = (patch: Partial<WorkerPolicy>) =>
|
||||||
|
setPolicy({ ...policy, ...patch });
|
||||||
|
return (
|
||||||
|
<article className="game-card worker-card">
|
||||||
|
<p className="eyebrow">
|
||||||
|
{worker.enabled ? "Working" : "Paused"} · one interval /{" "}
|
||||||
|
{worker.intervalSeconds}s
|
||||||
|
</p>
|
||||||
|
<h2>
|
||||||
|
{worker.kind === "gardener" ? "Junior gardener" : "Breeding specialist"}
|
||||||
|
</h2>
|
||||||
|
<form
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
action.mutate({
|
||||||
|
action: "assign",
|
||||||
|
data: { workerId: worker.id, greenhouseId: house, enabled, policy },
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<label>
|
||||||
|
Greenhouse
|
||||||
|
<select
|
||||||
|
aria-label="Greenhouse"
|
||||||
|
value={house}
|
||||||
|
onChange={(e) => setHouse(e.target.value)}
|
||||||
|
>
|
||||||
|
{game.greenhouses.map((h) => (
|
||||||
|
<option key={h.id} value={h.id}>
|
||||||
|
{h.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Policy
|
||||||
|
<select
|
||||||
|
aria-label="Policy"
|
||||||
|
value={policy.mode}
|
||||||
|
onChange={(e) =>
|
||||||
|
update({ mode: e.target.value as WorkerPolicy["mode"] })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{worker.kind === "gardener" ? (
|
||||||
|
<option value="repeat_crop">Harvest & replant</option>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<option value="repeat_cross">Repeat parent cross</option>
|
||||||
|
<option
|
||||||
|
value="target_trait"
|
||||||
|
disabled={
|
||||||
|
!game.research.find((r) => r.id === "selection")?.owned
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Target traits · level 10 research
|
||||||
|
</option>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
{worker.kind === "gardener" ? (
|
||||||
|
<label>
|
||||||
|
Seed to plant
|
||||||
|
<select
|
||||||
|
aria-label="Seed to plant"
|
||||||
|
value={policy.seedType}
|
||||||
|
onChange={(e) => update({ seedType: e.target.value as SeedType })}
|
||||||
|
>
|
||||||
|
{game.seedInfo.map((s) => (
|
||||||
|
<option key={s.id} value={s.id}>
|
||||||
|
{s.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{(["A", "B"] as const).map((side) => (
|
||||||
|
<label key={side}>
|
||||||
|
Parent {side}
|
||||||
|
<select
|
||||||
|
aria-label={"Parent " + side}
|
||||||
|
required
|
||||||
|
value={
|
||||||
|
(side === "A" ? policy.parentAId : policy.parentBId) ?? ""
|
||||||
|
}
|
||||||
|
onChange={(e) =>
|
||||||
|
update(
|
||||||
|
side === "A"
|
||||||
|
? { parentAId: e.target.value }
|
||||||
|
: { parentBId: e.target.value },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="">Choose an owned mature plant</option>
|
||||||
|
{parents.data?.map((p) => (
|
||||||
|
<option key={p.id} value={p.id}>
|
||||||
|
{p.name} · {p.rarity.tier}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
<label>
|
||||||
|
Coin reserve
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
max={1000000}
|
||||||
|
value={policy.reserveCoins}
|
||||||
|
onChange={(e) =>
|
||||||
|
update({ reserveCoins: Number(e.target.value) })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<p className="panel-note">
|
||||||
|
Each cross costs {game.breedCost} coins. Both parents are kept.
|
||||||
|
Breeders never auto-sell offspring.
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{policy.mode === "target_trait" && (
|
||||||
|
<>
|
||||||
|
<label>
|
||||||
|
Desired pigment
|
||||||
|
<select
|
||||||
|
aria-label="Desired pigment"
|
||||||
|
value={policy.targetPigment ?? ""}
|
||||||
|
onChange={(e) => {
|
||||||
|
const next = { ...policy };
|
||||||
|
if (e.target.value) next.targetPigment = e.target.value;
|
||||||
|
else delete next.targetPigment;
|
||||||
|
setPolicy(next);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">Any pigment</option>
|
||||||
|
{["green", "purple", "red", "yellow"].map((v) => (
|
||||||
|
<option key={v}>{v}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Desired pattern
|
||||||
|
<select
|
||||||
|
aria-label="Desired pattern"
|
||||||
|
value={policy.targetPattern ?? ""}
|
||||||
|
onChange={(e) => {
|
||||||
|
const next = { ...policy };
|
||||||
|
if (e.target.value) next.targetPattern = e.target.value;
|
||||||
|
else delete next.targetPattern;
|
||||||
|
setPolicy(next);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">Any pattern</option>
|
||||||
|
{["solid", "variegated", "marbled"].map((v) => (
|
||||||
|
<option key={v}>{v}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<p className="panel-note">
|
||||||
|
Matching children are protected. Targets never change inheritance
|
||||||
|
or mutation odds.
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{worker.kind === "gardener" && (
|
||||||
|
<label className="check-label">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={policy.autoSell}
|
||||||
|
onChange={(e) => update({ autoSell: e.target.checked })}
|
||||||
|
/>
|
||||||
|
Sell duplicate Common harvests automatically
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
<label className="check-label">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={enabled}
|
||||||
|
onChange={(e) => setEnabled(e.target.checked)}
|
||||||
|
/>
|
||||||
|
Enable automation
|
||||||
|
</label>
|
||||||
|
<button disabled={action.isPending || game.pendingWork}>
|
||||||
|
Save assignment
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<ActionFeedback action={action} />
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
export function Workers() {
|
||||||
|
const game = useGame(),
|
||||||
|
action = useGameAction();
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<GameHeading
|
||||||
|
title="A helping hand"
|
||||||
|
note="Your team works while you're away. Real seeds, real costs, every interval accounted for."
|
||||||
|
/>
|
||||||
|
<div className="game-grid">
|
||||||
|
{(["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 (
|
||||||
|
<article key={kind} className="game-card">
|
||||||
|
<h2>
|
||||||
|
{kind === "gardener"
|
||||||
|
? "Junior gardener"
|
||||||
|
: "Breeding specialist"}
|
||||||
|
</h2>
|
||||||
|
<p>
|
||||||
|
{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."}
|
||||||
|
</p>
|
||||||
|
<p className="coin-price">
|
||||||
|
◈ {game.workerCosts[kind]} coins · no wages
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
disabled={!!reason || action.isPending || game.pendingWork}
|
||||||
|
onClick={() =>
|
||||||
|
action.mutate({ action: "hire", data: { kind } })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{reason ?? "Hire " + kind}
|
||||||
|
</button>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<ActionFeedback action={action} />
|
||||||
|
<h2 className="section-title">
|
||||||
|
Your greenhouse team · {game.workers.length}/4
|
||||||
|
</h2>
|
||||||
|
{!game.workers.length && (
|
||||||
|
<p className="game-tip">
|
||||||
|
No employees yet. Growing, selling and completing quests will fund
|
||||||
|
your first gardener.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="game-grid">
|
||||||
|
{game.workers.map((w) => (
|
||||||
|
<Assignment key={w.id} worker={w} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="panel-note">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
<h2 className="section-title">Recent work</h2>
|
||||||
|
{game.recentRuns.length ? (
|
||||||
|
<ol className="work-log">
|
||||||
|
{game.recentRuns.map((run) => (
|
||||||
|
<li key={run.id}>
|
||||||
|
<time>{new Date(run.summary.to).toLocaleString()}</time> ·{" "}
|
||||||
|
{run.summary.harvested} harvested · {run.summary.sold} sold ·{" "}
|
||||||
|
{run.summary.breeds} crosses · {run.summary.coinDelta} coins ·{" "}
|
||||||
|
{run.summary.blocked} waiting intervals
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
) : (
|
||||||
|
<p>The work journal will fill after your team starts tending.</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
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 (
|
||||||
|
<>
|
||||||
|
<GameHeading
|
||||||
|
title="At your own pace"
|
||||||
|
note={"Signed in as " + game.player.username}
|
||||||
|
/>
|
||||||
|
<section className="game-card settings-card">
|
||||||
|
<h2>Comfort & account</h2>
|
||||||
|
<label>
|
||||||
|
Animations
|
||||||
|
<select
|
||||||
|
aria-label="Animations"
|
||||||
|
value={motion}
|
||||||
|
onChange={(e) => setMotion(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="system">Follow device preference</option>
|
||||||
|
<option value="reduced">Reduce motion</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<p>
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
void request("/auth/logout", { method: "POST", body: "{}" })
|
||||||
|
.then(() => location.assign("/"))
|
||||||
|
.catch((e) => setError(e.message));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Sign out
|
||||||
|
</button>
|
||||||
|
{error && <p role="alert">{error}</p>}
|
||||||
|
<p className="panel-note">
|
||||||
|
Fictional botany, deterministic genetics. No tracking, paid currency
|
||||||
|
or login streaks.
|
||||||
|
</p>
|
||||||
|
<Link className="text-link" to="/studies">
|
||||||
|
Renderer study gallery →
|
||||||
|
</Link>
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
export function Examples() {
|
||||||
|
const [cursor, setCursor] = useState<string | null>(null);
|
||||||
|
const result = useQuery({
|
||||||
|
queryKey: ["demo", cursor],
|
||||||
|
queryFn: () =>
|
||||||
|
request<
|
||||||
|
import("@botanica/shared").PageDto<import("@botanica/shared").PlantDto>
|
||||||
|
>(
|
||||||
|
"/demo/plants?limit=100" +
|
||||||
|
(cursor ? "&cursor=" + encodeURIComponent(cursor) : ""),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<GameHeading
|
||||||
|
title="The reference herbarium"
|
||||||
|
note="Original demonstration records, preserved in their own read-only collection. These are examples, not game inventory."
|
||||||
|
/>
|
||||||
|
{result.error && <p role="alert">{result.error.message}</p>}
|
||||||
|
<div className="game-grid">
|
||||||
|
{result.data?.items.map((p) => (
|
||||||
|
<article key={p.id} className="game-card">
|
||||||
|
<PlantPreview
|
||||||
|
phenotype={p.phenotype}
|
||||||
|
visualSeed={p.visualSeed}
|
||||||
|
stage="flowering"
|
||||||
|
size={128}
|
||||||
|
/>
|
||||||
|
<h2>{p.name}</h2>
|
||||||
|
<p>
|
||||||
|
{p.rarity.tier} · {p.phenotype.pigment} ·{" "}
|
||||||
|
{p.phenotype.leafPattern}
|
||||||
|
</p>
|
||||||
|
<Link className="text-link" to={"/examples/" + p.id}>
|
||||||
|
Inspect reference →
|
||||||
|
</Link>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="house-tabs">
|
||||||
|
{cursor && (
|
||||||
|
<button onClick={() => setCursor(null)}>First references</button>
|
||||||
|
)}
|
||||||
|
{result.data?.nextCursor && (
|
||||||
|
<button onClick={() => setCursor(result.data!.nextCursor!)}>
|
||||||
|
More references →
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
export function ReferenceDetails() {
|
||||||
|
const { id } = useParams(),
|
||||||
|
navigate = useNavigate(),
|
||||||
|
plant = useQuery({
|
||||||
|
queryKey: ["demo-plant", id],
|
||||||
|
queryFn: () =>
|
||||||
|
request<import("@botanica/shared").PlantDto>("/demo/plants/" + id),
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<GameHeading
|
||||||
|
title={plant.data?.name ?? "Reference specimen"}
|
||||||
|
note="The original laboratory archive · read-only genomes, lineage and artwork."
|
||||||
|
/>
|
||||||
|
<Link className="text-link" to="/examples">
|
||||||
|
← Reference herbarium
|
||||||
|
</Link>
|
||||||
|
{plant.error && <p role="alert">{plant.error.message}</p>}
|
||||||
|
{plant.data && (
|
||||||
|
<PlantRecord
|
||||||
|
plant={plant.data}
|
||||||
|
scope="demo"
|
||||||
|
onParent={(parent) => navigate("/examples/" + parent)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -57,8 +57,9 @@ export function Lab() {
|
||||||
<p className="eyebrow">The plant laboratory</p>
|
<p className="eyebrow">The plant laboratory</p>
|
||||||
<h1>Grow something unexpected.</h1>
|
<h1>Grow something unexpected.</h1>
|
||||||
<p>
|
<p>
|
||||||
Edit paired alleles and see their expression. Save a designed
|
Edit paired alleles and see their expression. Save a creative study
|
||||||
founder, then cross saved plants in the breeding lab.
|
or inspect a grown plant. Game breeding uses mature greenhouse
|
||||||
|
specimens.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Link className="button-link secondary" to="/breed">
|
<Link className="button-link secondary" to="/breed">
|
||||||
|
|
|
||||||
BIN
artifacts/m11/breeding-desktop.png
Normal file
|
After Width: | Height: | Size: 199 KiB |
116
artifacts/m11/browser-report.json
Normal file
|
|
@ -0,0 +1,116 @@
|
||||||
|
{
|
||||||
|
"checks": [
|
||||||
|
"Register, one-time recovery UI and isolated four-slot greenhouse",
|
||||||
|
"Live server-synchronized countdown advances between state polls",
|
||||||
|
"Cryptographic action IDs work without secure-context randomUUID (HTTP LAN compatibility)",
|
||||||
|
"Harvest, discovery, favorite safeguard, confirmed sale and one-time quest claims",
|
||||||
|
"Exact-price seed and growing-slot purchases",
|
||||||
|
"Paid inherited breeding, persisted lineage and favorite child",
|
||||||
|
"Advanced genome lab, deterministic renderer atlas and preserved reference herbarium",
|
||||||
|
"Late-game fixtures: upgrades, second house, research, worker policies and replay-safe hour offline",
|
||||||
|
"All gameplay routes usable at 360px and 1440px with reduced motion",
|
||||||
|
"Logout and password login restore only the same account",
|
||||||
|
"Unauthenticated collection access denied",
|
||||||
|
"Public HTTPS through Cloudflare: authenticated cookie is Secure, HttpOnly and SameSite=Lax",
|
||||||
|
"No browser JavaScript errors"
|
||||||
|
],
|
||||||
|
"errors": [],
|
||||||
|
"widths": [
|
||||||
|
{
|
||||||
|
"route": "/",
|
||||||
|
"width": 1440,
|
||||||
|
"overflow": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"route": "/shop",
|
||||||
|
"width": 1440,
|
||||||
|
"overflow": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"route": "/gallery",
|
||||||
|
"width": 1440,
|
||||||
|
"overflow": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"route": "/breed",
|
||||||
|
"width": 1440,
|
||||||
|
"overflow": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"route": "/workers",
|
||||||
|
"width": 1440,
|
||||||
|
"overflow": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"route": "/research",
|
||||||
|
"width": 1440,
|
||||||
|
"overflow": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"route": "/quests",
|
||||||
|
"width": 1440,
|
||||||
|
"overflow": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"route": "/settings",
|
||||||
|
"width": 1440,
|
||||||
|
"overflow": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"route": "/lab",
|
||||||
|
"width": 1440,
|
||||||
|
"overflow": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"route": "/",
|
||||||
|
"width": 360,
|
||||||
|
"overflow": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"route": "/shop",
|
||||||
|
"width": 360,
|
||||||
|
"overflow": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"route": "/gallery",
|
||||||
|
"width": 360,
|
||||||
|
"overflow": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"route": "/breed",
|
||||||
|
"width": 360,
|
||||||
|
"overflow": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"route": "/workers",
|
||||||
|
"width": 360,
|
||||||
|
"overflow": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"route": "/research",
|
||||||
|
"width": 360,
|
||||||
|
"overflow": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"route": "/quests",
|
||||||
|
"width": 360,
|
||||||
|
"overflow": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"route": "/settings",
|
||||||
|
"width": 360,
|
||||||
|
"overflow": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"route": "/lab",
|
||||||
|
"width": 360,
|
||||||
|
"overflow": 0
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fixtures": [
|
||||||
|
"Only new QA account: level 10 and ledger-recorded 20,000-coin grant for late-game UI",
|
||||||
|
"Only QA workers: persisted cursors shifted back one hour"
|
||||||
|
],
|
||||||
|
"playerId": "f73386a2-4c60-4bdf-b0cf-3846080f6128",
|
||||||
|
"childId": "eec3ce1a-661f-4764-a209-4ae897ced24e"
|
||||||
|
}
|
||||||
11
artifacts/m11/final-data-check.json
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"plantCount": 103,
|
||||||
|
"idsAndProvenance": "09972afb73a3b3b0fc187ec59182be9f"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"negativeBalances": 0,
|
||||||
|
"ledgerMismatches": 0,
|
||||||
|
"activeQaWorkers": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
BIN
artifacts/m11/first-discovery.png
Normal file
|
After Width: | Height: | Size: 184 KiB |
BIN
artifacts/m11/greenhouse-1440.png
Normal file
|
After Width: | Height: | Size: 251 KiB |
BIN
artifacts/m11/greenhouse-360.png
Normal file
|
After Width: | Height: | Size: 160 KiB |
BIN
artifacts/m11/greenhouse-desktop.png
Normal file
|
After Width: | Height: | Size: 151 KiB |
BIN
artifacts/m11/greenhouse-mobile-final.png
Normal file
|
After Width: | Height: | Size: 113 KiB |
1
artifacts/m11/legacy-after-migration.json
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
{"plantCount" : 103, "idsAndProvenance" : "09972afb73a3b3b0fc187ec59182be9f"}
|
||||||
1
artifacts/m11/legacy-baseline.json
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
{"plantCount" : 103, "idsAndProvenance" : "09972afb73a3b3b0fc187ec59182be9f"}
|
||||||
9
artifacts/m11/mobile-final.json
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
{
|
||||||
|
"hudHeight": 103,
|
||||||
|
"overflow": 0,
|
||||||
|
"groupTops": [
|
||||||
|
133.75,
|
||||||
|
134,
|
||||||
|
133.5
|
||||||
|
]
|
||||||
|
}
|
||||||
BIN
artifacts/m11/workers-desktop.png
Normal file
|
After Width: | Height: | Size: 129 KiB |
|
|
@ -25,6 +25,7 @@ services:
|
||||||
"vitest",
|
"vitest",
|
||||||
"run",
|
"run",
|
||||||
"apps/server/src/persistence.integration.test.ts",
|
"apps/server/src/persistence.integration.test.ts",
|
||||||
|
"apps/server/src/game.integration.test.ts",
|
||||||
]
|
]
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ services:
|
||||||
HOST_PORT: ${HOST_PORT:-8155}
|
HOST_PORT: ${HOST_PORT:-8155}
|
||||||
DATABASE_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
|
DATABASE_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
|
||||||
PUBLIC_DOMAIN: ${PUBLIC_DOMAIN:-http://localhost}
|
PUBLIC_DOMAIN: ${PUBLIC_DOMAIN:-http://localhost}
|
||||||
|
PUBLIC_ORIGINS: ${PUBLIC_ORIGINS:-http://localhost:8155,http://127.0.0.1:8155,http://192.168.1.197:8155,https://botanica.p20.dev}
|
||||||
depends_on:
|
depends_on:
|
||||||
bootstrap:
|
bootstrap:
|
||||||
condition: service_completed_successfully
|
condition: service_completed_successfully
|
||||||
|
|
|
||||||
|
|
@ -1,52 +1,52 @@
|
||||||
# Plant Laboratory API
|
# Botanica API examples
|
||||||
|
|
||||||
All endpoints use relative URLs under `/api/v1`. This is a single-owner local sandbox without authentication. The server fixes the sandbox owner; clients cannot assign ownership, ancestry, mutation outcomes, rarity or breeding seeds. Errors use `{error:{code,message,details?}}`.
|
All URLs below are relative to `http://localhost:8155/api/v1` (or the configured HTTPS public origin). Health remains `GET /health`. Catalog, phenotype resolution and SVG preview are stateless public operations. Player-owned routes require the HttpOnly session cookie; writes additionally require an approved `Origin` and `X-CSRF-Token` returned by authentication or `GET /me`.
|
||||||
|
|
||||||
## Design and save
|
## Session and recovery
|
||||||
|
|
||||||
```sh
|
- `POST /auth/register` and `POST /auth/login`: `{ "username": "your_name", "password": "your private password" }`. Usernames are case-insensitive, 3–24 ASCII letters/digits/underscores; passwords are 10–128 characters.
|
||||||
curl -fsS http://localhost:8155/api/v1/catalog/species
|
- Signup returns five `recoveryCodes` once. Save them privately. `POST /auth/recover` takes username, new password and `recoveryCode`; a code works once and revokes prior sessions.
|
||||||
curl -fsS -X POST http://localhost:8155/api/v1/plants \
|
- `GET /me`: current identity, coins, XP, level and CSRF token. `POST /auth/logout` revokes the current session. Never put passwords, cookies or recovery codes in logs or URLs.
|
||||||
-H 'Content-Type: application/json' \
|
|
||||||
-d '{"speciesId":"velvetleaf","name":"My study","genome":{"pigment":["P","G"],"leafShape":["L","O"],"leafPattern":["V","S"],"growthHabit":["B","C"],"flowerColor":["R","W"],"flowerDensity":["H","M"]}}'
|
## Game operations
|
||||||
|
|
||||||
|
`GET /game/state` returns the authoritative aggregate: player HUD, seed inventory, greenhouses with slots/timestamps, quests, shop/research unlocks, workers and the most recent offline calculation. Server UTC timestamps govern growth. Relevant reads/mutations reconcile bounded work under the player's row lock.
|
||||||
|
|
||||||
|
`POST /game/action` uses this envelope; generate a UUID once per intended operation and reuse it after a network failure:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"action": "plant",
|
||||||
|
"requestId": "a newly generated UUID",
|
||||||
|
"data": { "slotId": "your owned empty slot UUID", "seedType": "starter" }
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
A designed plant is a generation 0 founder. An optional signed-int32 `visualSeed` controls cosmetics; otherwise the server generates it securely. Named mutation flags are accepted only by non-persistent preview endpoints. The UI makes manual preview flags explicit and never saves them as bred mutations. Plant names contain 1–60 characters. Extra or immutable client fields are rejected.
|
Operation data:
|
||||||
|
|
||||||
## Collection and details
|
| action | data |
|
||||||
|
|---|---|
|
||||||
|
| plant | slotId, seedType: starter/uncommon/rare |
|
||||||
|
| harvest | slotId |
|
||||||
|
| purchase | itemId from state.shop, optional owned houseId |
|
||||||
|
| sell | plantId |
|
||||||
|
| flags | plantId, favorite: boolean, protected: boolean |
|
||||||
|
| claim | questId from state.quests |
|
||||||
|
| research | researchId from state.research |
|
||||||
|
| hire | kind: gardener/breeder |
|
||||||
|
| assign | workerId, greenhouseId, enabled, policy |
|
||||||
|
|
||||||
```sh
|
Policy fields: `mode` (repeat_crop/repeat_cross/target_trait), `seedType`, `autoSell` (false by default), `reserveCoins` (default 100), optional `parentAId`/`parentBId`, `targetPigment` and `targetPattern`. Breeders require two owned unsold game-grown parents. Target mode requires Selective breeding research and protects matching results without changing their odds. Saving a recipe starts it from the save time; no retroactive use of the new recipe.
|
||||||
curl -fsS 'http://localhost:8155/api/v1/plants?limit=24&pigment=purple&pattern=marbled&sort=rarity'
|
|
||||||
curl -fsS 'http://localhost:8155/api/v1/plants?q=Fern&sort=name'
|
|
||||||
curl -fsS http://localhost:8155/api/v1/plants/10000000-0000-4000-8000-000000000001
|
|
||||||
curl -fsS http://localhost:8155/api/v1/collection/stats
|
|
||||||
```
|
|
||||||
|
|
||||||
Lists return `{items,nextCursor,total}`. Pass an opaque `nextCursor` back as URL-encoded `cursor` with the same filters and sort. Limits are 1–100, default 24. Filters: `species`, `pigment`, `pattern`, `rarity`; `q` searches names, IDs, expressed pigment and pattern (up to 80 characters; wildcard characters are literal). Sorts: `newest`, `oldest`, `name`, `rarity`. UUID ties make every order stable; timestamp cursors retain PostgreSQL microseconds. Changing a cursor's filter/sort context returns 400. Counts describe the actual saved collection, independently of versioned rarity scores.
|
The server validates prices, inventory, level, ownership and readiness. Reusing a request ID for different content returns 409. Receipt retries replay the stored result. Pending large offline backlogs return `OFFLINE_PENDING`; fetch state to process subsequent batches before spending newly obtained resources. Sales preserve ancestry rows and exclude sold specimens from inventory. Protected/favorite plants cannot be sold; automated sales additionally exclude first discoveries, non-Common plants and the last specimen of a species.
|
||||||
|
|
||||||
Each plant DTO includes canonical genome, resolved phenotype, named modifiers, parents, generation, owner, name, origin, creation time, genome/renderer versions, visual seed and persisted birth rarity score/tier/reasons/policy version. `/plants/:id/birth` returns `{event}` for bred children or `{event:null}` for founders. No read operation rerolls inheritance, mutations or rarity.
|
## Collection and genetics
|
||||||
|
|
||||||
## Breed saved parents
|
- `GET /plants?limit=24&sort=newest`: own unsold plants; optional q, species, pigment, pattern, rarity and cursor. Sorts: newest/oldest/name/rarity. Cursor is bound to its filter/sort; reset it after changing either.
|
||||||
|
- `GET /plants/:id`, `/plants/:id/birth`, `/collection/stats`, `GET /breeding`: owned detail, ancestry/event and collection/journal data. Sold records remain readable to their owner for lineage.
|
||||||
|
- `POST /breeding`: `{ "parentAId": "owned grown UUID", "parentBId": "another owned grown UUID", "idempotencyKey": "new UUID" }`. Costs 75 coins at level 3. Authoritative genetics creates and records the child once, retaining both parents.
|
||||||
|
- `POST /plants`: advanced study only. Accepts speciesId, valid genome, name and optional cosmetic visualSeed. Excludes game rewards; no client-supplied rarity, mutations or ownership claims. This existing creative save operation is outside the monetary receipt API.
|
||||||
|
- `GET /demo/plants?limit=100` and `/demo/plants/:id`: authenticated read-only reference herbarium. It never becomes player inventory.
|
||||||
|
- `POST /phenotype/resolve`: speciesId, paired-allele genome and optional preview mutations/catalogVersion. `POST /preview` additionally accepts visualSeed, growthStage (seedling/juvenile/mature/flowering), rendererVersion and background (warm/transparent). Outputs deterministic original SVG; no persisted game plant is created.
|
||||||
|
|
||||||
```sh
|
Errors use `{ "error": { "code": "...", "message": "..." } }`; 401 means sign in, 403 means origin/CSRF rejection, 404 conceals inaccessible records, 409 indicates a game constraint and 429 throttles auth attempts. No client-provided owner ID is authoritative.
|
||||||
curl -fsS -X POST http://localhost:8155/api/v1/breeding \
|
|
||||||
-H 'Content-Type: application/json' \
|
|
||||||
-d '{"parentAId":"10000000-0000-4000-8000-000000000001","parentBId":"10000000-0000-4000-8000-000000000002","idempotencyKey":"90000000-0000-4000-8000-000000000001"}'
|
|
||||||
curl -fsS 'http://localhost:8155/api/v1/breeding?limit=12'
|
|
||||||
```
|
|
||||||
|
|
||||||
The POST returns `{plant,event}`. Use a fresh UUID key for each intended new cross; reuse the same key for retries. Concurrent identical keys return the original child/event. Reusing a key for another parent pair returns 409. Two distinct owned parents and an approved compatibility rule are required. A single transaction records the child, its parents, secure event seed, engine version and mutation log. Errors or transaction failure leave no orphan child. Generation is `max(parent generations)+1`.
|
|
||||||
|
|
||||||
## Preview and exports
|
|
||||||
|
|
||||||
`POST /phenotype/resolve` accepts a genome with `speciesId`, optional `mutations` and `catalogVersion:1`; it returns phenotype and rarity preview. `POST /preview` additionally accepts `visualSeed`, `growthStage`, `rendererVersion:1` and `background`; it returns a self-contained SVG string. The browser uses the identical pure renderer for live preview and SVG/PNG downloads. JSON export contains the saved canonical plant DTO and lineage. Image stage/background choices are cosmetic and do not change stored plants.
|
|
||||||
|
|
||||||
## Responses and limits
|
|
||||||
|
|
||||||
- 400: malformed UUID, unknown allele/version/species, extra fields, invalid query/cursor.
|
|
||||||
- 404: missing or non-owned plant.
|
|
||||||
- 409: incompatible species, same parent twice, idempotency key conflict.
|
|
||||||
- 500: generic unexpected failure, without SQL details or secrets.
|
|
||||||
- `/health` is outside `/api/v1`: 200 when the DB query succeeds, 503 otherwise.
|
|
||||||
|
|
||||||
The demo seed creates exactly 100 fixed-ID specimens, including two named parents. Re-running it fills missing examples and never overwrites saved plant genomes or ancestry. Demonstration mutations are curated illustrations, not claims about breeding frequency.
|
|
||||||
|
|
|
||||||
44
docs/BOTANICA_M5_M11_AGENT_RULES.md
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
> Supplement to the existing repository root `AGENTS.md`. Do not replace root instructions.
|
||||||
|
|
||||||
|
# Botanica — Codex operating instructions (M5–M11)
|
||||||
|
|
||||||
|
## Mission
|
||||||
|
Turn the EXISTING Plant Laboratory into **Botanica**, a cozy, engaging single-currency idle/incremental greenhouse management and genetics-collection game. Preserve working M0–M4 genetics, renderer, collection, and infrastructure. The existing repository, not this package, is the source of truth for implementation details. Read `docs/BOTANICA_M5_M11_GAME_DESIGN.md` and `docs/BOTANICA_M5_M11_IMPLEMENTATION.md` first.
|
||||||
|
|
||||||
|
## Execution — avoid token waste
|
||||||
|
- Work autonomously through M5–M11 in sequence, preferably in one coding session. Do not ask for approval at every milestone. Report only meaningful blockers or architectural incompatibilities.
|
||||||
|
- Start with ONE brief repo audit: file tree, packages, schema/migrations, existing API routes, UI components, compose/env. Reuse everything practical. Do not rewrite or scaffold a second app.
|
||||||
|
- Make a concise internal checklist. Skip lengthy planning essays, repeated status messages, and speculative refactors.
|
||||||
|
- Implement vertical slices that are actually playable; do not produce screens with nonfunctional buttons or placeholder stats.
|
||||||
|
- Use few dependencies. No microservices, WebSockets, Redis, message brokers, queue clusters, game engines, paid APIs, AI generation, or multiplayer trading in v1.
|
||||||
|
- No large test suite or repeated full test passes. Add focused tests only for monetary integrity, player isolation, breeding ownership, and offline-worker idempotency. Run lint/typecheck/build when a milestone changes those surfaces, and one full smoke test at the end. Reuse existing test commands; do not install elaborate test frameworks.
|
||||||
|
- If errors arise, fix them locally and continue. Only stop when data-loss risk, missing secrets, or truly ambiguous migration ownership demands a decision.
|
||||||
|
- Preserve existing deployment and keep host port **8155**, domain default **http://localhost**; honor existing variable names and add compatibility mapping if needed.
|
||||||
|
- Keep all existing DB volumes and user data; additive migrations and backups before destructive operations. Never run `docker compose down -v`, never reset databases, never drop/rename columns without a migration plan.
|
||||||
|
- Incrementally update existing AGENTS instructions instead of blindly overwriting them if this file is being overlaid. Merge relevant instructions manually.
|
||||||
|
- Commit in coherent slices if git is configured; do not fabricate git status or deployment success.
|
||||||
|
|
||||||
|
## Architecture invariants
|
||||||
|
- React/Vite/TypeScript frontend; Fastify/Node backend; PostgreSQL; Docker Compose unless existing code shows a more specific compatible setup.
|
||||||
|
- Server authoritative for balances, prices, inventory, growth, breeding, ownership, time, rewards, and worker automation.
|
||||||
|
- Money stored as integer coins. Every mutation is transactional; require atomic `UPDATE ... WHERE balance >= cost` or row locking. Protect repeat requests with an idempotency key where operations can be retried.
|
||||||
|
- Passwords Argon2id; HTTP-only same-site sessions; Secure on HTTPS; throttled login/register; no email required. Generate one-time recovery codes or clearly document no recovery (prefer codes). No plaintext passwords or session tokens in logs.
|
||||||
|
- Every player-owned query checks authenticated player ID server-side. Never take a client-supplied owner ID as authority.
|
||||||
|
- Existing genetics and SVG renderer stay deterministic. Keep demo/sandbox data distinct from real player ownership.
|
||||||
|
- No real-money purchases, premium currency, gambling, daily login punishment, artificial FOMO, predatory notifications, or deceptive odds.
|
||||||
|
- All app routes/buttons must have real actions or be visibly disabled with explanatory state. Mobile responsive and accessible; reduced-motion support.
|
||||||
|
|
||||||
|
## Product priorities
|
||||||
|
1. First-time player can register, immediately plant, harvest, sell, and replant.
|
||||||
|
2. Greenhouse is home screen; game HUD is always accessible; existing laboratory becomes an advanced screen.
|
||||||
|
3. Automation enables overnight progress but never invents free plants, bypasses resources, or consumes protected plants.
|
||||||
|
4. Breeding produces meaningful inheritable visual changes; keep genes comprehensible through an optional advanced inspector.
|
||||||
|
5. Gameplay should be satisfying without long tutorial prose.
|
||||||
|
|
||||||
|
## Definition of done
|
||||||
|
- Fresh account and existing sandbox coexist safely.
|
||||||
|
- No cross-user data access, negative balance, double sale/harvest, or duplicate worker offline rewards.
|
||||||
|
- Register → plant → harvest → sell → purchase → breed → assign worker → return after offline interval works.
|
||||||
|
- Existing M0–M4 renderer/genetics regression remains functional.
|
||||||
|
- `docker compose up -d --build` works on configured host and exposed port 8155; report if environment cannot actually run Docker.
|
||||||
|
- Write a brief `docs/GAME_IMPLEMENTATION_NOTES.md` describing what shipped, migrations, env changes, smoke-test result and any incomplete feature.
|
||||||
93
docs/BOTANICA_M5_M11_GAME_DESIGN.md
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
# BOTANICA — Game Design Specification v1.0
|
||||||
|
Status: implementation target for existing M0–M4 Plant Laboratory. Internal game name and public product: **Botanica** (do not rename to Botanical Empire).
|
||||||
|
|
||||||
|
## 1. Game fantasy and audience
|
||||||
|
An illustrated 2D cozy greenhouse empire: start with three seeds and a tiny glasshouse; collect unique, inheritable plants, expand production, unlock botanical research, employ gardeners and breeders, and build a one-of-a-kind collection. A management/incremental/clicker hybrid with genetics as its special differentiator. Browser-first, usable on desktop and mobile; no PvP in v1.
|
||||||
|
|
||||||
|
### Player promise
|
||||||
|
- Immediately understandable action: plant, grow, harvest, earn, expand.
|
||||||
|
- Discovery: genuine genetic combinations and attractive deterministic artwork, not random rarity stickers.
|
||||||
|
- Ownership: every specimen has an individual ID, lineage and purposeful keep/sell/breed decision.
|
||||||
|
- Idle comfort: workers make progress offline; returning yields a transparent production report; no punishment for taking breaks.
|
||||||
|
|
||||||
|
## 2. Loops
|
||||||
|
Short (20–90 seconds): select seed → plant in slot → timer → harvest → coins/XP → replant. Rapid first plant (~20 seconds), later varied durations.
|
||||||
|
Medium (5–20 minutes): fill contracts, buy slots/upgrades, breed selected specimens, add new species and mutations to collection.
|
||||||
|
Long (days): expand to specialized greenhouses, hire/upgrade workers, unlock advanced breeding goals, complete species/trait collection.
|
||||||
|
No clicking hundreds of times to grow one plant; interaction matters because the decisions matter.
|
||||||
|
|
||||||
|
## 3. Player onboarding / first 10 minutes
|
||||||
|
- Registration: username and password only; optional recovery codes shown once. Start with 120 coins, 3 basic seed units, 1 greenhouse (4 slots), no employees.
|
||||||
|
- Welcome panel gives ONE actionable instruction, not a multi-screen tutorial.
|
||||||
|
- First seed matures in ~20 seconds; harvest reveals visual specimen, XP and modest coins when sold; player can keep the plant instead.
|
||||||
|
- First quest: grow a plant (reward 50 coins). Second: sell a plant. Third: buy first slot upgrade. Fourth: make a cross (unlocked early with 2 eligible parents and a small coin cost).
|
||||||
|
- Visible next goal everywhere. Reveal Research and worker unlock previews without frustrating dead ends.
|
||||||
|
- Keep starter inventory/seed drops sustainable. A player cannot become permanently bankrupt: base seed pack may always be claimed free on cooldown OR seed returns are guaranteed at harvest; implement guaranteed simple seed recycling rather than a timed claim.
|
||||||
|
|
||||||
|
## 4. Screens and navigation
|
||||||
|
- **Greenhouse (home)**: illustrated backdrop and interactive 4–12 growing slots; each holds original SVG plant, state ring, grow countdown, one-click actions; header shows coins, XP/level, seeds, pending claim; quick links to shop, collection, workers. Show multiple greenhouses via tabs.
|
||||||
|
- **Shop**: seed packs (known genotype constraints and transparent odds), pots/slot expansions, greenhouse unlocks and equipment; clear cost/effect/locked reason.
|
||||||
|
- **Breeding**: choose 2 owned compatible mature specimens, show probable allele outcomes when known, cost/time, produce child/seed via authoritative engine; optional advanced lab retains old editor.
|
||||||
|
- **Collection**: owned plant inventory, rarity frame, favorites/protected, species/trait filters, new discovery markers, lineage; actions sell/keep/use as parent. Bulk selling excludes protected/unique items.
|
||||||
|
- **Workers**: hire, assign greenhouse, choose one of three policies, display capacity/cost/reserve settings and offline report.
|
||||||
|
- **Research**: simple small unlock tree tied to player level, coins and discoveries; clear unlock effect.
|
||||||
|
- **Quests/achievements**: short, comprehensible milestones; no daily-login streak requirements.
|
||||||
|
- **Settings/account**: logout, sound, animations, recovery code management if implemented.
|
||||||
|
|
||||||
|
Navigation: desktop side rail, mobile bottom bar. Do NOT render the app like an ecommerce landing page: minimize hero marketing blocks, oversize headings and decorative editorial sections; favor dense-but-readable gameplay HUD, framed slots, tactile buttons, state feedback, sprite/plant emphasis. shadcn/ui can power dialogs/forms, not dictate art direction.
|
||||||
|
|
||||||
|
## 5. Graphics / motion
|
||||||
|
Reuse existing deterministic SVG renderer and 2D botanical assets. Plant art is central and at least 50% of visual greenhouse region. Game UI aesthetic: deep botanical greens (#14251c, #233c2a), parchment (#f0ead2), warm gold (#deb967), rare accent palettes; distinct frame/color per rarity AND readable text labels. Prefer drawn pots and subtle bevel/texture, avoid generic SaaS dashboard cards and excessive gradients. Microanimations for growth, purchase feedback, harvest, new discovery. Respect `prefers-reduced-motion`. No random stock plant imagery within actual gameplay.
|
||||||
|
|
||||||
|
Suggested components: `GameShell`, `CurrencyHUD`, `XPBar`, `GreenhouseScene`, `PlantSlot`, `PlantCard`, `InventorySlot`, `GrowthTimer`, `ShopItem`, `WorkerAssignment`, `BreedingWorkbench`, `DiscoveryReveal`, `QuestTracker`, `OfflineReport`. Existing components should be adapted, not duplicated without reason.
|
||||||
|
|
||||||
|
## 6. Economy (initial balance; tune via config)
|
||||||
|
Currency **coins**, no premium currency. Amounts integers; sale proceeds from server formula. All values below are initial tuning, not hardcoded in UI.
|
||||||
|
|
||||||
|
| Item | Cost | Note |
|
||||||
|
|---|---:|---|
|
||||||
|
| Starter seed pack | 10 | basic species, guaranteed recyclable seed flow |
|
||||||
|
| Uncommon seed | 75 | unlocked at level 3 |
|
||||||
|
| Rare seed | 250 | unlocked at level 7; show transparent odds |
|
||||||
|
| Slot 5 | 100 | +1 plant slot |
|
||||||
|
| Slot 6 | 250 | +1 plant slot |
|
||||||
|
| Grow lamp | 300 | 10% faster growth in greenhouse |
|
||||||
|
| Irrigation | 650 | enables automated tending efficiency |
|
||||||
|
| Greenhouse 2 | 2,500 | 6 new slots, unlock level 5 |
|
||||||
|
| Junior gardener | 1,500 | plants/harvests at defined throughput |
|
||||||
|
| Breeding specialist | 5,000 | automated recipe breeding |
|
||||||
|
|
||||||
|
Base sale value Common 20–35, Uncommon 50–90, Rare 130–250, Epic 400–800, Legendary 1,200–2,000, Mythic not initially sold via simple price multiplier (special accomplishment). Server formula: base species value × rarity factor × bounded trait bonus, rounded to integer; cap outputs to avoid exploitable combinations. Breed costs 75 coins initially; optional timer (~2 minutes) once gameplay is established. Worker wages: no ongoing negative debt in v1; prefer a flat hire cost + throughput capacity limit. Buying and selling uses transactional ledger with reason, source and idempotency key.
|
||||||
|
|
||||||
|
Important: harvesting produces a specimen in collection and seed recycling; coins come on **sale/contract completion**, not double-granted for harvest and selling. Player chooses keep vs sell. Rare protected plants never auto-sold.
|
||||||
|
|
||||||
|
## 7. Growth and greenhouses
|
||||||
|
Persist plantedAt/readyAt or growth accumulated segments when upgrade modifiers change. Server time authoritative. States: empty / growing / ready / harvested (then slot empty). Mature plants stored as owned specimens; using one as breeding parent must not destroy it unless explicitly communicated. Grow times: starter tutorial ~20 sec; Common normal ~2–5 min; Uncommon 5–12 min; Rare 12–30 min; specialist species later longer. Offline growth naturally proceeds to readyAt; no decay, wilting or permanent loss on absence. Grow lamp reduces remaining duration according to documented rule, never retroactively corrupt timers.
|
||||||
|
|
||||||
|
## 8. Breeding and genetic rules
|
||||||
|
Reuse existing M0–M4 allele/genome rules and determinism. Require compatible species groups and eligible owned parents. If existing code treats breeding as instantly yielding a plant, wrap it in a game operation that consumes coins/time and creates owned offspring/seed. Record both parent IDs, algorithm version, RNG seed/visualSeed. A breeding event that is retried with same idempotency key cannot yield multiple offspring. A parent's selection must validate user ownership. Mutations are rolled ONCE server side and stored; never reroll on UI load. Breeding recipes: named parent pair or genotype filters. Genetic goal is a filter on generated offspring, not a magic success chance override.
|
||||||
|
|
||||||
|
Rarity and discovery: preserve current model unless a documented incompatibility. Rarity labels not a replacement for actual genotype scarcity; first discovery reward only once per player for a well-defined species + phenotype signature. Show odds only where the server can compute accurately, otherwise show a non-misleading 'possible outcomes' label.
|
||||||
|
|
||||||
|
## 9. Workers (v1 practical)
|
||||||
|
Worker types: Gardener (auto harvest+replant), Botanist (research/discovery boost later), Breeder (automatic recipe crosses). Implement Gardener and Breeder fully; Botanist can be a research unlock if not yet active, but do not show hire control without functionality.
|
||||||
|
- Worker policies: `repeat_crop` (selected seed type), `repeat_cross` (selected compatible parents), `target_trait` (choose desired genotype filters, available later).
|
||||||
|
- Each worker has throughput/interval and assigned greenhouse; actions consume coins, seeds, available slots and real owned parent eligibility.
|
||||||
|
- Queue or schedule runs from persisted `lastProcessedAt` and a capped catch-up window (e.g., 24 hours per calculation). Allow maximum 100 simulated events per request. Cursor advances only for events actually resolved; explicit surplus elapsed time may remain for future processing. No duplicate work across concurrent requests: transaction/advisory lock or `FOR UPDATE` on worker state.
|
||||||
|
- No worker auto-sells favorites, sole remaining specimen of a species, first-time discoveries or plants marked protected. Default automation OFF until player configures it.
|
||||||
|
- Summary after absence: elapsed interval, number harvested, sold, breeds, seed consumption, money delta, notable finds. Provide event log bounded to recent entries.
|
||||||
|
|
||||||
|
## 10. Quest, XP, progression
|
||||||
|
XP earned on first grow, harvest, successful breeding, first trait/species discovery, milestones; prevent XP farming from repeated no-op requests. Levels unlock features: level 1 grow/sell; level 2 shop upgrades; level 3 breeding; level 5 second greenhouse and gardener; level 7 rare seed and breeder; level 10 target breeding and advanced research. Keep initial achievements to ~12 handcrafted goals. One pinned quest tracker and honest progress meters. No daily streaks, coercive timers, push notifications or real-money loot boxes.
|
||||||
|
|
||||||
|
## 11. Data isolation / economy integrity
|
||||||
|
Player account only username+password; no email. Username normalized uniqueness, Argon2id hashes, same-origin HTTP-only sessions, secure cookie on HTTPS behind trusted proxy, rate limits, basic CSRF protection. Optionally one-time recovery codes shown once during signup. The existing sandbox/demo data must remain isolated: migration may place it under explicit demo/admin account or leave it read-only and unowned, but never make all players share it. Use positive integer coin balances, transaction ledger, ownership checks on all reads and writes, atomic sale/purchase/harvest, and idempotency for operations that might be retried.
|
||||||
|
|
||||||
|
## 12. Retention ethics / analytics
|
||||||
|
Provide progress clarity, optional short quests, visual reveals, collection completion, strategic tradeoffs and tangible offline rewards. Don't use punishing login streaks, pseudo-random hidden sale odds, manufactured scarcity or manipulative notifications. A session should have an interesting next action; a player can leave peacefully at any time. Minimal optional telemetry: anonymous aggregates of tutorial completion, first harvest, first cross, return session and retention after 1/7 days; do not use third-party tracking unless explicitly configured.
|
||||||
|
|
||||||
|
## 13. Out of scope for first release
|
||||||
|
Real-money economy, multiplayer trading, leaderboards, chat, PVP, social media integrations, seasonal battle pass, complex crop simulation, weather APIs, procedural 3D, real AI breeding assistant, extensive content pipeline. Do not invent them.
|
||||||
|
|
||||||
|
## 14. Product acceptance (final)
|
||||||
|
A new player can register, enter greenhouse, grow plant, harvest, sell, use coins to buy more seeds/slots, make a real genetic cross, favorite a rare child, hire gardener, assign an automation policy and return later to truthful offline progress. All UI actions work. Another player cannot see/access their plants. Existing Plant Laboratory genome and SVG features are preserved behind optional advanced route. Desktop and mobile function. Deployment stays on port 8155.
|
||||||
94
docs/BOTANICA_M5_M11_IMPLEMENTATION.md
Normal file
|
|
@ -0,0 +1,94 @@
|
||||||
|
# Implementation plan M5–M11 — economical Codex execution
|
||||||
|
|
||||||
|
Read `docs/BOTANICA_M5_M11_AGENT_RULES.md` and `docs/BOTANICA_M5_M11_GAME_DESIGN.md`. This is an implementation task, not a proposal-writing exercise. Adapt names and directories to the actual repo after a single audit.
|
||||||
|
|
||||||
|
## Phase 0 — one-time repo reconnaissance (no coding detour)
|
||||||
|
Read root docs/BOTANICA_M5_M11_AGENT_RULES.md, README, package manifests, current schema/migrations, API entrypoints, authentication (if present), genetics/renderer service signatures, collection components, compose file and env.example. Write a **brief** mapping of existing primitives to needed game features in `docs/GAME_IMPLEMENTATION_NOTES.md`. Do not print huge file trees. Do not invent endpoints before checking existing ones. Preserve existing app behavior as needed.
|
||||||
|
|
||||||
|
## M5 — accounts + ownership foundation
|
||||||
|
- Introduce players, session/auth, recovery codes if feasible with existing dependencies; minimal register/login/logout UI.
|
||||||
|
- Add owner reference to every player-owned entity (plants, greenhouse, seed inventory, breeding events, discoveries). Existing demo data migrates safely to a demo namespace or a dedicated owner, never a shared account; preserve record IDs and provenance.
|
||||||
|
- Server query scoping, username normalization, hashed passwords, cookie sessions, login throttle, CSRF strategy, `GET /api/me` or equivalent.
|
||||||
|
- Add initial player creation transaction: 120 coins, 3 starter seeds, greenhouse (4 slots), starter quest; idempotent signup/bootstrap.
|
||||||
|
- Do not force authentication for health probe. Authenticated API routes should return 401 rather than expose demo data.
|
||||||
|
- Acceptance: two accounts have separate collections; forbidden access is 403/404; no plaintext credentials; existing samples are preserved.
|
||||||
|
|
||||||
|
## M6 — game shell + greenhouse home
|
||||||
|
- New default home route is illustrated greenhouse, not marketing page/lab. Keep original lab accessible from 'Advanced Lab'.
|
||||||
|
- Responsive sidebar / mobile bottom nav; coins, level/XP, seed count, current quest. SVG plant thumbnails from existing renderer.
|
||||||
|
- Functional plant slots and timers backed by server state; clear empty/growing/ready labels; growth and harvest feedback; reduced motion.
|
||||||
|
- Organize game CSS tokens and small reusable `Game*` components. No wholesale frontend rewrite.
|
||||||
|
- Acceptance: new account lands directly in a working greenhouse; buttons/timers reflect server state; mobile viewport usable.
|
||||||
|
|
||||||
|
## M7 — economy, inventory, shop
|
||||||
|
- Integer coins with atomic ledger; seed inventory quantities; per-item price table in server config; purchase/sell transactions.
|
||||||
|
- Crop harvest adds a specimen plus recycled starter seed, not money; sale credits money. Protect favorites and sale limits.
|
||||||
|
- Shop: starter/uncommon/rare seed and first upgrades, with prerequisite levels; meaningful disabled states.
|
||||||
|
- Add bounded pricing by species/rarity/traits; test one concurrent double-spend and double-sell case.
|
||||||
|
- Acceptance: buy costs exact amount, insufficient coins rejects without partial changes, sale credits exactly once.
|
||||||
|
|
||||||
|
## M8 — progression + additional greenhouses
|
||||||
|
- Server timestamps for planting, growth, ready-to-harvest; upgrade modifiers; greenhouse 2 unlock; slot expansion and grow lamp/irrigation. No frequent DB writes for ticking seconds.
|
||||||
|
- Quest/XP/level system and small research unlock set; a concise onboarding path; persist claim state.
|
||||||
|
- All timer calculations in UTC. Legacy grow objects retained or migrated without data loss.
|
||||||
|
- Acceptance: reloading or offline time progresses accurately; harvest cannot be repeated; player always has sustainable access to base seeds.
|
||||||
|
|
||||||
|
## M9 — breeding gameplay
|
||||||
|
- Wrap current genetic cross engine in game operation: eligible owned mature parents, compatibility checks, level, cost, time where supported; idempotent result and parent lineage.
|
||||||
|
- UI: two parent slots, probabilities only if calculated accurately, create child, discover and reveal; advanced editor preserved.
|
||||||
|
- Add species/trait discovery entries and at most 12 handcrafted achievements/quests.
|
||||||
|
- Acceptance: real child phenotype visible via renderer; only own parents; cost once; new discovery awarded once.
|
||||||
|
|
||||||
|
## M10 — workers + safe offline progress
|
||||||
|
- Implement Gardener hire + assign; plant/harvest/replant policy; implement Breeder hire + repeat-cross recipe; both use real inventory and prices.
|
||||||
|
- Persist worker cursor/assignment/limits; bounded catch-up and lock strategy; worker progress applied transactionally without duplicates.
|
||||||
|
- Autosale is opt-in and respects protect/favorite/unique safeguards. Start with OFF. Display last-run summary and constraints.
|
||||||
|
- In first release, avoid complicated workforce simulation and tick daemons. Calculate on authenticated relevant API requests / brief server reconciliation.
|
||||||
|
- Acceptance: 1h offline yields valid finite results and correct balances; repeating same catch-up cannot double grant; no rare protected plant sold.
|
||||||
|
|
||||||
|
## M11 — playability, polish, delivery
|
||||||
|
- Final screen polish: framed illustrated slots, satisfying rare discovery reveal, responsive compact HUD, sound optional, disabled controls explained, quest pin, empty states.
|
||||||
|
- First session should guide user without wizard. Balance early costs/times from game design as configurable constants.
|
||||||
|
- Verify registration, basic loop, worker catch-up and preservation of existing lab features.
|
||||||
|
- Run one final lint/typecheck/test/build plus Docker Compose smoke if Docker is available. Document any commands untestable in your environment; do not claim success without running them.
|
||||||
|
- Do not spend tokens building additional infra or exhaustive automated test suites.
|
||||||
|
|
||||||
|
## Suggested DB entities (adapt existing ones)
|
||||||
|
`players` (id, normalized_username, password_hash, created_at, xp, level, coin_balance)
|
||||||
|
`sessions` (id_hash, player_id, expires_at, revoked_at)
|
||||||
|
`greenhouses` (id, player_id, type, tier, slots, modifiers)
|
||||||
|
`plant_slots` (id, greenhouse_id, slot_no, planted_seed_id/genome, planted_at, ready_at, growth_rules_version)
|
||||||
|
`inventory_seeds` (player_id, seed_type/genome_reference, quantity)
|
||||||
|
`plants` (existing columns + player_id, location, is_favorite, is_protected, sold_at)
|
||||||
|
`shop_purchases`, `coin_ledger` (player_id, amount signed, type, reference, request_id UNIQUE per player/action)
|
||||||
|
`workers` (id, player_id, kind, assignment, policy_json, enabled, last_processed_at)
|
||||||
|
`worker_runs` (worker_id, interval_start, interval_end, summary_json, unique interval constraint)
|
||||||
|
`quests`, `player_quests`, `player_research`, `discoveries` (or adapt existing).
|
||||||
|
Do not store derived XP/rarity twice without a documented reason. Avoid duplicating existing lineage/genome tables.
|
||||||
|
|
||||||
|
## Server API contracts (adapt existing)
|
||||||
|
- `POST /api/auth/register`, `POST /api/auth/login`, `POST /api/auth/logout`, `GET /api/me`
|
||||||
|
- `GET /api/game/state` aggregated bootstrap (balances, greenhouses, quests, workers, unlocks, inventory)
|
||||||
|
- `POST /api/greenhouses/:id/slots/:slot/plant`, `POST .../harvest`
|
||||||
|
- `GET /api/shop`, `POST /api/shop/purchase`
|
||||||
|
- `POST /api/plants/:id/sell`, `PATCH /api/plants/:id` for favorite/protection
|
||||||
|
- `POST /api/breeding/cross`, `GET /api/collection`
|
||||||
|
- `POST /api/workers/hire`, `PATCH /api/workers/:id`, `POST /api/workers/reconcile`
|
||||||
|
- `GET /api/quests`, `POST /api/quests/:id/claim` where needed.
|
||||||
|
All mutations validate schema, CSRF, authenticated ownership, price/level and request-id idempotency. Return stable typed data and clear error codes. Prefer aggregated reads over dozens of requests. Endpoints above are conceptual; consolidate or rename to fit actual project conventions.
|
||||||
|
|
||||||
|
## Critical smoke script (manual or minimal e2e)
|
||||||
|
1. Fresh register, log in, log out/in. A second player cannot see first player's plant.
|
||||||
|
2. Seed → plant → wait/advance controlled clock in test → harvest → keep/sell → buy a new seed.
|
||||||
|
3. Double submit purchase/sell/harvest with same ID; no duplicate rewards and never negative coins.
|
||||||
|
4. Breed two compatible owned mature parents; correct mutation once; lineage persisted.
|
||||||
|
5. Hire gardener, configure, reconcile an elapsed interval twice; no duplicate coins/plants; protected plant is never autosold.
|
||||||
|
6. Advanced Lab still renders old genotype and saved records.
|
||||||
|
7. Production app opens on configured port 8155, production HTTPS session works with public reverse proxy if configured.
|
||||||
|
|
||||||
|
## Practical work batching (minimize stops)
|
||||||
|
Batch into 3 broad passes rather than seven interruptions:
|
||||||
|
- Pass A: M5–M7 (identity, shell, economy)
|
||||||
|
- Pass B: M8–M10 (idle, breeding, workers)
|
||||||
|
- Pass C: M11 (visual polish, single final verification)
|
||||||
|
You may implement all passes without asking for permission. At the end provide a short factual result, migrations, env changes, tests actually run, and remaining caveats. When an important blocker occurs, ask only the one needed question.
|
||||||
|
|
@ -56,3 +56,13 @@ API examples and cursor/idempotency contracts are documented in `docs/API_EXAMPL
|
||||||
## LAN tunnel configuration — 2026-10-28
|
## LAN tunnel configuration — 2026-10-28
|
||||||
|
|
||||||
The owner configured botanica.p20.dev to use origin http://192.168.1.197:8155. Web publication therefore uses ${HOST_BIND:-0.0.0.0}:${HOST_PORT:-8155}:8080 so the tunnel can reach the host over LAN. HOST_BIND can be set to 127.0.0.1 for loopback-only access. API and PostgreSQL remain unpublished. HOST_PORT=8155 and PUBLIC_DOMAIN=http://localhost in .env were preserved; PUBLIC_DOMAIN is not a hostname allowlist.
|
The owner configured botanica.p20.dev to use origin http://192.168.1.197:8155. Web publication therefore uses ${HOST_BIND:-0.0.0.0}:${HOST_PORT:-8155}:8080 so the tunnel can reach the host over LAN. HOST_BIND can be set to 127.0.0.1 for loopback-only access. API and PostgreSQL remain unpublished. HOST_PORT=8155 and PUBLIC_DOMAIN=http://localhost in .env were preserved; PUBLIC_DOMAIN is not a hostname allowlist.
|
||||||
|
|
||||||
|
## Accounts and game deployment (M5–M11)
|
||||||
|
|
||||||
|
Migration `0003_game_foundation` only adds game/account data and replaces the old globally unique breeding request constraint with `(owner_id, idempotency_key)`. Original plant IDs, ancestry, genomes and volumes remain. The old sandbox owner has no login password; its records are exposed through authenticated read-only `/api/v1/demo/plants` endpoints and `/examples`. New accounts start empty and never inherit demo plants.
|
||||||
|
|
||||||
|
`PUBLIC_ORIGINS` is an optional comma-separated list of **exact browser origins, with port where used**, for register/login/recovery and CSRF checks. Compose defaults include localhost:8155, 127.0.0.1:8155, the configured LAN endpoint 192.168.1.197:8155, and https://botanica.p20.dev. Keep the requested `.env` values `HOST_PORT=8155` and `PUBLIC_DOMAIN=http://localhost`. For another deployment, supply its actual allowed origins; for `npm run dev`, add http://localhost:5173. No new secret is required: opaque sessions use secure random tokens, with only hashes stored in PostgreSQL. Passwords use Argon2id; recovery codes are stored as hashes and consumed atomically.
|
||||||
|
|
||||||
|
nginx preserves the forwarded HTTPS protocol. Auth cookies use HttpOnly, SameSite=Lax and Secure when the approved browser origin or reverse proxy indicates HTTPS. The API/DB remain private. Auth attempts are bounded per API-observed IP and username, with a separate total IP ceiling, in one API process (limits reset on restart). This intentionally uses no Redis or distributed service. Expired sessions are rejected; periodic DB maintenance may remove expired rows if needed.
|
||||||
|
|
||||||
|
Workers reconcile on game-state reads and game mutations, not a background daemon. Each calculation processes at most 100 chronological intervals and 24 hours per worker; unprocessed elapsed time remains queued. The player's row lock serializes all resource and worker changes. Blocked intervals advance with no reward; new purchases cannot be spent retroactively on pending work. “Process next batch” and the 15-second state refresh drain larger backlogs. Newly assigned/changed policies start from their save time. Pause before changing a long-running recipe if you wish to stop future work. Reports are persisted per worker interval range; auto-sale is off by default and restricted to duplicate Common harvests.
|
||||||
|
|
|
||||||
40
docs/GAME_IMPLEMENTATION_NOTES.md
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
# Botanica M5–M11 implementation notes
|
||||||
|
|
||||||
|
## Initial audit
|
||||||
|
Existing pure packages: genetics/rules resolve and breed versioned genomes; renderer composes original SVG primitives. Fastify uses PostgreSQL transactions and tracked SQL migrations; plants and breeding_events already carry owner IDs. React has laboratory, breeding, collection, lineage inspectors and image/JSON exports. Compose exposes only web on port 8155, including LAN access for the configured Cloudflare tunnel. Existing .env is preserved.
|
||||||
|
|
||||||
|
Implementation batches: M5–M7 accounts/scoping, greenhouse and integer economy; M8–M10 progression, paid breeding and bounded worker reconciliation; M11 game presentation and one final regression/integration/browser smoke. Existing sandbox rows remain owned by their original demo UUID and become explicitly read-only examples. Designed laboratory plants remain usable studies but are excluded from game sale/breeding rewards. No database reset or volume deletion.
|
||||||
|
|
||||||
|
## Shipped implementation
|
||||||
|
|
||||||
|
- **M5**: username/password registration/login, Argon2id, opaque hashed sessions (30 days), CSRF/origin checks, bounded process-local auth throttling, logout and five one-use recovery codes. Account creation atomically grants 120 coins, three starter seeds and a four-slot house. Owned reads/mutations use the authenticated player ID.
|
||||||
|
- **M6–M7**: greenhouse home with original SVG pots/plants, responsive rail/bottom navigation and persistent HUD. Seed inventory, server-priced purchases, confirmed specimen sale, favorites/protection and a signed integer coin ledger. Receipt keys make monetary requests replay-safe. Harvest yields a plant plus one recycled starter seed; coins come from sales and distinct discovery/quest rewards.
|
||||||
|
- **M8**: persisted UTC growth (20-second first crop, then 120/300/720 seconds), 12 handcrafted one-time goals, XP-derived levels, slot expansions, lamp, irrigation, second greenhouse and three real research unlocks. Lamp reduces remaining and future growth by 10%; irrigation reduces gardener intervals from 120 to 96 seconds.
|
||||||
|
- **M9**: existing engine wrapped in paid level-3 breeding (75 coins), with two distinct owned mature game plants, immutable parent lineage and replayed stored outcomes. Instant offspring reuses the original engine; no extra breeding timer or invented probability display. Discovery uniqueness uses the resolved species/catalog/phenotype signature, not a rarity roll.
|
||||||
|
- **M10**: hire/assign gardeners and breeders, default paused. Policies repeat_crop, repeat_cross and researched target_trait use real seeds/coins/parents. Targets protect matching offspring without changing odds. A player-row lock serializes reconciliation and manual spending; chronological event simulation is capped at 100 intervals/24 hours per calculation. Persisted cursors retain surplus time. Blocked intervals advance without rewards; new resource purchases cannot fuel historical pending work. Per-worker logs reconcile to the combined report. Auto-sale starts off, only considers newly harvested Common duplicates and excludes favorites, protected plants, first discoveries and the last specimen of a species. Breeders keep all offspring.
|
||||||
|
- **M11**: forest/parchment/gold shell, tactile growing slots, live countdown, discovery reveal, next-goal tracker, explained locks, account/reduced-motion preferences, compact mobile navigation and persistent offline summary. Existing laboratory, renderer atlas, owned collection filters, inspectors, provenance and JSON/SVG/PNG exports remain. `/examples` and reference detail routes retain the old archive read-only, including genomes, ancestry and exports; pagination includes legacy records beyond the 100 demos.
|
||||||
|
|
||||||
|
## Migration and environment
|
||||||
|
|
||||||
|
`0003_game_foundation.sql` is additive for account/game data. It replaces only the breeding request uniqueness constraint with owner+request uniqueness. No existing plant IDs, genomes, versions, owners or ancestry are migrated away. Existing sandbox rows keep their original owner, which has no account password. Designed studies in real accounts remain separate from salable/breedable game plants. PostgreSQL volumes were retained throughout.
|
||||||
|
|
||||||
|
The existing `.env` was not edited. `HOST_PORT=8155` and `PUBLIC_DOMAIN=http://localhost` remain. Optional `PUBLIC_ORIGINS` is passed through Compose; its explicit defaults include local, configured LAN and public HTTPS origins. See DEPLOYMENT.md for Vite/other deployments. No additional secret or service is required. Argon2 is the only new runtime dependency.
|
||||||
|
|
||||||
|
## Verification to date
|
||||||
|
|
||||||
|
- Full workspace typecheck passed; production npm build passed; Docker's server/web production builds also compile the delivered changes.
|
||||||
|
- `npm run test`: 16 existing domain/API checks passed, 16 artwork source files verified. The 13 database cases are intentionally skipped there without TEST_DATABASE_URL.
|
||||||
|
- Isolated `compose.test.yaml`: all 13 real PostgreSQL cases passed (eight existing persistence/rollback/pagination checks and five focused game cases). Game cases cover accounts/recovery/CSRF/isolation, simultaneous spend/sale/harvest, paid idempotent inheritance, and concurrent one-hour gardener/breeder reconciliation plus a capped two-day backlog. Late-game tests use explicit isolated fixtures, not hours of simulated manual leveling.
|
||||||
|
- Initial database acceptance found a UUID/text inference error in signup's welcome ledger row; explicit casts fixed it. Argon2/recovery test has a local 20-second timeout to tolerate concurrent Docker load. The final integration run passes all assertions.
|
||||||
|
- `docker compose config --quiet`, `docker compose up --build -d`, `docker compose ps` and proxied localhost health passed. Only web publishes 0.0.0.0:8155; API/DB remain private.
|
||||||
|
- Before/after migration checks match all **103** pre-existing plants, including owner, genome, visual seed, generation and parents (checksum `09972afb73a3b3b0fc187ec59182be9f`). Evidence: artifacts/m11/legacy-baseline.json and legacy-after-migration.json.
|
||||||
|
- `scripts/verify-game.cjs` passed all 13 browser checks: real registration/recovery-code display, live countdown, harvest/discovery, favorite protection, confirmed sale, quest claims, seed/slot purchases, paid inherited breeding and reload-persisted child favorite; advanced lab, renderer atlas and all 103 legacy references; late-game upgrades/research/worker assignment and replay-safe one-hour catch-up; logout/login; unauthenticated denial; and actual public HTTPS login through Cloudflare with Secure/HttpOnly/SameSite=Lax cookies. All nine gameplay routes have zero horizontal overflow at both 360px and 1440px, with reduced motion and no JavaScript page errors.
|
||||||
|
- Browser late-game setup is explicitly scoped to its newly registered QA UUID: level 10, a ledger-recorded 20,000-coin grant and worker cursors shifted back one hour. Early plant/harvest/sale/quest/purchase/breeding used real timers/resources without shortcuts. QA workers were paused afterward. No credentials or recovery codes are written to artifacts.
|
||||||
|
- Acceptance fixed signup SQL parameter typing, controlled-checkbox feedback and client/server harvest-boundary races. Harvest now waits for confirmed server readiness while countdowns remain smooth. Short explicit accessible select names avoid including every option in the control name. Cryptographically secure request-ID fallback was exercised with randomUUID deliberately unavailable, supporting the configured plain-HTTP LAN origin. Visual review led to an explicit row layout for the compact mobile HUD; final CSS/copy changes were rebuilt and checked separately.
|
||||||
|
- Evidence: artifacts/m11/browser-report.json, desktop/mobile greenhouse and breeding/worker screenshots. Both desktop and mobile visuals were inspected. There is no separate lint script in this repository; TypeScript, Prettier formatting, builds and the existing tests were used.
|
||||||
|
|
||||||
|
## Practical limits
|
||||||
|
|
||||||
|
One fictional species is inherited from M0–M4; no additional species or hybrid rules were invented. Workers calculate on relevant authenticated requests/15-second state refresh, without a daemon. Large backlogs require subsequent bounded batches. Throttle counters are local to the single API process and reset on restart; no distributed limiter is claimed. Recovery codes are shown once and consumable; rotation/self-service account deletion and sound are not implemented. Registration does not require email. No paid currency, runtime generation services, trackers or multiplayer were added.
|
||||||
|
|
||||||
|
Final follow-up: the rebuilt 360px HUD measures 103px tall, with coin/seed/player groups on the same row and zero overflow (artifacts/m11/mobile-final.json and greenhouse-mobile-final.png). Public HTTPS health returns connected JSON. A final SQL audit confirms the legacy checksum still matches, no player has negative coins, every player's balance equals their signed ledger total, and no QA worker remains enabled (artifacts/m11/final-data-check.json). Only temporary test Compose containers/network were removed; the application remains running. All M5–M11 gates are complete.
|
||||||
|
|
@ -5,6 +5,13 @@
|
||||||
- [x] M2 — procedural illustrated SVG renderer, exports, visual QA.
|
- [x] M2 — procedural illustrated SVG renderer, exports, visual QA.
|
||||||
- [x] M3 — lab editor, server persistence, breeding flow.
|
- [x] M3 — lab editor, server persistence, breeding flow.
|
||||||
- [x] M4 — gallery, provenance, UX polish, full acceptance.
|
- [x] M4 — gallery, provenance, UX polish, full acceptance.
|
||||||
|
- [x] M5 — accounts, recovery, ownership and safe bootstrap.
|
||||||
|
- [x] M6 — greenhouse home and responsive game shell.
|
||||||
|
- [x] M7 — integer economy, shop and protected collection actions.
|
||||||
|
- [x] M8 — growth, progression, quests, upgrades and research.
|
||||||
|
- [x] M9 — paid breeding and phenotype discoveries.
|
||||||
|
- [x] M10 — workers and bounded, replay-safe offline progress.
|
||||||
|
- [x] M11 — playability, visual/accessibility polish and final delivery checks.
|
||||||
|
|
||||||
## M0 — 2026-10-28
|
## M0 — 2026-10-28
|
||||||
|
|
||||||
|
|
@ -103,3 +110,21 @@ The idempotent seed now contains exactly 100 fixed-ID deterministic demonstratio
|
||||||
- Visually reviewed the complete desktop collection, desktop inspector, mobile collection/inspector and error state. Evidence and downloads are in artifacts/m4. Initial browser pagination assertions ran before React finished restoring the list after clearing empty search; the test now waits for the actual list. Keyboard QA found Chrome's native modal tab cycle can stop outside the document; explicit endpoint wrapping fixed it and the full suite passed afterward.
|
- Visually reviewed the complete desktop collection, desktop inspector, mobile collection/inspector and error state. Evidence and downloads are in artifacts/m4. Initial browser pagination assertions ran before React finished restoring the list after clearing empty search; the test now waits for the actual list. Keyboard QA found Chrome's native modal tab cycle can stop outside the document; explicit endpoint wrapping fixed it and the full suite passed afterward.
|
||||||
|
|
||||||
M0–M4 are complete at http://localhost:8155. No known milestone blockers. The app is ready for further local iteration, not public launch: authentication, real owner authorization and throttling remain future work. No M5 economy, accounts or multiplayer were added. The requested .env origin/port are unchanged and excluded from Git.
|
M0–M4 are complete at http://localhost:8155. No known milestone blockers. The app is ready for further local iteration, not public launch: authentication, real owner authorization and throttling remain future work. No M5 economy, accounts or multiplayer were added. The requested .env origin/port are unchanged and excluded from Git.
|
||||||
|
|
||||||
|
## M5–M11 game implementation — 2026-10-28
|
||||||
|
|
||||||
|
Implemented sequentially under the user's authorization to continue through all seven milestones. See GAME_IMPLEMENTATION_NOTES.md for the detailed migration, rules and verification record.
|
||||||
|
|
||||||
|
- [x] M5: transactional account bootstrap, Argon2id, hashed sessions, CSRF, throttling, recovery codes and ownership-scoped access. Original sandbox remains separate and read-only.
|
||||||
|
- [x] M6: illustrated greenhouse home, responsive game navigation, inventory/HUD/quest tracker and real plant/harvest actions.
|
||||||
|
- [x] M7: integer coin ledger, atomic/idempotent purchase/sale, seed recycling, favorites and protection. Concurrent overspend/double-sale checks pass.
|
||||||
|
- [x] M8: server UTC growth, level/XP progression, twelve one-time quests, research, extra slots/equipment and second greenhouse.
|
||||||
|
- [x] M9: paid level-3 genetic crosses, owned mature parent eligibility, preserved lineage and unique phenotype discoveries.
|
||||||
|
- [x] M10: paused-by-default gardeners/breeders, real-resource recipes, protected target matches, persisted per-worker summaries, serialized bounded offline reconciliation and opt-in safe Common-duplicate auto-sale. Controlled one-hour concurrency/replay and two-day backlog checks pass.
|
||||||
|
- [x] M11: final game presentation, accessible named controls, server-confirmed readiness, compact HUD and reduced motion. Complete browser smoke passes, including desktop/mobile and public HTTPS authentication.
|
||||||
|
|
||||||
|
The 103 pre-existing plants match their before-migration checksum for identity, owner, genome, visual seed, generation and parentage. No data volume was reset. Existing laboratory, renderer atlas, collection filtering, inspectors, ancestry and exports remain available. The original archive is inspectable/exportable through read-only reference routes, including records beyond the first 100 examples.
|
||||||
|
|
||||||
|
Verified so far: full workspace typecheck; npm production build; 16 existing unit/domain/API tests and 16 SVG source files; all 13 isolated PostgreSQL acceptance cases; Compose config/build/start/status and proxied health. Only web publishes port 8155. The literal .env origin/port are unchanged. Initial signup SQL parameter typing, controlled-checkbox feedback and client/server harvest-boundary issues found during acceptance were corrected; server readiness now gates harvest while the local countdown remains smooth.
|
||||||
|
|
||||||
|
Final browser acceptance: all 13 checks passed with no JavaScript page errors; nine routes fit 360px and 1440px with zero horizontal overflow. Early gameplay used real growth timers. Explicit late-game fixtures were limited to the new QA account (ledger-recorded coins/XP and shifted worker cursors); worker replay, research, upgrades and second-house UI passed, and QA workers were paused afterward. Actual Cloudflare HTTPS login restored the correct player with Secure, HttpOnly, SameSite=Lax cookie. Screenshots and factual reports are in artifacts/m11. See GAME_IMPLEMENTATION_NOTES.md for practical limits (single species, request-driven bounded workers, local throttle counters, no recovery-code rotation or sound). No milestone blocker remains; M5–M11 are complete.
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,13 @@
|
||||||
# Future development after Plant Laboratory
|
# Botanica roadmap
|
||||||
|
|
||||||
M0–M4 establish a local illustrated plant laboratory with deterministic genetics, original vector rendering, persistent breeding and a traceable collection.
|
M0–M4 supply deterministic genetics, original SVG rendering, persisted ancestry and the laboratory. M5–M11 add accounts, the greenhouse game, integer economy, progression, paid crosses and opt-in offline workers. See PROGRESS.md and GAME_IMPLEMENTATION_NOTES.md for delivered gates and actual checks.
|
||||||
|
|
||||||
Future milestones should be designed and gated separately:
|
Possible later work, requiring its own plan:
|
||||||
|
|
||||||
1. Authentication, ownership enforcement for real accounts, access control, request throttling and deployment hardening before public exposure. The current fixed sandbox owner is not authentication.
|
1. More species with authored botanical grammars, family-scoped allele catalogs and explicit hybrid rules.
|
||||||
2. Greenhouse progression: pots, seeds, resources and an explicit economy, with server-authoritative transactions.
|
2. Balance tuning from real play sessions, additional seed genomes and a broader discovery notebook.
|
||||||
3. Offline progression based on persisted event timestamps; test clock changes, retries and race conditions before adding automation.
|
3. Recovery-code rotation and account management beyond the current one-time codes/password recovery.
|
||||||
4. Research, upgrades and achievements, with versioned balance rules and migration policies.
|
4. Distributed login throttling only if deployment ever gains multiple API instances.
|
||||||
5. Additional species with authored botanical grammars, family-scoped allele catalogs and explicitly approved hybrid rules.
|
5. Additional worker policies and quality-of-life tools, with the same resource and protection invariants.
|
||||||
6. Social trading or multiplayer only after account security, provenance and economic invariants have been reviewed.
|
|
||||||
|
|
||||||
Do not add these systems implicitly to the laboratory. Preserve stored genomes, seeds, versions and ancestry as the model evolves. Future rarity policy changes require an explicit, reviewed recomputation migration rather than silently changing birth classifications.
|
Stored genomes, seeds, versions and ancestry remain authoritative. A rarity-policy change needs an explicit migration; never silently recompute birth classifications. No real-money currency, trading or multiplayer is part of this delivery.
|
||||||
|
|
|
||||||
233
package-lock.json
generated
|
|
@ -25,6 +25,7 @@
|
||||||
"@botanica/genetics": "0.0.0",
|
"@botanica/genetics": "0.0.0",
|
||||||
"@botanica/renderer": "0.0.0",
|
"@botanica/renderer": "0.0.0",
|
||||||
"@botanica/shared": "0.0.0",
|
"@botanica/shared": "0.0.0",
|
||||||
|
"@node-rs/argon2": "^2.2.2",
|
||||||
"drizzle-orm": "^0.45.4",
|
"drizzle-orm": "^0.45.4",
|
||||||
"fastify": "^5.12.7",
|
"fastify": "^5.12.7",
|
||||||
"pg": "^8.16.3",
|
"pg": "^8.16.3",
|
||||||
|
|
@ -704,6 +705,238 @@
|
||||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@node-rs/argon2": {
|
||||||
|
"version": "2.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@node-rs/argon2/-/argon2-2.2.2.tgz",
|
||||||
|
"integrity": "sha512-NtdlPPWtR5U4BxX5EGJYza+UklNaoWORmkaKCMqf/nyKeXWKVwNt8KfQoRpNT7OB1DcEwmePafO4wZyW+sbfCQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@node-rs/argon2-android-arm-eabi": "2.2.2",
|
||||||
|
"@node-rs/argon2-android-arm64": "2.2.2",
|
||||||
|
"@node-rs/argon2-darwin-arm64": "2.2.2",
|
||||||
|
"@node-rs/argon2-darwin-x64": "2.2.2",
|
||||||
|
"@node-rs/argon2-freebsd-x64": "2.2.2",
|
||||||
|
"@node-rs/argon2-linux-arm-gnueabihf": "2.2.2",
|
||||||
|
"@node-rs/argon2-linux-arm64-gnu": "2.2.2",
|
||||||
|
"@node-rs/argon2-linux-arm64-musl": "2.2.2",
|
||||||
|
"@node-rs/argon2-linux-x64-gnu": "2.2.2",
|
||||||
|
"@node-rs/argon2-linux-x64-musl": "2.2.2",
|
||||||
|
"@node-rs/argon2-win32-arm64-msvc": "2.2.2",
|
||||||
|
"@node-rs/argon2-win32-ia32-msvc": "2.2.2",
|
||||||
|
"@node-rs/argon2-win32-x64-msvc": "2.2.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@node-rs/argon2-android-arm-eabi": {
|
||||||
|
"version": "2.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@node-rs/argon2-android-arm-eabi/-/argon2-android-arm-eabi-2.2.2.tgz",
|
||||||
|
"integrity": "sha512-c11BOrPLBWNC1cDyzGB+Bil+flmxfZbB2hv8EhcobmAsFKMyqkHPNhUY4qhcjv1U+4+Vmw4G8H+l224iO2g/Dw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@node-rs/argon2-android-arm64": {
|
||||||
|
"version": "2.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@node-rs/argon2-android-arm64/-/argon2-android-arm64-2.2.2.tgz",
|
||||||
|
"integrity": "sha512-dpMYIoxhN/wY6hFH+3MM5kQErcH3luMekkszbkDF3NKqhB1kFRntkOLDpFY87oItBSHJrsoYkXC3vBRhjYoCjQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@node-rs/argon2-darwin-arm64": {
|
||||||
|
"version": "2.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@node-rs/argon2-darwin-arm64/-/argon2-darwin-arm64-2.2.2.tgz",
|
||||||
|
"integrity": "sha512-vxVAXcIG0iiHizkCuImmzVhOFsRnU4Z79xiOxdISLyI+QhOAWbbTaVSXw0PNfThB3zAdkzzDJ/WWu52Hs/qHGg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@node-rs/argon2-darwin-x64": {
|
||||||
|
"version": "2.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@node-rs/argon2-darwin-x64/-/argon2-darwin-x64-2.2.2.tgz",
|
||||||
|
"integrity": "sha512-r7GbSFpFZZ9EAV9cvIm9prUjOupK7QQXTyP+FJ6bWSMq0h+qRDBlUOXQXduDIIrLEGoBVelixKUtf4ksZ0ljoQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@node-rs/argon2-freebsd-x64": {
|
||||||
|
"version": "2.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@node-rs/argon2-freebsd-x64/-/argon2-freebsd-x64-2.2.2.tgz",
|
||||||
|
"integrity": "sha512-AmWxDSmQgprilD6NX3jmxCgcDvWAW7kGJnnWQo+0qav6nfMXdPZYgSezK/skmw1GBWipr4yZMIUzpeNHnVpMxg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@node-rs/argon2-linux-arm-gnueabihf": {
|
||||||
|
"version": "2.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@node-rs/argon2-linux-arm-gnueabihf/-/argon2-linux-arm-gnueabihf-2.2.2.tgz",
|
||||||
|
"integrity": "sha512-M1gnPBTr9GFeEUWkQ53qCJeTsX03C3kcUeHnDbN7dU2xkI58wMP4Z1sEBN7WmKJ/fTHYEgti4truz7r4A82DLg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@node-rs/argon2-linux-arm64-gnu": {
|
||||||
|
"version": "2.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@node-rs/argon2-linux-arm64-gnu/-/argon2-linux-arm64-gnu-2.2.2.tgz",
|
||||||
|
"integrity": "sha512-4CYDlIdITlLSVcAXYUtEoGUahRaayPXM+4Rcfg3BZx9WGi6UB8HUycs6GNMTPYXV5kVXCpyE2u1HmYUpgmIzOQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@node-rs/argon2-linux-arm64-musl": {
|
||||||
|
"version": "2.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@node-rs/argon2-linux-arm64-musl/-/argon2-linux-arm64-musl-2.2.2.tgz",
|
||||||
|
"integrity": "sha512-FYlfj3XgQ1xrx6HzQbKbUcKiYMG/3iSi/X81KyhHaItTJ7vZlMwrbVLl9yqDmvotKUF6VjrhrlZ8KRbbzchseA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@node-rs/argon2-linux-x64-gnu": {
|
||||||
|
"version": "2.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@node-rs/argon2-linux-x64-gnu/-/argon2-linux-x64-gnu-2.2.2.tgz",
|
||||||
|
"integrity": "sha512-ULXLs8UFYZANlwiROM7AeccxZndr2Xk3IstADq3jAUhnwV2W75mxMBTABUZ+7AdKRhPLOrxU77OgnMWlDkYJQg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@node-rs/argon2-linux-x64-musl": {
|
||||||
|
"version": "2.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@node-rs/argon2-linux-x64-musl/-/argon2-linux-x64-musl-2.2.2.tgz",
|
||||||
|
"integrity": "sha512-zgjIwc9TyGodn62vGc+36DGwFqVV5e7L8Ptd2FQWuBxe5aFrcFxvtDGn1/qfYwoHLv09utgtR+RBAPCXepWG3Q==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@node-rs/argon2-win32-arm64-msvc": {
|
||||||
|
"version": "2.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@node-rs/argon2-win32-arm64-msvc/-/argon2-win32-arm64-msvc-2.2.2.tgz",
|
||||||
|
"integrity": "sha512-3nLhvLie/ec5xfxUBXXqGMb7oGeqF+YXrcGckqaTaGkPtOBozho0Kh2IuP/zi9BcOriirTG2LW3gtIOfpuh+mQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@node-rs/argon2-win32-ia32-msvc": {
|
||||||
|
"version": "2.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@node-rs/argon2-win32-ia32-msvc/-/argon2-win32-ia32-msvc-2.2.2.tgz",
|
||||||
|
"integrity": "sha512-6RJKD6cFl7UeiYJLjRsXPywVXSFSaSDdr6xGpztNDV31QHCEecWnsnNmKxzi4gVpln2F9XeWEtIgM4Nqw1hLbA==",
|
||||||
|
"cpu": [
|
||||||
|
"ia32"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@node-rs/argon2-win32-x64-msvc": {
|
||||||
|
"version": "2.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@node-rs/argon2-win32-x64-msvc/-/argon2-win32-x64-msvc-2.2.2.tgz",
|
||||||
|
"integrity": "sha512-dQOO9S4QKBaVul0Uyk/2quJc/OBeHNZRJP+GZZTy1A5kYIaAfIJGHUYLY6oFoAlcuLIkdFouLaHX3fCl8tZm/w==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@oxc-project/types": {
|
"node_modules/@oxc-project/types": {
|
||||||
"version": "0.155.0",
|
"version": "0.155.0",
|
||||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.155.0.tgz",
|
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.155.0.tgz",
|
||||||
|
|
|
||||||
117
packages/shared/src/game.ts
Normal file
|
|
@ -0,0 +1,117 @@
|
||||||
|
import { z } from "zod";
|
||||||
|
export const seedTypes = ["starter", "uncommon", "rare"] as const;
|
||||||
|
export type SeedType = (typeof seedTypes)[number];
|
||||||
|
export const gameActionSchema = z.strictObject({
|
||||||
|
action: z.enum([
|
||||||
|
"plant",
|
||||||
|
"harvest",
|
||||||
|
"purchase",
|
||||||
|
"sell",
|
||||||
|
"flags",
|
||||||
|
"claim",
|
||||||
|
"research",
|
||||||
|
"hire",
|
||||||
|
"assign",
|
||||||
|
]),
|
||||||
|
requestId: z.uuid(),
|
||||||
|
data: z.record(z.string(), z.unknown()).default({}),
|
||||||
|
});
|
||||||
|
export type GameAction = z.infer<typeof gameActionSchema>;
|
||||||
|
export interface GamePlayer {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
coins: number;
|
||||||
|
xp: number;
|
||||||
|
level: number;
|
||||||
|
nextLevelXp: number;
|
||||||
|
csrfToken: string;
|
||||||
|
}
|
||||||
|
export interface GameSlot {
|
||||||
|
id: string;
|
||||||
|
slotNo: number;
|
||||||
|
seedType: SeedType | null;
|
||||||
|
plantedAt: string | null;
|
||||||
|
readyAt: string | null;
|
||||||
|
ready: boolean;
|
||||||
|
visualSeed: number | null;
|
||||||
|
phenotype: import("@botanica/genetics").Phenotype | null;
|
||||||
|
}
|
||||||
|
export interface GameHouse {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
slots: number;
|
||||||
|
lamp: boolean;
|
||||||
|
irrigation: boolean;
|
||||||
|
nextSlotCost: number | null;
|
||||||
|
plants: GameSlot[];
|
||||||
|
}
|
||||||
|
export interface GameItem {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
cost: number;
|
||||||
|
level: number;
|
||||||
|
disabledReason: string | null;
|
||||||
|
}
|
||||||
|
export interface GameQuest {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
progress: number;
|
||||||
|
target: number;
|
||||||
|
coins: number;
|
||||||
|
xp: number;
|
||||||
|
claimed: boolean;
|
||||||
|
}
|
||||||
|
export interface WorkerPolicy {
|
||||||
|
mode: "repeat_crop" | "repeat_cross" | "target_trait";
|
||||||
|
seedType: SeedType;
|
||||||
|
parentAId?: string;
|
||||||
|
parentBId?: string;
|
||||||
|
targetPigment?: string;
|
||||||
|
targetPattern?: string;
|
||||||
|
autoSell: boolean;
|
||||||
|
reserveCoins: number;
|
||||||
|
}
|
||||||
|
export interface GameWorker {
|
||||||
|
id: string;
|
||||||
|
kind: "gardener" | "breeder";
|
||||||
|
greenhouseId: string | null;
|
||||||
|
enabled: boolean;
|
||||||
|
policy: WorkerPolicy;
|
||||||
|
lastProcessedAt: string;
|
||||||
|
intervalSeconds: number;
|
||||||
|
}
|
||||||
|
export interface OfflineSummary {
|
||||||
|
events: number;
|
||||||
|
harvested: number;
|
||||||
|
sold: number;
|
||||||
|
breeds: number;
|
||||||
|
seedsUsed: number;
|
||||||
|
coinDelta: number;
|
||||||
|
notable: string[];
|
||||||
|
blocked: number;
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
}
|
||||||
|
export interface GameState {
|
||||||
|
serverNow: string;
|
||||||
|
player: GamePlayer;
|
||||||
|
seeds: Record<SeedType, number>;
|
||||||
|
greenhouses: GameHouse[];
|
||||||
|
quests: GameQuest[];
|
||||||
|
shop: GameItem[];
|
||||||
|
research: (GameItem & { owned: boolean })[];
|
||||||
|
workers: GameWorker[];
|
||||||
|
report: OfflineSummary | null;
|
||||||
|
pendingWork: boolean;
|
||||||
|
recentRuns: { id: string; summary: OfflineSummary }[];
|
||||||
|
breedCost: number;
|
||||||
|
workerCosts: { gardener: number; breeder: number };
|
||||||
|
seedInfo: {
|
||||||
|
id: SeedType;
|
||||||
|
name: string;
|
||||||
|
seconds: number;
|
||||||
|
description: string;
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
|
@ -38,6 +38,12 @@ export const pageQuerySchema = z.strictObject({
|
||||||
limit: z.coerce.number().int().min(1).max(100).default(24),
|
limit: z.coerce.number().int().min(1).max(100).default(24),
|
||||||
});
|
});
|
||||||
export interface PlantDto {
|
export interface PlantDto {
|
||||||
|
gameEligible?: boolean;
|
||||||
|
firstDiscovery?: boolean;
|
||||||
|
saleCoins?: number | null;
|
||||||
|
isFavorite?: boolean;
|
||||||
|
isProtected?: boolean;
|
||||||
|
soldAt?: string | null;
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
ownerId: string;
|
ownerId: string;
|
||||||
|
|
@ -92,3 +98,4 @@ export interface CollectionStats {
|
||||||
total: number;
|
total: number;
|
||||||
byRarity: Partial<Record<PlantDto["rarity"]["tier"], number>>;
|
byRarity: Partial<Record<PlantDto["rarity"]["tier"], number>>;
|
||||||
}
|
}
|
||||||
|
export * from "./game.js";
|
||||||
|
|
|
||||||
452
scripts/verify-game.cjs
Normal file
|
|
@ -0,0 +1,452 @@
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const assert = require("node:assert/strict");
|
||||||
|
const { randomBytes } = require("node:crypto");
|
||||||
|
const { execFileSync } = require("node:child_process");
|
||||||
|
const { chromium } = require(process.env.PLAYWRIGHT_MODULE || "playwright");
|
||||||
|
const base = process.env.GAME_URL || "http://localhost:8155",
|
||||||
|
directory = "artifacts/m11";
|
||||||
|
const report = {
|
||||||
|
checks: [],
|
||||||
|
errors: [],
|
||||||
|
widths: [],
|
||||||
|
fixtures: [],
|
||||||
|
playerId: null,
|
||||||
|
childId: null,
|
||||||
|
};
|
||||||
|
function check(name) {
|
||||||
|
report.checks.push(name);
|
||||||
|
console.log("PASS " + name);
|
||||||
|
}
|
||||||
|
function sql(query) {
|
||||||
|
return execFileSync(
|
||||||
|
"docker",
|
||||||
|
[
|
||||||
|
"compose",
|
||||||
|
"exec",
|
||||||
|
"-T",
|
||||||
|
"db",
|
||||||
|
"sh",
|
||||||
|
"-c",
|
||||||
|
'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -t -A -v ON_ERROR_STOP=1',
|
||||||
|
],
|
||||||
|
{ input: query, encoding: "utf8" },
|
||||||
|
).trim();
|
||||||
|
}
|
||||||
|
(async () => {
|
||||||
|
fs.mkdirSync(directory, { recursive: true });
|
||||||
|
const browser = await chromium.launch({
|
||||||
|
headless: true,
|
||||||
|
...(process.env.BROWSER_EXECUTABLE
|
||||||
|
? { executablePath: process.env.BROWSER_EXECUTABLE }
|
||||||
|
: {}),
|
||||||
|
});
|
||||||
|
const context = await browser.newContext({
|
||||||
|
viewport: { width: 1440, height: 1100 },
|
||||||
|
reducedMotion: "reduce",
|
||||||
|
acceptDownloads: true,
|
||||||
|
}),
|
||||||
|
page = await context.newPage();
|
||||||
|
await context.addInitScript(() =>
|
||||||
|
Object.defineProperty(window.crypto, "randomUUID", {
|
||||||
|
value: undefined,
|
||||||
|
configurable: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
page.on("pageerror", (e) => report.errors.push(e.message));
|
||||||
|
page.setDefaultTimeout(15000);
|
||||||
|
const username = "qa_" + randomBytes(5).toString("hex"),
|
||||||
|
password = randomBytes(20).toString("hex");
|
||||||
|
async function go(path) {
|
||||||
|
await page.goto(base + path);
|
||||||
|
await page.locator(".game-hud").waitFor();
|
||||||
|
}
|
||||||
|
async function actionClick(button) {
|
||||||
|
const response = page.waitForResponse(
|
||||||
|
(r) =>
|
||||||
|
r.url().endsWith("/api/v1/game/action") &&
|
||||||
|
r.request().method() === "POST",
|
||||||
|
);
|
||||||
|
await button.click();
|
||||||
|
const r = await response;
|
||||||
|
assert(r.ok(), await r.text());
|
||||||
|
return r.json();
|
||||||
|
}
|
||||||
|
async function quest(name) {
|
||||||
|
await go("/quests");
|
||||||
|
await actionClick(
|
||||||
|
page
|
||||||
|
.locator(".game-card")
|
||||||
|
.filter({ has: page.getByRole("heading", { name, exact: true }) })
|
||||||
|
.getByRole("button", { name: "Claim reward" }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
async function read(path) {
|
||||||
|
const r = await context.request.get(base + "/api/v1" + path);
|
||||||
|
assert(r.ok(), await r.text());
|
||||||
|
return r.json();
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await page.goto(base);
|
||||||
|
await page
|
||||||
|
.getByRole("button", { name: "Create account", exact: true })
|
||||||
|
.click();
|
||||||
|
await page.getByLabel("Username", { exact: true }).fill(username);
|
||||||
|
await page.getByLabel("Password", { exact: true }).fill(password);
|
||||||
|
await page
|
||||||
|
.getByRole("button", { name: "Start growing", exact: true })
|
||||||
|
.click();
|
||||||
|
await page
|
||||||
|
.getByRole("heading", { name: "Keep your recovery codes" })
|
||||||
|
.waitFor();
|
||||||
|
assert.equal(await page.locator(".recovery-codes code").count(), 5);
|
||||||
|
await page.getByRole("button", { name: /I've saved my codes/ }).click();
|
||||||
|
await page.locator(".growing-slot").first().waitFor();
|
||||||
|
assert.equal(await page.locator(".growing-slot").count(), 4);
|
||||||
|
report.playerId = (await read("/me")).id;
|
||||||
|
check("Register, one-time recovery UI and isolated four-slot greenhouse");
|
||||||
|
await page.screenshot({
|
||||||
|
path: directory + "/greenhouse-desktop.png",
|
||||||
|
fullPage: true,
|
||||||
|
});
|
||||||
|
for (let i = 0; i < 3; i++)
|
||||||
|
await actionClick(
|
||||||
|
page
|
||||||
|
.locator(".growing-slot")
|
||||||
|
.nth(i)
|
||||||
|
.getByRole("button", { name: "Plant starter seed", exact: true }),
|
||||||
|
);
|
||||||
|
const original = await page
|
||||||
|
.locator(".growing-slot")
|
||||||
|
.first()
|
||||||
|
.locator(".slot-info p")
|
||||||
|
.innerText();
|
||||||
|
await page.waitForFunction(
|
||||||
|
(text) =>
|
||||||
|
document.querySelector(".growing-slot .slot-info p")?.textContent !==
|
||||||
|
text,
|
||||||
|
original,
|
||||||
|
{ timeout: 5000 },
|
||||||
|
);
|
||||||
|
check("Live server-synchronized countdown advances between state polls");
|
||||||
|
check(
|
||||||
|
"Cryptographic action IDs work without secure-context randomUUID (HTTP LAN compatibility)",
|
||||||
|
);
|
||||||
|
await page
|
||||||
|
.locator(".growing-slot")
|
||||||
|
.first()
|
||||||
|
.getByRole("button", { name: "Harvest plant" })
|
||||||
|
.waitFor({ timeout: 30000 });
|
||||||
|
const harvested = await actionClick(
|
||||||
|
page
|
||||||
|
.locator(".growing-slot")
|
||||||
|
.first()
|
||||||
|
.getByRole("button", { name: "Harvest plant" }),
|
||||||
|
);
|
||||||
|
await page.getByRole("heading", { name: "A first of its kind." }).waitFor();
|
||||||
|
await page.screenshot({
|
||||||
|
path: directory + "/first-discovery.png",
|
||||||
|
fullPage: true,
|
||||||
|
});
|
||||||
|
await quest("First green");
|
||||||
|
await go("/plants/" + harvested.plantId);
|
||||||
|
await page.getByLabel("Favorite", { exact: true }).check();
|
||||||
|
await page
|
||||||
|
.getByRole("button", { name: "Protected from sale", exact: true })
|
||||||
|
.waitFor();
|
||||||
|
assert(
|
||||||
|
await page
|
||||||
|
.getByRole("button", { name: "Protected from sale", exact: true })
|
||||||
|
.isDisabled(),
|
||||||
|
);
|
||||||
|
await page.getByLabel("Favorite", { exact: true }).uncheck();
|
||||||
|
await page.getByRole("button", { name: /Sell specimen/ }).click();
|
||||||
|
await actionClick(
|
||||||
|
page.getByRole("button", { name: "Confirm sale", exact: true }),
|
||||||
|
);
|
||||||
|
await page
|
||||||
|
.getByRole("button", { name: "Sold · lineage preserved" })
|
||||||
|
.waitFor();
|
||||||
|
await quest("A little seed money");
|
||||||
|
check(
|
||||||
|
"Harvest, discovery, favorite safeguard, confirmed sale and one-time quest claims",
|
||||||
|
);
|
||||||
|
await go("/shop");
|
||||||
|
await actionClick(
|
||||||
|
page
|
||||||
|
.locator(".game-card")
|
||||||
|
.filter({
|
||||||
|
has: page.getByRole("heading", {
|
||||||
|
name: "Extra growing slot",
|
||||||
|
exact: true,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.getByRole("button", { name: "Purchase", exact: true }),
|
||||||
|
);
|
||||||
|
await quest("Room to grow");
|
||||||
|
await go("/shop");
|
||||||
|
await actionClick(
|
||||||
|
page
|
||||||
|
.locator(".game-card")
|
||||||
|
.filter({
|
||||||
|
has: page.getByRole("heading", { name: "Starter seed", exact: true }),
|
||||||
|
})
|
||||||
|
.getByRole("button", { name: "Purchase", exact: true }),
|
||||||
|
);
|
||||||
|
check("Exact-price seed and growing-slot purchases");
|
||||||
|
await go("/");
|
||||||
|
assert.equal(await page.locator(".growing-slot").count(), 5);
|
||||||
|
console.log("Waiting for two real two-minute crops…");
|
||||||
|
for (const i of [1, 2]) {
|
||||||
|
const button = page
|
||||||
|
.locator(".growing-slot")
|
||||||
|
.nth(i)
|
||||||
|
.getByRole("button", { name: "Harvest plant" });
|
||||||
|
await button.waitFor({ timeout: 130000 });
|
||||||
|
await actionClick(button);
|
||||||
|
}
|
||||||
|
const plants = (await read("/plants?limit=100")).items.filter(
|
||||||
|
(p) => p.gameEligible,
|
||||||
|
);
|
||||||
|
assert.equal(plants.length, 2);
|
||||||
|
await go("/breed");
|
||||||
|
await page
|
||||||
|
.getByLabel("Parent A", { exact: true })
|
||||||
|
.selectOption(plants[0].id);
|
||||||
|
await page
|
||||||
|
.getByLabel("Parent B", { exact: true })
|
||||||
|
.selectOption(plants[1].id);
|
||||||
|
const breeding = page.waitForResponse(
|
||||||
|
(r) =>
|
||||||
|
r.url().endsWith("/api/v1/breeding") && r.request().method() === "POST",
|
||||||
|
);
|
||||||
|
await page.getByRole("button", { name: "Breed selected parents" }).click();
|
||||||
|
const bred = await breeding;
|
||||||
|
assert(bred.ok(), await bred.text());
|
||||||
|
const child = (await bred.json()).plant;
|
||||||
|
report.childId = child.id;
|
||||||
|
assert.deepEqual(child.parents, [plants[0].id, plants[1].id]);
|
||||||
|
await page.locator(".offspring-card").waitFor();
|
||||||
|
await page.screenshot({
|
||||||
|
path: directory + "/breeding-desktop.png",
|
||||||
|
fullPage: true,
|
||||||
|
});
|
||||||
|
await go("/plants/" + child.id);
|
||||||
|
await page.getByLabel("Favorite", { exact: true }).check();
|
||||||
|
await page.getByRole("button", { name: "Protected from sale" }).waitFor();
|
||||||
|
await page.reload();
|
||||||
|
await page.getByLabel("Favorite", { exact: true }).waitFor();
|
||||||
|
assert(await page.getByLabel("Favorite", { exact: true }).isChecked());
|
||||||
|
check("Paid inherited breeding, persisted lineage and favorite child");
|
||||||
|
await go("/lab?plant=" + child.id);
|
||||||
|
await page.locator(".render-workbench").waitFor();
|
||||||
|
assert((await page.locator(".plant-preview svg").count()) > 0);
|
||||||
|
await go("/studies");
|
||||||
|
assert((await page.locator(".plant-preview svg").count()) > 0);
|
||||||
|
await go("/examples");
|
||||||
|
await page.locator(".game-card").first().waitFor();
|
||||||
|
assert.equal(await page.locator(".game-card").count(), 100);
|
||||||
|
await page
|
||||||
|
.getByRole("link", { name: "Inspect reference →", exact: true })
|
||||||
|
.first()
|
||||||
|
.click();
|
||||||
|
await page
|
||||||
|
.getByRole("heading", { name: "Genotype & phenotype", exact: true })
|
||||||
|
.waitFor();
|
||||||
|
assert.equal(await page.locator(".plant-economy-actions").count(), 0);
|
||||||
|
await go("/examples");
|
||||||
|
await page
|
||||||
|
.getByRole("button", { name: "More references →", exact: true })
|
||||||
|
.click();
|
||||||
|
await page.waitForFunction(
|
||||||
|
() => document.querySelectorAll(".game-card").length === 3,
|
||||||
|
);
|
||||||
|
check(
|
||||||
|
"Advanced genome lab, deterministic renderer atlas and preserved reference herbarium",
|
||||||
|
);
|
||||||
|
// Explicit, UUID-scoped fixtures apply only to this newly registered QA account.
|
||||||
|
// The real onboarding above uses no time/balance shortcuts. Integration tests cover worker resources with a controlled clock.
|
||||||
|
if (process.env.GAME_QA_FIXTURES === "1") {
|
||||||
|
assert.match(report.playerId, /^[a-f0-9-]{36}$/);
|
||||||
|
sql(
|
||||||
|
`UPDATE players SET xp=3000 WHERE id='${report.playerId}'; WITH funded AS (UPDATE players SET coin_balance=coin_balance+20000 WHERE id='${report.playerId}' RETURNING id,coin_balance) INSERT INTO coin_ledger(id,player_id,amount,balance_after,reason,reference) SELECT gen_random_uuid(),id,20000,coin_balance,'qa_fixture','m11-browser' FROM funded;`,
|
||||||
|
);
|
||||||
|
report.fixtures.push(
|
||||||
|
"Only new QA account: level 10 and ledger-recorded 20,000-coin grant for late-game UI",
|
||||||
|
);
|
||||||
|
await go("/shop");
|
||||||
|
for (const name of ["Grow lamp", "Irrigation", "Second greenhouse"])
|
||||||
|
await actionClick(
|
||||||
|
page
|
||||||
|
.locator(".game-card")
|
||||||
|
.filter({ has: page.getByRole("heading", { name, exact: true }) })
|
||||||
|
.getByRole("button", { name: "Purchase", exact: true }),
|
||||||
|
);
|
||||||
|
await go("/research");
|
||||||
|
for (const name of [
|
||||||
|
"Living compost",
|
||||||
|
"Botanical notes",
|
||||||
|
"Selective breeding",
|
||||||
|
])
|
||||||
|
await actionClick(
|
||||||
|
page
|
||||||
|
.locator(".game-card")
|
||||||
|
.filter({ has: page.getByRole("heading", { name, exact: true }) })
|
||||||
|
.getByRole("button", { name: "Research", exact: true }),
|
||||||
|
);
|
||||||
|
await go("/workers");
|
||||||
|
for (const kind of ["gardener", "breeder"])
|
||||||
|
await actionClick(
|
||||||
|
page.getByRole("button", { name: "Hire " + kind, exact: true }),
|
||||||
|
);
|
||||||
|
const gardener = page.locator(".worker-card").filter({
|
||||||
|
has: page.getByRole("heading", {
|
||||||
|
name: "Junior gardener",
|
||||||
|
exact: true,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
breeder = page.locator(".worker-card").filter({
|
||||||
|
has: page.getByRole("heading", {
|
||||||
|
name: "Breeding specialist",
|
||||||
|
exact: true,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
await gardener.getByLabel("Enable automation", { exact: true }).check();
|
||||||
|
await gardener
|
||||||
|
.getByLabel("Sell duplicate Common harvests automatically", {
|
||||||
|
exact: true,
|
||||||
|
})
|
||||||
|
.check();
|
||||||
|
await actionClick(
|
||||||
|
gardener.getByRole("button", { name: "Save assignment", exact: true }),
|
||||||
|
);
|
||||||
|
await breeder
|
||||||
|
.getByLabel("Parent A", { exact: true })
|
||||||
|
.selectOption(plants[0].id);
|
||||||
|
await breeder
|
||||||
|
.getByLabel("Parent B", { exact: true })
|
||||||
|
.selectOption(plants[1].id);
|
||||||
|
await breeder
|
||||||
|
.getByLabel("Policy", { exact: true })
|
||||||
|
.selectOption("target_trait");
|
||||||
|
await breeder
|
||||||
|
.getByLabel("Desired pigment", { exact: true })
|
||||||
|
.selectOption(child.phenotype.pigment);
|
||||||
|
await breeder.getByLabel("Enable automation", { exact: true }).check();
|
||||||
|
await actionClick(
|
||||||
|
breeder.getByRole("button", { name: "Save assignment", exact: true }),
|
||||||
|
);
|
||||||
|
await page.screenshot({
|
||||||
|
path: directory + "/workers-desktop.png",
|
||||||
|
fullPage: true,
|
||||||
|
});
|
||||||
|
sql(
|
||||||
|
`UPDATE workers SET last_processed_at=last_processed_at-interval '1 hour' WHERE player_id='${report.playerId}';`,
|
||||||
|
);
|
||||||
|
report.fixtures.push(
|
||||||
|
"Only QA workers: persisted cursors shifted back one hour",
|
||||||
|
);
|
||||||
|
const s = await read("/game/state");
|
||||||
|
assert(s.report && s.report.harvested > 0 && s.report.breeds > 0);
|
||||||
|
const after = await read("/game/state");
|
||||||
|
assert.equal(after.report, null);
|
||||||
|
await go("/workers");
|
||||||
|
check(
|
||||||
|
"Late-game fixtures: upgrades, second house, research, worker policies and replay-safe hour offline",
|
||||||
|
);
|
||||||
|
// Leave QA workers paused so automated acceptance does not keep producing records.
|
||||||
|
sql(
|
||||||
|
`UPDATE workers SET enabled=false WHERE player_id='${report.playerId}';`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (const width of [1440, 360]) {
|
||||||
|
await page.setViewportSize({ width, height: 1000 });
|
||||||
|
for (const route of [
|
||||||
|
"/",
|
||||||
|
"/shop",
|
||||||
|
"/gallery",
|
||||||
|
"/breed",
|
||||||
|
"/workers",
|
||||||
|
"/research",
|
||||||
|
"/quests",
|
||||||
|
"/settings",
|
||||||
|
"/lab",
|
||||||
|
]) {
|
||||||
|
await go(route);
|
||||||
|
const overflow = await page.evaluate(
|
||||||
|
() => document.documentElement.scrollWidth - innerWidth,
|
||||||
|
);
|
||||||
|
report.widths.push({ route, width, overflow });
|
||||||
|
assert(overflow <= 1, `${route} overflow ${overflow}px at ${width}`);
|
||||||
|
if (route === "/")
|
||||||
|
await page.screenshot({
|
||||||
|
path: directory + "/greenhouse-" + width + ".png",
|
||||||
|
fullPage: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
check("All gameplay routes usable at 360px and 1440px with reduced motion");
|
||||||
|
await go("/settings");
|
||||||
|
await page.getByRole("button", { name: "Sign out", exact: true }).click();
|
||||||
|
await page
|
||||||
|
.getByRole("button", { name: "Enter greenhouse", exact: true })
|
||||||
|
.waitFor();
|
||||||
|
await page.getByLabel("Username", { exact: true }).fill(username);
|
||||||
|
await page.getByLabel("Password", { exact: true }).fill(password);
|
||||||
|
await page
|
||||||
|
.getByRole("button", { name: "Enter greenhouse", exact: true })
|
||||||
|
.click();
|
||||||
|
await page.locator(".game-hud").waitFor();
|
||||||
|
assert.equal((await read("/me")).id, report.playerId);
|
||||||
|
check("Logout and password login restore only the same account");
|
||||||
|
const other = await browser.newContext(),
|
||||||
|
otherPage = await other.newPage();
|
||||||
|
await otherPage.goto(base);
|
||||||
|
const denied = await other.request.get(
|
||||||
|
base + "/api/v1/plants/" + report.childId,
|
||||||
|
);
|
||||||
|
assert.equal(denied.status(), 401);
|
||||||
|
await other.close();
|
||||||
|
check("Unauthenticated collection access denied");
|
||||||
|
if (process.env.GAME_PUBLIC_URL) {
|
||||||
|
const publicUrl = process.env.GAME_PUBLIC_URL,
|
||||||
|
remote = await browser.newContext(),
|
||||||
|
login = await remote.request.post(publicUrl + "/api/v1/auth/login", {
|
||||||
|
headers: { origin: publicUrl },
|
||||||
|
data: { username, password },
|
||||||
|
});
|
||||||
|
assert(login.ok(), "Public HTTPS login failed: " + login.status());
|
||||||
|
const me = await remote.request.get(publicUrl + "/api/v1/me");
|
||||||
|
assert(me.ok(), "Public session was not restored");
|
||||||
|
assert.equal((await me.json()).id, report.playerId);
|
||||||
|
const cookie = (await remote.cookies()).find(
|
||||||
|
(c) => c.name === "botanica_session",
|
||||||
|
);
|
||||||
|
assert(cookie?.secure && cookie?.httpOnly && cookie?.sameSite === "Lax");
|
||||||
|
await remote.close();
|
||||||
|
check(
|
||||||
|
"Public HTTPS through Cloudflare: authenticated cookie is Secure, HttpOnly and SameSite=Lax",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert.deepEqual(report.errors, []);
|
||||||
|
check("No browser JavaScript errors");
|
||||||
|
} catch (error) {
|
||||||
|
const message = String(error.message).replaceAll(password, "[redacted]");
|
||||||
|
report.failure = { url: page.url(), message };
|
||||||
|
if (await page.locator(".game-hud").count())
|
||||||
|
await page.screenshot({
|
||||||
|
path: directory + "/failure.png",
|
||||||
|
fullPage: true,
|
||||||
|
});
|
||||||
|
throw new Error(message);
|
||||||
|
} finally {
|
||||||
|
fs.writeFileSync(
|
||||||
|
directory + "/browser-report.json",
|
||||||
|
JSON.stringify(report, null, 2),
|
||||||
|
);
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
|
console.log(JSON.stringify(report, null, 2));
|
||||||
|
})().catch((e) => {
|
||||||
|
console.error(e.message);
|
||||||
|
process.exitCode = 1;
|
||||||
|
});
|
||||||