Botanica/apps/server/src/game-service.ts

1077 lines
35 KiB
TypeScript

import { randomUUID } from "node:crypto";
import type { Pool, PoolClient } from "pg";
import { z } from "zod";
import { resolvePhenotype, canBreed, type Genome } from "@botanica/genetics";
import {
seedTypes,
type GameState,
type GameAction,
type WorkerPolicy,
type OfflineSummary,
type GameWorker,
} from "@botanica/shared";
import {
GAME,
SEEDS,
QUESTS,
RESEARCH,
levelFor,
saleValue,
nextSlotCost,
} from "./game-config.js";
import { PlantRepository, insertPlant, generateSeed } from "./plants.js";
import { ApiError } from "./errors.js";
import { digest, type Identity } from "./auth.js";
type Client = PoolClient;
interface Player {
id: string;
coin_balance: number;
xp: number;
game_stats: Record<string, number>;
}
interface Worker {
id: string;
kind: "gardener" | "breeder";
greenhouse_id: string | null;
enabled: boolean;
policy: WorkerPolicy;
last_processed_at: Date;
irrigation?: boolean;
}
function fail(code: string, message: string): never {
throw new ApiError(409, code, message);
}
const idSchema = z.strictObject({ plantId: z.uuid() });
const assignSchema = z.strictObject({
workerId: z.uuid(),
greenhouseId: z.uuid(),
enabled: z.boolean(),
policy: z.strictObject({
mode: z.enum(["repeat_crop", "repeat_cross", "target_trait"]),
seedType: z.enum(seedTypes).default("starter"),
parentAId: z.uuid().optional(),
parentBId: z.uuid().optional(),
targetPigment: z.enum(["green", "purple", "red", "yellow"]).optional(),
targetPattern: z.enum(["solid", "variegated", "marbled"]).optional(),
autoSell: z.boolean().default(false),
reserveCoins: z.number().int().min(0).max(1000000).default(100),
}),
});
export class GameService {
constructor(
private pool: Pool,
private clock: () => Date = () => new Date(),
) {}
async transaction<T>(
owner: string,
operation: (c: Client, p: Player, now: Date) => Promise<T>,
) {
const c = await this.pool.connect();
try {
await c.query("BEGIN");
const p = (
await c.query<Player>(
"SELECT id,coin_balance,xp,game_stats FROM players WHERE id=$1 FOR UPDATE",
[owner],
)
).rows[0];
if (!p) throw new ApiError(401, "AUTH_REQUIRED", "Sign in again.");
const result = await operation(c, p, this.clock());
await c.query("COMMIT");
return result;
} catch (error) {
await c.query("ROLLBACK");
throw error;
} finally {
c.release();
}
}
async money(
c: Client,
p: Player,
amount: number,
reason: string,
reference: string,
) {
if (!Number.isSafeInteger(amount)) throw new Error("Invalid ledger amount");
const row = (
await c.query(
"UPDATE players SET coin_balance=coin_balance+$2 WHERE id=$1 AND coin_balance+$2 BETWEEN 0 AND 1000000000 RETURNING coin_balance",
[p.id, amount],
)
).rows[0];
if (!row)
fail(
"INSUFFICIENT_COINS",
"Not enough coins, or the coin storage limit was reached.",
);
p.coin_balance = row.coin_balance;
await c.query(
"INSERT INTO coin_ledger(id,player_id,amount,balance_after,reason,reference) VALUES($1,$2,$3,$4,$5,$6)",
[randomUUID(), p.id, amount, p.coin_balance, reason, reference],
);
}
async progress(c: Client, p: Player, stat: string, amount = 1, xp = 0) {
p.game_stats[stat] = (p.game_stats[stat] ?? 0) + amount;
p.xp += xp;
await c.query("UPDATE players SET game_stats=$2,xp=$3 WHERE id=$1", [
p.id,
JSON.stringify(p.game_stats),
p.xp,
]);
}
requireLevel(p: Player, level: number) {
if (levelFor(p.xp).level < level)
fail("LEVEL_LOCKED", "Reach level " + level + " to unlock this action.");
}
async researched(c: Client, owner: string, id: string) {
return !!(
await c.query(
"SELECT 1 FROM player_research WHERE player_id=$1 AND research_id=$2",
[owner, id],
)
).rowCount;
}
async discovery(
c: Client,
p: Player,
plant: { id: string; phenotype: ReturnType<typeof resolvePhenotype> },
) {
const { catalogVersion, speciesId, ...traits } = plant.phenotype;
const signature = JSON.stringify({ speciesId, catalogVersion, ...traits });
if (
(
await c.query(
"INSERT INTO discoveries(player_id,signature,plant_id) VALUES($1,$2,$3) ON CONFLICT DO NOTHING RETURNING signature",
[p.id, signature, plant.id],
)
).rowCount
) {
await c.query(
"UPDATE plants SET first_discovery=true WHERE id=$1 AND owner_id=$2",
[plant.id, p.id],
);
await this.money(c, p, 10, "discovery", plant.id);
await this.progress(
c,
p,
"discoveries",
1,
25 + ((await this.researched(c, p.id, "botany")) ? 10 : 0),
);
return true;
}
return false;
}
async plant(
c: Client,
p: Player,
slotId: string,
seedType: keyof typeof SEEDS,
now: Date,
) {
const slot = (
await c.query(
"SELECT s.*,g.lamp FROM plant_slots s JOIN greenhouses g ON g.id=s.greenhouse_id WHERE s.id=$1 AND g.player_id=$2 FOR UPDATE OF s",
[slotId, p.id],
)
).rows[0];
if (!slot)
throw new ApiError(404, "SLOT_NOT_FOUND", "Growing slot not found.");
if (slot.seed_type)
fail("SLOT_OCCUPIED", "This slot already has a growing plant.");
const used = await c.query(
"UPDATE inventory_seeds SET quantity=quantity-1 WHERE player_id=$1 AND seed_type=$2 AND quantity>0 RETURNING quantity",
[p.id, seedType],
);
if (!used.rowCount)
fail(
"NO_SEEDS",
"No " +
seedType +
" seeds available. Harvest a crop to recycle a starter seed.",
);
this.requireLevel(p, SEEDS[seedType].level);
const seconds =
(p.game_stats.planted ?? 0) === 0
? GAME.firstGrowSeconds
: SEEDS[seedType].seconds;
await c.query(
"UPDATE plant_slots SET seed_type=$2,genome=$3,visual_seed=$4,planted_at=$5,ready_at=$6 WHERE id=$1",
[
slotId,
seedType,
JSON.stringify(SEEDS[seedType].genome),
generateSeed(),
now,
new Date(now.getTime() + seconds * 1000 * (slot.lamp ? 0.9 : 1)),
],
);
await this.progress(c, p, "planted");
return { message: "Seed planted.", slotId };
}
async harvest(c: Client, p: Player, slotId: string, now: Date) {
const slot = (
await c.query(
"SELECT s.* FROM plant_slots s JOIN greenhouses g ON g.id=s.greenhouse_id WHERE s.id=$1 AND g.player_id=$2 FOR UPDATE OF s",
[slotId, p.id],
)
).rows[0];
if (!slot)
throw new ApiError(404, "SLOT_NOT_FOUND", "Growing slot not found.");
if (!slot.seed_type) fail("NOT_GROWING", "This slot is empty.");
if (new Date(slot.ready_at) > now)
fail("NOT_READY", "This plant is still growing.");
const plant = (await insertPlant(
c,
{
name:
"Velvetleaf " +
String((p.game_stats.harvested ?? 0) + 1).padStart(3, "0"),
speciesId: "velvetleaf",
genome: slot.genome,
visualSeed: slot.visual_seed,
creationSource: "designed",
},
p.id,
))!;
await c.query(
"UPDATE plants SET game_eligible=true WHERE id=$1 AND owner_id=$2",
[plant.id, p.id],
);
await c.query(
"INSERT INTO inventory_seeds VALUES($1,'starter',1) ON CONFLICT(player_id,seed_type) DO UPDATE SET quantity=inventory_seeds.quantity+1",
[p.id],
);
await c.query(
"UPDATE plant_slots SET seed_type=NULL,genome=NULL,visual_seed=NULL,planted_at=NULL,ready_at=NULL WHERE id=$1",
[slotId],
);
await this.progress(
c,
p,
"harvested",
1,
35 + ((await this.researched(c, p.id, "compost")) ? 10 : 0),
);
const discovered = await this.discovery(c, p, plant);
return {
message: discovered
? "A new discovery! Plant harvested; one starter seed recycled."
: "Plant harvested; one starter seed recycled.",
plantId: plant.id,
discovered,
};
}
async eligibleParents(c: Client, owner: string, a: string, b: string) {
if (a === b) fail("SAME_PARENT", "Choose two different parents.");
const rows = (
await c.query(
"SELECT * FROM plants WHERE owner_id=$1 AND id=ANY($2::uuid[]) AND sold_at IS NULL AND game_eligible=true",
[owner, [a, b]],
)
).rows;
if (rows.length !== 2)
throw new ApiError(
404,
"PARENT_UNAVAILABLE",
"Choose two owned, unsold game-grown plants. Lab studies cannot be used for game rewards.",
);
if (!canBreed(rows[0].species_id, rows[1].species_id).compatible)
fail("INCOMPATIBLE_SPECIES", "These species are not compatible.");
}
async crossIn(
c: Client,
p: Player,
input: { parentAId: string; parentBId: string; idempotencyKey: string },
) {
this.requireLevel(p, 3);
await this.eligibleParents(c, p.id, input.parentAId, input.parentBId);
await this.money(c, p, -GAME.breedCost, "breeding", input.idempotencyKey);
const result = await new PlantRepository(this.pool, p.id).cross(input, c);
await c.query(
"UPDATE plants SET game_eligible=true WHERE id=$1 AND owner_id=$2",
[result.plant.id, p.id],
);
result.plant.gameEligible = true;
await this.progress(c, p, "crosses", 1, 50);
await this.discovery(c, p, result.plant);
result.plant = await new PlantRepository(this.pool, p.id).getPlant(
result.plant.id,
c,
);
return result;
}
async sell(
c: Client,
p: Player,
plantId: string,
automatic = false,
now = this.clock(),
) {
const plant = (
await c.query(
"SELECT * FROM plants WHERE id=$1 AND owner_id=$2 FOR UPDATE",
[plantId, p.id],
)
).rows[0];
if (!plant || plant.sold_at)
fail("PLANT_UNAVAILABLE", "This plant is unavailable or already sold.");
if (!plant.game_eligible)
fail(
"STUDY_ONLY",
"Laboratory and demo studies are not salable game plants.",
);
if (plant.is_favorite || plant.is_protected)
fail("PROTECTED_PLANT", "Remove favorite/protection before selling.");
const value = saleValue(plant.rarity_tier, plant.rarity_score);
if (value === null)
fail(
"MYTHIC_KEEPSAKE",
"Mythic specimens are keepsakes and cannot be sold.",
);
if (automatic) {
if (plant.first_discovery || plant.rarity_tier !== "Common") return false;
const count = Number(
(
await c.query(
"SELECT count(*) FROM plants WHERE owner_id=$1 AND species_id=$2 AND sold_at IS NULL AND game_eligible=true",
[p.id, plant.species_id],
)
).rows[0].count,
);
if (count <= 1) return false;
}
await c.query("UPDATE plants SET sold_at=$3 WHERE id=$1 AND owner_id=$2", [
plantId,
p.id,
now,
]);
await this.money(c, p, value, "sale", plantId);
await this.progress(c, p, "sold", 1, 10);
return { message: "Plant sold for " + value + " coins.", coins: value };
}
async shop(c: Client, p: Player) {
const houses = (
await c.query(
"SELECT * FROM greenhouses WHERE player_id=$1 ORDER BY id",
[p.id],
)
).rows,
house = houses[0];
const level = levelFor(p.xp).level;
const items = [
...Object.entries(SEEDS).map(([id, s]) => ({
id: "seed_" + id,
name: s.name,
description: s.description,
cost: s.cost,
level: s.level,
})),
{
id: "slot",
name: "Extra growing slot",
description: "Add one slot to the selected greenhouse (maximum 12).",
cost: nextSlotCost(house?.slots ?? 4) ?? 2000,
level: 2,
},
{
id: "lamp",
name: "Grow lamp",
description:
"10% faster growth. Remaining time of current crops is reduced once.",
cost: 300,
level: 2,
},
{
id: "irrigation",
name: "Irrigation",
description: "Assigned gardeners work every 96 seconds instead of 120.",
cost: 650,
level: 3,
},
{
id: "greenhouse",
name: "Second greenhouse",
description: "Six additional growing slots.",
cost: 2500,
level: 5,
},
];
return items.map((item) => ({
...item,
disabledReason:
level < item.level
? "Unlocks at level " + item.level
: item.id === "greenhouse" && houses.length >= 2
? "Both greenhouses owned."
: null,
}));
}
async purchase(c: Client, p: Player, data: unknown, now: Date) {
const input = z
.strictObject({ itemId: z.string(), houseId: z.uuid().optional() })
.parse(data),
item = (await this.shop(c, p)).find((i) => i.id === input.itemId);
if (!item) fail("UNKNOWN_ITEM", "Unknown shop item.");
this.requireLevel(p, item.level);
const house = (
await c.query(
"SELECT * FROM greenhouses WHERE player_id=$1 AND ($2::uuid IS NULL OR id=$2) ORDER BY id LIMIT 1",
[p.id, input.houseId ?? null],
)
).rows[0];
if (!house) fail("HOUSE_NOT_FOUND", "Select your greenhouse.");
let cost = item.cost;
if (item.id === "slot") {
if (house.slots >= 12)
fail("SLOTS_FULL", "This greenhouse already has 12 slots.");
cost = nextSlotCost(house.slots)!;
}
if (
(item.id === "lamp" && house.lamp) ||
(item.id === "irrigation" && house.irrigation)
)
fail("ALREADY_OWNED", "This equipment is already installed.");
if (item.disabledReason) fail("ITEM_UNAVAILABLE", item.disabledReason);
await this.money(c, p, -cost, "purchase", item.id);
if (item.id.startsWith("seed_")) {
const seed = item.id.slice(5);
await c.query(
"INSERT INTO inventory_seeds VALUES($1,$2,1) ON CONFLICT(player_id,seed_type) DO UPDATE SET quantity=inventory_seeds.quantity+1",
[p.id, seed],
);
} else if (item.id === "slot") {
await c.query("UPDATE greenhouses SET slots=slots+1 WHERE id=$1", [
house.id,
]);
await c.query(
"INSERT INTO plant_slots(id,greenhouse_id,slot_no) VALUES($1,$2,$3)",
[randomUUID(), house.id, house.slots],
);
await this.progress(c, p, "slotsBought");
} else if (item.id === "lamp") {
await c.query("UPDATE greenhouses SET lamp=true WHERE id=$1", [house.id]);
await c.query(
"UPDATE plant_slots SET ready_at=$2::timestamptz+(ready_at-$2::timestamptz)*0.9 WHERE greenhouse_id=$1 AND ready_at>$2",
[house.id, now],
);
} else if (item.id === "irrigation")
await c.query("UPDATE greenhouses SET irrigation=true WHERE id=$1", [
house.id,
]);
else {
const id = randomUUID();
await c.query(
"INSERT INTO greenhouses(id,player_id,name,slots) VALUES($1,$2,$3,6)",
[id, p.id, "The Conservatory"],
);
for (let i = 0; i < 6; i++)
await c.query(
"INSERT INTO plant_slots(id,greenhouse_id,slot_no) VALUES($1,$2,$3)",
[randomUUID(), id, i],
);
}
await this.progress(c, p, "purchases");
return { message: item.name + " purchased.", cost };
}
async questState(c: Client, p: Player) {
const claimed = new Set(
(
await c.query("SELECT quest_id FROM player_quests WHERE player_id=$1", [
p.id,
])
).rows.map((r) => r.quest_id),
);
const kept = Number(
(
await c.query(
"SELECT count(*) FROM plants WHERE owner_id=$1 AND game_eligible AND sold_at IS NULL",
[p.id],
)
).rows[0].count,
),
rare = Number(
(
await c.query(
"SELECT count(*) FROM plants WHERE owner_id=$1 AND game_eligible AND sold_at IS NULL AND rarity_score>=25",
[p.id],
)
).rows[0].count,
),
houses =
(await c.query("SELECT id FROM greenhouses WHERE player_id=$1", [p.id]))
.rowCount ?? 0,
workers =
(await c.query("SELECT id FROM workers WHERE player_id=$1", [p.id]))
.rowCount ?? 0;
const stats: Record<string, number> = {
...p.game_stats,
kept,
rare,
houses,
workers,
};
return QUESTS.map((q) => ({
...q,
progress: Math.min(q.target, stats[q.stat] ?? 0),
claimed: claimed.has(q.id),
}));
}
async claim(c: Client, p: Player, data: unknown) {
const { questId } = z.strictObject({ questId: z.string() }).parse(data),
quest = (await this.questState(c, p)).find((q) => q.id === questId);
if (!quest || quest.claimed || quest.progress < quest.target)
fail("QUEST_NOT_READY", "This quest is not ready to claim.");
await c.query(
"INSERT INTO player_quests(player_id,quest_id) VALUES($1,$2)",
[p.id, questId],
);
await this.money(c, p, quest.coins, "quest", questId);
await this.progress(c, p, "quests", 1, quest.xp);
return {
message:
"Quest complete: +" + quest.coins + " coins, +" + quest.xp + " XP.",
};
}
async research(c: Client, p: Player, data: unknown) {
const { researchId } = z
.strictObject({ researchId: z.string() })
.parse(data),
item = RESEARCH.find((r) => r.id === researchId);
if (!item) fail("UNKNOWN_RESEARCH", "Unknown research.");
this.requireLevel(p, item.level);
if (await this.researched(c, p.id, item.id))
fail("ALREADY_OWNED", "Research already completed.");
await this.money(c, p, -item.cost, "research", item.id);
await c.query("INSERT INTO player_research VALUES($1,$2,now())", [
p.id,
item.id,
]);
return { message: item.name + " researched." };
}
async hire(c: Client, p: Player, data: unknown, now: Date) {
const { kind } = z
.strictObject({ kind: z.enum(["gardener", "breeder"]) })
.parse(data);
this.requireLevel(p, kind === "gardener" ? 5 : 7);
if (
Number(
(
await c.query("SELECT count(*) FROM workers WHERE player_id=$1", [
p.id,
])
).rows[0].count,
) >= 4
)
fail("WORKER_LIMIT", "Your greenhouse team is full (four workers).");
await this.money(c, p, -GAME.workerCosts[kind], "hire", kind);
await c.query(
"INSERT INTO workers(id,player_id,kind,last_processed_at) VALUES($1,$2,$3,$4)",
[randomUUID(), p.id, kind, now],
);
return {
message: "Worker hired. Configure an assignment to enable automation.",
};
}
async assign(c: Client, p: Player, data: unknown, now: Date) {
const input = assignSchema.parse(data),
worker = (
await c.query("SELECT * FROM workers WHERE id=$1 AND player_id=$2", [
input.workerId,
p.id,
])
).rows[0],
house = (
await c.query(
"SELECT id FROM greenhouses WHERE id=$1 AND player_id=$2",
[input.greenhouseId, p.id],
)
).rows[0];
if (!worker || !house)
throw new ApiError(
404,
"WORKER_NOT_FOUND",
"Worker or greenhouse not found.",
);
if (
(worker.kind === "gardener" && input.policy.mode !== "repeat_crop") ||
(worker.kind === "breeder" && input.policy.mode === "repeat_crop")
)
fail("INVALID_POLICY", "Choose a policy for this worker type.");
if (worker.kind === "breeder") {
if (!input.policy.parentAId || !input.policy.parentBId)
fail("PARENTS_REQUIRED", "Select two game-grown parents.");
await this.eligibleParents(
c,
p.id,
input.policy.parentAId,
input.policy.parentBId,
);
}
this.requireLevel(p, SEEDS[input.policy.seedType].level);
if (
input.policy.mode === "target_trait" &&
!(await this.researched(c, p.id, "selection"))
)
fail("RESEARCH_REQUIRED", "Complete Selective breeding research first.");
await c.query(
"UPDATE workers SET greenhouse_id=$3,enabled=$4,policy=$5,last_processed_at=$6 WHERE id=$1 AND player_id=$2",
[
input.workerId,
p.id,
input.greenhouseId,
input.enabled,
JSON.stringify(input.policy),
now,
],
);
return {
message: input.enabled
? "Automation enabled from now."
: "Worker paused.",
};
}
async pending(c: Client, owner: string, now: Date) {
return !!(
await c.query(
"SELECT 1 FROM workers w LEFT JOIN greenhouses g ON g.id=w.greenhouse_id WHERE w.player_id=$1 AND w.enabled AND w.last_processed_at + make_interval(secs=> CASE WHEN w.kind='breeder' THEN 300 WHEN g.irrigation THEN 96 ELSE 120 END) <= $2 LIMIT 1",
[owner, now],
)
).rowCount;
}
async reconcileIn(c: Client, p: Player, now: Date) {
const workers = (
await c.query<Worker>(
"SELECT w.*,g.irrigation FROM workers w LEFT JOIN greenhouses g ON g.id=w.greenhouse_id WHERE w.player_id=$1 AND w.enabled ORDER BY w.id",
[p.id],
)
).rows;
const summary: OfflineSummary = {
events: 0,
harvested: 0,
sold: 0,
breeds: 0,
seedsUsed: 0,
coinDelta: 0,
notable: [],
blocked: 0,
from: now.toISOString(),
to: now.toISOString(),
},
balance = p.coin_balance;
const starts = new Map(workers.map((w) => [w.id, w.last_processed_at]));
const runs = new Map<string, OfflineSummary>();
for (let event = 0; event < GAME.maxWorkerEvents; event++) {
const candidates = workers
.map((w) => ({
w,
time: new Date(
w.last_processed_at.getTime() +
(w.kind === "breeder" ? 300 : w.irrigation ? 96 : 120) * 1000,
),
}))
.filter(
(v) =>
v.time <= now &&
v.time.getTime() - starts.get(v.w.id)!.getTime() <=
GAME.catchupHours * 3600000,
)
.sort(
(a, b) =>
a.time.getTime() - b.time.getTime() || a.w.id.localeCompare(b.w.id),
);
if (!candidates.length) break;
const { w, time } = candidates[0];
const before = { ...summary, notable: [...summary.notable] },
coinsBefore = p.coin_balance;
if (summary.events === 0)
summary.from = w.last_processed_at.toISOString();
summary.events++;
summary.to = time.toISOString();
let acted = false;
const policy = w.policy;
if (w.kind === "gardener" && w.greenhouse_id) {
const slots = (
await c.query(
"SELECT * FROM plant_slots WHERE greenhouse_id=$1 ORDER BY slot_no",
[w.greenhouse_id],
)
).rows;
const ready = slots.find(
(s) => s.seed_type && new Date(s.ready_at) <= time,
);
if (ready) {
const result = await this.harvest(c, p, ready.id, time);
summary.harvested++;
acted = true;
if (result.discovered) summary.notable.push(result.plantId);
if (policy.autoSell) {
const plant = (
await c.query(
"SELECT * FROM plants WHERE id=$1 AND owner_id=$2",
[result.plantId, p.id],
)
).rows[0];
if (
!plant.first_discovery &&
plant.rarity_tier === "Common" &&
!plant.is_favorite &&
!plant.is_protected &&
(await this.sell(c, p, plant.id, true, time))
)
summary.sold++;
}
const qty =
(
await c.query(
"SELECT quantity FROM inventory_seeds WHERE player_id=$1 AND seed_type=$2",
[p.id, policy.seedType],
)
).rows[0]?.quantity ?? 0;
if (qty > 0) {
await this.plant(c, p, ready.id, policy.seedType, time);
summary.seedsUsed++;
}
} else {
const empty = slots.find((s) => !s.seed_type),
qty =
(
await c.query(
"SELECT quantity FROM inventory_seeds WHERE player_id=$1 AND seed_type=$2",
[p.id, policy.seedType],
)
).rows[0]?.quantity ?? 0;
if (empty && qty > 0) {
await this.plant(c, p, empty.id, policy.seedType, time);
summary.seedsUsed++;
acted = true;
}
}
} else if (
w.kind === "breeder" &&
policy.parentAId &&
policy.parentBId &&
p.coin_balance >= GAME.breedCost + policy.reserveCoins
) {
const valid = (
await c.query(
"SELECT count(*) FROM plants WHERE owner_id=$1 AND id=ANY($2::uuid[]) AND game_eligible AND sold_at IS NULL",
[p.id, [policy.parentAId, policy.parentBId]],
)
).rows[0];
if (Number(valid.count) === 2) {
const result = await this.crossIn(c, p, {
parentAId: policy.parentAId,
parentBId: policy.parentBId,
idempotencyKey: randomUUID(),
});
summary.breeds++;
acted = true;
if (result.plant.rarity.score >= 25)
summary.notable.push(result.plant.id);
if (
policy.mode === "target_trait" &&
(!policy.targetPigment ||
result.plant.phenotype.pigment === policy.targetPigment) &&
(!policy.targetPattern ||
result.plant.phenotype.leafPattern === policy.targetPattern)
)
await c.query(
"UPDATE plants SET is_protected=true WHERE id=$1 AND owner_id=$2",
[result.plant.id, p.id],
);
}
}
if (!acted) summary.blocked++;
const run = runs.get(w.id) ?? {
events: 0,
harvested: 0,
sold: 0,
breeds: 0,
seedsUsed: 0,
coinDelta: 0,
notable: [],
blocked: 0,
from: starts.get(w.id)!.toISOString(),
to: time.toISOString(),
};
for (const metric of [
"events",
"harvested",
"sold",
"breeds",
"seedsUsed",
"blocked",
] as const)
run[metric] += summary[metric] - before[metric];
run.coinDelta += p.coin_balance - coinsBefore;
run.notable.push(...summary.notable.slice(before.notable.length));
run.to = time.toISOString();
runs.set(w.id, run);
w.last_processed_at = time;
await c.query(
"UPDATE workers SET last_processed_at=$3 WHERE id=$1 AND player_id=$2",
[w.id, p.id, time],
);
}
summary.coinDelta = p.coin_balance - balance;
for (const w of workers)
if (w.last_processed_at > starts.get(w.id)!) {
await c.query(
"INSERT INTO worker_runs(id,player_id,worker_id,interval_start,interval_end,summary) VALUES($1,$2,$3,$4,$5,$6)",
[
randomUUID(),
p.id,
w.id,
starts.get(w.id),
w.last_processed_at,
JSON.stringify(runs.get(w.id)),
],
);
}
return summary.events ? summary : null;
}
async reconcile(owner: string) {
return this.transaction(owner, (c, p, now) => this.reconcileIn(c, p, now));
}
async action(owner: string, input: GameAction) {
await this.reconcile(owner);
return this.transaction(owner, async (c, p, now) => {
const fingerprint = digest(
JSON.stringify({ action: input.action, data: input.data }),
),
existing = (
await c.query(
"SELECT * FROM game_receipts WHERE player_id=$1 AND request_id=$2",
[owner, input.requestId],
)
).rows[0];
if (existing) {
if (existing.body_hash !== fingerprint)
fail(
"IDEMPOTENCY_CONFLICT",
"This request ID belongs to another action.",
);
return existing.response;
}
if (await this.pending(c, owner, now))
fail(
"OFFLINE_PENDING",
"Process the remaining offline batch before performing a new action.",
);
let result: unknown;
switch (input.action) {
case "plant": {
const data = z
.strictObject({ slotId: z.uuid(), seedType: z.enum(seedTypes) })
.parse(input.data);
result = await this.plant(c, p, data.slotId, data.seedType, now);
break;
}
case "harvest": {
const data = z.strictObject({ slotId: z.uuid() }).parse(input.data);
result = await this.harvest(c, p, data.slotId, now);
break;
}
case "purchase":
result = await this.purchase(c, p, input.data, now);
break;
case "sell":
result = await this.sell(
c,
p,
idSchema.parse(input.data).plantId,
false,
now,
);
break;
case "flags": {
const data = idSchema
.extend({ favorite: z.boolean(), protected: z.boolean() })
.parse(input.data);
if (
!(
await c.query(
"UPDATE plants SET is_favorite=$3,is_protected=$4 WHERE id=$1 AND owner_id=$2 AND sold_at IS NULL RETURNING id",
[data.plantId, owner, data.favorite, data.protected],
)
).rowCount
)
throw new ApiError(404, "PLANT_NOT_FOUND", "Plant unavailable.");
result = { message: "Collection safeguards updated." };
break;
}
case "claim":
result = await this.claim(c, p, input.data);
break;
case "research":
result = await this.research(c, p, input.data);
break;
case "hire":
result = await this.hire(c, p, input.data, now);
break;
case "assign":
result = await this.assign(c, p, input.data, now);
break;
}
await c.query(
"INSERT INTO game_receipts(player_id,request_id,operation,body_hash,response) VALUES($1,$2,$3,$4,$5)",
[
owner,
input.requestId,
input.action,
fingerprint,
JSON.stringify(result),
],
);
return result;
});
}
async cross(
owner: string,
input: { parentAId: string; parentBId: string; idempotencyKey: string },
) {
await this.reconcile(owner);
return this.transaction(owner, async (c, p, now) => {
const key = input.idempotencyKey,
hash = digest(JSON.stringify(input)),
existing = (
await c.query(
"SELECT * FROM game_receipts WHERE player_id=$1 AND request_id=$2",
[owner, key],
)
).rows[0];
if (existing) {
if (existing.body_hash !== hash)
fail("IDEMPOTENCY_CONFLICT", "This key belongs to another cross.");
return existing.response;
}
if (await this.pending(c, owner, now))
fail("OFFLINE_PENDING", "Process remaining offline work first.");
const result = await this.crossIn(c, p, input);
await c.query(
"INSERT INTO game_receipts(player_id,request_id,operation,body_hash,response) VALUES($1,$2,'cross',$3,$4)",
[owner, key, hash, JSON.stringify(result)],
);
return result;
});
}
async state(identity: Identity): Promise<GameState> {
return this.transaction(identity.id, async (c, p, now) => {
const report = await this.reconcileIn(c, p, now),
houses = (
await c.query(
"SELECT * FROM greenhouses WHERE player_id=$1 ORDER BY name",
[p.id],
)
).rows,
slots = (
await c.query(
"SELECT s.* FROM plant_slots s JOIN greenhouses g ON g.id=s.greenhouse_id WHERE g.player_id=$1 ORDER BY s.slot_no",
[p.id],
)
).rows,
seeds = Object.fromEntries(
(
await c.query(
"SELECT seed_type,quantity FROM inventory_seeds WHERE player_id=$1",
[p.id],
)
).rows.map((r) => [r.seed_type, r.quantity]),
),
workers = (
await c.query<Worker>(
"SELECT w.*,g.irrigation FROM workers w LEFT JOIN greenhouses g ON g.id=w.greenhouse_id WHERE w.player_id=$1 ORDER BY w.id",
[p.id],
)
).rows,
research = (
await c.query(
"SELECT research_id FROM player_research WHERE player_id=$1",
[p.id],
)
).rows.map((r) => r.research_id);
return {
serverNow: now.toISOString(),
player: {
id: p.id,
username: identity.username,
coins: p.coin_balance,
xp: p.xp,
...levelFor(p.xp),
csrfToken: identity.csrfToken,
},
seeds: { starter: 0, uncommon: 0, rare: 0, ...seeds },
greenhouses: houses.map((h) => ({
id: h.id,
name: h.name,
slots: h.slots,
lamp: h.lamp,
irrigation: h.irrigation,
nextSlotCost: nextSlotCost(h.slots),
plants: slots
.filter((s) => s.greenhouse_id === h.id)
.map((s) => ({
id: s.id,
slotNo: s.slot_no,
seedType: s.seed_type,
plantedAt: s.planted_at?.toISOString() ?? null,
readyAt: s.ready_at?.toISOString() ?? null,
ready: !!s.ready_at && s.ready_at <= now,
visualSeed: s.visual_seed,
phenotype: s.genome
? resolvePhenotype("velvetleaf", s.genome)
: null,
})),
})),
quests: await this.questState(c, p),
shop: await this.shop(c, p),
research: RESEARCH.map((r) => ({
...r,
owned: research.includes(r.id),
disabledReason: research.includes(r.id)
? "Completed"
: levelFor(p.xp).level < r.level
? "Unlocks at level " + r.level
: null,
})),
workers: workers.map(
(w) =>
({
id: w.id,
kind: w.kind,
greenhouseId: w.greenhouse_id,
enabled: w.enabled,
policy: {
mode: w.kind === "gardener" ? "repeat_crop" : "repeat_cross",
seedType: "starter",
autoSell: false,
reserveCoins: 100,
...(w.policy as Partial<WorkerPolicy>),
},
lastProcessedAt: w.last_processed_at.toISOString(),
intervalSeconds:
w.kind === "breeder" ? 300 : w.irrigation ? 96 : 120,
}) satisfies GameWorker,
),
report,
pendingWork: await this.pending(c, p.id, now),
recentRuns: (
await c.query(
"SELECT id,summary FROM worker_runs WHERE player_id=$1 ORDER BY created_at DESC LIMIT 12",
[p.id],
)
).rows,
breedCost: GAME.breedCost,
workerCosts: GAME.workerCosts,
seedInfo: Object.entries(SEEDS).map(([id, s]) => ({
id: id as keyof typeof SEEDS,
name: s.name,
seconds: s.seconds,
description: s.description,
})),
};
});
}
}