Add account-isolated greenhouse economy and bounded offline workers

This commit is contained in:
Christian Johannesen 2026-10-28 12:27:07 +01:00
parent c9b935f842
commit 8296386c7d
20 changed files with 2635 additions and 30 deletions

View file

@ -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

View 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));

View file

@ -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",

View file

@ -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
View 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.",
);
}

View file

@ -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;

View 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)),
);
}

View 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,
),
);
}

File diff suppressed because it is too large Load diff

View 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);
});
},
);

View file

@ -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,

View file

@ -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)),
); );
} }

View file

@ -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();
} }
} }
} }

View file

@ -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()),
], ],
); );

View file

@ -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; }
} }

View file

@ -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:

View file

@ -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

233
package-lock.json generated
View file

@ -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",

116
packages/shared/src/game.ts Normal file
View file

@ -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<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;
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;
}[];
}

View file

@ -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";