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_PASSWORD=change-me-before-exposing
|
||||
NODE_ENV=development
|
||||
# Optional comma-separated exact browser origins for authenticated POST requests.
|
||||
# PUBLIC_DOMAIN remains an origin without a port. Add Vite's origin for npm run dev.
|
||||
# PUBLIC_ORIGINS=http://localhost:8155,http://localhost:5173,http://127.0.0.1:8155,http://192.168.1.197:8155,https://botanica.p20.dev
|
||||
|
|
|
|||
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"
|
||||
},
|
||||
"dependencies": {
|
||||
"fastify": "^5.12.7",
|
||||
"zod": "^4.0.0",
|
||||
"drizzle-orm": "^0.45.4",
|
||||
"pg": "^8.16.3",
|
||||
"@botanica/genetics": "0.0.0",
|
||||
"@botanica/renderer": "0.0.0",
|
||||
"@botanica/shared": "0.0.0",
|
||||
"@botanica/renderer": "0.0.0"
|
||||
"@node-rs/argon2": "^2.2.2",
|
||||
"drizzle-orm": "^0.45.4",
|
||||
"fastify": "^5.12.7",
|
||||
"pg": "^8.16.3",
|
||||
"zod": "^4.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/pg": "^8.15.5",
|
||||
|
|
|
|||
|
|
@ -1,3 +1,11 @@
|
|||
import {
|
||||
registerAuth,
|
||||
authenticate,
|
||||
checkCsrf,
|
||||
type Identity,
|
||||
} from "./auth.js";
|
||||
import { registerGameRoutes } from "./game-routes.js";
|
||||
import type { FastifyRequest } from "fastify";
|
||||
import type { Pool } from "pg";
|
||||
import { ApiError } from "./errors.js";
|
||||
import { registerPlantRoutes } from "./plant-routes.js";
|
||||
|
|
@ -10,8 +18,21 @@ export function createApp(
|
|||
checkDatabase: () => Promise<unknown>,
|
||||
logger = false,
|
||||
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) => {
|
||||
try {
|
||||
await checkDatabase();
|
||||
|
|
@ -27,7 +48,33 @@ export function createApp(
|
|||
});
|
||||
registerGeneticsRoutes(app);
|
||||
registerPreviewRoute(app);
|
||||
if (pool) registerPlantRoutes(app, pool);
|
||||
if (pool) {
|
||||
registerAuth(app, pool);
|
||||
app.addHook("preHandler", async (request) => {
|
||||
const path = request.url.split("?")[0];
|
||||
if (
|
||||
!path.startsWith("/api/v1/") ||
|
||||
path.startsWith("/api/v1/auth/") ||
|
||||
[
|
||||
"/api/v1/me",
|
||||
"/api/v1/catalog/species",
|
||||
"/api/v1/phenotype/resolve",
|
||||
"/api/v1/preview",
|
||||
].includes(path)
|
||||
)
|
||||
return;
|
||||
request.identity = identityForTest
|
||||
? await identityForTest(request)
|
||||
: await authenticate(pool, request);
|
||||
if (
|
||||
!["GET", "HEAD", "OPTIONS"].includes(request.method) &&
|
||||
!identityForTest
|
||||
)
|
||||
checkCsrf(request, request.identity);
|
||||
});
|
||||
registerPlantRoutes(app, pool);
|
||||
registerGameRoutes(app, pool);
|
||||
}
|
||||
app.setNotFoundHandler((_request, reply) =>
|
||||
reply
|
||||
.code(404)
|
||||
|
|
|
|||
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") +
|
||||
" AS resolved_pigment, " +
|
||||
trait("leafPattern") +
|
||||
" AS resolved_pattern FROM plants p WHERE owner_id=$1) ";
|
||||
" AS resolved_pattern FROM plants p WHERE owner_id=$1 AND sold_at IS NULL) ";
|
||||
const conditions: string[] = [];
|
||||
for (const [column, value] of [
|
||||
["species_id", query.species],
|
||||
|
|
@ -159,7 +159,7 @@ export async function collectionStats(
|
|||
rarity_tier: NonNullable<keyof CollectionStats["byRarity"]>;
|
||||
count: string;
|
||||
}>(
|
||||
"SELECT rarity_tier,count(*) FROM plants WHERE owner_id=$1 GROUP BY rarity_tier",
|
||||
"SELECT rarity_tier,count(*) FROM plants WHERE owner_id=$1 AND sold_at IS NULL GROUP BY rarity_tier",
|
||||
[ownerId],
|
||||
)
|
||||
).rows;
|
||||
|
|
|
|||
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",
|
||||
() => {
|
||||
const pool = new pg.Pool({ connectionString: databaseUrl });
|
||||
const app = createApp(() => pool.query("SELECT 1"), false, pool);
|
||||
const app = createApp(
|
||||
() => pool.query("SELECT 1"),
|
||||
false,
|
||||
pool,
|
||||
async () => ({
|
||||
id: DEMO_PLAYER_ID,
|
||||
username: "test-sandbox",
|
||||
csrfToken: "test-only",
|
||||
}),
|
||||
);
|
||||
let childId = "";
|
||||
beforeAll(async () => {
|
||||
await migrate(pool);
|
||||
|
|
@ -29,7 +38,7 @@ describe.skipIf(!databaseUrl)(
|
|||
(await pool.query("SELECT count(*) FROM schema_migrations")).rows[0]
|
||||
.count,
|
||||
),
|
||||
).toBe(2);
|
||||
).toBe(3);
|
||||
expect(
|
||||
Number(
|
||||
(await pool.query("SELECT count(*) FROM players")).rows[0].count,
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import { GameService } from "./game-service.js";
|
||||
import { DEMO_PLAYER_ID } from "./constants.js";
|
||||
import { z } from "zod";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import type { Pool } from "pg";
|
||||
|
|
@ -9,28 +11,40 @@ import {
|
|||
} from "@botanica/shared";
|
||||
import { PlantRepository } from "./plants.js";
|
||||
export function registerPlantRoutes(app: FastifyInstance, pool: Pool) {
|
||||
const repository = new PlantRepository(pool);
|
||||
const repository = (request: import("fastify").FastifyRequest) =>
|
||||
new PlantRepository(pool, request.identity!.id);
|
||||
app.get("/api/v1/plants", (request) =>
|
||||
repository.listPlants(plantListQuerySchema.parse(request.query)),
|
||||
repository(request).listPlants(plantListQuerySchema.parse(request.query)),
|
||||
);
|
||||
app.get("/api/v1/collection/stats", () => repository.stats());
|
||||
app.get("/api/v1/collection/stats", (request) => repository(request).stats());
|
||||
app.get("/api/v1/plants/:id/birth", (request) =>
|
||||
repository.birthEvent(z.object({ id: z.uuid() }).parse(request.params).id),
|
||||
repository(request).birthEvent(
|
||||
z.object({ id: z.uuid() }).parse(request.params).id,
|
||||
),
|
||||
);
|
||||
app.get("/api/v1/plants/:id", (request) =>
|
||||
repository.getPlant(z.object({ id: z.uuid() }).parse(request.params).id),
|
||||
repository(request).getPlant(
|
||||
z.object({ id: z.uuid() }).parse(request.params).id,
|
||||
),
|
||||
);
|
||||
app.post("/api/v1/plants", async (request, reply) =>
|
||||
reply
|
||||
.code(201)
|
||||
.send(
|
||||
await repository.createPlant(createPlantSchema.parse(request.body)),
|
||||
await repository(request).createPlant(
|
||||
createPlantSchema.parse(request.body),
|
||||
),
|
||||
),
|
||||
);
|
||||
app.post("/api/v1/breeding", (request) =>
|
||||
repository.cross(breedingRequestSchema.parse(request.body)),
|
||||
request.identity!.id === DEMO_PLAYER_ID
|
||||
? repository(request).cross(breedingRequestSchema.parse(request.body))
|
||||
: new GameService(pool).cross(
|
||||
request.identity!.id,
|
||||
breedingRequestSchema.parse(request.body),
|
||||
),
|
||||
);
|
||||
app.get("/api/v1/breeding", (request) =>
|
||||
repository.listEvents(pageQuerySchema.parse(request.query)),
|
||||
repository(request).listEvents(pageQuerySchema.parse(request.query)),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import type {
|
|||
} from "@botanica/shared";
|
||||
import { DEMO_PLAYER_ID } from "./constants.js";
|
||||
import { ApiError } from "./errors.js";
|
||||
import { saleValue } from "./game-config.js";
|
||||
export const generateSeed = () => randomBytes(4).readInt32BE();
|
||||
export interface PlantRow extends QueryResultRow {
|
||||
id: string;
|
||||
|
|
@ -39,6 +40,11 @@ export interface PlantRow extends QueryResultRow {
|
|||
created_at: Date;
|
||||
cursor_time?: string;
|
||||
sort_name?: string;
|
||||
game_eligible: boolean;
|
||||
first_discovery: boolean;
|
||||
is_favorite: boolean;
|
||||
is_protected: boolean;
|
||||
sold_at: Date | null;
|
||||
creation_source: PlantDto["creationSource"];
|
||||
rarity_policy_version: 1;
|
||||
rarity_score: number;
|
||||
|
|
@ -81,6 +87,14 @@ function plantDto(row: PlantRow): PlantDto {
|
|||
: [],
|
||||
createdAt: row.created_at.toISOString(),
|
||||
creationSource: row.creation_source,
|
||||
gameEligible: row.game_eligible ?? false,
|
||||
firstDiscovery: row.first_discovery ?? false,
|
||||
saleCoins: row.game_eligible
|
||||
? saleValue(row.rarity_tier, row.rarity_score)
|
||||
: null,
|
||||
isFavorite: row.is_favorite ?? false,
|
||||
isProtected: row.is_protected ?? false,
|
||||
soldAt: row.sold_at?.toISOString() ?? null,
|
||||
rarity: {
|
||||
score: row.rarity_score,
|
||||
tier: row.rarity_tier,
|
||||
|
|
@ -255,10 +269,13 @@ export class PlantRepository {
|
|||
total,
|
||||
};
|
||||
}
|
||||
async cross(input: BreedingRequest): Promise<BreedingResponse> {
|
||||
const client = await this.pool.connect();
|
||||
async cross(
|
||||
input: BreedingRequest,
|
||||
transaction?: PoolClient,
|
||||
): Promise<BreedingResponse> {
|
||||
const client = transaction ?? (await this.pool.connect());
|
||||
try {
|
||||
await client.query("BEGIN");
|
||||
if (!transaction) await client.query("BEGIN");
|
||||
await client.query(
|
||||
"SELECT pg_advisory_xact_lock(hashtextextended($1,0))",
|
||||
[this.ownerId + ":" + input.idempotencyKey],
|
||||
|
|
@ -280,7 +297,7 @@ export class PlantRepository {
|
|||
"This request key already belongs to another parent pair.",
|
||||
);
|
||||
const plant = await this.getPlant(existing.child_id, client);
|
||||
await client.query("COMMIT");
|
||||
if (!transaction) await client.query("COMMIT");
|
||||
return { plant, event: eventDto(existing) };
|
||||
}
|
||||
if (input.parentAId === input.parentBId)
|
||||
|
|
@ -292,7 +309,7 @@ export class PlantRepository {
|
|||
// Read owned rows before interpreting their versioned genomes.
|
||||
const rows = (
|
||||
await client.query<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]],
|
||||
)
|
||||
).rows;
|
||||
|
|
@ -302,7 +319,7 @@ export class PlantRepository {
|
|||
throw new ApiError(
|
||||
404,
|
||||
"PLANT_NOT_FOUND",
|
||||
"One or both parents are not in this sandbox.",
|
||||
"One or both parents are unavailable in your collection.",
|
||||
);
|
||||
try {
|
||||
if (!canBreed(a.species_id, b.species_id).compatible)
|
||||
|
|
@ -362,13 +379,13 @@ export class PlantRepository {
|
|||
],
|
||||
)
|
||||
).rows[0];
|
||||
await client.query("COMMIT");
|
||||
if (!transaction) await client.query("COMMIT");
|
||||
return { plant, event: eventDto(event) };
|
||||
} catch (error) {
|
||||
await client.query("ROLLBACK");
|
||||
if (!transaction) await client.query("ROLLBACK");
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
if (!transaction) client.release();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import {
|
|||
pgTable,
|
||||
text,
|
||||
integer,
|
||||
boolean,
|
||||
uuid,
|
||||
jsonb,
|
||||
timestamp,
|
||||
|
|
@ -19,6 +20,11 @@ export const species = pgTable("species", {
|
|||
export const players = pgTable("players", {
|
||||
id: uuid().primaryKey(),
|
||||
displayName: text("display_name").notNull(),
|
||||
normalizedUsername: text("normalized_username").unique(),
|
||||
passwordHash: text("password_hash"),
|
||||
coinBalance: integer("coin_balance").notNull().default(0),
|
||||
xp: integer("xp").notNull().default(0),
|
||||
gameStats: jsonb("game_stats").notNull().default({}),
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
.defaultNow()
|
||||
.notNull(),
|
||||
|
|
@ -30,6 +36,11 @@ export const plants = pgTable(
|
|||
name: text().notNull().default("Velvetleaf"),
|
||||
creationSource: text("creation_source").notNull().default("designed"),
|
||||
rarityReasons: jsonb("rarity_reasons").notNull().default([]),
|
||||
gameEligible: boolean("game_eligible").notNull().default(false),
|
||||
isFavorite: boolean("is_favorite").notNull().default(false),
|
||||
isProtected: boolean("is_protected").notNull().default(false),
|
||||
firstDiscovery: boolean("first_discovery").notNull().default(false),
|
||||
soldAt: timestamp("sold_at", { withTimezone: true }),
|
||||
ownerId: uuid("owner_id")
|
||||
.notNull()
|
||||
.references(() => players.id),
|
||||
|
|
@ -82,7 +93,7 @@ export const breedingEvents = pgTable(
|
|||
},
|
||||
(t) => [
|
||||
unique().on(t.childId),
|
||||
unique().on(t.idempotencyKey),
|
||||
unique("breeding_owner_request").on(t.ownerId, t.idempotencyKey),
|
||||
index("breeding_owner_created").on(t.ownerId, t.createdAt.desc()),
|
||||
],
|
||||
);
|
||||
|
|
|
|||
|
|
@ -7,6 +7,10 @@ server {
|
|||
add_header X-Content-Type-Options nosniff always;
|
||||
add_header Referrer-Policy same-origin always;
|
||||
location = /health { proxy_pass http://api:3000/health; }
|
||||
location /api/ { proxy_pass http://api:3000; }
|
||||
location /api/ {
|
||||
proxy_set_header Host $http_host;
|
||||
proxy_set_header X-Forwarded-Proto $http_x_forwarded_proto;
|
||||
proxy_pass http://api:3000;
|
||||
}
|
||||
location / { try_files $uri $uri/ /index.html; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ services:
|
|||
"vitest",
|
||||
"run",
|
||||
"apps/server/src/persistence.integration.test.ts",
|
||||
"apps/server/src/game.integration.test.ts",
|
||||
]
|
||||
depends_on:
|
||||
db:
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ services:
|
|||
HOST_PORT: ${HOST_PORT:-8155}
|
||||
DATABASE_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
|
||||
PUBLIC_DOMAIN: ${PUBLIC_DOMAIN:-http://localhost}
|
||||
PUBLIC_ORIGINS: ${PUBLIC_ORIGINS:-http://localhost:8155,http://127.0.0.1:8155,http://192.168.1.197:8155,https://botanica.p20.dev}
|
||||
depends_on:
|
||||
bootstrap:
|
||||
condition: service_completed_successfully
|
||||
|
|
|
|||
233
package-lock.json
generated
233
package-lock.json
generated
|
|
@ -25,6 +25,7 @@
|
|||
"@botanica/genetics": "0.0.0",
|
||||
"@botanica/renderer": "0.0.0",
|
||||
"@botanica/shared": "0.0.0",
|
||||
"@node-rs/argon2": "^2.2.2",
|
||||
"drizzle-orm": "^0.45.4",
|
||||
"fastify": "^5.12.7",
|
||||
"pg": "^8.16.3",
|
||||
|
|
@ -704,6 +705,238 @@
|
|||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@node-rs/argon2": {
|
||||
"version": "2.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@node-rs/argon2/-/argon2-2.2.2.tgz",
|
||||
"integrity": "sha512-NtdlPPWtR5U4BxX5EGJYza+UklNaoWORmkaKCMqf/nyKeXWKVwNt8KfQoRpNT7OB1DcEwmePafO4wZyW+sbfCQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@node-rs/argon2-android-arm-eabi": "2.2.2",
|
||||
"@node-rs/argon2-android-arm64": "2.2.2",
|
||||
"@node-rs/argon2-darwin-arm64": "2.2.2",
|
||||
"@node-rs/argon2-darwin-x64": "2.2.2",
|
||||
"@node-rs/argon2-freebsd-x64": "2.2.2",
|
||||
"@node-rs/argon2-linux-arm-gnueabihf": "2.2.2",
|
||||
"@node-rs/argon2-linux-arm64-gnu": "2.2.2",
|
||||
"@node-rs/argon2-linux-arm64-musl": "2.2.2",
|
||||
"@node-rs/argon2-linux-x64-gnu": "2.2.2",
|
||||
"@node-rs/argon2-linux-x64-musl": "2.2.2",
|
||||
"@node-rs/argon2-win32-arm64-msvc": "2.2.2",
|
||||
"@node-rs/argon2-win32-ia32-msvc": "2.2.2",
|
||||
"@node-rs/argon2-win32-x64-msvc": "2.2.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@node-rs/argon2-android-arm-eabi": {
|
||||
"version": "2.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@node-rs/argon2-android-arm-eabi/-/argon2-android-arm-eabi-2.2.2.tgz",
|
||||
"integrity": "sha512-c11BOrPLBWNC1cDyzGB+Bil+flmxfZbB2hv8EhcobmAsFKMyqkHPNhUY4qhcjv1U+4+Vmw4G8H+l224iO2g/Dw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@node-rs/argon2-android-arm64": {
|
||||
"version": "2.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@node-rs/argon2-android-arm64/-/argon2-android-arm64-2.2.2.tgz",
|
||||
"integrity": "sha512-dpMYIoxhN/wY6hFH+3MM5kQErcH3luMekkszbkDF3NKqhB1kFRntkOLDpFY87oItBSHJrsoYkXC3vBRhjYoCjQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@node-rs/argon2-darwin-arm64": {
|
||||
"version": "2.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@node-rs/argon2-darwin-arm64/-/argon2-darwin-arm64-2.2.2.tgz",
|
||||
"integrity": "sha512-vxVAXcIG0iiHizkCuImmzVhOFsRnU4Z79xiOxdISLyI+QhOAWbbTaVSXw0PNfThB3zAdkzzDJ/WWu52Hs/qHGg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@node-rs/argon2-darwin-x64": {
|
||||
"version": "2.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@node-rs/argon2-darwin-x64/-/argon2-darwin-x64-2.2.2.tgz",
|
||||
"integrity": "sha512-r7GbSFpFZZ9EAV9cvIm9prUjOupK7QQXTyP+FJ6bWSMq0h+qRDBlUOXQXduDIIrLEGoBVelixKUtf4ksZ0ljoQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@node-rs/argon2-freebsd-x64": {
|
||||
"version": "2.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@node-rs/argon2-freebsd-x64/-/argon2-freebsd-x64-2.2.2.tgz",
|
||||
"integrity": "sha512-AmWxDSmQgprilD6NX3jmxCgcDvWAW7kGJnnWQo+0qav6nfMXdPZYgSezK/skmw1GBWipr4yZMIUzpeNHnVpMxg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@node-rs/argon2-linux-arm-gnueabihf": {
|
||||
"version": "2.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@node-rs/argon2-linux-arm-gnueabihf/-/argon2-linux-arm-gnueabihf-2.2.2.tgz",
|
||||
"integrity": "sha512-M1gnPBTr9GFeEUWkQ53qCJeTsX03C3kcUeHnDbN7dU2xkI58wMP4Z1sEBN7WmKJ/fTHYEgti4truz7r4A82DLg==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@node-rs/argon2-linux-arm64-gnu": {
|
||||
"version": "2.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@node-rs/argon2-linux-arm64-gnu/-/argon2-linux-arm64-gnu-2.2.2.tgz",
|
||||
"integrity": "sha512-4CYDlIdITlLSVcAXYUtEoGUahRaayPXM+4Rcfg3BZx9WGi6UB8HUycs6GNMTPYXV5kVXCpyE2u1HmYUpgmIzOQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@node-rs/argon2-linux-arm64-musl": {
|
||||
"version": "2.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@node-rs/argon2-linux-arm64-musl/-/argon2-linux-arm64-musl-2.2.2.tgz",
|
||||
"integrity": "sha512-FYlfj3XgQ1xrx6HzQbKbUcKiYMG/3iSi/X81KyhHaItTJ7vZlMwrbVLl9yqDmvotKUF6VjrhrlZ8KRbbzchseA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@node-rs/argon2-linux-x64-gnu": {
|
||||
"version": "2.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@node-rs/argon2-linux-x64-gnu/-/argon2-linux-x64-gnu-2.2.2.tgz",
|
||||
"integrity": "sha512-ULXLs8UFYZANlwiROM7AeccxZndr2Xk3IstADq3jAUhnwV2W75mxMBTABUZ+7AdKRhPLOrxU77OgnMWlDkYJQg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@node-rs/argon2-linux-x64-musl": {
|
||||
"version": "2.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@node-rs/argon2-linux-x64-musl/-/argon2-linux-x64-musl-2.2.2.tgz",
|
||||
"integrity": "sha512-zgjIwc9TyGodn62vGc+36DGwFqVV5e7L8Ptd2FQWuBxe5aFrcFxvtDGn1/qfYwoHLv09utgtR+RBAPCXepWG3Q==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@node-rs/argon2-win32-arm64-msvc": {
|
||||
"version": "2.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@node-rs/argon2-win32-arm64-msvc/-/argon2-win32-arm64-msvc-2.2.2.tgz",
|
||||
"integrity": "sha512-3nLhvLie/ec5xfxUBXXqGMb7oGeqF+YXrcGckqaTaGkPtOBozho0Kh2IuP/zi9BcOriirTG2LW3gtIOfpuh+mQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@node-rs/argon2-win32-ia32-msvc": {
|
||||
"version": "2.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@node-rs/argon2-win32-ia32-msvc/-/argon2-win32-ia32-msvc-2.2.2.tgz",
|
||||
"integrity": "sha512-6RJKD6cFl7UeiYJLjRsXPywVXSFSaSDdr6xGpztNDV31QHCEecWnsnNmKxzi4gVpln2F9XeWEtIgM4Nqw1hLbA==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@node-rs/argon2-win32-x64-msvc": {
|
||||
"version": "2.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@node-rs/argon2-win32-x64-msvc/-/argon2-win32-x64-msvc-2.2.2.tgz",
|
||||
"integrity": "sha512-dQOO9S4QKBaVul0Uyk/2quJc/OBeHNZRJP+GZZTy1A5kYIaAfIJGHUYLY6oFoAlcuLIkdFouLaHX3fCl8tZm/w==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/@oxc-project/types": {
|
||||
"version": "0.155.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.155.0.tgz",
|
||||
|
|
|
|||
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),
|
||||
});
|
||||
export interface PlantDto {
|
||||
gameEligible?: boolean;
|
||||
firstDiscovery?: boolean;
|
||||
saleCoins?: number | null;
|
||||
isFavorite?: boolean;
|
||||
isProtected?: boolean;
|
||||
soldAt?: string | null;
|
||||
id: string;
|
||||
name: string;
|
||||
ownerId: string;
|
||||
|
|
@ -92,3 +98,4 @@ export interface CollectionStats {
|
|||
total: number;
|
||||
byRarity: Partial<Record<PlantDto["rarity"]["tier"], number>>;
|
||||
}
|
||||
export * from "./game.js";
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue