diff --git a/.env.example b/.env.example index 5ab629f..1e59e62 100644 --- a/.env.example +++ b/.env.example @@ -4,3 +4,6 @@ POSTGRES_DB=botanica POSTGRES_USER=botanica POSTGRES_PASSWORD=change-me-before-exposing 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 diff --git a/apps/server/migrations/0003_game_foundation.sql b/apps/server/migrations/0003_game_foundation.sql new file mode 100644 index 0000000..93e0ec1 --- /dev/null +++ b/apps/server/migrations/0003_game_foundation.sql @@ -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)); diff --git a/apps/server/package.json b/apps/server/package.json index 133a613..3a4b4d7 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -11,13 +11,14 @@ "db:seed": "tsx src/seed.ts" }, "dependencies": { - "fastify": "^5.12.7", - "zod": "^4.0.0", - "drizzle-orm": "^0.45.4", - "pg": "^8.16.3", "@botanica/genetics": "0.0.0", + "@botanica/renderer": "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": { "@types/pg": "^8.15.5", diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts index 37d0490..20c28bc 100644 --- a/apps/server/src/app.ts +++ b/apps/server/src/app.ts @@ -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 { ApiError } from "./errors.js"; import { registerPlantRoutes } from "./plant-routes.js"; @@ -10,8 +18,21 @@ export function createApp( checkDatabase: () => Promise, logger = false, pool?: Pool, + identityForTest?: (request: FastifyRequest) => Promise, ) { - 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) => { try { await checkDatabase(); @@ -27,7 +48,33 @@ export function createApp( }); registerGeneticsRoutes(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) => reply .code(404) diff --git a/apps/server/src/auth.ts b/apps/server/src/auth.ts new file mode 100644 index 0000000..fd3b11b --- /dev/null +++ b/apps/server/src/auth.ts @@ -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(); + 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 { + 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.", + ); +} diff --git a/apps/server/src/collection.ts b/apps/server/src/collection.ts index 7ba039b..15e4f3e 100644 --- a/apps/server/src/collection.ts +++ b/apps/server/src/collection.ts @@ -44,7 +44,7 @@ export async function listCollection( trait("pigment") + " AS resolved_pigment, " + 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[] = []; for (const [column, value] of [ ["species_id", query.species], @@ -159,7 +159,7 @@ export async function collectionStats( rarity_tier: NonNullable; 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], ) ).rows; diff --git a/apps/server/src/game-config.ts b/apps/server/src/game-config.ts new file mode 100644 index 0000000..a3458de --- /dev/null +++ b/apps/server/src/game-config.ts @@ -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 = { + 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)), + ); +} diff --git a/apps/server/src/game-routes.ts b/apps/server/src/game-routes.ts new file mode 100644 index 0000000..88027c2 --- /dev/null +++ b/apps/server/src/game-routes.ts @@ -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, + ), + ); +} diff --git a/apps/server/src/game-service.ts b/apps/server/src/game-service.ts new file mode 100644 index 0000000..5326734 --- /dev/null +++ b/apps/server/src/game-service.ts @@ -0,0 +1,1076 @@ +import { randomUUID } from "node:crypto"; +import type { Pool, PoolClient } from "pg"; +import { z } from "zod"; +import { resolvePhenotype, canBreed, type Genome } from "@botanica/genetics"; +import { + seedTypes, + type GameState, + type GameAction, + type WorkerPolicy, + type OfflineSummary, + type GameWorker, +} from "@botanica/shared"; +import { + GAME, + SEEDS, + QUESTS, + RESEARCH, + levelFor, + saleValue, + nextSlotCost, +} from "./game-config.js"; +import { PlantRepository, insertPlant, generateSeed } from "./plants.js"; +import { ApiError } from "./errors.js"; +import { digest, type Identity } from "./auth.js"; +type Client = PoolClient; +interface Player { + id: string; + coin_balance: number; + xp: number; + game_stats: Record; +} +interface Worker { + id: string; + kind: "gardener" | "breeder"; + greenhouse_id: string | null; + enabled: boolean; + policy: WorkerPolicy; + last_processed_at: Date; + irrigation?: boolean; +} +function fail(code: string, message: string): never { + throw new ApiError(409, code, message); +} +const idSchema = z.strictObject({ plantId: z.uuid() }); +const assignSchema = z.strictObject({ + workerId: z.uuid(), + greenhouseId: z.uuid(), + enabled: z.boolean(), + policy: z.strictObject({ + mode: z.enum(["repeat_crop", "repeat_cross", "target_trait"]), + seedType: z.enum(seedTypes).default("starter"), + parentAId: z.uuid().optional(), + parentBId: z.uuid().optional(), + targetPigment: z.enum(["green", "purple", "red", "yellow"]).optional(), + targetPattern: z.enum(["solid", "variegated", "marbled"]).optional(), + autoSell: z.boolean().default(false), + reserveCoins: z.number().int().min(0).max(1000000).default(100), + }), +}); +export class GameService { + constructor( + private pool: Pool, + private clock: () => Date = () => new Date(), + ) {} + async transaction( + owner: string, + operation: (c: Client, p: Player, now: Date) => Promise, + ) { + const c = await this.pool.connect(); + try { + await c.query("BEGIN"); + const p = ( + await c.query( + "SELECT id,coin_balance,xp,game_stats FROM players WHERE id=$1 FOR UPDATE", + [owner], + ) + ).rows[0]; + if (!p) throw new ApiError(401, "AUTH_REQUIRED", "Sign in again."); + const result = await operation(c, p, this.clock()); + await c.query("COMMIT"); + return result; + } catch (error) { + await c.query("ROLLBACK"); + throw error; + } finally { + c.release(); + } + } + async money( + c: Client, + p: Player, + amount: number, + reason: string, + reference: string, + ) { + if (!Number.isSafeInteger(amount)) throw new Error("Invalid ledger amount"); + const row = ( + await c.query( + "UPDATE players SET coin_balance=coin_balance+$2 WHERE id=$1 AND coin_balance+$2 BETWEEN 0 AND 1000000000 RETURNING coin_balance", + [p.id, amount], + ) + ).rows[0]; + if (!row) + fail( + "INSUFFICIENT_COINS", + "Not enough coins, or the coin storage limit was reached.", + ); + p.coin_balance = row.coin_balance; + await c.query( + "INSERT INTO coin_ledger(id,player_id,amount,balance_after,reason,reference) VALUES($1,$2,$3,$4,$5,$6)", + [randomUUID(), p.id, amount, p.coin_balance, reason, reference], + ); + } + async progress(c: Client, p: Player, stat: string, amount = 1, xp = 0) { + p.game_stats[stat] = (p.game_stats[stat] ?? 0) + amount; + p.xp += xp; + await c.query("UPDATE players SET game_stats=$2,xp=$3 WHERE id=$1", [ + p.id, + JSON.stringify(p.game_stats), + p.xp, + ]); + } + requireLevel(p: Player, level: number) { + if (levelFor(p.xp).level < level) + fail("LEVEL_LOCKED", "Reach level " + level + " to unlock this action."); + } + async researched(c: Client, owner: string, id: string) { + return !!( + await c.query( + "SELECT 1 FROM player_research WHERE player_id=$1 AND research_id=$2", + [owner, id], + ) + ).rowCount; + } + async discovery( + c: Client, + p: Player, + plant: { id: string; phenotype: ReturnType }, + ) { + const { catalogVersion, speciesId, ...traits } = plant.phenotype; + const signature = JSON.stringify({ speciesId, catalogVersion, ...traits }); + if ( + ( + await c.query( + "INSERT INTO discoveries(player_id,signature,plant_id) VALUES($1,$2,$3) ON CONFLICT DO NOTHING RETURNING signature", + [p.id, signature, plant.id], + ) + ).rowCount + ) { + await c.query( + "UPDATE plants SET first_discovery=true WHERE id=$1 AND owner_id=$2", + [plant.id, p.id], + ); + await this.money(c, p, 10, "discovery", plant.id); + await this.progress( + c, + p, + "discoveries", + 1, + 25 + ((await this.researched(c, p.id, "botany")) ? 10 : 0), + ); + return true; + } + return false; + } + async plant( + c: Client, + p: Player, + slotId: string, + seedType: keyof typeof SEEDS, + now: Date, + ) { + const slot = ( + await c.query( + "SELECT s.*,g.lamp FROM plant_slots s JOIN greenhouses g ON g.id=s.greenhouse_id WHERE s.id=$1 AND g.player_id=$2 FOR UPDATE OF s", + [slotId, p.id], + ) + ).rows[0]; + if (!slot) + throw new ApiError(404, "SLOT_NOT_FOUND", "Growing slot not found."); + if (slot.seed_type) + fail("SLOT_OCCUPIED", "This slot already has a growing plant."); + const used = await c.query( + "UPDATE inventory_seeds SET quantity=quantity-1 WHERE player_id=$1 AND seed_type=$2 AND quantity>0 RETURNING quantity", + [p.id, seedType], + ); + if (!used.rowCount) + fail( + "NO_SEEDS", + "No " + + seedType + + " seeds available. Harvest a crop to recycle a starter seed.", + ); + this.requireLevel(p, SEEDS[seedType].level); + const seconds = + (p.game_stats.planted ?? 0) === 0 + ? GAME.firstGrowSeconds + : SEEDS[seedType].seconds; + await c.query( + "UPDATE plant_slots SET seed_type=$2,genome=$3,visual_seed=$4,planted_at=$5,ready_at=$6 WHERE id=$1", + [ + slotId, + seedType, + JSON.stringify(SEEDS[seedType].genome), + generateSeed(), + now, + new Date(now.getTime() + seconds * 1000 * (slot.lamp ? 0.9 : 1)), + ], + ); + await this.progress(c, p, "planted"); + return { message: "Seed planted.", slotId }; + } + async harvest(c: Client, p: Player, slotId: string, now: Date) { + const slot = ( + await c.query( + "SELECT s.* FROM plant_slots s JOIN greenhouses g ON g.id=s.greenhouse_id WHERE s.id=$1 AND g.player_id=$2 FOR UPDATE OF s", + [slotId, p.id], + ) + ).rows[0]; + if (!slot) + throw new ApiError(404, "SLOT_NOT_FOUND", "Growing slot not found."); + if (!slot.seed_type) fail("NOT_GROWING", "This slot is empty."); + if (new Date(slot.ready_at) > now) + fail("NOT_READY", "This plant is still growing."); + const plant = (await insertPlant( + c, + { + name: + "Velvetleaf " + + String((p.game_stats.harvested ?? 0) + 1).padStart(3, "0"), + speciesId: "velvetleaf", + genome: slot.genome, + visualSeed: slot.visual_seed, + creationSource: "designed", + }, + p.id, + ))!; + await c.query( + "UPDATE plants SET game_eligible=true WHERE id=$1 AND owner_id=$2", + [plant.id, p.id], + ); + await c.query( + "INSERT INTO inventory_seeds VALUES($1,'starter',1) ON CONFLICT(player_id,seed_type) DO UPDATE SET quantity=inventory_seeds.quantity+1", + [p.id], + ); + await c.query( + "UPDATE plant_slots SET seed_type=NULL,genome=NULL,visual_seed=NULL,planted_at=NULL,ready_at=NULL WHERE id=$1", + [slotId], + ); + await this.progress( + c, + p, + "harvested", + 1, + 35 + ((await this.researched(c, p.id, "compost")) ? 10 : 0), + ); + const discovered = await this.discovery(c, p, plant); + return { + message: discovered + ? "A new discovery! Plant harvested; one starter seed recycled." + : "Plant harvested; one starter seed recycled.", + plantId: plant.id, + discovered, + }; + } + async eligibleParents(c: Client, owner: string, a: string, b: string) { + if (a === b) fail("SAME_PARENT", "Choose two different parents."); + const rows = ( + await c.query( + "SELECT * FROM plants WHERE owner_id=$1 AND id=ANY($2::uuid[]) AND sold_at IS NULL AND game_eligible=true", + [owner, [a, b]], + ) + ).rows; + if (rows.length !== 2) + throw new ApiError( + 404, + "PARENT_UNAVAILABLE", + "Choose two owned, unsold game-grown plants. Lab studies cannot be used for game rewards.", + ); + if (!canBreed(rows[0].species_id, rows[1].species_id).compatible) + fail("INCOMPATIBLE_SPECIES", "These species are not compatible."); + } + async crossIn( + c: Client, + p: Player, + input: { parentAId: string; parentBId: string; idempotencyKey: string }, + ) { + this.requireLevel(p, 3); + await this.eligibleParents(c, p.id, input.parentAId, input.parentBId); + await this.money(c, p, -GAME.breedCost, "breeding", input.idempotencyKey); + const result = await new PlantRepository(this.pool, p.id).cross(input, c); + await c.query( + "UPDATE plants SET game_eligible=true WHERE id=$1 AND owner_id=$2", + [result.plant.id, p.id], + ); + result.plant.gameEligible = true; + await this.progress(c, p, "crosses", 1, 50); + await this.discovery(c, p, result.plant); + result.plant = await new PlantRepository(this.pool, p.id).getPlant( + result.plant.id, + c, + ); + return result; + } + async sell( + c: Client, + p: Player, + plantId: string, + automatic = false, + now = this.clock(), + ) { + const plant = ( + await c.query( + "SELECT * FROM plants WHERE id=$1 AND owner_id=$2 FOR UPDATE", + [plantId, p.id], + ) + ).rows[0]; + if (!plant || plant.sold_at) + fail("PLANT_UNAVAILABLE", "This plant is unavailable or already sold."); + if (!plant.game_eligible) + fail( + "STUDY_ONLY", + "Laboratory and demo studies are not salable game plants.", + ); + if (plant.is_favorite || plant.is_protected) + fail("PROTECTED_PLANT", "Remove favorite/protection before selling."); + const value = saleValue(plant.rarity_tier, plant.rarity_score); + if (value === null) + fail( + "MYTHIC_KEEPSAKE", + "Mythic specimens are keepsakes and cannot be sold.", + ); + if (automatic) { + if (plant.first_discovery || plant.rarity_tier !== "Common") return false; + const count = Number( + ( + await c.query( + "SELECT count(*) FROM plants WHERE owner_id=$1 AND species_id=$2 AND sold_at IS NULL AND game_eligible=true", + [p.id, plant.species_id], + ) + ).rows[0].count, + ); + if (count <= 1) return false; + } + await c.query("UPDATE plants SET sold_at=$3 WHERE id=$1 AND owner_id=$2", [ + plantId, + p.id, + now, + ]); + await this.money(c, p, value, "sale", plantId); + await this.progress(c, p, "sold", 1, 10); + return { message: "Plant sold for " + value + " coins.", coins: value }; + } + async shop(c: Client, p: Player) { + const houses = ( + await c.query( + "SELECT * FROM greenhouses WHERE player_id=$1 ORDER BY id", + [p.id], + ) + ).rows, + house = houses[0]; + const level = levelFor(p.xp).level; + const items = [ + ...Object.entries(SEEDS).map(([id, s]) => ({ + id: "seed_" + id, + name: s.name, + description: s.description, + cost: s.cost, + level: s.level, + })), + { + id: "slot", + name: "Extra growing slot", + description: "Add one slot to the selected greenhouse (maximum 12).", + cost: nextSlotCost(house?.slots ?? 4) ?? 2000, + level: 2, + }, + { + id: "lamp", + name: "Grow lamp", + description: + "10% faster growth. Remaining time of current crops is reduced once.", + cost: 300, + level: 2, + }, + { + id: "irrigation", + name: "Irrigation", + description: "Assigned gardeners work every 96 seconds instead of 120.", + cost: 650, + level: 3, + }, + { + id: "greenhouse", + name: "Second greenhouse", + description: "Six additional growing slots.", + cost: 2500, + level: 5, + }, + ]; + return items.map((item) => ({ + ...item, + disabledReason: + level < item.level + ? "Unlocks at level " + item.level + : item.id === "greenhouse" && houses.length >= 2 + ? "Both greenhouses owned." + : null, + })); + } + async purchase(c: Client, p: Player, data: unknown, now: Date) { + const input = z + .strictObject({ itemId: z.string(), houseId: z.uuid().optional() }) + .parse(data), + item = (await this.shop(c, p)).find((i) => i.id === input.itemId); + if (!item) fail("UNKNOWN_ITEM", "Unknown shop item."); + this.requireLevel(p, item.level); + const house = ( + await c.query( + "SELECT * FROM greenhouses WHERE player_id=$1 AND ($2::uuid IS NULL OR id=$2) ORDER BY id LIMIT 1", + [p.id, input.houseId ?? null], + ) + ).rows[0]; + if (!house) fail("HOUSE_NOT_FOUND", "Select your greenhouse."); + let cost = item.cost; + if (item.id === "slot") { + if (house.slots >= 12) + fail("SLOTS_FULL", "This greenhouse already has 12 slots."); + cost = nextSlotCost(house.slots)!; + } + if ( + (item.id === "lamp" && house.lamp) || + (item.id === "irrigation" && house.irrigation) + ) + fail("ALREADY_OWNED", "This equipment is already installed."); + if (item.disabledReason) fail("ITEM_UNAVAILABLE", item.disabledReason); + await this.money(c, p, -cost, "purchase", item.id); + if (item.id.startsWith("seed_")) { + const seed = item.id.slice(5); + await c.query( + "INSERT INTO inventory_seeds VALUES($1,$2,1) ON CONFLICT(player_id,seed_type) DO UPDATE SET quantity=inventory_seeds.quantity+1", + [p.id, seed], + ); + } else if (item.id === "slot") { + await c.query("UPDATE greenhouses SET slots=slots+1 WHERE id=$1", [ + house.id, + ]); + await c.query( + "INSERT INTO plant_slots(id,greenhouse_id,slot_no) VALUES($1,$2,$3)", + [randomUUID(), house.id, house.slots], + ); + await this.progress(c, p, "slotsBought"); + } else if (item.id === "lamp") { + await c.query("UPDATE greenhouses SET lamp=true WHERE id=$1", [house.id]); + await c.query( + "UPDATE plant_slots SET ready_at=$2::timestamptz+(ready_at-$2::timestamptz)*0.9 WHERE greenhouse_id=$1 AND ready_at>$2", + [house.id, now], + ); + } else if (item.id === "irrigation") + await c.query("UPDATE greenhouses SET irrigation=true WHERE id=$1", [ + house.id, + ]); + else { + const id = randomUUID(); + await c.query( + "INSERT INTO greenhouses(id,player_id,name,slots) VALUES($1,$2,$3,6)", + [id, p.id, "The Conservatory"], + ); + for (let i = 0; i < 6; i++) + await c.query( + "INSERT INTO plant_slots(id,greenhouse_id,slot_no) VALUES($1,$2,$3)", + [randomUUID(), id, i], + ); + } + await this.progress(c, p, "purchases"); + return { message: item.name + " purchased.", cost }; + } + async questState(c: Client, p: Player) { + const claimed = new Set( + ( + await c.query("SELECT quest_id FROM player_quests WHERE player_id=$1", [ + p.id, + ]) + ).rows.map((r) => r.quest_id), + ); + const kept = Number( + ( + await c.query( + "SELECT count(*) FROM plants WHERE owner_id=$1 AND game_eligible AND sold_at IS NULL", + [p.id], + ) + ).rows[0].count, + ), + rare = Number( + ( + await c.query( + "SELECT count(*) FROM plants WHERE owner_id=$1 AND game_eligible AND sold_at IS NULL AND rarity_score>=25", + [p.id], + ) + ).rows[0].count, + ), + houses = + (await c.query("SELECT id FROM greenhouses WHERE player_id=$1", [p.id])) + .rowCount ?? 0, + workers = + (await c.query("SELECT id FROM workers WHERE player_id=$1", [p.id])) + .rowCount ?? 0; + const stats: Record = { + ...p.game_stats, + kept, + rare, + houses, + workers, + }; + return QUESTS.map((q) => ({ + ...q, + progress: Math.min(q.target, stats[q.stat] ?? 0), + claimed: claimed.has(q.id), + })); + } + async claim(c: Client, p: Player, data: unknown) { + const { questId } = z.strictObject({ questId: z.string() }).parse(data), + quest = (await this.questState(c, p)).find((q) => q.id === questId); + if (!quest || quest.claimed || quest.progress < quest.target) + fail("QUEST_NOT_READY", "This quest is not ready to claim."); + await c.query( + "INSERT INTO player_quests(player_id,quest_id) VALUES($1,$2)", + [p.id, questId], + ); + await this.money(c, p, quest.coins, "quest", questId); + await this.progress(c, p, "quests", 1, quest.xp); + return { + message: + "Quest complete: +" + quest.coins + " coins, +" + quest.xp + " XP.", + }; + } + async research(c: Client, p: Player, data: unknown) { + const { researchId } = z + .strictObject({ researchId: z.string() }) + .parse(data), + item = RESEARCH.find((r) => r.id === researchId); + if (!item) fail("UNKNOWN_RESEARCH", "Unknown research."); + this.requireLevel(p, item.level); + if (await this.researched(c, p.id, item.id)) + fail("ALREADY_OWNED", "Research already completed."); + await this.money(c, p, -item.cost, "research", item.id); + await c.query("INSERT INTO player_research VALUES($1,$2,now())", [ + p.id, + item.id, + ]); + return { message: item.name + " researched." }; + } + async hire(c: Client, p: Player, data: unknown, now: Date) { + const { kind } = z + .strictObject({ kind: z.enum(["gardener", "breeder"]) }) + .parse(data); + this.requireLevel(p, kind === "gardener" ? 5 : 7); + if ( + Number( + ( + await c.query("SELECT count(*) FROM workers WHERE player_id=$1", [ + p.id, + ]) + ).rows[0].count, + ) >= 4 + ) + fail("WORKER_LIMIT", "Your greenhouse team is full (four workers)."); + await this.money(c, p, -GAME.workerCosts[kind], "hire", kind); + await c.query( + "INSERT INTO workers(id,player_id,kind,last_processed_at) VALUES($1,$2,$3,$4)", + [randomUUID(), p.id, kind, now], + ); + return { + message: "Worker hired. Configure an assignment to enable automation.", + }; + } + async assign(c: Client, p: Player, data: unknown, now: Date) { + const input = assignSchema.parse(data), + worker = ( + await c.query("SELECT * FROM workers WHERE id=$1 AND player_id=$2", [ + input.workerId, + p.id, + ]) + ).rows[0], + house = ( + await c.query( + "SELECT id FROM greenhouses WHERE id=$1 AND player_id=$2", + [input.greenhouseId, p.id], + ) + ).rows[0]; + if (!worker || !house) + throw new ApiError( + 404, + "WORKER_NOT_FOUND", + "Worker or greenhouse not found.", + ); + if ( + (worker.kind === "gardener" && input.policy.mode !== "repeat_crop") || + (worker.kind === "breeder" && input.policy.mode === "repeat_crop") + ) + fail("INVALID_POLICY", "Choose a policy for this worker type."); + if (worker.kind === "breeder") { + if (!input.policy.parentAId || !input.policy.parentBId) + fail("PARENTS_REQUIRED", "Select two game-grown parents."); + await this.eligibleParents( + c, + p.id, + input.policy.parentAId, + input.policy.parentBId, + ); + } + this.requireLevel(p, SEEDS[input.policy.seedType].level); + if ( + input.policy.mode === "target_trait" && + !(await this.researched(c, p.id, "selection")) + ) + fail("RESEARCH_REQUIRED", "Complete Selective breeding research first."); + await c.query( + "UPDATE workers SET greenhouse_id=$3,enabled=$4,policy=$5,last_processed_at=$6 WHERE id=$1 AND player_id=$2", + [ + input.workerId, + p.id, + input.greenhouseId, + input.enabled, + JSON.stringify(input.policy), + now, + ], + ); + return { + message: input.enabled + ? "Automation enabled from now." + : "Worker paused.", + }; + } + async pending(c: Client, owner: string, now: Date) { + return !!( + await c.query( + "SELECT 1 FROM workers w LEFT JOIN greenhouses g ON g.id=w.greenhouse_id WHERE w.player_id=$1 AND w.enabled AND w.last_processed_at + make_interval(secs=> CASE WHEN w.kind='breeder' THEN 300 WHEN g.irrigation THEN 96 ELSE 120 END) <= $2 LIMIT 1", + [owner, now], + ) + ).rowCount; + } + async reconcileIn(c: Client, p: Player, now: Date) { + const workers = ( + await c.query( + "SELECT w.*,g.irrigation FROM workers w LEFT JOIN greenhouses g ON g.id=w.greenhouse_id WHERE w.player_id=$1 AND w.enabled ORDER BY w.id", + [p.id], + ) + ).rows; + const summary: OfflineSummary = { + events: 0, + harvested: 0, + sold: 0, + breeds: 0, + seedsUsed: 0, + coinDelta: 0, + notable: [], + blocked: 0, + from: now.toISOString(), + to: now.toISOString(), + }, + balance = p.coin_balance; + const starts = new Map(workers.map((w) => [w.id, w.last_processed_at])); + const runs = new Map(); + for (let event = 0; event < GAME.maxWorkerEvents; event++) { + const candidates = workers + .map((w) => ({ + w, + time: new Date( + w.last_processed_at.getTime() + + (w.kind === "breeder" ? 300 : w.irrigation ? 96 : 120) * 1000, + ), + })) + .filter( + (v) => + v.time <= now && + v.time.getTime() - starts.get(v.w.id)!.getTime() <= + GAME.catchupHours * 3600000, + ) + .sort( + (a, b) => + a.time.getTime() - b.time.getTime() || a.w.id.localeCompare(b.w.id), + ); + if (!candidates.length) break; + const { w, time } = candidates[0]; + const before = { ...summary, notable: [...summary.notable] }, + coinsBefore = p.coin_balance; + if (summary.events === 0) + summary.from = w.last_processed_at.toISOString(); + summary.events++; + summary.to = time.toISOString(); + let acted = false; + const policy = w.policy; + if (w.kind === "gardener" && w.greenhouse_id) { + const slots = ( + await c.query( + "SELECT * FROM plant_slots WHERE greenhouse_id=$1 ORDER BY slot_no", + [w.greenhouse_id], + ) + ).rows; + const ready = slots.find( + (s) => s.seed_type && new Date(s.ready_at) <= time, + ); + if (ready) { + const result = await this.harvest(c, p, ready.id, time); + summary.harvested++; + acted = true; + if (result.discovered) summary.notable.push(result.plantId); + if (policy.autoSell) { + const plant = ( + await c.query( + "SELECT * FROM plants WHERE id=$1 AND owner_id=$2", + [result.plantId, p.id], + ) + ).rows[0]; + if ( + !plant.first_discovery && + plant.rarity_tier === "Common" && + !plant.is_favorite && + !plant.is_protected && + (await this.sell(c, p, plant.id, true, time)) + ) + summary.sold++; + } + const qty = + ( + await c.query( + "SELECT quantity FROM inventory_seeds WHERE player_id=$1 AND seed_type=$2", + [p.id, policy.seedType], + ) + ).rows[0]?.quantity ?? 0; + if (qty > 0) { + await this.plant(c, p, ready.id, policy.seedType, time); + summary.seedsUsed++; + } + } else { + const empty = slots.find((s) => !s.seed_type), + qty = + ( + await c.query( + "SELECT quantity FROM inventory_seeds WHERE player_id=$1 AND seed_type=$2", + [p.id, policy.seedType], + ) + ).rows[0]?.quantity ?? 0; + if (empty && qty > 0) { + await this.plant(c, p, empty.id, policy.seedType, time); + summary.seedsUsed++; + acted = true; + } + } + } else if ( + w.kind === "breeder" && + policy.parentAId && + policy.parentBId && + p.coin_balance >= GAME.breedCost + policy.reserveCoins + ) { + const valid = ( + await c.query( + "SELECT count(*) FROM plants WHERE owner_id=$1 AND id=ANY($2::uuid[]) AND game_eligible AND sold_at IS NULL", + [p.id, [policy.parentAId, policy.parentBId]], + ) + ).rows[0]; + if (Number(valid.count) === 2) { + const result = await this.crossIn(c, p, { + parentAId: policy.parentAId, + parentBId: policy.parentBId, + idempotencyKey: randomUUID(), + }); + summary.breeds++; + acted = true; + if (result.plant.rarity.score >= 25) + summary.notable.push(result.plant.id); + if ( + policy.mode === "target_trait" && + (!policy.targetPigment || + result.plant.phenotype.pigment === policy.targetPigment) && + (!policy.targetPattern || + result.plant.phenotype.leafPattern === policy.targetPattern) + ) + await c.query( + "UPDATE plants SET is_protected=true WHERE id=$1 AND owner_id=$2", + [result.plant.id, p.id], + ); + } + } + if (!acted) summary.blocked++; + const run = runs.get(w.id) ?? { + events: 0, + harvested: 0, + sold: 0, + breeds: 0, + seedsUsed: 0, + coinDelta: 0, + notable: [], + blocked: 0, + from: starts.get(w.id)!.toISOString(), + to: time.toISOString(), + }; + for (const metric of [ + "events", + "harvested", + "sold", + "breeds", + "seedsUsed", + "blocked", + ] as const) + run[metric] += summary[metric] - before[metric]; + run.coinDelta += p.coin_balance - coinsBefore; + run.notable.push(...summary.notable.slice(before.notable.length)); + run.to = time.toISOString(); + runs.set(w.id, run); + w.last_processed_at = time; + await c.query( + "UPDATE workers SET last_processed_at=$3 WHERE id=$1 AND player_id=$2", + [w.id, p.id, time], + ); + } + summary.coinDelta = p.coin_balance - balance; + for (const w of workers) + if (w.last_processed_at > starts.get(w.id)!) { + await c.query( + "INSERT INTO worker_runs(id,player_id,worker_id,interval_start,interval_end,summary) VALUES($1,$2,$3,$4,$5,$6)", + [ + randomUUID(), + p.id, + w.id, + starts.get(w.id), + w.last_processed_at, + JSON.stringify(runs.get(w.id)), + ], + ); + } + return summary.events ? summary : null; + } + async reconcile(owner: string) { + return this.transaction(owner, (c, p, now) => this.reconcileIn(c, p, now)); + } + async action(owner: string, input: GameAction) { + await this.reconcile(owner); + return this.transaction(owner, async (c, p, now) => { + const fingerprint = digest( + JSON.stringify({ action: input.action, data: input.data }), + ), + existing = ( + await c.query( + "SELECT * FROM game_receipts WHERE player_id=$1 AND request_id=$2", + [owner, input.requestId], + ) + ).rows[0]; + if (existing) { + if (existing.body_hash !== fingerprint) + fail( + "IDEMPOTENCY_CONFLICT", + "This request ID belongs to another action.", + ); + return existing.response; + } + if (await this.pending(c, owner, now)) + fail( + "OFFLINE_PENDING", + "Process the remaining offline batch before performing a new action.", + ); + let result: unknown; + switch (input.action) { + case "plant": { + const data = z + .strictObject({ slotId: z.uuid(), seedType: z.enum(seedTypes) }) + .parse(input.data); + result = await this.plant(c, p, data.slotId, data.seedType, now); + break; + } + case "harvest": { + const data = z.strictObject({ slotId: z.uuid() }).parse(input.data); + result = await this.harvest(c, p, data.slotId, now); + break; + } + case "purchase": + result = await this.purchase(c, p, input.data, now); + break; + case "sell": + result = await this.sell( + c, + p, + idSchema.parse(input.data).plantId, + false, + now, + ); + break; + case "flags": { + const data = idSchema + .extend({ favorite: z.boolean(), protected: z.boolean() }) + .parse(input.data); + if ( + !( + await c.query( + "UPDATE plants SET is_favorite=$3,is_protected=$4 WHERE id=$1 AND owner_id=$2 AND sold_at IS NULL RETURNING id", + [data.plantId, owner, data.favorite, data.protected], + ) + ).rowCount + ) + throw new ApiError(404, "PLANT_NOT_FOUND", "Plant unavailable."); + result = { message: "Collection safeguards updated." }; + break; + } + case "claim": + result = await this.claim(c, p, input.data); + break; + case "research": + result = await this.research(c, p, input.data); + break; + case "hire": + result = await this.hire(c, p, input.data, now); + break; + case "assign": + result = await this.assign(c, p, input.data, now); + break; + } + await c.query( + "INSERT INTO game_receipts(player_id,request_id,operation,body_hash,response) VALUES($1,$2,$3,$4,$5)", + [ + owner, + input.requestId, + input.action, + fingerprint, + JSON.stringify(result), + ], + ); + return result; + }); + } + async cross( + owner: string, + input: { parentAId: string; parentBId: string; idempotencyKey: string }, + ) { + await this.reconcile(owner); + return this.transaction(owner, async (c, p, now) => { + const key = input.idempotencyKey, + hash = digest(JSON.stringify(input)), + existing = ( + await c.query( + "SELECT * FROM game_receipts WHERE player_id=$1 AND request_id=$2", + [owner, key], + ) + ).rows[0]; + if (existing) { + if (existing.body_hash !== hash) + fail("IDEMPOTENCY_CONFLICT", "This key belongs to another cross."); + return existing.response; + } + if (await this.pending(c, owner, now)) + fail("OFFLINE_PENDING", "Process remaining offline work first."); + const result = await this.crossIn(c, p, input); + await c.query( + "INSERT INTO game_receipts(player_id,request_id,operation,body_hash,response) VALUES($1,$2,'cross',$3,$4)", + [owner, key, hash, JSON.stringify(result)], + ); + return result; + }); + } + async state(identity: Identity): Promise { + return this.transaction(identity.id, async (c, p, now) => { + const report = await this.reconcileIn(c, p, now), + houses = ( + await c.query( + "SELECT * FROM greenhouses WHERE player_id=$1 ORDER BY name", + [p.id], + ) + ).rows, + slots = ( + await c.query( + "SELECT s.* FROM plant_slots s JOIN greenhouses g ON g.id=s.greenhouse_id WHERE g.player_id=$1 ORDER BY s.slot_no", + [p.id], + ) + ).rows, + seeds = Object.fromEntries( + ( + await c.query( + "SELECT seed_type,quantity FROM inventory_seeds WHERE player_id=$1", + [p.id], + ) + ).rows.map((r) => [r.seed_type, r.quantity]), + ), + workers = ( + await c.query( + "SELECT w.*,g.irrigation FROM workers w LEFT JOIN greenhouses g ON g.id=w.greenhouse_id WHERE w.player_id=$1 ORDER BY w.id", + [p.id], + ) + ).rows, + research = ( + await c.query( + "SELECT research_id FROM player_research WHERE player_id=$1", + [p.id], + ) + ).rows.map((r) => r.research_id); + return { + serverNow: now.toISOString(), + player: { + id: p.id, + username: identity.username, + coins: p.coin_balance, + xp: p.xp, + ...levelFor(p.xp), + csrfToken: identity.csrfToken, + }, + seeds: { starter: 0, uncommon: 0, rare: 0, ...seeds }, + greenhouses: houses.map((h) => ({ + id: h.id, + name: h.name, + slots: h.slots, + lamp: h.lamp, + irrigation: h.irrigation, + nextSlotCost: nextSlotCost(h.slots), + plants: slots + .filter((s) => s.greenhouse_id === h.id) + .map((s) => ({ + id: s.id, + slotNo: s.slot_no, + seedType: s.seed_type, + plantedAt: s.planted_at?.toISOString() ?? null, + readyAt: s.ready_at?.toISOString() ?? null, + visualSeed: s.visual_seed, + phenotype: s.genome + ? resolvePhenotype("velvetleaf", s.genome) + : null, + })), + })), + quests: await this.questState(c, p), + shop: await this.shop(c, p), + research: RESEARCH.map((r) => ({ + ...r, + owned: research.includes(r.id), + disabledReason: research.includes(r.id) + ? "Completed" + : levelFor(p.xp).level < r.level + ? "Unlocks at level " + r.level + : null, + })), + workers: workers.map( + (w) => + ({ + id: w.id, + kind: w.kind, + greenhouseId: w.greenhouse_id, + enabled: w.enabled, + policy: { + mode: w.kind === "gardener" ? "repeat_crop" : "repeat_cross", + seedType: "starter", + autoSell: false, + reserveCoins: 100, + ...(w.policy as Partial), + }, + lastProcessedAt: w.last_processed_at.toISOString(), + intervalSeconds: + w.kind === "breeder" ? 300 : w.irrigation ? 96 : 120, + }) satisfies GameWorker, + ), + report, + pendingWork: await this.pending(c, p.id, now), + recentRuns: ( + await c.query( + "SELECT id,summary FROM worker_runs WHERE player_id=$1 ORDER BY created_at DESC LIMIT 12", + [p.id], + ) + ).rows, + breedCost: GAME.breedCost, + workerCosts: GAME.workerCosts, + seedInfo: Object.entries(SEEDS).map(([id, s]) => ({ + id: id as keyof typeof SEEDS, + name: s.name, + seconds: s.seconds, + description: s.description, + })), + }; + }); + } +} diff --git a/apps/server/src/game.integration.test.ts b/apps/server/src/game.integration.test.ts new file mode 100644 index 0000000..9a81372 --- /dev/null +++ b/apps/server/src/game.integration.test.ts @@ -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 { + 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, + requestId = randomUUID(), + ) => game.action(a.identity.id, { action, data, requestId }); + const httpAction = ( + a: Account, + action: GameAction["action"], + data: Record, + ) => + 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); + }); + }, +); diff --git a/apps/server/src/persistence.integration.test.ts b/apps/server/src/persistence.integration.test.ts index 3592a76..ad40c11 100644 --- a/apps/server/src/persistence.integration.test.ts +++ b/apps/server/src/persistence.integration.test.ts @@ -11,7 +11,16 @@ describe.skipIf(!databaseUrl)( "PostgreSQL persistence and transaction acceptance", () => { 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 = ""; beforeAll(async () => { await migrate(pool); @@ -29,7 +38,7 @@ describe.skipIf(!databaseUrl)( (await pool.query("SELECT count(*) FROM schema_migrations")).rows[0] .count, ), - ).toBe(2); + ).toBe(3); expect( Number( (await pool.query("SELECT count(*) FROM players")).rows[0].count, diff --git a/apps/server/src/plant-routes.ts b/apps/server/src/plant-routes.ts index 49e3725..076decf 100644 --- a/apps/server/src/plant-routes.ts +++ b/apps/server/src/plant-routes.ts @@ -1,3 +1,5 @@ +import { GameService } from "./game-service.js"; +import { DEMO_PLAYER_ID } from "./constants.js"; import { z } from "zod"; import type { FastifyInstance } from "fastify"; import type { Pool } from "pg"; @@ -9,28 +11,40 @@ import { } from "@botanica/shared"; import { PlantRepository } from "./plants.js"; 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) => - 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) => - 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) => - 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) => reply .code(201) .send( - await repository.createPlant(createPlantSchema.parse(request.body)), + await repository(request).createPlant( + createPlantSchema.parse(request.body), + ), ), ); 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) => - repository.listEvents(pageQuerySchema.parse(request.query)), + repository(request).listEvents(pageQuerySchema.parse(request.query)), ); } diff --git a/apps/server/src/plants.ts b/apps/server/src/plants.ts index 83a357d..f10b519 100644 --- a/apps/server/src/plants.ts +++ b/apps/server/src/plants.ts @@ -22,6 +22,7 @@ import type { } from "@botanica/shared"; import { DEMO_PLAYER_ID } from "./constants.js"; import { ApiError } from "./errors.js"; +import { saleValue } from "./game-config.js"; export const generateSeed = () => randomBytes(4).readInt32BE(); export interface PlantRow extends QueryResultRow { id: string; @@ -39,6 +40,11 @@ export interface PlantRow extends QueryResultRow { created_at: Date; cursor_time?: string; sort_name?: string; + game_eligible: boolean; + first_discovery: boolean; + is_favorite: boolean; + is_protected: boolean; + sold_at: Date | null; creation_source: PlantDto["creationSource"]; rarity_policy_version: 1; rarity_score: number; @@ -81,6 +87,14 @@ function plantDto(row: PlantRow): PlantDto { : [], createdAt: row.created_at.toISOString(), 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: { score: row.rarity_score, tier: row.rarity_tier, @@ -255,10 +269,13 @@ export class PlantRepository { total, }; } - async cross(input: BreedingRequest): Promise { - const client = await this.pool.connect(); + async cross( + input: BreedingRequest, + transaction?: PoolClient, + ): Promise { + const client = transaction ?? (await this.pool.connect()); try { - await client.query("BEGIN"); + if (!transaction) await client.query("BEGIN"); await client.query( "SELECT pg_advisory_xact_lock(hashtextextended($1,0))", [this.ownerId + ":" + input.idempotencyKey], @@ -280,7 +297,7 @@ export class PlantRepository { "This request key already belongs to another parent pair.", ); const plant = await this.getPlant(existing.child_id, client); - await client.query("COMMIT"); + if (!transaction) await client.query("COMMIT"); return { plant, event: eventDto(existing) }; } if (input.parentAId === input.parentBId) @@ -292,7 +309,7 @@ export class PlantRepository { // Read owned rows before interpreting their versioned genomes. const rows = ( await client.query( - "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]], ) ).rows; @@ -302,7 +319,7 @@ export class PlantRepository { throw new ApiError( 404, "PLANT_NOT_FOUND", - "One or both parents are not in this sandbox.", + "One or both parents are unavailable in your collection.", ); try { if (!canBreed(a.species_id, b.species_id).compatible) @@ -362,13 +379,13 @@ export class PlantRepository { ], ) ).rows[0]; - await client.query("COMMIT"); + if (!transaction) await client.query("COMMIT"); return { plant, event: eventDto(event) }; } catch (error) { - await client.query("ROLLBACK"); + if (!transaction) await client.query("ROLLBACK"); throw error; } finally { - client.release(); + if (!transaction) client.release(); } } } diff --git a/apps/server/src/schema.ts b/apps/server/src/schema.ts index 3d06a2c..00039bf 100644 --- a/apps/server/src/schema.ts +++ b/apps/server/src/schema.ts @@ -2,6 +2,7 @@ import { pgTable, text, integer, + boolean, uuid, jsonb, timestamp, @@ -19,6 +20,11 @@ export const species = pgTable("species", { export const players = pgTable("players", { id: uuid().primaryKey(), 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 }) .defaultNow() .notNull(), @@ -30,6 +36,11 @@ export const plants = pgTable( name: text().notNull().default("Velvetleaf"), creationSource: text("creation_source").notNull().default("designed"), 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") .notNull() .references(() => players.id), @@ -82,7 +93,7 @@ export const breedingEvents = pgTable( }, (t) => [ 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()), ], ); diff --git a/apps/web/nginx.conf b/apps/web/nginx.conf index 36c5db7..3fb7ea5 100644 --- a/apps/web/nginx.conf +++ b/apps/web/nginx.conf @@ -7,6 +7,10 @@ server { add_header X-Content-Type-Options nosniff always; add_header Referrer-Policy same-origin always; 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; } } diff --git a/compose.test.yaml b/compose.test.yaml index f957687..bf7b798 100644 --- a/compose.test.yaml +++ b/compose.test.yaml @@ -25,6 +25,7 @@ services: "vitest", "run", "apps/server/src/persistence.integration.test.ts", + "apps/server/src/game.integration.test.ts", ] depends_on: db: diff --git a/compose.yaml b/compose.yaml index cd1b530..9271419 100644 --- a/compose.yaml +++ b/compose.yaml @@ -20,6 +20,7 @@ services: HOST_PORT: ${HOST_PORT:-8155} DATABASE_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB} 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: bootstrap: condition: service_completed_successfully diff --git a/package-lock.json b/package-lock.json index d784937..df53105 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,6 +25,7 @@ "@botanica/genetics": "0.0.0", "@botanica/renderer": "0.0.0", "@botanica/shared": "0.0.0", + "@node-rs/argon2": "^2.2.2", "drizzle-orm": "^0.45.4", "fastify": "^5.12.7", "pg": "^8.16.3", @@ -704,6 +705,238 @@ "@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": { "version": "0.155.0", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.155.0.tgz", diff --git a/packages/shared/src/game.ts b/packages/shared/src/game.ts new file mode 100644 index 0000000..8718bd6 --- /dev/null +++ b/packages/shared/src/game.ts @@ -0,0 +1,116 @@ +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; +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; + 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; + 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; + }[]; +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index e7e4ba6..9454efa 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -38,6 +38,12 @@ export const pageQuerySchema = z.strictObject({ limit: z.coerce.number().int().min(1).max(100).default(24), }); export interface PlantDto { + gameEligible?: boolean; + firstDiscovery?: boolean; + saleCoins?: number | null; + isFavorite?: boolean; + isProtected?: boolean; + soldAt?: string | null; id: string; name: string; ownerId: string; @@ -92,3 +98,4 @@ export interface CollectionStats { total: number; byRarity: Partial>; } +export * from "./game.js";