Add account-isolated greenhouse economy and bounded offline workers
This commit is contained in:
parent
c9b935f842
commit
8296386c7d
20 changed files with 2635 additions and 30 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
28
apps/server/migrations/0003_game_foundation.sql
Normal file
28
apps/server/migrations/0003_game_foundation.sql
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
ALTER TABLE players ADD COLUMN normalized_username text UNIQUE;
|
||||||
|
ALTER TABLE players ADD COLUMN password_hash text;
|
||||||
|
ALTER TABLE players ADD COLUMN coin_balance integer NOT NULL DEFAULT 0 CHECK(coin_balance BETWEEN 0 AND 1000000000);
|
||||||
|
ALTER TABLE players ADD COLUMN xp integer NOT NULL DEFAULT 0 CHECK(xp>=0);
|
||||||
|
ALTER TABLE players ADD COLUMN game_stats jsonb NOT NULL DEFAULT '{}'::jsonb;
|
||||||
|
ALTER TABLE plants ADD COLUMN game_eligible boolean NOT NULL DEFAULT false;
|
||||||
|
ALTER TABLE plants ADD COLUMN is_favorite boolean NOT NULL DEFAULT false;
|
||||||
|
ALTER TABLE plants ADD COLUMN is_protected boolean NOT NULL DEFAULT false;
|
||||||
|
ALTER TABLE plants ADD COLUMN first_discovery boolean NOT NULL DEFAULT false;
|
||||||
|
ALTER TABLE plants ADD COLUMN sold_at timestamptz;
|
||||||
|
ALTER TABLE breeding_events DROP CONSTRAINT breeding_events_idempotency_key_key;
|
||||||
|
ALTER TABLE breeding_events ADD CONSTRAINT breeding_owner_request UNIQUE(owner_id,idempotency_key);
|
||||||
|
CREATE TABLE sessions(token_hash text PRIMARY KEY,player_id uuid NOT NULL REFERENCES players(id),expires_at timestamptz NOT NULL);
|
||||||
|
CREATE INDEX sessions_owner ON sessions(player_id);
|
||||||
|
CREATE TABLE recovery_codes(player_id uuid REFERENCES players(id),code_hash text PRIMARY KEY,used_at timestamptz);
|
||||||
|
CREATE TABLE inventory_seeds(player_id uuid REFERENCES players(id),seed_type text NOT NULL,quantity integer NOT NULL CHECK(quantity>=0),PRIMARY KEY(player_id,seed_type));
|
||||||
|
CREATE TABLE greenhouses(id uuid PRIMARY KEY,player_id uuid NOT NULL REFERENCES players(id),name text NOT NULL,slots integer NOT NULL DEFAULT 4 CHECK(slots BETWEEN 4 AND 12),lamp boolean NOT NULL DEFAULT false,irrigation boolean NOT NULL DEFAULT false);
|
||||||
|
CREATE INDEX greenhouse_owner ON greenhouses(player_id);
|
||||||
|
CREATE TABLE plant_slots(id uuid PRIMARY KEY,greenhouse_id uuid NOT NULL REFERENCES greenhouses(id),slot_no integer NOT NULL,seed_type text,genome jsonb,visual_seed integer,planted_at timestamptz,ready_at timestamptz,growth_rules_version integer NOT NULL DEFAULT 1,UNIQUE(greenhouse_id,slot_no));
|
||||||
|
CREATE TABLE game_receipts(player_id uuid REFERENCES players(id),request_id uuid NOT NULL,operation text NOT NULL,body_hash text NOT NULL,response jsonb NOT NULL,created_at timestamptz NOT NULL DEFAULT now(),PRIMARY KEY(player_id,request_id));
|
||||||
|
CREATE TABLE coin_ledger(id uuid PRIMARY KEY,player_id uuid NOT NULL REFERENCES players(id),amount integer NOT NULL,balance_after integer NOT NULL CHECK(balance_after>=0),reason text NOT NULL,reference text NOT NULL,created_at timestamptz NOT NULL DEFAULT now());
|
||||||
|
CREATE INDEX ledger_owner_created ON coin_ledger(player_id,created_at DESC);
|
||||||
|
CREATE TABLE discoveries(player_id uuid REFERENCES players(id),signature text NOT NULL,plant_id uuid REFERENCES plants(id),created_at timestamptz NOT NULL DEFAULT now(),PRIMARY KEY(player_id,signature));
|
||||||
|
CREATE TABLE player_quests(player_id uuid REFERENCES players(id),quest_id text NOT NULL,claimed_at timestamptz NOT NULL DEFAULT now(),PRIMARY KEY(player_id,quest_id));
|
||||||
|
CREATE TABLE player_research(player_id uuid REFERENCES players(id),research_id text NOT NULL,created_at timestamptz NOT NULL DEFAULT now(),PRIMARY KEY(player_id,research_id));
|
||||||
|
CREATE TABLE workers(id uuid PRIMARY KEY,player_id uuid NOT NULL REFERENCES players(id),kind text NOT NULL CHECK(kind IN ('gardener','breeder')),greenhouse_id uuid REFERENCES greenhouses(id),enabled boolean NOT NULL DEFAULT false,policy jsonb NOT NULL DEFAULT '{}'::jsonb,last_processed_at timestamptz NOT NULL DEFAULT now());
|
||||||
|
CREATE INDEX worker_owner ON workers(player_id);
|
||||||
|
CREATE TABLE worker_runs(id uuid PRIMARY KEY,player_id uuid NOT NULL REFERENCES players(id),worker_id uuid NOT NULL REFERENCES workers(id),interval_start timestamptz NOT NULL,interval_end timestamptz NOT NULL,summary jsonb NOT NULL,created_at timestamptz NOT NULL DEFAULT now(),UNIQUE(worker_id,interval_start,interval_end));
|
||||||
|
|
@ -11,13 +11,14 @@
|
||||||
"db:seed": "tsx src/seed.ts"
|
"db:seed": "tsx src/seed.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"fastify": "^5.12.7",
|
|
||||||
"zod": "^4.0.0",
|
|
||||||
"drizzle-orm": "^0.45.4",
|
|
||||||
"pg": "^8.16.3",
|
|
||||||
"@botanica/genetics": "0.0.0",
|
"@botanica/genetics": "0.0.0",
|
||||||
|
"@botanica/renderer": "0.0.0",
|
||||||
"@botanica/shared": "0.0.0",
|
"@botanica/shared": "0.0.0",
|
||||||
"@botanica/renderer": "0.0.0"
|
"@node-rs/argon2": "^2.2.2",
|
||||||
|
"drizzle-orm": "^0.45.4",
|
||||||
|
"fastify": "^5.12.7",
|
||||||
|
"pg": "^8.16.3",
|
||||||
|
"zod": "^4.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/pg": "^8.15.5",
|
"@types/pg": "^8.15.5",
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,11 @@
|
||||||
|
import {
|
||||||
|
registerAuth,
|
||||||
|
authenticate,
|
||||||
|
checkCsrf,
|
||||||
|
type Identity,
|
||||||
|
} from "./auth.js";
|
||||||
|
import { registerGameRoutes } from "./game-routes.js";
|
||||||
|
import type { FastifyRequest } from "fastify";
|
||||||
import type { Pool } from "pg";
|
import type { Pool } from "pg";
|
||||||
import { ApiError } from "./errors.js";
|
import { ApiError } from "./errors.js";
|
||||||
import { registerPlantRoutes } from "./plant-routes.js";
|
import { registerPlantRoutes } from "./plant-routes.js";
|
||||||
|
|
@ -10,8 +18,21 @@ export function createApp(
|
||||||
checkDatabase: () => Promise<unknown>,
|
checkDatabase: () => Promise<unknown>,
|
||||||
logger = false,
|
logger = false,
|
||||||
pool?: Pool,
|
pool?: Pool,
|
||||||
|
identityForTest?: (request: FastifyRequest) => Promise<Identity>,
|
||||||
) {
|
) {
|
||||||
const app = Fastify({ logger, bodyLimit: 65536 });
|
const app = Fastify({
|
||||||
|
logger: logger
|
||||||
|
? {
|
||||||
|
redact: [
|
||||||
|
"req.headers.cookie",
|
||||||
|
"req.headers.authorization",
|
||||||
|
"req.headers.x-csrf-token",
|
||||||
|
"res.headers.set-cookie",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
: false,
|
||||||
|
bodyLimit: 65536,
|
||||||
|
});
|
||||||
app.get("/health", async (_request, reply) => {
|
app.get("/health", async (_request, reply) => {
|
||||||
try {
|
try {
|
||||||
await checkDatabase();
|
await checkDatabase();
|
||||||
|
|
@ -27,7 +48,33 @@ export function createApp(
|
||||||
});
|
});
|
||||||
registerGeneticsRoutes(app);
|
registerGeneticsRoutes(app);
|
||||||
registerPreviewRoute(app);
|
registerPreviewRoute(app);
|
||||||
if (pool) registerPlantRoutes(app, pool);
|
if (pool) {
|
||||||
|
registerAuth(app, pool);
|
||||||
|
app.addHook("preHandler", async (request) => {
|
||||||
|
const path = request.url.split("?")[0];
|
||||||
|
if (
|
||||||
|
!path.startsWith("/api/v1/") ||
|
||||||
|
path.startsWith("/api/v1/auth/") ||
|
||||||
|
[
|
||||||
|
"/api/v1/me",
|
||||||
|
"/api/v1/catalog/species",
|
||||||
|
"/api/v1/phenotype/resolve",
|
||||||
|
"/api/v1/preview",
|
||||||
|
].includes(path)
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
request.identity = identityForTest
|
||||||
|
? await identityForTest(request)
|
||||||
|
: await authenticate(pool, request);
|
||||||
|
if (
|
||||||
|
!["GET", "HEAD", "OPTIONS"].includes(request.method) &&
|
||||||
|
!identityForTest
|
||||||
|
)
|
||||||
|
checkCsrf(request, request.identity);
|
||||||
|
});
|
||||||
|
registerPlantRoutes(app, pool);
|
||||||
|
registerGameRoutes(app, pool);
|
||||||
|
}
|
||||||
app.setNotFoundHandler((_request, reply) =>
|
app.setNotFoundHandler((_request, reply) =>
|
||||||
reply
|
reply
|
||||||
.code(404)
|
.code(404)
|
||||||
|
|
|
||||||
303
apps/server/src/auth.ts
Normal file
303
apps/server/src/auth.ts
Normal file
|
|
@ -0,0 +1,303 @@
|
||||||
|
import {
|
||||||
|
randomBytes,
|
||||||
|
randomUUID,
|
||||||
|
createHash,
|
||||||
|
timingSafeEqual,
|
||||||
|
} from "node:crypto";
|
||||||
|
import { hash, verify, Algorithm } from "@node-rs/argon2";
|
||||||
|
import type { Pool } from "pg";
|
||||||
|
import type { FastifyInstance, FastifyRequest, FastifyReply } from "fastify";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { ApiError } from "./errors.js";
|
||||||
|
import { GAME, levelFor } from "./game-config.js";
|
||||||
|
export const digest = (value: string) =>
|
||||||
|
createHash("sha256").update(value).digest("hex");
|
||||||
|
declare module "fastify" {
|
||||||
|
interface FastifyRequest {
|
||||||
|
identity?: Identity;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export interface Identity {
|
||||||
|
id: string;
|
||||||
|
username: string;
|
||||||
|
csrfToken: string;
|
||||||
|
}
|
||||||
|
const credentials = z.strictObject({
|
||||||
|
username: z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.regex(/^[a-zA-Z0-9_]{3,24}$/, "Use 3–24 letters, numbers or underscores."),
|
||||||
|
password: z.string().min(10).max(128),
|
||||||
|
});
|
||||||
|
export function allowedOrigin(request: FastifyRequest) {
|
||||||
|
const origin = request.headers.origin;
|
||||||
|
if (!origin) return false;
|
||||||
|
const defaults = [
|
||||||
|
"http://localhost:8155",
|
||||||
|
"http://127.0.0.1:8155",
|
||||||
|
"http://192.168.1.197:8155",
|
||||||
|
"https://botanica.p20.dev",
|
||||||
|
];
|
||||||
|
return (
|
||||||
|
process.env.PUBLIC_ORIGINS?.split(",").map((s) => s.trim()) ?? defaults
|
||||||
|
).includes(origin);
|
||||||
|
}
|
||||||
|
function cookie(
|
||||||
|
reply: FastifyReply,
|
||||||
|
request: FastifyRequest,
|
||||||
|
value: string,
|
||||||
|
maxAge = 2592000,
|
||||||
|
) {
|
||||||
|
const secure =
|
||||||
|
request.headers.origin?.startsWith("https://") ||
|
||||||
|
request.headers["x-forwarded-proto"] === "https";
|
||||||
|
reply.header(
|
||||||
|
"set-cookie",
|
||||||
|
`botanica_session=${value}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${maxAge}${secure ? "; Secure" : ""}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
export function registerAuth(app: FastifyInstance, pool: Pool) {
|
||||||
|
const attempts = new Map<string, { count: number; until: number }>();
|
||||||
|
function throttle(key: string, limit: number) {
|
||||||
|
const now = Date.now(),
|
||||||
|
previous = attempts.get(key);
|
||||||
|
if (previous && previous.until > now) {
|
||||||
|
if (++previous.count <= limit) return;
|
||||||
|
} else {
|
||||||
|
if (attempts.size >= 4096)
|
||||||
|
for (const [id, entry] of attempts)
|
||||||
|
if (entry.until <= now) attempts.delete(id);
|
||||||
|
if (attempts.size < 4096) {
|
||||||
|
attempts.set(key, { count: 1, until: now + 900000 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new ApiError(
|
||||||
|
429,
|
||||||
|
"AUTH_THROTTLED",
|
||||||
|
"Too many attempts. Try again in 15 minutes.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
app.addHook("preHandler", async (request) => {
|
||||||
|
if (request.url.startsWith("/api/v1/auth/") && request.method === "POST") {
|
||||||
|
if (!allowedOrigin(request))
|
||||||
|
throw new ApiError(
|
||||||
|
403,
|
||||||
|
"ORIGIN_REJECTED",
|
||||||
|
"Use this application’s own origin.",
|
||||||
|
);
|
||||||
|
const username =
|
||||||
|
typeof request.body === "object" &&
|
||||||
|
request.body !== null &&
|
||||||
|
"username" in request.body &&
|
||||||
|
typeof request.body.username === "string"
|
||||||
|
? request.body.username.toLowerCase().slice(0, 24)
|
||||||
|
: "";
|
||||||
|
throttle("ip:" + request.ip, 100);
|
||||||
|
throttle(request.ip + ":" + username, 20);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
async function startSession(
|
||||||
|
id: string,
|
||||||
|
reply: FastifyReply,
|
||||||
|
request: FastifyRequest,
|
||||||
|
) {
|
||||||
|
const token = randomBytes(32).toString("hex");
|
||||||
|
await pool.query(
|
||||||
|
"INSERT INTO sessions(token_hash,player_id,expires_at) VALUES($1,$2,now()+interval '30 days')",
|
||||||
|
[digest(token), id],
|
||||||
|
);
|
||||||
|
cookie(reply, request, token);
|
||||||
|
return { csrfToken: digest("csrf:" + token) };
|
||||||
|
}
|
||||||
|
app.post("/api/v1/auth/register", async (request, reply) => {
|
||||||
|
const input = credentials.parse(request.body),
|
||||||
|
passwordHash = await hash(input.password, {
|
||||||
|
algorithm: Algorithm.Argon2id,
|
||||||
|
memoryCost: 19456,
|
||||||
|
timeCost: 2,
|
||||||
|
parallelism: 1,
|
||||||
|
}),
|
||||||
|
id = randomUUID(),
|
||||||
|
client = await pool.connect(),
|
||||||
|
codes = Array.from({ length: 5 }, () => randomBytes(12).toString("hex"));
|
||||||
|
try {
|
||||||
|
await client.query("BEGIN");
|
||||||
|
const inserted = await client.query(
|
||||||
|
"INSERT INTO players(id,display_name,normalized_username,password_hash,coin_balance) VALUES($1,$2,$3,$4,$5) ON CONFLICT(normalized_username) DO NOTHING RETURNING id",
|
||||||
|
[
|
||||||
|
id,
|
||||||
|
input.username,
|
||||||
|
input.username.toLowerCase(),
|
||||||
|
passwordHash,
|
||||||
|
GAME.starterCoins,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
if (!inserted.rowCount)
|
||||||
|
throw new ApiError(
|
||||||
|
409,
|
||||||
|
"USERNAME_TAKEN",
|
||||||
|
"That username is already in use.",
|
||||||
|
);
|
||||||
|
await client.query(
|
||||||
|
"INSERT INTO inventory_seeds VALUES($1,'starter',$2)",
|
||||||
|
[id, GAME.starterSeeds],
|
||||||
|
);
|
||||||
|
const house = randomUUID();
|
||||||
|
await client.query(
|
||||||
|
"INSERT INTO greenhouses(id,player_id,name) VALUES($1,$2,$3)",
|
||||||
|
[house, id, "Little Glasshouse"],
|
||||||
|
);
|
||||||
|
for (let i = 0; i < 4; i++)
|
||||||
|
await client.query(
|
||||||
|
"INSERT INTO plant_slots(id,greenhouse_id,slot_no) VALUES($1,$2,$3)",
|
||||||
|
[randomUUID(), house, i],
|
||||||
|
);
|
||||||
|
for (const code of codes)
|
||||||
|
await client.query(
|
||||||
|
"INSERT INTO recovery_codes(player_id,code_hash) VALUES($1,$2)",
|
||||||
|
[id, digest(code)],
|
||||||
|
);
|
||||||
|
await client.query(
|
||||||
|
"INSERT INTO coin_ledger(id,player_id,amount,balance_after,reason,reference) VALUES($1,$2::uuid,$3,$3,'welcome',$2::uuid::text)",
|
||||||
|
[randomUUID(), id, GAME.starterCoins],
|
||||||
|
);
|
||||||
|
await client.query("COMMIT");
|
||||||
|
return reply.code(201).send({
|
||||||
|
...(await startSession(id, reply, request)),
|
||||||
|
recoveryCodes: codes,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
await client.query("ROLLBACK");
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
app.post("/api/v1/auth/login", async (request, reply) => {
|
||||||
|
const input = credentials.parse(request.body),
|
||||||
|
row = (
|
||||||
|
await pool.query(
|
||||||
|
"SELECT id,password_hash FROM players WHERE normalized_username=$1",
|
||||||
|
[input.username.toLowerCase()],
|
||||||
|
)
|
||||||
|
).rows[0];
|
||||||
|
if (!row || !(await verify(row.password_hash, input.password)))
|
||||||
|
throw new ApiError(
|
||||||
|
401,
|
||||||
|
"INVALID_LOGIN",
|
||||||
|
"Username or password is incorrect.",
|
||||||
|
);
|
||||||
|
return startSession(row.id, reply, request);
|
||||||
|
});
|
||||||
|
app.post("/api/v1/auth/recover", async (request, reply) => {
|
||||||
|
const input = credentials
|
||||||
|
.extend({ recoveryCode: z.string().regex(/^[a-f0-9]{24}$/) })
|
||||||
|
.parse(request.body),
|
||||||
|
passwordHash = await hash(input.password, {
|
||||||
|
algorithm: Algorithm.Argon2id,
|
||||||
|
memoryCost: 19456,
|
||||||
|
timeCost: 2,
|
||||||
|
parallelism: 1,
|
||||||
|
}),
|
||||||
|
client = await pool.connect();
|
||||||
|
try {
|
||||||
|
await client.query("BEGIN");
|
||||||
|
const row = (
|
||||||
|
await client.query(
|
||||||
|
"UPDATE recovery_codes c SET used_at=now() FROM players p WHERE c.player_id=p.id AND p.normalized_username=$1 AND c.code_hash=$2 AND c.used_at IS NULL RETURNING p.id",
|
||||||
|
[input.username.toLowerCase(), digest(input.recoveryCode)],
|
||||||
|
)
|
||||||
|
).rows[0];
|
||||||
|
if (!row)
|
||||||
|
throw new ApiError(
|
||||||
|
401,
|
||||||
|
"INVALID_RECOVERY",
|
||||||
|
"Username or recovery code is incorrect or already used.",
|
||||||
|
);
|
||||||
|
await client.query("UPDATE players SET password_hash=$2 WHERE id=$1", [
|
||||||
|
row.id,
|
||||||
|
passwordHash,
|
||||||
|
]);
|
||||||
|
await client.query("DELETE FROM sessions WHERE player_id=$1", [row.id]);
|
||||||
|
await client.query("COMMIT");
|
||||||
|
return startSession(row.id, reply, request);
|
||||||
|
} catch (error) {
|
||||||
|
await client.query("ROLLBACK");
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
app.post("/api/v1/auth/logout", async (request, reply) => {
|
||||||
|
const identity = await authenticate(pool, request);
|
||||||
|
checkCsrf(request, identity);
|
||||||
|
await pool.query("DELETE FROM sessions WHERE token_hash=$1", [
|
||||||
|
digest(sessionToken(request)),
|
||||||
|
]);
|
||||||
|
cookie(reply, request, "", 0);
|
||||||
|
return { ok: true };
|
||||||
|
});
|
||||||
|
app.get("/api/v1/me", async (request) => {
|
||||||
|
const identity = await authenticate(pool, request),
|
||||||
|
row = (
|
||||||
|
await pool.query("SELECT coin_balance,xp FROM players WHERE id=$1", [
|
||||||
|
identity.id,
|
||||||
|
])
|
||||||
|
).rows[0];
|
||||||
|
return {
|
||||||
|
...identity,
|
||||||
|
coins: row.coin_balance,
|
||||||
|
xp: row.xp,
|
||||||
|
...levelFor(row.xp),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function sessionToken(request: FastifyRequest) {
|
||||||
|
return (
|
||||||
|
request.headers.cookie
|
||||||
|
?.split(";")
|
||||||
|
.map((c) => c.trim())
|
||||||
|
.find((c) => c.startsWith("botanica_session="))
|
||||||
|
?.slice(17) ?? ""
|
||||||
|
);
|
||||||
|
}
|
||||||
|
export async function authenticate(
|
||||||
|
pool: Pool,
|
||||||
|
request: FastifyRequest,
|
||||||
|
): Promise<Identity> {
|
||||||
|
const token = sessionToken(request);
|
||||||
|
if (!/^[a-f0-9]{64}$/.test(token))
|
||||||
|
throw new ApiError(401, "AUTH_REQUIRED", "Sign in to your greenhouse.");
|
||||||
|
const row = (
|
||||||
|
await pool.query(
|
||||||
|
"SELECT p.id,p.display_name FROM sessions s JOIN players p ON p.id=s.player_id WHERE s.token_hash=$1 AND s.expires_at>now()",
|
||||||
|
[digest(token)],
|
||||||
|
)
|
||||||
|
).rows[0];
|
||||||
|
if (!row)
|
||||||
|
throw new ApiError(
|
||||||
|
401,
|
||||||
|
"AUTH_REQUIRED",
|
||||||
|
"Your session expired. Please sign in.",
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
username: row.display_name,
|
||||||
|
csrfToken: digest("csrf:" + token),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
export function checkCsrf(request: FastifyRequest, identity: Identity) {
|
||||||
|
const token = request.headers["x-csrf-token"];
|
||||||
|
if (
|
||||||
|
!allowedOrigin(request) ||
|
||||||
|
typeof token !== "string" ||
|
||||||
|
!/^[a-f0-9]{64}$/.test(token) ||
|
||||||
|
!timingSafeEqual(Buffer.from(token), Buffer.from(identity.csrfToken))
|
||||||
|
)
|
||||||
|
throw new ApiError(
|
||||||
|
403,
|
||||||
|
"CSRF_REJECTED",
|
||||||
|
"Refresh the page before trying this action again.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -44,7 +44,7 @@ export async function listCollection(
|
||||||
trait("pigment") +
|
trait("pigment") +
|
||||||
" AS resolved_pigment, " +
|
" AS resolved_pigment, " +
|
||||||
trait("leafPattern") +
|
trait("leafPattern") +
|
||||||
" AS resolved_pattern FROM plants p WHERE owner_id=$1) ";
|
" AS resolved_pattern FROM plants p WHERE owner_id=$1 AND sold_at IS NULL) ";
|
||||||
const conditions: string[] = [];
|
const conditions: string[] = [];
|
||||||
for (const [column, value] of [
|
for (const [column, value] of [
|
||||||
["species_id", query.species],
|
["species_id", query.species],
|
||||||
|
|
@ -159,7 +159,7 @@ export async function collectionStats(
|
||||||
rarity_tier: NonNullable<keyof CollectionStats["byRarity"]>;
|
rarity_tier: NonNullable<keyof CollectionStats["byRarity"]>;
|
||||||
count: string;
|
count: string;
|
||||||
}>(
|
}>(
|
||||||
"SELECT rarity_tier,count(*) FROM plants WHERE owner_id=$1 GROUP BY rarity_tier",
|
"SELECT rarity_tier,count(*) FROM plants WHERE owner_id=$1 AND sold_at IS NULL GROUP BY rarity_tier",
|
||||||
[ownerId],
|
[ownerId],
|
||||||
)
|
)
|
||||||
).rows;
|
).rows;
|
||||||
|
|
|
||||||
235
apps/server/src/game-config.ts
Normal file
235
apps/server/src/game-config.ts
Normal file
|
|
@ -0,0 +1,235 @@
|
||||||
|
import { genomeSchema, type Genome } from "@botanica/genetics";
|
||||||
|
import type { SeedType } from "@botanica/shared";
|
||||||
|
export const GAME = {
|
||||||
|
starterCoins: 120,
|
||||||
|
starterSeeds: 3,
|
||||||
|
breedCost: 75,
|
||||||
|
firstGrowSeconds: 20,
|
||||||
|
maxWorkerEvents: 100,
|
||||||
|
catchupHours: 24,
|
||||||
|
workerCosts: { gardener: 1500, breeder: 5000 },
|
||||||
|
workerIntervals: { gardener: 120, breeder: 300 },
|
||||||
|
} as const;
|
||||||
|
export const SLOT_PRICES = [
|
||||||
|
100, 250, 500, 750, 1000, 1250, 1500, 2000,
|
||||||
|
] as const;
|
||||||
|
export const nextSlotCost = (slots: number): number | null =>
|
||||||
|
SLOT_PRICES[slots - 4] ?? null;
|
||||||
|
export const SEEDS: Record<
|
||||||
|
SeedType,
|
||||||
|
{
|
||||||
|
name: string;
|
||||||
|
cost: number;
|
||||||
|
level: number;
|
||||||
|
seconds: number;
|
||||||
|
description: string;
|
||||||
|
genome: Genome;
|
||||||
|
}
|
||||||
|
> = {
|
||||||
|
starter: {
|
||||||
|
name: "Starter seed",
|
||||||
|
cost: 10,
|
||||||
|
level: 1,
|
||||||
|
seconds: 120,
|
||||||
|
description:
|
||||||
|
"Known purple, lobed, solid foliage. Heterozygous traits make interesting parents. Every harvest returns one starter seed.",
|
||||||
|
genome: genomeSchema.parse({
|
||||||
|
pigment: ["P", "G"],
|
||||||
|
leafShape: ["L", "O"],
|
||||||
|
leafPattern: ["S", "S"],
|
||||||
|
growthHabit: ["T", "B"],
|
||||||
|
flowerColor: ["B", "W"],
|
||||||
|
flowerDensity: ["H", "M"],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
uncommon: {
|
||||||
|
name: "Variegated seed",
|
||||||
|
cost: 75,
|
||||||
|
level: 3,
|
||||||
|
seconds: 300,
|
||||||
|
description:
|
||||||
|
"Guaranteed variegated foliage with purple pigment. No rarity dice roll.",
|
||||||
|
genome: genomeSchema.parse({
|
||||||
|
pigment: ["P", "G"],
|
||||||
|
leafShape: ["L", "O"],
|
||||||
|
leafPattern: ["V", "S"],
|
||||||
|
growthHabit: ["B", "C"],
|
||||||
|
flowerColor: ["R", "W"],
|
||||||
|
flowerDensity: ["H", "M"],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
rare: {
|
||||||
|
name: "Heirloom seed",
|
||||||
|
cost: 250,
|
||||||
|
level: 7,
|
||||||
|
seconds: 720,
|
||||||
|
description:
|
||||||
|
"Known green, oval, solid foliage. Rare classification from two recessive traits.",
|
||||||
|
genome: genomeSchema.parse({
|
||||||
|
pigment: ["G", "G"],
|
||||||
|
leafShape: ["O", "O"],
|
||||||
|
leafPattern: ["S", "S"],
|
||||||
|
growthHabit: ["T", "T"],
|
||||||
|
flowerColor: ["B", "B"],
|
||||||
|
flowerDensity: ["H", "H"],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const thresholds = [0, 60, 160, 300, 500, 800, 1200, 1700, 2300, 3000];
|
||||||
|
export function levelFor(xp: number) {
|
||||||
|
let level = 1;
|
||||||
|
while (level < 10 && xp >= thresholds[level]) level++;
|
||||||
|
return {
|
||||||
|
level,
|
||||||
|
nextLevelXp: thresholds[level] ?? thresholds[9] + (level - 9) * 1000,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
export const QUESTS = [
|
||||||
|
{
|
||||||
|
id: "grow",
|
||||||
|
name: "First green",
|
||||||
|
description: "Harvest your first plant.",
|
||||||
|
stat: "harvested",
|
||||||
|
target: 1,
|
||||||
|
coins: 50,
|
||||||
|
xp: 40,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "sell",
|
||||||
|
name: "A little seed money",
|
||||||
|
description: "Sell one grown specimen.",
|
||||||
|
stat: "sold",
|
||||||
|
target: 1,
|
||||||
|
coins: 50,
|
||||||
|
xp: 60,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "slot",
|
||||||
|
name: "Room to grow",
|
||||||
|
description: "Purchase a slot expansion.",
|
||||||
|
stat: "slotsBought",
|
||||||
|
target: 1,
|
||||||
|
coins: 100,
|
||||||
|
xp: 80,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "cross",
|
||||||
|
name: "Family resemblance",
|
||||||
|
description: "Make your first genetic cross.",
|
||||||
|
stat: "crosses",
|
||||||
|
target: 1,
|
||||||
|
coins: 200,
|
||||||
|
xp: 100,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "keep",
|
||||||
|
name: "A living cabinet",
|
||||||
|
description: "Keep five mature game plants.",
|
||||||
|
stat: "kept",
|
||||||
|
target: 5,
|
||||||
|
coins: 100,
|
||||||
|
xp: 100,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "harvest10",
|
||||||
|
name: "Green fingers",
|
||||||
|
description: "Harvest ten plants.",
|
||||||
|
stat: "harvested",
|
||||||
|
target: 10,
|
||||||
|
coins: 300,
|
||||||
|
xp: 200,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "sell10",
|
||||||
|
name: "Market gardener",
|
||||||
|
description: "Sell ten plants.",
|
||||||
|
stat: "sold",
|
||||||
|
target: 10,
|
||||||
|
coins: 300,
|
||||||
|
xp: 150,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "discover",
|
||||||
|
name: "A curious eye",
|
||||||
|
description: "Discover five phenotype signatures.",
|
||||||
|
stat: "discoveries",
|
||||||
|
target: 5,
|
||||||
|
coins: 400,
|
||||||
|
xp: 200,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "plant20",
|
||||||
|
name: "A growing habit",
|
||||||
|
description: "Plant twenty seeds.",
|
||||||
|
stat: "planted",
|
||||||
|
target: 20,
|
||||||
|
coins: 400,
|
||||||
|
xp: 250,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "house",
|
||||||
|
name: "Under new glass",
|
||||||
|
description: "Open your second greenhouse.",
|
||||||
|
stat: "houses",
|
||||||
|
target: 2,
|
||||||
|
coins: 500,
|
||||||
|
xp: 300,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "worker",
|
||||||
|
name: "A helping hand",
|
||||||
|
description: "Hire a worker.",
|
||||||
|
stat: "workers",
|
||||||
|
target: 1,
|
||||||
|
coins: 250,
|
||||||
|
xp: 200,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "rare",
|
||||||
|
name: "Something special",
|
||||||
|
description: "Keep a Rare or higher game plant.",
|
||||||
|
stat: "rare",
|
||||||
|
target: 1,
|
||||||
|
coins: 250,
|
||||||
|
xp: 200,
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
export const RESEARCH = [
|
||||||
|
{
|
||||||
|
id: "compost",
|
||||||
|
name: "Living compost",
|
||||||
|
description: "+10 XP on each harvest.",
|
||||||
|
cost: 200,
|
||||||
|
level: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "botany",
|
||||||
|
name: "Botanical notes",
|
||||||
|
description: "+10 XP on each first discovery.",
|
||||||
|
cost: 400,
|
||||||
|
level: 3,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "selection",
|
||||||
|
name: "Selective breeding",
|
||||||
|
description:
|
||||||
|
"Unlock target-trait worker recipes. Matching offspring are protected; odds are never changed.",
|
||||||
|
cost: 1500,
|
||||||
|
level: 10,
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
export function saleValue(tier: string, score: number) {
|
||||||
|
const factor: Record<string, number> = {
|
||||||
|
Common: 1,
|
||||||
|
Uncommon: 2.5,
|
||||||
|
Rare: 7,
|
||||||
|
Epic: 22,
|
||||||
|
Legendary: 65,
|
||||||
|
};
|
||||||
|
return tier === "Mythic"
|
||||||
|
? null
|
||||||
|
: Math.min(
|
||||||
|
2000,
|
||||||
|
Math.round(25 * (factor[tier] ?? 0) * (1 + Math.min(100, score) / 250)),
|
||||||
|
);
|
||||||
|
}
|
||||||
32
apps/server/src/game-routes.ts
Normal file
32
apps/server/src/game-routes.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
import type { FastifyInstance } from "fastify";
|
||||||
|
import type { Pool } from "pg";
|
||||||
|
import { gameActionSchema, plantListQuerySchema } from "@botanica/shared";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { GameService } from "./game-service.js";
|
||||||
|
import { PlantRepository } from "./plants.js";
|
||||||
|
import { DEMO_PLAYER_ID } from "./constants.js";
|
||||||
|
export function registerGameRoutes(app: FastifyInstance, pool: Pool) {
|
||||||
|
const game = new GameService(pool);
|
||||||
|
app.get("/api/v1/game/state", (request) => game.state(request.identity!));
|
||||||
|
app.post("/api/v1/game/action", (request) =>
|
||||||
|
game.action(request.identity!.id, gameActionSchema.parse(request.body)),
|
||||||
|
);
|
||||||
|
app.get("/api/v1/demo/plants", (request) =>
|
||||||
|
new PlantRepository(pool, DEMO_PLAYER_ID).listPlants(
|
||||||
|
plantListQuerySchema.parse(request.query),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
app.get("/api/v1/demo/collection/stats", () =>
|
||||||
|
new PlantRepository(pool, DEMO_PLAYER_ID).stats(),
|
||||||
|
);
|
||||||
|
app.get("/api/v1/demo/plants/:id/birth", (request) =>
|
||||||
|
new PlantRepository(pool, DEMO_PLAYER_ID).birthEvent(
|
||||||
|
z.object({ id: z.uuid() }).parse(request.params).id,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
app.get("/api/v1/demo/plants/:id", (request) =>
|
||||||
|
new PlantRepository(pool, DEMO_PLAYER_ID).getPlant(
|
||||||
|
z.object({ id: z.uuid() }).parse(request.params).id,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
1076
apps/server/src/game-service.ts
Normal file
1076
apps/server/src/game-service.ts
Normal file
File diff suppressed because it is too large
Load diff
467
apps/server/src/game.integration.test.ts
Normal file
467
apps/server/src/game.integration.test.ts
Normal file
|
|
@ -0,0 +1,467 @@
|
||||||
|
import { beforeAll, afterAll, describe, it, expect } from "vitest";
|
||||||
|
import pg from "pg";
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { createApp } from "./app.js";
|
||||||
|
import { migrate } from "./migrations.js";
|
||||||
|
import { seedDatabase } from "./seed-data.js";
|
||||||
|
import { GameService } from "./game-service.js";
|
||||||
|
import { digest, type Identity } from "./auth.js";
|
||||||
|
import type { GameAction, GameState } from "@botanica/shared";
|
||||||
|
import { saleValue } from "./game-config.js";
|
||||||
|
import { DEMO_PLAYER_ID } from "./constants.js";
|
||||||
|
|
||||||
|
// Separate schema on the disposable Compose test database, never the application database.
|
||||||
|
const url = process.env.TEST_DATABASE_URL,
|
||||||
|
schema = "game_" + randomUUID().replaceAll("-", "");
|
||||||
|
describe.skipIf(!url)(
|
||||||
|
"Game ownership, monetary integrity and offline acceptance",
|
||||||
|
() => {
|
||||||
|
const pool = new pg.Pool({
|
||||||
|
connectionString: url,
|
||||||
|
options: "-c search_path=" + schema,
|
||||||
|
});
|
||||||
|
const app = createApp(() => pool.query("SELECT 1"), false, pool);
|
||||||
|
let now = new Date("2026-01-01T00:00:00Z");
|
||||||
|
const game = new GameService(pool, () => now);
|
||||||
|
const advance = (seconds: number) => {
|
||||||
|
now = new Date(now.getTime() + seconds * 1000);
|
||||||
|
};
|
||||||
|
type Account = {
|
||||||
|
identity: Identity;
|
||||||
|
cookie: string;
|
||||||
|
csrf: string;
|
||||||
|
codes: string[];
|
||||||
|
username: string;
|
||||||
|
};
|
||||||
|
async function account(): Promise<Account> {
|
||||||
|
const username = "test_" + randomUUID().slice(0, 8),
|
||||||
|
r = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/v1/auth/register",
|
||||||
|
headers: { origin: "http://localhost:8155" },
|
||||||
|
payload: { username, password: "fixture-password-123" },
|
||||||
|
});
|
||||||
|
expect(r.statusCode, r.body).toBe(201);
|
||||||
|
const cookie = String(r.headers["set-cookie"]).split(";")[0],
|
||||||
|
csrf = r.json().csrfToken,
|
||||||
|
me = await app.inject({ url: "/api/v1/me", headers: { cookie } });
|
||||||
|
expect(me.statusCode).toBe(200);
|
||||||
|
return {
|
||||||
|
identity: me.json(),
|
||||||
|
cookie,
|
||||||
|
csrf,
|
||||||
|
codes: r.json().recoveryCodes,
|
||||||
|
username,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const act = (
|
||||||
|
a: Account,
|
||||||
|
action: GameAction["action"],
|
||||||
|
data: Record<string, unknown>,
|
||||||
|
requestId = randomUUID(),
|
||||||
|
) => game.action(a.identity.id, { action, data, requestId });
|
||||||
|
const httpAction = (
|
||||||
|
a: Account,
|
||||||
|
action: GameAction["action"],
|
||||||
|
data: Record<string, unknown>,
|
||||||
|
) =>
|
||||||
|
app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/v1/game/action",
|
||||||
|
headers: {
|
||||||
|
cookie: a.cookie,
|
||||||
|
origin: "http://localhost:8155",
|
||||||
|
"x-csrf-token": a.csrf,
|
||||||
|
},
|
||||||
|
payload: { action, data, requestId: randomUUID() },
|
||||||
|
});
|
||||||
|
async function state(a: Account) {
|
||||||
|
return game.state(a.identity);
|
||||||
|
}
|
||||||
|
async function grow(a: Account, count = 1) {
|
||||||
|
const s = await state(a),
|
||||||
|
slots = s.greenhouses[0].plants.slice(0, count);
|
||||||
|
for (const slot of slots)
|
||||||
|
await act(a, "plant", { slotId: slot.id, seedType: "starter" });
|
||||||
|
advance(121);
|
||||||
|
const result = [];
|
||||||
|
for (const slot of slots)
|
||||||
|
result.push(await act(a, "harvest", { slotId: slot.id }));
|
||||||
|
return result as { plantId: string }[];
|
||||||
|
}
|
||||||
|
beforeAll(async () => {
|
||||||
|
const admin = new pg.Pool({ connectionString: url });
|
||||||
|
try {
|
||||||
|
await admin.query("CREATE SCHEMA " + schema);
|
||||||
|
} finally {
|
||||||
|
await admin.end();
|
||||||
|
}
|
||||||
|
await migrate(pool);
|
||||||
|
await seedDatabase(pool);
|
||||||
|
});
|
||||||
|
afterAll(async () => {
|
||||||
|
await app.close();
|
||||||
|
await pool.end();
|
||||||
|
});
|
||||||
|
it("authenticates with hashed credentials, CSRF and one-use recovery; bootstraps isolated accounts", async () => {
|
||||||
|
const a = await account(),
|
||||||
|
b = await account(),
|
||||||
|
s = await state(a);
|
||||||
|
expect(s.player.coins).toBe(120);
|
||||||
|
expect(s.seeds.starter).toBe(3);
|
||||||
|
expect(s.greenhouses[0].plants).toHaveLength(4);
|
||||||
|
expect((await app.inject("/api/v1/plants")).statusCode).toBe(401);
|
||||||
|
expect((await app.inject("/health")).statusCode).toBe(200);
|
||||||
|
const row = (
|
||||||
|
await pool.query("SELECT password_hash FROM players WHERE id=$1", [
|
||||||
|
a.identity.id,
|
||||||
|
])
|
||||||
|
).rows[0];
|
||||||
|
expect(row.password_hash).toMatch(/^\$argon2id\$/);
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await pool.query(
|
||||||
|
"SELECT token_hash FROM sessions WHERE player_id=$1",
|
||||||
|
[a.identity.id],
|
||||||
|
)
|
||||||
|
).rows[0].token_hash,
|
||||||
|
).toBe(digest(a.cookie.split("=")[1]));
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await pool.query(
|
||||||
|
"SELECT code_hash FROM recovery_codes WHERE player_id=$1",
|
||||||
|
[a.identity.id],
|
||||||
|
)
|
||||||
|
).rows.map((r) => r.code_hash),
|
||||||
|
).not.toContain(a.codes[0]);
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/v1/game/action",
|
||||||
|
headers: { cookie: a.cookie, origin: "http://localhost:8155" },
|
||||||
|
payload: {
|
||||||
|
action: "purchase",
|
||||||
|
requestId: randomUUID(),
|
||||||
|
data: { itemId: "seed_starter" },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
).statusCode,
|
||||||
|
).toBe(403);
|
||||||
|
const [plant] = await grow(a);
|
||||||
|
for (const path of [
|
||||||
|
"/plants/" + plant.plantId,
|
||||||
|
"/plants/" + plant.plantId + "/birth",
|
||||||
|
])
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await app.inject({
|
||||||
|
url: "/api/v1" + path,
|
||||||
|
headers: { cookie: b.cookie },
|
||||||
|
})
|
||||||
|
).statusCode,
|
||||||
|
).toBe(404);
|
||||||
|
expect(
|
||||||
|
(await httpAction(b, "sell", { plantId: plant.plantId })).statusCode,
|
||||||
|
).toBe(409);
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await httpAction(b, "flags", {
|
||||||
|
plantId: plant.plantId,
|
||||||
|
favorite: true,
|
||||||
|
protected: true,
|
||||||
|
})
|
||||||
|
).statusCode,
|
||||||
|
).toBe(404);
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await httpAction(b, "plant", {
|
||||||
|
slotId: s.greenhouses[0].plants[0].id,
|
||||||
|
seedType: "starter",
|
||||||
|
})
|
||||||
|
).statusCode,
|
||||||
|
).toBe(404);
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await app.inject({
|
||||||
|
url: "/api/v1/plants",
|
||||||
|
headers: { cookie: b.cookie },
|
||||||
|
})
|
||||||
|
).json().total,
|
||||||
|
).toBe(0);
|
||||||
|
expect(
|
||||||
|
Number(
|
||||||
|
(
|
||||||
|
await pool.query("SELECT count(*) FROM plants WHERE owner_id=$1", [
|
||||||
|
DEMO_PLAYER_ID,
|
||||||
|
])
|
||||||
|
).rows[0].count,
|
||||||
|
),
|
||||||
|
).toBe(100);
|
||||||
|
const recover = {
|
||||||
|
username: a.username,
|
||||||
|
password: "replacement-password-123",
|
||||||
|
recoveryCode: a.codes[0],
|
||||||
|
},
|
||||||
|
headers = { origin: "http://localhost:8155" };
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/v1/auth/recover",
|
||||||
|
headers,
|
||||||
|
payload: recover,
|
||||||
|
})
|
||||||
|
).statusCode,
|
||||||
|
).toBe(200);
|
||||||
|
expect(
|
||||||
|
(await app.inject({ url: "/api/v1/me", headers: { cookie: a.cookie } }))
|
||||||
|
.statusCode,
|
||||||
|
).toBe(401);
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/v1/auth/recover",
|
||||||
|
headers,
|
||||||
|
payload: recover,
|
||||||
|
})
|
||||||
|
).statusCode,
|
||||||
|
).toBe(401);
|
||||||
|
}, 20000);
|
||||||
|
it("serializes competing purchases, rejects overspending and replays each receipt without a second debit", async () => {
|
||||||
|
const a = await account();
|
||||||
|
await pool.query("UPDATE players SET coin_balance=10 WHERE id=$1", [
|
||||||
|
a.identity.id,
|
||||||
|
]);
|
||||||
|
const results = await Promise.allSettled([
|
||||||
|
act(a, "purchase", { itemId: "seed_starter" }),
|
||||||
|
act(a, "purchase", { itemId: "seed_starter" }),
|
||||||
|
]);
|
||||||
|
expect(results.filter((r) => r.status === "fulfilled")).toHaveLength(1);
|
||||||
|
expect((await state(a)).player.coins).toBe(0);
|
||||||
|
expect((await state(a)).seeds.starter).toBe(4);
|
||||||
|
await pool.query("UPDATE players SET coin_balance=20 WHERE id=$1", [
|
||||||
|
a.identity.id,
|
||||||
|
]);
|
||||||
|
const key = randomUUID(),
|
||||||
|
input = { itemId: "seed_starter" };
|
||||||
|
const retried = await Promise.all([
|
||||||
|
act(a, "purchase", input, key),
|
||||||
|
act(a, "purchase", input, key),
|
||||||
|
]);
|
||||||
|
expect(retried[0]).toEqual(retried[1]);
|
||||||
|
expect((await state(a)).player.coins).toBe(10);
|
||||||
|
expect((await state(a)).seeds.starter).toBe(5);
|
||||||
|
await expect(
|
||||||
|
act(a, "purchase", { itemId: "seed_uncommon" }, key),
|
||||||
|
).rejects.toMatchObject({ code: "IDEMPOTENCY_CONFLICT" });
|
||||||
|
});
|
||||||
|
it("harvests and sells once under concurrent requests, recycles seeds and protects favorites", async () => {
|
||||||
|
const a = await account(),
|
||||||
|
slot = (await state(a)).greenhouses[0].plants[0];
|
||||||
|
await act(a, "plant", { slotId: slot.id, seedType: "starter" });
|
||||||
|
await expect(
|
||||||
|
act(a, "harvest", { slotId: slot.id }),
|
||||||
|
).rejects.toMatchObject({ code: "NOT_READY" });
|
||||||
|
advance(21);
|
||||||
|
const results = await Promise.allSettled([
|
||||||
|
act(a, "harvest", { slotId: slot.id }),
|
||||||
|
act(a, "harvest", { slotId: slot.id }),
|
||||||
|
]);
|
||||||
|
expect(results.filter((r) => r.status === "fulfilled")).toHaveLength(1);
|
||||||
|
const harvested = results.find(
|
||||||
|
(r) => r.status === "fulfilled",
|
||||||
|
) as PromiseFulfilledResult<{ plantId: string }>;
|
||||||
|
expect((await state(a)).seeds.starter).toBe(3);
|
||||||
|
expect((await state(a)).player.coins).toBe(130); // only first-discovery reward, no harvest sale payout
|
||||||
|
const plantId = harvested.value.plantId;
|
||||||
|
await act(a, "flags", { plantId, favorite: true, protected: true });
|
||||||
|
await expect(act(a, "sell", { plantId })).rejects.toMatchObject({
|
||||||
|
code: "PROTECTED_PLANT",
|
||||||
|
});
|
||||||
|
await act(a, "flags", { plantId, favorite: false, protected: false });
|
||||||
|
const sales = await Promise.allSettled([
|
||||||
|
act(a, "sell", { plantId }),
|
||||||
|
act(a, "sell", { plantId }),
|
||||||
|
]);
|
||||||
|
expect(sales.filter((r) => r.status === "fulfilled")).toHaveLength(1);
|
||||||
|
const plant = (
|
||||||
|
await pool.query("SELECT * FROM plants WHERE id=$1", [plantId])
|
||||||
|
).rows[0];
|
||||||
|
expect(plant.sold_at).toBeTruthy();
|
||||||
|
expect((await state(a)).player.coins).toBe(
|
||||||
|
130 + saleValue(plant.rarity_tier, plant.rarity_score)!,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await pool.query(
|
||||||
|
"SELECT count(*) FROM coin_ledger WHERE player_id=$1 AND reason='sale'",
|
||||||
|
[a.identity.id],
|
||||||
|
)
|
||||||
|
).rows[0].count,
|
||||||
|
).toBe("1");
|
||||||
|
});
|
||||||
|
it("charges a real inherited cross once, preserves lineage and rejects foreign or designed parents", async () => {
|
||||||
|
const a = await account(),
|
||||||
|
b = await account(),
|
||||||
|
[left, right] = await grow(a, 2),
|
||||||
|
[foreign] = await grow(b);
|
||||||
|
await pool.query("UPDATE players SET xp=160 WHERE id=$1", [
|
||||||
|
a.identity.id,
|
||||||
|
]);
|
||||||
|
const input = {
|
||||||
|
parentAId: left.plantId,
|
||||||
|
parentBId: right.plantId,
|
||||||
|
idempotencyKey: randomUUID(),
|
||||||
|
},
|
||||||
|
before = (await state(a)).player.coins;
|
||||||
|
const [first, retry] = (await Promise.all([
|
||||||
|
game.cross(a.identity.id, input),
|
||||||
|
game.cross(a.identity.id, input),
|
||||||
|
])) as [
|
||||||
|
{ plant: { id: string; parents: string[]; gameEligible: boolean } },
|
||||||
|
unknown,
|
||||||
|
];
|
||||||
|
expect(first).toEqual(retry);
|
||||||
|
expect(first.plant.parents).toEqual([left.plantId, right.plantId]);
|
||||||
|
expect(first.plant.gameEligible).toBe(true);
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await pool.query(
|
||||||
|
"SELECT amount FROM coin_ledger WHERE player_id=$1 AND reason='breeding'",
|
||||||
|
[a.identity.id],
|
||||||
|
)
|
||||||
|
).rows,
|
||||||
|
).toEqual([{ amount: -75 }]);
|
||||||
|
expect((await state(a)).player.coins).toBeGreaterThanOrEqual(before - 75);
|
||||||
|
await expect(
|
||||||
|
game.cross(a.identity.id, {
|
||||||
|
...input,
|
||||||
|
parentBId: foreign.plantId,
|
||||||
|
idempotencyKey: randomUUID(),
|
||||||
|
}),
|
||||||
|
).rejects.toMatchObject({ code: "PARENT_UNAVAILABLE" });
|
||||||
|
const designed = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/api/v1/plants",
|
||||||
|
headers: {
|
||||||
|
cookie: a.cookie,
|
||||||
|
origin: "http://localhost:8155",
|
||||||
|
"x-csrf-token": a.csrf,
|
||||||
|
},
|
||||||
|
payload: {
|
||||||
|
name: "Study",
|
||||||
|
speciesId: "velvetleaf",
|
||||||
|
genome: (
|
||||||
|
await pool.query("SELECT genome FROM plants WHERE id=$1", [
|
||||||
|
left.plantId,
|
||||||
|
])
|
||||||
|
).rows[0].genome,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(designed.statusCode).toBe(201);
|
||||||
|
await expect(
|
||||||
|
game.cross(a.identity.id, {
|
||||||
|
...input,
|
||||||
|
parentBId: designed.json().id,
|
||||||
|
idempotencyKey: randomUUID(),
|
||||||
|
}),
|
||||||
|
).rejects.toMatchObject({ code: "PARENT_UNAVAILABLE" });
|
||||||
|
await expect(
|
||||||
|
act(a, "sell", { plantId: designed.json().id }),
|
||||||
|
).rejects.toMatchObject({ code: "STUDY_ONLY" });
|
||||||
|
});
|
||||||
|
it("reconciles a finite hour for gardeners and breeders once; protects keepsakes and retains capped backlog", async () => {
|
||||||
|
const a = await account(),
|
||||||
|
b = await account(),
|
||||||
|
parents = await grow(a, 2); // controlled test fixtures unlock late game without waiting through the whole progression
|
||||||
|
await pool.query(
|
||||||
|
"UPDATE players SET xp=3000,coin_balance=10000 WHERE id=$1",
|
||||||
|
[a.identity.id],
|
||||||
|
);
|
||||||
|
await act(a, "flags", {
|
||||||
|
plantId: parents[0].plantId,
|
||||||
|
favorite: true,
|
||||||
|
protected: true,
|
||||||
|
});
|
||||||
|
await act(a, "hire", { kind: "gardener" });
|
||||||
|
await act(a, "hire", { kind: "breeder" });
|
||||||
|
let s = await state(a);
|
||||||
|
expect(s.workers.every((w) => !w.enabled)).toBe(true);
|
||||||
|
const gardener = s.workers.find((w) => w.kind === "gardener")!,
|
||||||
|
breeder = s.workers.find((w) => w.kind === "breeder")!,
|
||||||
|
house = s.greenhouses[0].id;
|
||||||
|
const policy = {
|
||||||
|
mode: "repeat_crop",
|
||||||
|
seedType: "starter",
|
||||||
|
autoSell: true,
|
||||||
|
reserveCoins: 100,
|
||||||
|
};
|
||||||
|
await act(a, "assign", {
|
||||||
|
workerId: gardener.id,
|
||||||
|
greenhouseId: house,
|
||||||
|
enabled: true,
|
||||||
|
policy,
|
||||||
|
});
|
||||||
|
await act(a, "assign", {
|
||||||
|
workerId: breeder.id,
|
||||||
|
greenhouseId: house,
|
||||||
|
enabled: true,
|
||||||
|
policy: {
|
||||||
|
...policy,
|
||||||
|
mode: "repeat_cross",
|
||||||
|
autoSell: false,
|
||||||
|
parentAId: parents[0].plantId,
|
||||||
|
parentBId: parents[1].plantId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await httpAction(b, "assign", {
|
||||||
|
workerId: gardener.id,
|
||||||
|
greenhouseId: house,
|
||||||
|
enabled: true,
|
||||||
|
policy,
|
||||||
|
})
|
||||||
|
).statusCode,
|
||||||
|
).toBe(404);
|
||||||
|
advance(3600);
|
||||||
|
const summaries = await Promise.all([
|
||||||
|
game.reconcile(a.identity.id),
|
||||||
|
game.reconcile(a.identity.id),
|
||||||
|
]);
|
||||||
|
expect(summaries.filter(Boolean)).toHaveLength(1);
|
||||||
|
const report = summaries.find(Boolean)!;
|
||||||
|
expect(report.events).toBe(42);
|
||||||
|
expect(report.harvested).toBeGreaterThan(0);
|
||||||
|
expect(report.breeds).toBe(12);
|
||||||
|
expect(report.seedsUsed).toBeGreaterThanOrEqual(report.harvested);
|
||||||
|
s = await state(a);
|
||||||
|
expect(s.report).toBeNull();
|
||||||
|
expect(s.player.coins).toBeGreaterThanOrEqual(0);
|
||||||
|
const keepsake = (
|
||||||
|
await pool.query("SELECT sold_at FROM plants WHERE id=$1", [
|
||||||
|
parents[0].plantId,
|
||||||
|
])
|
||||||
|
).rows[0];
|
||||||
|
expect(keepsake.sold_at).toBeNull();
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
await pool.query(
|
||||||
|
"SELECT count(*) FROM plants WHERE owner_id=$1 AND sold_at IS NOT NULL AND (is_favorite OR is_protected OR first_discovery OR rarity_tier!='Common')",
|
||||||
|
[a.identity.id],
|
||||||
|
)
|
||||||
|
).rows[0].count,
|
||||||
|
).toBe("0");
|
||||||
|
expect(s.recentRuns.reduce((n, r) => n + r.summary.events, 0)).toBe(42);
|
||||||
|
expect(s.recentRuns.reduce((n, r) => n + r.summary.coinDelta, 0)).toBe(
|
||||||
|
report.coinDelta,
|
||||||
|
);
|
||||||
|
advance(48 * 3600);
|
||||||
|
const capped = await game.reconcile(a.identity.id);
|
||||||
|
expect(capped!.events).toBe(100);
|
||||||
|
expect((await game.state(a.identity)).pendingWork).toBe(true);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
@ -11,7 +11,16 @@ describe.skipIf(!databaseUrl)(
|
||||||
"PostgreSQL persistence and transaction acceptance",
|
"PostgreSQL persistence and transaction acceptance",
|
||||||
() => {
|
() => {
|
||||||
const pool = new pg.Pool({ connectionString: databaseUrl });
|
const pool = new pg.Pool({ connectionString: databaseUrl });
|
||||||
const app = createApp(() => pool.query("SELECT 1"), false, pool);
|
const app = createApp(
|
||||||
|
() => pool.query("SELECT 1"),
|
||||||
|
false,
|
||||||
|
pool,
|
||||||
|
async () => ({
|
||||||
|
id: DEMO_PLAYER_ID,
|
||||||
|
username: "test-sandbox",
|
||||||
|
csrfToken: "test-only",
|
||||||
|
}),
|
||||||
|
);
|
||||||
let childId = "";
|
let childId = "";
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
await migrate(pool);
|
await migrate(pool);
|
||||||
|
|
@ -29,7 +38,7 @@ describe.skipIf(!databaseUrl)(
|
||||||
(await pool.query("SELECT count(*) FROM schema_migrations")).rows[0]
|
(await pool.query("SELECT count(*) FROM schema_migrations")).rows[0]
|
||||||
.count,
|
.count,
|
||||||
),
|
),
|
||||||
).toBe(2);
|
).toBe(3);
|
||||||
expect(
|
expect(
|
||||||
Number(
|
Number(
|
||||||
(await pool.query("SELECT count(*) FROM players")).rows[0].count,
|
(await pool.query("SELECT count(*) FROM players")).rows[0].count,
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import { GameService } from "./game-service.js";
|
||||||
|
import { DEMO_PLAYER_ID } from "./constants.js";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import type { FastifyInstance } from "fastify";
|
import type { FastifyInstance } from "fastify";
|
||||||
import type { Pool } from "pg";
|
import type { Pool } from "pg";
|
||||||
|
|
@ -9,28 +11,40 @@ import {
|
||||||
} from "@botanica/shared";
|
} from "@botanica/shared";
|
||||||
import { PlantRepository } from "./plants.js";
|
import { PlantRepository } from "./plants.js";
|
||||||
export function registerPlantRoutes(app: FastifyInstance, pool: Pool) {
|
export function registerPlantRoutes(app: FastifyInstance, pool: Pool) {
|
||||||
const repository = new PlantRepository(pool);
|
const repository = (request: import("fastify").FastifyRequest) =>
|
||||||
|
new PlantRepository(pool, request.identity!.id);
|
||||||
app.get("/api/v1/plants", (request) =>
|
app.get("/api/v1/plants", (request) =>
|
||||||
repository.listPlants(plantListQuerySchema.parse(request.query)),
|
repository(request).listPlants(plantListQuerySchema.parse(request.query)),
|
||||||
);
|
);
|
||||||
app.get("/api/v1/collection/stats", () => repository.stats());
|
app.get("/api/v1/collection/stats", (request) => repository(request).stats());
|
||||||
app.get("/api/v1/plants/:id/birth", (request) =>
|
app.get("/api/v1/plants/:id/birth", (request) =>
|
||||||
repository.birthEvent(z.object({ id: z.uuid() }).parse(request.params).id),
|
repository(request).birthEvent(
|
||||||
|
z.object({ id: z.uuid() }).parse(request.params).id,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
app.get("/api/v1/plants/:id", (request) =>
|
app.get("/api/v1/plants/:id", (request) =>
|
||||||
repository.getPlant(z.object({ id: z.uuid() }).parse(request.params).id),
|
repository(request).getPlant(
|
||||||
|
z.object({ id: z.uuid() }).parse(request.params).id,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
app.post("/api/v1/plants", async (request, reply) =>
|
app.post("/api/v1/plants", async (request, reply) =>
|
||||||
reply
|
reply
|
||||||
.code(201)
|
.code(201)
|
||||||
.send(
|
.send(
|
||||||
await repository.createPlant(createPlantSchema.parse(request.body)),
|
await repository(request).createPlant(
|
||||||
|
createPlantSchema.parse(request.body),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
app.post("/api/v1/breeding", (request) =>
|
app.post("/api/v1/breeding", (request) =>
|
||||||
repository.cross(breedingRequestSchema.parse(request.body)),
|
request.identity!.id === DEMO_PLAYER_ID
|
||||||
|
? repository(request).cross(breedingRequestSchema.parse(request.body))
|
||||||
|
: new GameService(pool).cross(
|
||||||
|
request.identity!.id,
|
||||||
|
breedingRequestSchema.parse(request.body),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
app.get("/api/v1/breeding", (request) =>
|
app.get("/api/v1/breeding", (request) =>
|
||||||
repository.listEvents(pageQuerySchema.parse(request.query)),
|
repository(request).listEvents(pageQuerySchema.parse(request.query)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,7 @@ import type {
|
||||||
} from "@botanica/shared";
|
} from "@botanica/shared";
|
||||||
import { DEMO_PLAYER_ID } from "./constants.js";
|
import { DEMO_PLAYER_ID } from "./constants.js";
|
||||||
import { ApiError } from "./errors.js";
|
import { ApiError } from "./errors.js";
|
||||||
|
import { saleValue } from "./game-config.js";
|
||||||
export const generateSeed = () => randomBytes(4).readInt32BE();
|
export const generateSeed = () => randomBytes(4).readInt32BE();
|
||||||
export interface PlantRow extends QueryResultRow {
|
export interface PlantRow extends QueryResultRow {
|
||||||
id: string;
|
id: string;
|
||||||
|
|
@ -39,6 +40,11 @@ export interface PlantRow extends QueryResultRow {
|
||||||
created_at: Date;
|
created_at: Date;
|
||||||
cursor_time?: string;
|
cursor_time?: string;
|
||||||
sort_name?: string;
|
sort_name?: string;
|
||||||
|
game_eligible: boolean;
|
||||||
|
first_discovery: boolean;
|
||||||
|
is_favorite: boolean;
|
||||||
|
is_protected: boolean;
|
||||||
|
sold_at: Date | null;
|
||||||
creation_source: PlantDto["creationSource"];
|
creation_source: PlantDto["creationSource"];
|
||||||
rarity_policy_version: 1;
|
rarity_policy_version: 1;
|
||||||
rarity_score: number;
|
rarity_score: number;
|
||||||
|
|
@ -81,6 +87,14 @@ function plantDto(row: PlantRow): PlantDto {
|
||||||
: [],
|
: [],
|
||||||
createdAt: row.created_at.toISOString(),
|
createdAt: row.created_at.toISOString(),
|
||||||
creationSource: row.creation_source,
|
creationSource: row.creation_source,
|
||||||
|
gameEligible: row.game_eligible ?? false,
|
||||||
|
firstDiscovery: row.first_discovery ?? false,
|
||||||
|
saleCoins: row.game_eligible
|
||||||
|
? saleValue(row.rarity_tier, row.rarity_score)
|
||||||
|
: null,
|
||||||
|
isFavorite: row.is_favorite ?? false,
|
||||||
|
isProtected: row.is_protected ?? false,
|
||||||
|
soldAt: row.sold_at?.toISOString() ?? null,
|
||||||
rarity: {
|
rarity: {
|
||||||
score: row.rarity_score,
|
score: row.rarity_score,
|
||||||
tier: row.rarity_tier,
|
tier: row.rarity_tier,
|
||||||
|
|
@ -255,10 +269,13 @@ export class PlantRepository {
|
||||||
total,
|
total,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
async cross(input: BreedingRequest): Promise<BreedingResponse> {
|
async cross(
|
||||||
const client = await this.pool.connect();
|
input: BreedingRequest,
|
||||||
|
transaction?: PoolClient,
|
||||||
|
): Promise<BreedingResponse> {
|
||||||
|
const client = transaction ?? (await this.pool.connect());
|
||||||
try {
|
try {
|
||||||
await client.query("BEGIN");
|
if (!transaction) await client.query("BEGIN");
|
||||||
await client.query(
|
await client.query(
|
||||||
"SELECT pg_advisory_xact_lock(hashtextextended($1,0))",
|
"SELECT pg_advisory_xact_lock(hashtextextended($1,0))",
|
||||||
[this.ownerId + ":" + input.idempotencyKey],
|
[this.ownerId + ":" + input.idempotencyKey],
|
||||||
|
|
@ -280,7 +297,7 @@ export class PlantRepository {
|
||||||
"This request key already belongs to another parent pair.",
|
"This request key already belongs to another parent pair.",
|
||||||
);
|
);
|
||||||
const plant = await this.getPlant(existing.child_id, client);
|
const plant = await this.getPlant(existing.child_id, client);
|
||||||
await client.query("COMMIT");
|
if (!transaction) await client.query("COMMIT");
|
||||||
return { plant, event: eventDto(existing) };
|
return { plant, event: eventDto(existing) };
|
||||||
}
|
}
|
||||||
if (input.parentAId === input.parentBId)
|
if (input.parentAId === input.parentBId)
|
||||||
|
|
@ -292,7 +309,7 @@ export class PlantRepository {
|
||||||
// Read owned rows before interpreting their versioned genomes.
|
// Read owned rows before interpreting their versioned genomes.
|
||||||
const rows = (
|
const rows = (
|
||||||
await client.query<PlantRow>(
|
await client.query<PlantRow>(
|
||||||
"SELECT * FROM plants WHERE owner_id=$1 AND id=ANY($2::uuid[]) FOR SHARE",
|
"SELECT * FROM plants WHERE owner_id=$1 AND sold_at IS NULL AND id=ANY($2::uuid[]) FOR SHARE",
|
||||||
[this.ownerId, [input.parentAId, input.parentBId]],
|
[this.ownerId, [input.parentAId, input.parentBId]],
|
||||||
)
|
)
|
||||||
).rows;
|
).rows;
|
||||||
|
|
@ -302,7 +319,7 @@ export class PlantRepository {
|
||||||
throw new ApiError(
|
throw new ApiError(
|
||||||
404,
|
404,
|
||||||
"PLANT_NOT_FOUND",
|
"PLANT_NOT_FOUND",
|
||||||
"One or both parents are not in this sandbox.",
|
"One or both parents are unavailable in your collection.",
|
||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
if (!canBreed(a.species_id, b.species_id).compatible)
|
if (!canBreed(a.species_id, b.species_id).compatible)
|
||||||
|
|
@ -362,13 +379,13 @@ export class PlantRepository {
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
).rows[0];
|
).rows[0];
|
||||||
await client.query("COMMIT");
|
if (!transaction) await client.query("COMMIT");
|
||||||
return { plant, event: eventDto(event) };
|
return { plant, event: eventDto(event) };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await client.query("ROLLBACK");
|
if (!transaction) await client.query("ROLLBACK");
|
||||||
throw error;
|
throw error;
|
||||||
} finally {
|
} finally {
|
||||||
client.release();
|
if (!transaction) client.release();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import {
|
||||||
pgTable,
|
pgTable,
|
||||||
text,
|
text,
|
||||||
integer,
|
integer,
|
||||||
|
boolean,
|
||||||
uuid,
|
uuid,
|
||||||
jsonb,
|
jsonb,
|
||||||
timestamp,
|
timestamp,
|
||||||
|
|
@ -19,6 +20,11 @@ export const species = pgTable("species", {
|
||||||
export const players = pgTable("players", {
|
export const players = pgTable("players", {
|
||||||
id: uuid().primaryKey(),
|
id: uuid().primaryKey(),
|
||||||
displayName: text("display_name").notNull(),
|
displayName: text("display_name").notNull(),
|
||||||
|
normalizedUsername: text("normalized_username").unique(),
|
||||||
|
passwordHash: text("password_hash"),
|
||||||
|
coinBalance: integer("coin_balance").notNull().default(0),
|
||||||
|
xp: integer("xp").notNull().default(0),
|
||||||
|
gameStats: jsonb("game_stats").notNull().default({}),
|
||||||
createdAt: timestamp("created_at", { withTimezone: true })
|
createdAt: timestamp("created_at", { withTimezone: true })
|
||||||
.defaultNow()
|
.defaultNow()
|
||||||
.notNull(),
|
.notNull(),
|
||||||
|
|
@ -30,6 +36,11 @@ export const plants = pgTable(
|
||||||
name: text().notNull().default("Velvetleaf"),
|
name: text().notNull().default("Velvetleaf"),
|
||||||
creationSource: text("creation_source").notNull().default("designed"),
|
creationSource: text("creation_source").notNull().default("designed"),
|
||||||
rarityReasons: jsonb("rarity_reasons").notNull().default([]),
|
rarityReasons: jsonb("rarity_reasons").notNull().default([]),
|
||||||
|
gameEligible: boolean("game_eligible").notNull().default(false),
|
||||||
|
isFavorite: boolean("is_favorite").notNull().default(false),
|
||||||
|
isProtected: boolean("is_protected").notNull().default(false),
|
||||||
|
firstDiscovery: boolean("first_discovery").notNull().default(false),
|
||||||
|
soldAt: timestamp("sold_at", { withTimezone: true }),
|
||||||
ownerId: uuid("owner_id")
|
ownerId: uuid("owner_id")
|
||||||
.notNull()
|
.notNull()
|
||||||
.references(() => players.id),
|
.references(() => players.id),
|
||||||
|
|
@ -82,7 +93,7 @@ export const breedingEvents = pgTable(
|
||||||
},
|
},
|
||||||
(t) => [
|
(t) => [
|
||||||
unique().on(t.childId),
|
unique().on(t.childId),
|
||||||
unique().on(t.idempotencyKey),
|
unique("breeding_owner_request").on(t.ownerId, t.idempotencyKey),
|
||||||
index("breeding_owner_created").on(t.ownerId, t.createdAt.desc()),
|
index("breeding_owner_created").on(t.ownerId, t.createdAt.desc()),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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; }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ services:
|
||||||
"vitest",
|
"vitest",
|
||||||
"run",
|
"run",
|
||||||
"apps/server/src/persistence.integration.test.ts",
|
"apps/server/src/persistence.integration.test.ts",
|
||||||
|
"apps/server/src/game.integration.test.ts",
|
||||||
]
|
]
|
||||||
depends_on:
|
depends_on:
|
||||||
db:
|
db:
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ services:
|
||||||
HOST_PORT: ${HOST_PORT:-8155}
|
HOST_PORT: ${HOST_PORT:-8155}
|
||||||
DATABASE_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
|
DATABASE_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
|
||||||
PUBLIC_DOMAIN: ${PUBLIC_DOMAIN:-http://localhost}
|
PUBLIC_DOMAIN: ${PUBLIC_DOMAIN:-http://localhost}
|
||||||
|
PUBLIC_ORIGINS: ${PUBLIC_ORIGINS:-http://localhost:8155,http://127.0.0.1:8155,http://192.168.1.197:8155,https://botanica.p20.dev}
|
||||||
depends_on:
|
depends_on:
|
||||||
bootstrap:
|
bootstrap:
|
||||||
condition: service_completed_successfully
|
condition: service_completed_successfully
|
||||||
|
|
|
||||||
233
package-lock.json
generated
233
package-lock.json
generated
|
|
@ -25,6 +25,7 @@
|
||||||
"@botanica/genetics": "0.0.0",
|
"@botanica/genetics": "0.0.0",
|
||||||
"@botanica/renderer": "0.0.0",
|
"@botanica/renderer": "0.0.0",
|
||||||
"@botanica/shared": "0.0.0",
|
"@botanica/shared": "0.0.0",
|
||||||
|
"@node-rs/argon2": "^2.2.2",
|
||||||
"drizzle-orm": "^0.45.4",
|
"drizzle-orm": "^0.45.4",
|
||||||
"fastify": "^5.12.7",
|
"fastify": "^5.12.7",
|
||||||
"pg": "^8.16.3",
|
"pg": "^8.16.3",
|
||||||
|
|
@ -704,6 +705,238 @@
|
||||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@node-rs/argon2": {
|
||||||
|
"version": "2.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@node-rs/argon2/-/argon2-2.2.2.tgz",
|
||||||
|
"integrity": "sha512-NtdlPPWtR5U4BxX5EGJYza+UklNaoWORmkaKCMqf/nyKeXWKVwNt8KfQoRpNT7OB1DcEwmePafO4wZyW+sbfCQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@node-rs/argon2-android-arm-eabi": "2.2.2",
|
||||||
|
"@node-rs/argon2-android-arm64": "2.2.2",
|
||||||
|
"@node-rs/argon2-darwin-arm64": "2.2.2",
|
||||||
|
"@node-rs/argon2-darwin-x64": "2.2.2",
|
||||||
|
"@node-rs/argon2-freebsd-x64": "2.2.2",
|
||||||
|
"@node-rs/argon2-linux-arm-gnueabihf": "2.2.2",
|
||||||
|
"@node-rs/argon2-linux-arm64-gnu": "2.2.2",
|
||||||
|
"@node-rs/argon2-linux-arm64-musl": "2.2.2",
|
||||||
|
"@node-rs/argon2-linux-x64-gnu": "2.2.2",
|
||||||
|
"@node-rs/argon2-linux-x64-musl": "2.2.2",
|
||||||
|
"@node-rs/argon2-win32-arm64-msvc": "2.2.2",
|
||||||
|
"@node-rs/argon2-win32-ia32-msvc": "2.2.2",
|
||||||
|
"@node-rs/argon2-win32-x64-msvc": "2.2.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@node-rs/argon2-android-arm-eabi": {
|
||||||
|
"version": "2.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@node-rs/argon2-android-arm-eabi/-/argon2-android-arm-eabi-2.2.2.tgz",
|
||||||
|
"integrity": "sha512-c11BOrPLBWNC1cDyzGB+Bil+flmxfZbB2hv8EhcobmAsFKMyqkHPNhUY4qhcjv1U+4+Vmw4G8H+l224iO2g/Dw==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@node-rs/argon2-android-arm64": {
|
||||||
|
"version": "2.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@node-rs/argon2-android-arm64/-/argon2-android-arm64-2.2.2.tgz",
|
||||||
|
"integrity": "sha512-dpMYIoxhN/wY6hFH+3MM5kQErcH3luMekkszbkDF3NKqhB1kFRntkOLDpFY87oItBSHJrsoYkXC3vBRhjYoCjQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"android"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@node-rs/argon2-darwin-arm64": {
|
||||||
|
"version": "2.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@node-rs/argon2-darwin-arm64/-/argon2-darwin-arm64-2.2.2.tgz",
|
||||||
|
"integrity": "sha512-vxVAXcIG0iiHizkCuImmzVhOFsRnU4Z79xiOxdISLyI+QhOAWbbTaVSXw0PNfThB3zAdkzzDJ/WWu52Hs/qHGg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@node-rs/argon2-darwin-x64": {
|
||||||
|
"version": "2.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@node-rs/argon2-darwin-x64/-/argon2-darwin-x64-2.2.2.tgz",
|
||||||
|
"integrity": "sha512-r7GbSFpFZZ9EAV9cvIm9prUjOupK7QQXTyP+FJ6bWSMq0h+qRDBlUOXQXduDIIrLEGoBVelixKUtf4ksZ0ljoQ==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@node-rs/argon2-freebsd-x64": {
|
||||||
|
"version": "2.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@node-rs/argon2-freebsd-x64/-/argon2-freebsd-x64-2.2.2.tgz",
|
||||||
|
"integrity": "sha512-AmWxDSmQgprilD6NX3jmxCgcDvWAW7kGJnnWQo+0qav6nfMXdPZYgSezK/skmw1GBWipr4yZMIUzpeNHnVpMxg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"freebsd"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@node-rs/argon2-linux-arm-gnueabihf": {
|
||||||
|
"version": "2.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@node-rs/argon2-linux-arm-gnueabihf/-/argon2-linux-arm-gnueabihf-2.2.2.tgz",
|
||||||
|
"integrity": "sha512-M1gnPBTr9GFeEUWkQ53qCJeTsX03C3kcUeHnDbN7dU2xkI58wMP4Z1sEBN7WmKJ/fTHYEgti4truz7r4A82DLg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@node-rs/argon2-linux-arm64-gnu": {
|
||||||
|
"version": "2.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@node-rs/argon2-linux-arm64-gnu/-/argon2-linux-arm64-gnu-2.2.2.tgz",
|
||||||
|
"integrity": "sha512-4CYDlIdITlLSVcAXYUtEoGUahRaayPXM+4Rcfg3BZx9WGi6UB8HUycs6GNMTPYXV5kVXCpyE2u1HmYUpgmIzOQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@node-rs/argon2-linux-arm64-musl": {
|
||||||
|
"version": "2.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@node-rs/argon2-linux-arm64-musl/-/argon2-linux-arm64-musl-2.2.2.tgz",
|
||||||
|
"integrity": "sha512-FYlfj3XgQ1xrx6HzQbKbUcKiYMG/3iSi/X81KyhHaItTJ7vZlMwrbVLl9yqDmvotKUF6VjrhrlZ8KRbbzchseA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@node-rs/argon2-linux-x64-gnu": {
|
||||||
|
"version": "2.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@node-rs/argon2-linux-x64-gnu/-/argon2-linux-x64-gnu-2.2.2.tgz",
|
||||||
|
"integrity": "sha512-ULXLs8UFYZANlwiROM7AeccxZndr2Xk3IstADq3jAUhnwV2W75mxMBTABUZ+7AdKRhPLOrxU77OgnMWlDkYJQg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@node-rs/argon2-linux-x64-musl": {
|
||||||
|
"version": "2.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@node-rs/argon2-linux-x64-musl/-/argon2-linux-x64-musl-2.2.2.tgz",
|
||||||
|
"integrity": "sha512-zgjIwc9TyGodn62vGc+36DGwFqVV5e7L8Ptd2FQWuBxe5aFrcFxvtDGn1/qfYwoHLv09utgtR+RBAPCXepWG3Q==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@node-rs/argon2-win32-arm64-msvc": {
|
||||||
|
"version": "2.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@node-rs/argon2-win32-arm64-msvc/-/argon2-win32-arm64-msvc-2.2.2.tgz",
|
||||||
|
"integrity": "sha512-3nLhvLie/ec5xfxUBXXqGMb7oGeqF+YXrcGckqaTaGkPtOBozho0Kh2IuP/zi9BcOriirTG2LW3gtIOfpuh+mQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@node-rs/argon2-win32-ia32-msvc": {
|
||||||
|
"version": "2.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@node-rs/argon2-win32-ia32-msvc/-/argon2-win32-ia32-msvc-2.2.2.tgz",
|
||||||
|
"integrity": "sha512-6RJKD6cFl7UeiYJLjRsXPywVXSFSaSDdr6xGpztNDV31QHCEecWnsnNmKxzi4gVpln2F9XeWEtIgM4Nqw1hLbA==",
|
||||||
|
"cpu": [
|
||||||
|
"ia32"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@node-rs/argon2-win32-x64-msvc": {
|
||||||
|
"version": "2.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@node-rs/argon2-win32-x64-msvc/-/argon2-win32-x64-msvc-2.2.2.tgz",
|
||||||
|
"integrity": "sha512-dQOO9S4QKBaVul0Uyk/2quJc/OBeHNZRJP+GZZTy1A5kYIaAfIJGHUYLY6oFoAlcuLIkdFouLaHX3fCl8tZm/w==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@oxc-project/types": {
|
"node_modules/@oxc-project/types": {
|
||||||
"version": "0.155.0",
|
"version": "0.155.0",
|
||||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.155.0.tgz",
|
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.155.0.tgz",
|
||||||
|
|
|
||||||
116
packages/shared/src/game.ts
Normal file
116
packages/shared/src/game.ts
Normal 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;
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
|
@ -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";
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue