diff --git a/apps/server/Dockerfile b/apps/server/Dockerfile index d6094f2..6c54a86 100644 --- a/apps/server/Dockerfile +++ b/apps/server/Dockerfile @@ -7,11 +7,13 @@ COPY packages/ packages/ RUN npm ci COPY tsconfig.base.json ./ COPY apps/server/ apps/server/ -RUN npm run build:packages && npm run build -w @botanica/server && npm prune --omit=dev +RUN npm run build:packages && npm run build -w @botanica/server +FROM build AS production-dependencies +RUN npm prune --omit=dev FROM node:24-alpine ENV NODE_ENV=production WORKDIR /app -COPY --from=build --chown=node:node /app/node_modules ./node_modules +COPY --from=production-dependencies --chown=node:node /app/node_modules ./node_modules COPY --from=build --chown=node:node /app/packages ./packages COPY --from=build --chown=node:node /app/apps/server/package.json ./package.json COPY --from=build --chown=node:node /app/apps/server/dist ./dist diff --git a/apps/server/migrations/0002_plant_details.sql b/apps/server/migrations/0002_plant_details.sql new file mode 100644 index 0000000..5c9294f --- /dev/null +++ b/apps/server/migrations/0002_plant_details.sql @@ -0,0 +1,3 @@ +ALTER TABLE plants ADD COLUMN name text NOT NULL DEFAULT 'Velvetleaf' CHECK (char_length(name) BETWEEN 1 AND 60); +ALTER TABLE plants ADD COLUMN creation_source text NOT NULL DEFAULT 'designed' CHECK (creation_source IN ('designed','bred','demo')); +ALTER TABLE plants ADD COLUMN rarity_reasons jsonb NOT NULL DEFAULT '[]'::jsonb; diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts index 74bc811..37d0490 100644 --- a/apps/server/src/app.ts +++ b/apps/server/src/app.ts @@ -1,3 +1,6 @@ +import type { Pool } from "pg"; +import { ApiError } from "./errors.js"; +import { registerPlantRoutes } from "./plant-routes.js"; import { registerPreviewRoute } from "./preview-route.js"; import { ZodError } from "zod"; import { GeneticsError } from "@botanica/genetics"; @@ -6,6 +9,7 @@ import Fastify from "fastify"; export function createApp( checkDatabase: () => Promise, logger = false, + pool?: Pool, ) { const app = Fastify({ logger, bodyLimit: 65536 }); app.get("/health", async (_request, reply) => { @@ -23,12 +27,17 @@ export function createApp( }); registerGeneticsRoutes(app); registerPreviewRoute(app); + if (pool) registerPlantRoutes(app, pool); app.setNotFoundHandler((_request, reply) => reply .code(404) .send({ error: { code: "NOT_FOUND", message: "Route not found." } }), ); app.setErrorHandler((error, request, reply) => { + if (error instanceof ApiError) + return reply + .code(error.statusCode) + .send({ error: { code: error.code, message: error.message } }); if (error instanceof ZodError) return reply.code(400).send({ error: { diff --git a/apps/server/src/constants.ts b/apps/server/src/constants.ts new file mode 100644 index 0000000..ff8f094 --- /dev/null +++ b/apps/server/src/constants.ts @@ -0,0 +1,3 @@ +export const DEMO_PLAYER_ID = "00000000-0000-4000-8000-000000000001"; +export const DEMO_PARENT_A = "10000000-0000-4000-8000-000000000001"; +export const DEMO_PARENT_B = "10000000-0000-4000-8000-000000000002"; diff --git a/apps/server/src/errors.ts b/apps/server/src/errors.ts new file mode 100644 index 0000000..6524c0b --- /dev/null +++ b/apps/server/src/errors.ts @@ -0,0 +1,9 @@ +export class ApiError extends Error { + constructor( + public statusCode: number, + public code: string, + message: string, + ) { + super(message); + } +} diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index f47cf1b..7766352 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -3,7 +3,7 @@ import { connectDatabase } from "./db.js"; import { createApp } from "./app.js"; const config = readConfig(process.env); const { pool } = connectDatabase(config.DATABASE_URL); -const app = createApp(() => pool.query("SELECT 1"), true); +const app = createApp(() => pool.query("SELECT 1"), true, pool); app.addHook("onClose", async () => { await pool.end(); }); diff --git a/apps/server/src/migrate.ts b/apps/server/src/migrate.ts index 588e2c8..394e725 100644 --- a/apps/server/src/migrate.ts +++ b/apps/server/src/migrate.ts @@ -1,35 +1,9 @@ -import { readFile } from "node:fs/promises"; import { connectDatabase } from "./db.js"; +import { migrate } from "./migrations.js"; const { pool } = connectDatabase(process.env.DATABASE_URL ?? ""); -const client = await pool.connect(); try { - await client.query("BEGIN"); - await client.query("SELECT pg_advisory_xact_lock(8155000)"); - await client.query( - "CREATE TABLE IF NOT EXISTS schema_migrations (name text PRIMARY KEY, applied_at timestamptz NOT NULL DEFAULT now())", - ); - const name = "0001_initial"; - const exists = await client.query( - "SELECT name FROM schema_migrations WHERE name = $1", - [name], - ); - if (!exists.rowCount) { - await client.query( - await readFile( - new URL("../migrations/0001_initial.sql", import.meta.url), - "utf8", - ), - ); - await client.query("INSERT INTO schema_migrations(name) VALUES ($1)", [ - name, - ]); - } - await client.query("COMMIT"); + await migrate(pool); console.log("Migrations current"); -} catch (error) { - await client.query("ROLLBACK"); - throw error; } finally { - client.release(); await pool.end(); } diff --git a/apps/server/src/migrations.ts b/apps/server/src/migrations.ts new file mode 100644 index 0000000..26bfd14 --- /dev/null +++ b/apps/server/src/migrations.ts @@ -0,0 +1,37 @@ +import { readFile, readdir } from "node:fs/promises"; +import type { Pool } from "pg"; +export async function migrate(pool: Pool) { + const client = await pool.connect(); + try { + await client.query("BEGIN"); + await client.query("SELECT pg_advisory_xact_lock(8155000)"); + await client.query( + "CREATE TABLE IF NOT EXISTS schema_migrations (name text PRIMARY KEY, applied_at timestamptz NOT NULL DEFAULT now())", + ); + const directory = new URL("../migrations/", import.meta.url); + for (const file of (await readdir(directory)) + .filter((f) => /^\d{4}_[a-z_]+\.sql$/.test(f)) + .sort()) { + const name = file.replace(/\.sql$/, ""); + if ( + ( + await client.query( + "SELECT name FROM schema_migrations WHERE name=$1", + [name], + ) + ).rowCount + ) + continue; + await client.query(await readFile(new URL(file, directory), "utf8")); + await client.query("INSERT INTO schema_migrations(name) VALUES($1)", [ + name, + ]); + } + await client.query("COMMIT"); + } catch (error) { + await client.query("ROLLBACK"); + throw error; + } finally { + client.release(); + } +} diff --git a/apps/server/src/persistence.integration.test.ts b/apps/server/src/persistence.integration.test.ts new file mode 100644 index 0000000..8ceb98f --- /dev/null +++ b/apps/server/src/persistence.integration.test.ts @@ -0,0 +1,260 @@ +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 { DEMO_PLAYER_ID, DEMO_PARENT_A, DEMO_PARENT_B } from "./constants.js"; +import { breed, exampleGenome } from "@botanica/genetics"; +const databaseUrl = process.env.TEST_DATABASE_URL; +describe.skipIf(!databaseUrl)( + "PostgreSQL persistence and transaction acceptance", + () => { + const pool = new pg.Pool({ connectionString: databaseUrl }); + const app = createApp(() => pool.query("SELECT 1"), false, pool); + let childId = ""; + beforeAll(async () => { + await migrate(pool); + await migrate(pool); + await seedDatabase(pool); + await seedDatabase(pool); + }); + afterAll(async () => { + await app.close(); + await pool.end(); + }); + it("migrates a clean DB and repeats bootstrap without duplicates", async () => { + expect( + Number( + (await pool.query("SELECT count(*) FROM schema_migrations")).rows[0] + .count, + ), + ).toBe(2); + expect( + Number( + (await pool.query("SELECT count(*) FROM players")).rows[0].count, + ), + ).toBe(1); + expect( + Number((await pool.query("SELECT count(*) FROM plants")).rows[0].count), + ).toBe(2); + }); + it("persists a server-validated designed founder with secure default seed and rejects immutable claims", async () => { + const payload = { + name: "Acceptance founder", + speciesId: "velvetleaf", + genome: exampleGenome, + }; + const response = await app.inject({ + method: "POST", + url: "/api/v1/plants", + payload, + }); + expect(response.statusCode).toBe(201); + const plant = response.json(); + expect(plant.parents).toEqual([]); + expect(plant.generation).toBe(0); + expect(plant.creationSource).toBe("designed"); + expect(Number.isInteger(plant.visualSeed)).toBe(true); + expect((await app.inject("/api/v1/plants/" + plant.id)).json()).toEqual( + plant, + ); + for (const bad of [ + { ...payload, mutations: ["golden_veins"] }, + { ...payload, rarityScore: 100 }, + { ...payload, parentAId: DEMO_PARENT_A }, + { ...payload, genome: { ...exampleGenome, pigment: ["X", "G"] } }, + ]) + expect( + ( + await app.inject({ + method: "POST", + url: "/api/v1/plants", + payload: bad, + }) + ).statusCode, + ).toBe(400); + }); + it("paginates tied microsecond timestamps without missing or duplicating plants", async () => { + const seen = new Set(); + let cursor: string | null = null; + do { + const response = await app.inject( + "/api/v1/plants?limit=1" + + (cursor ? "&cursor=" + encodeURIComponent(cursor) : ""), + ); + expect(response.statusCode).toBe(200); + const page = response.json(); + expect(page.items).toHaveLength(1); + expect(seen.has(page.items[0].id)).toBe(false); + seen.add(page.items[0].id); + cursor = page.nextCursor; + } while (cursor); + expect(seen.size).toBe(3); + expect( + (await app.inject("/api/v1/plants?cursor=invalid")).statusCode, + ).toBe(400); + }); + it("commits exactly one child and event for concurrent retries and replays stored event seed", async () => { + const input = { + parentAId: DEMO_PARENT_A, + parentBId: DEMO_PARENT_B, + idempotencyKey: randomUUID(), + }; + const responses = await Promise.all( + Array.from({ length: 12 }, () => + app.inject({ + method: "POST", + url: "/api/v1/breeding", + payload: input, + }), + ), + ); + expect(responses.every((r) => r.statusCode === 200)).toBe(true); + const result = responses[0].json(); + expect(new Set(responses.map((r) => r.json().plant.id)).size).toBe(1); + childId = result.plant.id; + expect(result.plant.parents).toEqual([DEMO_PARENT_A, DEMO_PARENT_B]); + expect(result.plant.generation).toBe(1); + expect(result.plant.creationSource).toBe("bred"); + const a = (await app.inject("/api/v1/plants/" + DEMO_PARENT_A)).json(), + b = (await app.inject("/api/v1/plants/" + DEMO_PARENT_B)).json(); + const expected = breed( + { + id: a.id, + speciesId: a.speciesId, + genome: a.genome, + generation: a.generation, + genomeVersion: a.genomeVersion, + }, + { + id: b.id, + speciesId: b.speciesId, + genome: b.genome, + generation: b.generation, + genomeVersion: b.genomeVersion, + }, + result.event.rngSeed, + ); + expect(result.plant.genome).toEqual(expected.genome); + expect(result.plant.mutations).toEqual(expected.mutations); + expect(result.plant.rarity).toEqual(expected.rarity); + expect(result.event.mutationEvents).toEqual(expected.mutationEvents); + expect( + Number((await pool.query("SELECT count(*) FROM plants")).rows[0].count), + ).toBe(4); + expect( + Number( + (await pool.query("SELECT count(*) FROM breeding_events")).rows[0] + .count, + ), + ).toBe(1); + expect( + ( + await app.inject({ + method: "POST", + url: "/api/v1/breeding", + payload: { ...input, parentAId: childId }, + }) + ).statusCode, + ).toBe(409); + expect((await app.inject("/api/v1/breeding")).json().items).toHaveLength( + 1, + ); + }); + it("increments max parent generation and preserves persisted mutations/rarity across reads", async () => { + const result = ( + await app.inject({ + method: "POST", + url: "/api/v1/breeding", + payload: { + parentAId: childId, + parentBId: DEMO_PARENT_A, + idempotencyKey: randomUUID(), + }, + }) + ).json(); + expect(result.plant.generation).toBe(2); + const first = await app.inject("/api/v1/plants/" + result.plant.id); + expect((await app.inject("/api/v1/plants/" + result.plant.id)).body).toBe( + first.body, + ); + }); + it("rejects unknown cross-species records and foreign-owned parents without creating a child", async () => { + const alien = randomUUID(), + foreign = randomUUID(), + owner = randomUUID(); + await pool.query( + "INSERT INTO species VALUES('alien','Alien','alien',1,'{}')", + ); + await pool.query("INSERT INTO players(id,display_name) VALUES($1,$2)", [ + owner, + "Other owner", + ]); + await pool.query( + "INSERT INTO plants(id,owner_id,species_id,genome_version,genome,mutations,visual_seed,renderer_version,rarity_policy_version,rarity_score,rarity_tier,generation) SELECT $1,owner_id,$2,genome_version,genome,mutations,visual_seed,renderer_version,rarity_policy_version,rarity_score,rarity_tier,generation FROM plants WHERE id=$3", + [alien, "alien", DEMO_PARENT_A], + ); + await pool.query( + "INSERT INTO plants(id,owner_id,species_id,genome_version,genome,mutations,visual_seed,renderer_version,rarity_policy_version,rarity_score,rarity_tier,generation) SELECT $1,$2,species_id,genome_version,genome,mutations,visual_seed,renderer_version,rarity_policy_version,rarity_score,rarity_tier,generation FROM plants WHERE id=$3", + [foreign, owner, DEMO_PARENT_A], + ); + const before = Number( + (await pool.query("SELECT count(*) FROM plants")).rows[0].count, + ); + for (const [id, status] of [ + [alien, 409], + [foreign, 404], + ] as const) { + const response = await app.inject({ + method: "POST", + url: "/api/v1/breeding", + payload: { + parentAId: DEMO_PARENT_A, + parentBId: id, + idempotencyKey: randomUUID(), + }, + }); + expect(response.statusCode).toBe(status); + if (status === 409) + expect(response.json().error.code).toBe("INCOMPATIBLE_SPECIES"); + } + expect( + Number((await pool.query("SELECT count(*) FROM plants")).rows[0].count), + ).toBe(before); + }); + it("rolls back the child when event insertion fails and returns a safe error", async () => { + await pool.query( + "CREATE FUNCTION reject_event() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'private database detail'; END $$", + ); + await pool.query( + "CREATE TRIGGER reject_event BEFORE INSERT ON breeding_events FOR EACH ROW EXECUTE FUNCTION reject_event()", + ); + const before = Number( + (await pool.query("SELECT count(*) FROM plants")).rows[0].count, + ); + try { + const response = await app.inject({ + method: "POST", + url: "/api/v1/breeding", + payload: { + parentAId: DEMO_PARENT_A, + parentBId: DEMO_PARENT_B, + idempotencyKey: randomUUID(), + }, + }); + expect(response.statusCode).toBe(500); + expect(response.body).not.toContain("private"); + expect(response.body).not.toContain("postgres"); + expect( + Number( + (await pool.query("SELECT count(*) FROM plants")).rows[0].count, + ), + ).toBe(before); + } finally { + await pool.query("DROP TRIGGER reject_event ON breeding_events"); + await pool.query("DROP FUNCTION reject_event()"); + } + }); + }, +); diff --git a/apps/server/src/plant-routes.ts b/apps/server/src/plant-routes.ts new file mode 100644 index 0000000..748a709 --- /dev/null +++ b/apps/server/src/plant-routes.ts @@ -0,0 +1,31 @@ +import { z } from "zod"; +import type { FastifyInstance } from "fastify"; +import type { Pool } from "pg"; +import { + createPlantSchema, + breedingRequestSchema, + pageQuerySchema, +} from "@botanica/shared"; +import { PlantRepository } from "./plants.js"; +export function registerPlantRoutes(app: FastifyInstance, pool: Pool) { + const repository = new PlantRepository(pool); + app.get("/api/v1/plants", (request) => + repository.listPlants(pageQuerySchema.parse(request.query)), + ); + app.get("/api/v1/plants/:id", (request) => + repository.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)), + ), + ); + app.post("/api/v1/breeding", (request) => + repository.cross(breedingRequestSchema.parse(request.body)), + ); + app.get("/api/v1/breeding", (request) => + repository.listEvents(pageQuerySchema.parse(request.query)), + ); +} diff --git a/apps/server/src/plants.ts b/apps/server/src/plants.ts new file mode 100644 index 0000000..ed112bf --- /dev/null +++ b/apps/server/src/plants.ts @@ -0,0 +1,380 @@ +import { randomBytes, randomUUID } from "node:crypto"; +import type { Pool, PoolClient, QueryResultRow } from "pg"; +import { z } from "zod"; +import { + breed, + canBreed, + classifyRarity, + resolvePhenotype, + validateGenome, + type Genome, + type NamedMutation, +} from "@botanica/genetics"; +import type { + PlantDto, + BreedingEventDto, + BreedingResponse, + CreatePlantRequest, + BreedingRequest, + PageDto, +} from "@botanica/shared"; +import { DEMO_PLAYER_ID } from "./constants.js"; +import { ApiError } from "./errors.js"; +export const generateSeed = () => randomBytes(4).readInt32BE(); +interface PlantRow extends QueryResultRow { + id: string; + name: string; + owner_id: string; + species_id: string; + genome: Genome; + mutations: NamedMutation[]; + genome_version: number; + renderer_version: number; + visual_seed: number; + generation: number; + parent_a_id: string | null; + parent_b_id: string | null; + created_at: Date; + cursor_time?: string; + creation_source: PlantDto["creationSource"]; + rarity_policy_version: 1; + rarity_score: number; + rarity_tier: PlantDto["rarity"]["tier"]; + rarity_reasons: PlantDto["rarity"]["reasons"]; +} +interface EventRow extends QueryResultRow { + id: string; + parent_a_id: string; + parent_b_id: string; + child_id: string; + rng_seed: number; + engine_version: number; + mutation_events: BreedingEventDto["mutationEvents"]; + idempotency_key: string; + created_at: Date; + cursor_time?: string; +} +function plantDto(row: PlantRow): PlantDto { + return { + id: row.id, + name: row.name, + ownerId: row.owner_id, + speciesId: row.species_id, + genome: row.genome, + mutations: row.mutations, + phenotype: resolvePhenotype( + row.species_id, + row.genome, + row.mutations, + row.genome_version, + ), + visualSeed: row.visual_seed, + genomeVersion: row.genome_version, + rendererVersion: row.renderer_version, + generation: row.generation, + parents: + row.parent_a_id && row.parent_b_id + ? [row.parent_a_id, row.parent_b_id] + : [], + createdAt: row.created_at.toISOString(), + creationSource: row.creation_source, + rarity: { + score: row.rarity_score, + tier: row.rarity_tier, + rarityPolicyVersion: row.rarity_policy_version, + reasons: row.rarity_reasons, + }, + }; +} +const eventDto = (row: EventRow): BreedingEventDto => ({ + id: row.id, + parentAId: row.parent_a_id, + parentBId: row.parent_b_id, + childId: row.child_id, + rngSeed: row.rng_seed, + engineVersion: row.engine_version, + mutationEvents: row.mutation_events, + idempotencyKey: row.idempotency_key, + createdAt: row.created_at.toISOString(), +}); +const cursorSchema = z.strictObject({ + createdAt: z.iso.datetime({ offset: true }), + id: z.uuid(), +}); +export function decodeCursor(cursor?: string) { + if (!cursor) return null; + try { + return cursorSchema.parse( + JSON.parse(Buffer.from(cursor, "base64url").toString("utf8")), + ); + } catch { + throw new ApiError(400, "INVALID_CURSOR", "The page cursor is invalid."); + } +} +const cursorFor = (row: { + id: string; + created_at: Date; + cursor_time?: string; +}) => + Buffer.from( + JSON.stringify({ + createdAt: row.cursor_time ?? row.created_at.toISOString(), + id: row.id, + }), + ).toString("base64url"); +export async function insertPlant( + client: PoolClient, + input: { + id?: string; + name: string; + speciesId: string; + genome: Genome; + visualSeed: number; + mutations?: NamedMutation[]; + generation?: number; + parents?: [string, string]; + creationSource: PlantDto["creationSource"]; + rarity?: PlantDto["rarity"]; + }, + ownerId = DEMO_PLAYER_ID, +) { + const mutations = input.mutations ?? [], + genome = validateGenome(input.speciesId, input.genome), + phenotype = resolvePhenotype(input.speciesId, genome, mutations), + rarity = input.rarity ?? classifyRarity(phenotype); + const result = await client.query( + `INSERT INTO plants(id,name,owner_id,species_id,genome_version,genome,mutations,visual_seed,renderer_version,rarity_policy_version,rarity_score,rarity_tier,rarity_reasons,generation,parent_a_id,parent_b_id,creation_source) VALUES($1,$2,$3,$4,1,$5,$6,$7,1,$8,$9,$10,$11,$12,$13,$14,$15) ON CONFLICT(id) DO NOTHING RETURNING *`, + [ + input.id ?? randomUUID(), + input.name, + ownerId, + input.speciesId, + JSON.stringify(genome), + JSON.stringify(mutations), + input.visualSeed, + rarity.rarityPolicyVersion, + rarity.score, + rarity.tier, + JSON.stringify(rarity.reasons), + input.generation ?? 0, + input.parents?.[0] ?? null, + input.parents?.[1] ?? null, + input.creationSource, + ], + ); + return result.rows[0] ? plantDto(result.rows[0]) : null; +} +export class PlantRepository { + constructor( + private pool: Pool, + private ownerId = DEMO_PLAYER_ID, + ) {} + async getPlant(id: string, client: Pool | PoolClient = this.pool) { + const result = await client.query( + "SELECT * FROM plants WHERE id=$1 AND owner_id=$2", + [id, this.ownerId], + ); + if (!result.rows[0]) + throw new ApiError( + 404, + "PLANT_NOT_FOUND", + "Plant not found in this sandbox.", + ); + return plantDto(result.rows[0]); + } + async createPlant(input: CreatePlantRequest) { + const genome = validateGenome(input.speciesId, input.genome), + client = await this.pool.connect(); + try { + return (await insertPlant( + client, + { + ...input, + name: input.name ?? "Velvetleaf", + genome, + visualSeed: input.visualSeed ?? generateSeed(), + creationSource: "designed", + }, + this.ownerId, + ))!; + } finally { + client.release(); + } + } + async listPlants(query: { + limit: number; + cursor?: string; + }): Promise> { + const cursor = decodeCursor(query.cursor); + const rows = await this.pool.query( + `SELECT *, to_char(created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.US"Z"') AS cursor_time FROM plants WHERE owner_id=$1 AND ($2::timestamptz IS NULL OR (created_at,id)<($2::timestamptz,$3::uuid)) ORDER BY created_at DESC,id DESC LIMIT $4`, + [ + this.ownerId, + cursor?.createdAt ?? null, + cursor?.id ?? null, + query.limit + 1, + ], + ); + const total = Number( + ( + await this.pool.query( + "SELECT count(*) AS count FROM plants WHERE owner_id=$1", + [this.ownerId], + ) + ).rows[0].count, + ); + const hasNext = rows.rows.length > query.limit, + items = rows.rows.slice(0, query.limit); + return { + items: items.map(plantDto), + nextCursor: hasNext ? cursorFor(items.at(-1)!) : null, + total, + }; + } + async listEvents(query: { + limit: number; + cursor?: string; + }): Promise> { + const cursor = decodeCursor(query.cursor), + rows = await this.pool.query( + `SELECT *, to_char(created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.US"Z"') AS cursor_time FROM breeding_events WHERE owner_id=$1 AND ($2::timestamptz IS NULL OR (created_at,id)<($2::timestamptz,$3::uuid)) ORDER BY created_at DESC,id DESC LIMIT $4`, + [ + this.ownerId, + cursor?.createdAt ?? null, + cursor?.id ?? null, + query.limit + 1, + ], + ), + items = rows.rows.slice(0, query.limit); + const total = Number( + ( + await this.pool.query( + "SELECT count(*) AS count FROM breeding_events WHERE owner_id=$1", + [this.ownerId], + ) + ).rows[0].count, + ); + return { + items: items.map(eventDto), + nextCursor: + rows.rows.length > query.limit ? cursorFor(items.at(-1)!) : null, + total, + }; + } + async cross(input: BreedingRequest): Promise { + const client = await this.pool.connect(); + try { + await client.query("BEGIN"); + await client.query( + "SELECT pg_advisory_xact_lock(hashtextextended($1,0))", + [this.ownerId + ":" + input.idempotencyKey], + ); + const existing = ( + await client.query( + "SELECT * FROM breeding_events WHERE idempotency_key=$1 AND owner_id=$2", + [input.idempotencyKey, this.ownerId], + ) + ).rows[0]; + if (existing) { + if ( + existing.parent_a_id !== input.parentAId || + existing.parent_b_id !== input.parentBId + ) + throw new ApiError( + 409, + "IDEMPOTENCY_CONFLICT", + "This request key already belongs to another parent pair.", + ); + const plant = await this.getPlant(existing.child_id, client); + await client.query("COMMIT"); + return { plant, event: eventDto(existing) }; + } + if (input.parentAId === input.parentBId) + throw new ApiError( + 409, + "SAME_PARENT", + "Select two different parent plants.", + ); + // Read owned rows before interpreting their versioned genomes. + const rows = ( + await client.query( + "SELECT * FROM plants WHERE owner_id=$1 AND id=ANY($2::uuid[]) FOR SHARE", + [this.ownerId, [input.parentAId, input.parentBId]], + ) + ).rows; + const a = rows.find((r) => r.id === input.parentAId), + b = rows.find((r) => r.id === input.parentBId); + if (!a || !b) + throw new ApiError( + 404, + "PLANT_NOT_FOUND", + "One or both parents are not in this sandbox.", + ); + try { + if (!canBreed(a.species_id, b.species_id).compatible) + throw new Error("Incompatible"); + } catch { + throw new ApiError( + 409, + "INCOMPATIBLE_SPECIES", + "These species have no approved compatibility or hybrid rule.", + ); + } + const result = breed( + { + id: a.id, + speciesId: a.species_id, + genome: a.genome, + generation: a.generation, + genomeVersion: a.genome_version, + }, + { + id: b.id, + speciesId: b.species_id, + genome: b.genome, + generation: b.generation, + genomeVersion: b.genome_version, + }, + generateSeed(), + ); + const plant = (await insertPlant( + client, + { + name: "Velvetleaf · generation " + result.generation, + speciesId: result.speciesId, + genome: result.genome, + visualSeed: result.visualSeed, + mutations: result.mutations, + parents: result.parents, + generation: result.generation, + creationSource: "bred", + rarity: result.rarity, + }, + this.ownerId, + ))!; + const event = ( + await client.query( + "INSERT INTO breeding_events(id,owner_id,parent_a_id,parent_b_id,child_id,rng_seed,engine_version,mutation_events,idempotency_key) VALUES($1,$2,$3,$4,$5,$6,$7,$8,$9) RETURNING *", + [ + randomUUID(), + this.ownerId, + a.id, + b.id, + plant.id, + result.rngSeed, + result.engineVersion, + JSON.stringify(result.mutationEvents), + input.idempotencyKey, + ], + ) + ).rows[0]; + await client.query("COMMIT"); + return { plant, event: eventDto(event) }; + } catch (error) { + await client.query("ROLLBACK"); + throw error; + } finally { + client.release(); + } + } +} diff --git a/apps/server/src/schema.ts b/apps/server/src/schema.ts index 8c5e25b..3d06a2c 100644 --- a/apps/server/src/schema.ts +++ b/apps/server/src/schema.ts @@ -27,6 +27,9 @@ export const plants = pgTable( "plants", { id: uuid().primaryKey(), + name: text().notNull().default("Velvetleaf"), + creationSource: text("creation_source").notNull().default("designed"), + rarityReasons: jsonb("rarity_reasons").notNull().default([]), ownerId: uuid("owner_id") .notNull() .references(() => players.id), diff --git a/apps/server/src/seed-data.ts b/apps/server/src/seed-data.ts new file mode 100644 index 0000000..c7392a0 --- /dev/null +++ b/apps/server/src/seed-data.ts @@ -0,0 +1,50 @@ +import { velvetleaf, exampleGenome } from "@botanica/genetics"; +import type { Pool } from "pg"; +import { insertPlant } from "./plants.js"; +import { DEMO_PLAYER_ID, DEMO_PARENT_A, DEMO_PARENT_B } from "./constants.js"; +export async function seedDatabase(pool: Pool) { + const client = await pool.connect(); + try { + await client.query("BEGIN"); + await client.query( + "INSERT INTO players(id,display_name) VALUES($1,$2) ON CONFLICT DO NOTHING", + [DEMO_PLAYER_ID, "Sandbox botanist"], + ); + await client.query( + "INSERT INTO species(id,name,compatibility_group,catalog_version,config_jsonb) VALUES($1,$2,$3,$4,$5) ON CONFLICT(id) DO UPDATE SET config_jsonb=excluded.config_jsonb,catalog_version=excluded.catalog_version", + [ + velvetleaf.id, + velvetleaf.name, + velvetleaf.compatibilityGroup, + 1, + JSON.stringify(velvetleaf), + ], + ); + await insertPlant(client, { + id: DEMO_PARENT_A, + name: "Moss · demo parent", + speciesId: "velvetleaf", + genome: { + ...exampleGenome, + pigment: ["G", "G"], + leafPattern: ["S", "S"], + }, + visualSeed: 8155, + creationSource: "demo", + }); + await insertPlant(client, { + id: DEMO_PARENT_B, + name: "Plum · demo parent", + speciesId: "velvetleaf", + genome: exampleGenome, + visualSeed: 1440, + creationSource: "demo", + }); + await client.query("COMMIT"); + } catch (error) { + await client.query("ROLLBACK"); + throw error; + } finally { + client.release(); + } +} diff --git a/apps/server/src/seed.ts b/apps/server/src/seed.ts index ae97d87..1d46a18 100644 --- a/apps/server/src/seed.ts +++ b/apps/server/src/seed.ts @@ -1,31 +1,9 @@ -import { velvetleaf } from "@botanica/genetics"; import { connectDatabase } from "./db.js"; -import { players, species } from "./schema.js"; -const { pool, db } = connectDatabase(process.env.DATABASE_URL ?? ""); +import { seedDatabase } from "./seed-data.js"; +const { pool } = connectDatabase(process.env.DATABASE_URL ?? ""); try { - await db.transaction(async (tx) => { - await tx - .insert(players) - .values({ - id: "00000000-0000-4000-8000-000000000001", - displayName: "Sandbox botanist", - }) - .onConflictDoNothing(); - await tx - .insert(species) - .values({ - id: "velvetleaf", - name: "Velvetleaf", - compatibilityGroup: "velvetleaf", - catalogVersion: 1, - config: velvetleaf, - }) - .onConflictDoUpdate({ - target: species.id, - set: { config: velvetleaf, catalogVersion: 1 }, - }); - }); - console.log("Sandbox player and velvetleaf catalog ready"); + await seedDatabase(pool); + console.log("Sandbox catalog and demo parents ready"); } finally { await pool.end(); } diff --git a/apps/web/src/components/ExportActions.tsx b/apps/web/src/components/ExportActions.tsx new file mode 100644 index 0000000..ccfcb18 --- /dev/null +++ b/apps/web/src/components/ExportActions.tsx @@ -0,0 +1,44 @@ +import { useState } from "react"; +import type { Phenotype } from "@botanica/genetics"; +import type { GrowthStage, Background } from "@botanica/renderer"; +import { exportPlant } from "../lib/exportPlant"; +export function ExportActions({ + phenotype, + visualSeed, + stage, + background = "transparent", +}: { + phenotype: Phenotype; + visualSeed: number; + stage: GrowthStage; + background?: Background; +}) { + const [busy, setBusy] = useState(false), + [message, setMessage] = useState(""); + async function save(format: "svg" | "png") { + setBusy(true); + try { + await exportPlant(format, phenotype, visualSeed, stage, background); + setMessage(format.toUpperCase() + " exported."); + } catch (error) { + setMessage(error instanceof Error ? error.message : "Export failed."); + } finally { + setBusy(false); + } + } + return ( + <> +
+ + +
+

+ {message} +

+ + ); +} diff --git a/apps/web/src/components/GenomeEditor.tsx b/apps/web/src/components/GenomeEditor.tsx new file mode 100644 index 0000000..81181ff --- /dev/null +++ b/apps/web/src/components/GenomeEditor.tsx @@ -0,0 +1,56 @@ +import { loci, type Genome, type Locus, velvetleaf } from "@botanica/genetics"; +export const locusLabels: Record = { + pigment: "Pigment", + leafShape: "Leaf shape", + leafPattern: "Leaf pattern", + growthHabit: "Growth habit", + flowerColor: "Flower color", + flowerDensity: "Flower density", +}; +export function GenomeEditor({ + genome, + onChange, +}: { + genome: Genome; + onChange: (genome: Genome) => void; +}) { + return ( +
+ Diploid genotype +

+ One allele from each parent. Dominance determines the expressed trait. +

+ {loci.map((locus) => ( +
+ {locusLabels[locus]} + {([0, 1] as const).map((index) => ( + + ))} +
+ ))} +
+ ); +} diff --git a/apps/web/src/components/QueryMessage.tsx b/apps/web/src/components/QueryMessage.tsx new file mode 100644 index 0000000..40e2559 --- /dev/null +++ b/apps/web/src/components/QueryMessage.tsx @@ -0,0 +1,27 @@ +export function QueryMessage({ + error, + retry, + loading = false, +}: { + error?: Error | null; + retry?: () => void; + loading?: boolean; +}) { + return ( +
+

+ {loading + ? "Growing your view…" + : (error?.message ?? "Nothing here yet.")} +

+ {error && retry && ( + + )} +
+ ); +} diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts new file mode 100644 index 0000000..9cb687c --- /dev/null +++ b/apps/web/src/lib/api.ts @@ -0,0 +1,84 @@ +import type { + CatalogResponse, + PlantDto, + PageDto, + BreedingResponse, + BreedingEventDto, + CreatePlantRequest, + BreedingRequest, +} from "@botanica/shared"; +export class RequestError extends Error { + constructor( + message: string, + public code: string, + public status: number, + ) { + super(message); + } +} +export async function request( + url: string, + options: RequestInit = {}, +): Promise { + let response: Response; + try { + response = await fetch("/api/v1" + url, { + ...options, + headers: { "Content-Type": "application/json", ...options.headers }, + }); + } catch { + throw new RequestError( + "Cannot reach the laboratory. Check your connection and retry.", + "NETWORK_ERROR", + 0, + ); + } + const body = await response.json(); + if (!response.ok) + throw new RequestError( + body.error?.message ?? "The request failed.", + body.error?.code ?? "REQUEST_ERROR", + response.status, + ); + return body as T; +} +export const api = { + catalog: () => request("/catalog/species"), + plant: (id: string) => request("/plants/" + encodeURIComponent(id)), + plants: (query = "") => + request>("/plants" + (query ? "?" + query : "")), + create: (input: CreatePlantRequest) => + request("/plants", { + method: "POST", + body: JSON.stringify(input), + }), + cross: (input: BreedingRequest) => + request("/breeding", { + method: "POST", + body: JSON.stringify(input), + }), + events: (cursor?: string) => + request>( + "/breeding?limit=12" + + (cursor ? "&cursor=" + encodeURIComponent(cursor) : ""), + ), +}; +export const plantQuery = (id: string) => ({ + queryKey: ["plant", id], + queryFn: () => api.plant(id), +}); +export async function loadAllParents() { + const items: PlantDto[] = []; + let cursor: string | null = null; + do { + const page = await api.plants( + new URLSearchParams({ + limit: "100", + ...(cursor ? { cursor } : {}), + }).toString(), + ); + items.push(...page.items); + cursor = page.nextCursor; + } while (cursor && items.length < 1000); + return items; +} diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx index ee35f2b..654995e 100644 --- a/apps/web/src/main.tsx +++ b/apps/web/src/main.tsx @@ -1,3 +1,6 @@ +import { Lab } from "./pages/Lab"; +import { BreedingLab } from "./pages/BreedingLab"; +import { PlantDetails } from "./pages/PlantDetails"; import { RenderLab } from "./pages/RenderLab"; import { RenderGallery } from "./pages/RenderGallery"; import React from "react"; @@ -237,7 +240,9 @@ function App() { path={`/${key}`} element={ key === "lab" ? ( - + + ) : key === "breed" ? ( + ) : key === "gallery" ? ( ) : ( @@ -246,6 +251,8 @@ function App() { } /> ))} + } /> + } /> void; + plants: PlantDto[]; +}) { + const plant = plants.find((p) => p.id === id); + return ( +
+ + {plant ? ( + <> + +

{plant.name}

+

+ {plant.phenotype.pigment} · {plant.phenotype.leafShape} ·{" "} + {plant.phenotype.leafPattern} +

+ + Generation {plant.generation} · {plant.rarity.tier} + + + Inspect parent → + + + ) : ( +
Select a parent to see its traits.
+ )} +
+ ); +} +export function BreedingLab() { + const parents = useQuery({ queryKey: ["parents"], queryFn: loadAllParents }), + [a, setA] = useState(""), + [b, setB] = useState(""), + [cursor, setCursor] = useState(), + history = useQuery({ + queryKey: ["breeding", cursor], + queryFn: () => api.events(cursor), + }), + client = useQueryClient(); + const key = useRef<{ pair: string; id: string } | null>(null); + const cross = useMutation({ + mutationFn: api.cross, + onSuccess: () => { + void client.invalidateQueries({ queryKey: ["parents"] }); + void client.invalidateQueries({ queryKey: ["plants"] }); + void client.invalidateQueries({ queryKey: ["breeding"] }); + }, + }); + const list = parents.data ?? [], + left = list.find((p) => p.id === a), + right = list.find((p) => p.id === b); + let message = "Choose two saved parents to check compatibility.", + compatible = false; + if (left && right) { + if (a === b) message = "Select two different parent plants."; + else { + try { + compatible = canBreed(left.speciesId, right.speciesId).compatible; + } catch { + compatible = false; + } + message = compatible + ? "Compatible velvetleaf pair. The server will choose and save the child." + : "These species have no approved compatibility or hybrid rule."; + } + } + function choose(side: "A" | "B", id: string) { + if (side === "A") setA(id); + else setB(id); + key.current = null; + cross.reset(); + } + function breed() { + const pair = a + ":" + b; + if (!key.current || key.current.pair !== pair) + key.current = { pair, id: crypto.randomUUID() }; + cross.mutate({ + parentAId: a, + parentBId: b, + idempotencyKey: key.current.id, + }); + } + return ( + <> +
+
+

The next generation

+

Let their traits meet.

+

+ Choose two saved plants. Inheritance, mutations and the child's + identity are decided once by the server. +

+
+ + Design a parent → + +
+ {parents.isPending ? ( + + ) : parents.error ? ( + void parents.refetch()} + /> + ) : !list.length ? ( +
+

No saved parents yet

+

Design and save two plants in the laboratory.

+ + Open laboratory → + +
+ ) : ( + <> +
+ choose("A", id)} + plants={list} + /> + + choose("B", id)} + plants={list} + /> +
+
+

{message}

+ + {cross.error && ( +

+ {cross.error.message} Retry uses the same request key. +

+ )} + {cross.data && ( + + )} +
+ + )} + {cross.data && ( +
+ +
+

A new discovery

+

{cross.data.plant.name}

+

+ {cross.data.plant.phenotype.pigment} ·{" "} + {cross.data.plant.phenotype.leafPattern} ·{" "} + {cross.data.plant.rarity.tier} +

+

+ {cross.data.event.mutationEvents.length + ? cross.data.event.mutationEvents.length + + " mutation events recorded." + : "No mutations in this cross."} +

+ + Inspect child & lineage → + +
+
+ )} +
+
+

The breeding journal

+ {cursor && ( + + )} +
+ {history.isPending ? ( + + ) : history.error ? ( + void history.refetch()} + /> + ) : history.data?.items.length ? ( + <> +
    + {history.data.items.map((event) => ( +
  1. +
    + + Parent {event.parentAId.slice(0, 8)} + + × + + Parent {event.parentBId.slice(0, 8)} + +
    + View child → + +
  2. + ))} +
+ {history.data.nextCursor && ( + + )} + + ) : ( +
+

The first cross is still to come

+

Every saved offspring will leave a trace here.

+
+ )} +
+ + ); +} diff --git a/apps/web/src/pages/Lab.tsx b/apps/web/src/pages/Lab.tsx new file mode 100644 index 0000000..81af1e7 --- /dev/null +++ b/apps/web/src/pages/Lab.tsx @@ -0,0 +1,275 @@ +import { useEffect, useMemo, useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { Link, useSearchParams } from "react-router"; +import { + classifyRarity, + resolvePhenotype, + exampleGenome, + type Genome, +} from "@botanica/genetics"; +import { stages, type GrowthStage, type Background } from "@botanica/renderer"; +import { PlantPreview } from "../components/PlantPreview"; +import { GenomeEditor } from "../components/GenomeEditor"; +import { ExportActions } from "../components/ExportActions"; +import { QueryMessage } from "../components/QueryMessage"; +import { api, plantQuery } from "../lib/api"; +export function Lab() { + const [search] = useSearchParams(), + sourceId = search.get("plant") ?? ""; + const catalog = useQuery({ queryKey: ["catalog"], queryFn: api.catalog }), + source = useQuery({ ...plantQuery(sourceId), enabled: !!sourceId }); + const [genome, setGenome] = useState(() => + structuredClone(exampleGenome), + ), + [stage, setStage] = useState("flowering"), + [seed, setSeed] = useState(8155), + [golden, setGolden] = useState(false), + [background, setBackground] = useState("warm"), + [name, setName] = useState("My velvetleaf"); + const client = useQueryClient(); + const save = useMutation({ + mutationFn: api.create, + onSuccess: () => { + void client.invalidateQueries({ queryKey: ["plants"] }); + void client.invalidateQueries({ queryKey: ["parents"] }); + }, + }); + useEffect(() => { + if (source.data) { + setGenome(structuredClone(source.data.genome)); + setSeed(source.data.visualSeed); + setGolden(false); + setName((source.data.name + " study").slice(0, 60)); + save.reset(); + } + }, [source.data]); + const phenotype = useMemo( + () => + resolvePhenotype("velvetleaf", genome, golden ? ["golden_veins"] : []), + [genome, golden], + ), + rarity = classifyRarity(phenotype); + return ( + <> +
+
+

The plant laboratory

+

Grow something unexpected.

+

+ Edit paired alleles and see their expression. Save a designed + founder, then cross saved plants in the breeding lab. +

+
+ + Open breeding lab → + +
+ {sourceId && source.isPending && } + {source.error && ( + void source.refetch()} + /> + )} +
+
+
+ VELVETLEAF / LIVE PREVIEW + Genome v1 · Renderer v1 +
+ +
+
+

{name || "Velvetleaf"}

+

+ {phenotype.pigment} · {phenotype.leafShape} ·{" "} + {phenotype.leafPattern} +

+
+ +
+
+

Resolved phenotype

+
+ {( + [ + "pigment", + "leafShape", + "leafPattern", + "growthHabit", + "flowerColor", + "flowerDensity", + ] as const + ).map((key) => ( +
+
{key.replace(/([A-Z])/g, " $1")}
+
{phenotype[key]}
+
+ ))} +
+
Derived rarity
+
+ {rarity.tier} · {rarity.score}/100 +
+
+
+

+ {rarity.reasons + .map((r) => r.label + " +" + r.points) + .join("; ") || "No unusual traits."} +

+ + Policy v1 · preview classification + {golden ? " includes a manual mutation" : ""} + +
+
+ +
+ + ); +} diff --git a/apps/web/src/pages/PlantDetails.tsx b/apps/web/src/pages/PlantDetails.tsx new file mode 100644 index 0000000..b2f0f2c --- /dev/null +++ b/apps/web/src/pages/PlantDetails.tsx @@ -0,0 +1,125 @@ +import { useQuery } from "@tanstack/react-query"; +import { Link, useParams } from "react-router"; +import { PlantPreview } from "../components/PlantPreview"; +import { ExportActions } from "../components/ExportActions"; +import { QueryMessage } from "../components/QueryMessage"; +import { plantQuery } from "../lib/api"; +import { loci } from "@botanica/genetics"; +import { locusLabels } from "../components/GenomeEditor"; +export function PlantDetails() { + const { id = "" } = useParams(), + plant = useQuery(plantQuery(id)); + if (plant.isPending) return ; + if (plant.error) + return ( + void plant.refetch()} /> + ); + const p = plant.data; + return ( + <> +
+
+

A living record

+

{p.name}

+

+ Generation {p.generation} ·{" "} + {p.creationSource === "bred" + ? "Bred offspring" + : p.creationSource === "demo" + ? "Demonstration plant" + : "Designed founder"} +

+
+ + Back to breeding → + +
+
+
+ +
+ + {p.phenotype.pigment} · {p.phenotype.leafShape} ·{" "} + {p.phenotype.leafPattern} + + +
+
+ +
+ + ); +} diff --git a/apps/web/src/styles.css b/apps/web/src/styles.css index 7819677..2efae80 100644 --- a/apps/web/src/styles.css +++ b/apps/web/src/styles.css @@ -694,3 +694,374 @@ button:disabled { width: 100%; } } +/* Persistent laboratory and breeding */ +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} +.button-link { + display: inline-flex; + align-items: center; + justify-content: center; + border: 1px solid #32684a; + border-radius: 6px; + padding: 11px 14px; + font-size: 12px; + white-space: nowrap; +} +.secondary { + background: transparent; + color: #32684a; +} +.secondary:hover { + background: #e8ecdf; + color: #20392d; +} +.query-message { + padding: 30px; + border: 1px solid #d8ded2; + border-radius: 10px; + margin: 24px 0; + background: #fffcf4; + font-size: 14px; + color: #637565; +} +.error-message { + border-color: #b76f52; +} +.error-text { + color: #954b34; + font-size: 12px; + line-height: 1.7; +} +.success-text { + color: #32684a; + font-size: 12px; + line-height: 1.7; +} +.success-text a { + text-decoration: underline; +} +input:not([type="checkbox"]) { + font: + 12px Arial, + sans-serif; + color: #20392d; + border: 1px solid #cbd3c3; + border-radius: 6px; + background: #fffcf4; + padding: 11px 9px; + min-width: 0; + width: 100%; + height: 39px; +} +.editor-workbench { + align-items: start; +} +.editor-workbench .specimen { + position: sticky; + top: 12px; +} +.genome-editor { + padding: 18px 0 8px; + margin: 15px 0; + border: 0; + border-top: 1px solid #d8ded2; + border-bottom: 1px solid #d8ded2; +} +.genome-editor legend { + font: + 21px Georgia, + serif; + padding: 0 8px 0 0; +} +.genome-editor > p { + font-size: 11px; + color: #637565; + line-height: 1.7; + margin: 0 0 15px; +} +.locus-editor { + display: grid; + grid-template-columns: 83px minmax(0, 1fr) minmax(0, 1fr); + align-items: center; + gap: 9px; + font-size: 11px; +} +.study-panel .locus-editor label { + margin: 5px 0; +} +.locus-editor select { + padding: 8px 5px; + font-size: 11px; +} +.save-plant { + border-top: 1px solid #d8ded2; + margin-top: 20px; + padding-top: 8px; +} +.save-plant > button { + width: 100%; +} +.phenotype-inspector { + border-top: 1px solid #d8ded2; + padding: 12px 0 18px; + margin-top: 12px; +} +.phenotype-inspector h3 { + font-size: 22px; +} +.phenotype-inspector dl { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px; +} +.phenotype-inspector dl > div { + display: flex; + flex-direction: column; + gap: 4px; + font-size: 12px; +} +.phenotype-inspector dt { + color: #637565; + font-size: 10px; + text-transform: capitalize; +} +.phenotype-inspector dd { + margin: 0; + text-transform: capitalize; +} +.phenotype-inspector p, +.phenotype-inspector small { + font-size: 10px; + line-height: 1.8; + color: #637565; +} +.breeding-pair { + display: grid; + grid-template-columns: minmax(0, 1fr) 60px minmax(0, 1fr); + align-items: center; +} +.parent-card { + background: #fffcf4; + border: 1px solid #d8ded2; + border-radius: 12px; + padding: 24px; + min-width: 0; +} +.parent-card > label { + display: flex; + flex-direction: column; + gap: 10px; + font: + 20px Georgia, + serif; +} +.parent-card .plant-preview { + margin: auto; +} +.parent-card h2 { + font-size: 23px; +} +.parent-card p { + font-size: 12px; + color: #637565; +} +.parent-card .text-link { + display: block; +} +.parent-empty { + height: 300px; + display: grid; + place-items: center; + color: #637565; + font-size: 13px; + text-align: center; +} +.cross-symbol { + text-align: center; + font: + 36px Georgia, + serif; + color: #7f9376; +} +.cross-controls { + display: flex; + align-items: center; + justify-content: center; + gap: 20px; + flex-wrap: wrap; + border-bottom: 1px solid #d8ded2; + padding: 24px 0 30px; + margin-bottom: 30px; +} +.cross-controls > p { + width: 100%; + text-align: center; + font-size: 13px; + color: #637565; +} +.offspring-card { + display: flex; + gap: 24px; + align-items: center; + padding: 25px; + border: 1px solid #c4cfb9; + background: #edf0e4; + border-radius: 12px; + margin: 25px 0 35px; +} +.offspring-card .eyebrow { + margin-bottom: 10px; +} +.offspring-card p:not(.eyebrow) { + font-size: 12px; + color: #637565; +} +.breeding-history { + padding-top: 15px; +} +.breeding-history ol { + padding: 0; + list-style: none; +} +.breeding-history li { + display: flex; + justify-content: space-between; + gap: 15px; + align-items: center; + padding: 18px 0; + border-bottom: 1px solid #d8ded2; + font-size: 12px; +} +.breeding-history time { + font-size: 10px; + color: #637565; +} +.breeding-history a:hover, +.lineage-links a:hover { + text-decoration: underline; +} +.genome-record { + margin: 24px 0; +} +.genome-record > div { + display: flex; + justify-content: space-between; + gap: 20px; + border-bottom: 1px solid #d8ded2; + padding: 12px 0; + font-size: 12px; +} +.genome-record dt { + color: #637565; +} +.genome-record dd { + margin: 0; + display: flex; + gap: 16px; + align-items: center; + text-transform: capitalize; +} +.genome-record code { + font-size: 12px; + background: #edf0e4; + padding: 4px 6px; + border-radius: 4px; +} +.lineage-links { + padding: 0; + list-style: none; + font-size: 13px; + line-height: 2.2; +} +.provenance { + font-size: 11px; + line-height: 1.7; +} +.provenance dd { + margin: 3px 0 10px; + overflow-wrap: anywhere; +} +details { + margin: 22px 0; + color: #637565; + font-size: 12px; +} +summary { + cursor: pointer; +} +.plant-details .specimen-bottom { + font-size: 12px; + line-height: 1.7; +} +@media (max-width: 1000px) { + .locus-editor { + grid-template-columns: 70px minmax(0, 1fr) minmax(0, 1fr); + gap: 5px; + } + .locus-editor select { + font-size: 10px; + } + .breeding-pair { + grid-template-columns: minmax(0, 1fr) 40px minmax(0, 1fr); + } +} +@media (max-width: 720px) { + .editor-workbench .specimen { + position: static; + } + .locus-editor { + grid-template-columns: 76px minmax(0, 1fr) minmax(0, 1fr); + gap: 7px; + } + .locus-editor select { + font-size: 11px; + } + .breeding-pair { + grid-template-columns: 1fr; + gap: 15px; + } + .parent-card { + padding: 20px; + } + .parent-card .plant-preview { + max-width: 260px !important; + } + .cross-symbol { + font-size: 28px; + } + .parent-empty { + height: 120px; + } + .breeding-history li { + flex-wrap: wrap; + } + .breeding-history li > div { + width: 100%; + } + .offspring-card { + padding: 14px; + gap: 12px; + align-items: flex-start; + } + .offspring-card .plant-preview { + max-width: 90px !important; + } + .offspring-card h2 { + font-size: 23px; + } + .genome-record dd { + gap: 9px; + } + .phenotype-inspector dl { + gap: 12px; + } +} diff --git a/artifacts/m3/breed-desktop.png b/artifacts/m3/breed-desktop.png new file mode 100644 index 0000000..bc11bf2 Binary files /dev/null and b/artifacts/m3/breed-desktop.png differ diff --git a/artifacts/m3/breed-mobile.png b/artifacts/m3/breed-mobile.png new file mode 100644 index 0000000..a9af4e5 Binary files /dev/null and b/artifacts/m3/breed-mobile.png differ diff --git a/artifacts/m3/browser-checks.json b/artifacts/m3/browser-checks.json new file mode 100644 index 0000000..aeafe2e --- /dev/null +++ b/artifacts/m3/browser-checks.json @@ -0,0 +1,15 @@ +{ + "savedPlantId": "df2f5481-7e5d-47ba-ae41-985aaf0561ee", + "childId": "8c6d8b2b-836f-42e4-9ce7-0d42a773a287", + "eventId": "0a87f7ef-6844-4ff3-a04e-fae3c33bd35e", + "checks": [ + "allele preview update", + "manual mutation not persisted", + "saved founder reload", + "persisted cross and generation", + "child reload", + "lineage parent link", + "360px no overflow" + ], + "errors": [] +} \ No newline at end of file diff --git a/artifacts/m3/details-mobile.png b/artifacts/m3/details-mobile.png new file mode 100644 index 0000000..408b929 Binary files /dev/null and b/artifacts/m3/details-mobile.png differ diff --git a/artifacts/m3/lab-desktop.png b/artifacts/m3/lab-desktop.png new file mode 100644 index 0000000..9c6eb57 Binary files /dev/null and b/artifacts/m3/lab-desktop.png differ diff --git a/artifacts/m3/lab-mobile.png b/artifacts/m3/lab-mobile.png new file mode 100644 index 0000000..e99f7b5 Binary files /dev/null and b/artifacts/m3/lab-mobile.png differ diff --git a/artifacts/m3/lineage-desktop.png b/artifacts/m3/lineage-desktop.png new file mode 100644 index 0000000..acdda23 Binary files /dev/null and b/artifacts/m3/lineage-desktop.png differ diff --git a/compose.test.yaml b/compose.test.yaml new file mode 100644 index 0000000..f957687 --- /dev/null +++ b/compose.test.yaml @@ -0,0 +1,31 @@ +name: botanica-checks +services: + db: + image: postgres:17-alpine + environment: + POSTGRES_DB: acceptance + POSTGRES_USER: acceptance + POSTGRES_PASSWORD: ephemeral-test-only + tmpfs: [/var/lib/postgresql/data] + healthcheck: + test: ["CMD-SHELL", "pg_isready -U acceptance -d acceptance"] + interval: 2s + timeout: 3s + retries: 20 + test: + build: + context: . + dockerfile: apps/server/Dockerfile + target: build + environment: + TEST_DATABASE_URL: postgres://acceptance:ephemeral-test-only@db:5432/acceptance + command: + [ + "npx", + "vitest", + "run", + "apps/server/src/persistence.integration.test.ts", + ] + depends_on: + db: + condition: service_healthy diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index 08f8e13..276dcc1 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -3,7 +3,7 @@ - [x] M0 — workspaces, Compose, healthcheck, migrations, responsive shell. - [x] M1 — genetics contracts, deterministic PRNG, pure engine + tests. - [x] M2 — procedural illustrated SVG renderer, exports, visual QA. -- [ ] M3 — lab editor, server persistence, breeding flow. +- [x] M3 — lab editor, server persistence, breeding flow. - [ ] M4 — gallery, provenance, UX polish, full acceptance. ## M0 — 2026-10-28 @@ -70,3 +70,15 @@ Added memoized PlantPreview adapter, a usable `/lab` visual-study workbench (cur Screenshots, real exported SVG/PNG files and structured measurements are in `artifacts/m2/`. The optional browser script is checked in and documents its external Playwright/browser requirements in README. No known M2 blockers. Local sandbox only; persistent plant creation, full genome editor, saved breeding and lineage navigation are intentionally not implemented before M3. `.env` values remain unchanged and excluded from Git. Stop at M2 as requested; next milestone is M3. + +## M3 — 2026-10-28 + +Gate complete before starting M4. Added full diploid genome editor with live phenotype/rarity explanation, species selection, stage/cosmetic seed controls, manual-mutation preview labeling and saved founder creation. Added saved plant inspector with genotype, phenotype, provenance, parents and navigation. Added saved-parent breeding UI, compatibility messages, persistent offspring and paginated journal. + +Server validates shared strict DTOs, generates initial/event seeds with crypto.randomBytes, scopes records to the sandbox owner, and persists canonical genome, mutation outcome, rarity score/tier/reasons, versions and lineage. Breeding takes only parent IDs and idempotency key. A transaction-scoped advisory lock serializes identical keys; child and event commit together; reuse with a different pair returns 409. Additive 0002 migration stores names, creation source and birth-time rarity explanations without rewriting genomes or ancestry. Cursor pagination preserves PostgreSQL microseconds to handle timestamp ties correctly. Seed creates two fixed demo parents idempotently. Manual mutation previews are excluded from saved designed founders and explicitly explained in the UI. + +Checks run: `npm run typecheck`, `npm run test` (16 unit/domain/API tests passed; seven DB cases intentionally skipped without TEST_DATABASE_URL), `npm run build`, `docker compose config --quiet`, `docker compose up --build -d`, `docker compose ps`, and proxied curl health (200 connected). Separately ran `docker compose -f compose.test.yaml up --build --abort-on-container-exit --exit-code-from test`: all seven real PostgreSQL integration cases passed on a clean tmpfs DB. They cover repeated clean migration/seed, canonical persistence/claim rejection, tied-timestamp pagination, 12 simultaneous retries producing exactly one child/event with replayable seed, generation increments, incompatible and foreign-owned parent rejection, and forced event failure rolling back the child with a safe 500 response. Removed only the isolated test containers afterward. + +`node scripts/verify-m3.cjs` passed live browser acceptance: editing a pigment allele changes visible phenotype/SVG; save excludes the manual mutation; founder and child survive reload; offspring has two persisted parents and generation 1; lineage navigates to the saved founder; laboratory, breeding and detail pages fit 360px. Desktop/mobile screenshots and result IDs are in artifacts/m3. The first browser attempt used a label-text locator that included select-option text; the actual accessible combobox names were verified and the test corrected. No app defect was suppressed. Visually reviewed laboratory and breeding screens. + +URL: http://localhost:8155. Known limit: collection filtering/demo population belongs to M4; no auth or public release. The UI acceptance founder and its offspring are retained as evidence of persistence. Continuing to M4 under user authorization. diff --git a/package.json b/package.json index 6827a17..0234b56 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,8 @@ "build:packages": "npm run build -w @botanica/rules && npm run build -w @botanica/genetics && npm run build -w @botanica/shared && npm run build -w @botanica/renderer", "pretest": "npm run build:packages", "predev": "npm run build:packages", - "assets:write": "npm run build:packages && node scripts/write-assets.mjs" + "assets:write": "npm run build:packages && node scripts/write-assets.mjs", + "test:integration": "docker compose -f compose.test.yaml up --build --abort-on-container-exit --exit-code-from test" }, "devDependencies": { "typescript": "~5.9.3", diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index e806cdc..a93e472 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -21,3 +21,58 @@ export interface CatalogResponse { catalogVersion: 1; species: typeof catalog; } + +export const createPlantSchema = z.strictObject({ + speciesId: z.string().min(1), + genome: genomeSchema, + visualSeed: z.number().int().min(-2147483648).max(2147483647).optional(), + name: z.string().trim().min(1).max(60).default("Velvetleaf"), +}); +export const breedingRequestSchema = z.strictObject({ + parentAId: z.uuid(), + parentBId: z.uuid(), + idempotencyKey: z.uuid(), +}); +export const pageQuerySchema = z.strictObject({ + cursor: z.string().max(300).optional(), + limit: z.coerce.number().int().min(1).max(100).default(24), +}); +export interface PlantDto { + id: string; + name: string; + ownerId: string; + speciesId: string; + genome: import("@botanica/genetics").Genome; + mutations: import("@botanica/genetics").NamedMutation[]; + phenotype: Phenotype; + visualSeed: number; + genomeVersion: number; + rendererVersion: number; + generation: number; + parents: [] | [string, string]; + createdAt: string; + creationSource: "designed" | "bred" | "demo"; + rarity: ReturnType; +} +export interface PageDto { + items: T[]; + nextCursor: string | null; + total: number; +} +export interface BreedingEventDto { + id: string; + parentAId: string; + parentBId: string; + childId: string; + rngSeed: number; + engineVersion: number; + mutationEvents: import("@botanica/genetics").MutationEvent[]; + idempotencyKey: string; + createdAt: string; +} +export interface BreedingResponse { + plant: PlantDto; + event: BreedingEventDto; +} +export type CreatePlantRequest = z.input; +export type BreedingRequest = z.infer; diff --git a/scripts/verify-m3.cjs b/scripts/verify-m3.cjs new file mode 100644 index 0000000..d1e3145 --- /dev/null +++ b/scripts/verify-m3.cjs @@ -0,0 +1,163 @@ +const fs = require("fs"); +const { chromium } = require(process.env.PLAYWRIGHT_MODULE || "playwright"); +(async () => { + fs.mkdirSync("artifacts/m3", { recursive: true }); + const browser = await chromium.launch({ + headless: true, + ...(process.env.BROWSER_EXECUTABLE + ? { executablePath: process.env.BROWSER_EXECUTABLE } + : {}), + }); + const page = await browser.newPage({ + viewport: { width: 1440, height: 1100 }, + reducedMotion: "reduce", + }); + const errors = []; + page.on("pageerror", (e) => errors.push(e.message)); + await page.goto("http://localhost:8155/lab"); + await page + .getByRole("combobox", { name: "Pigment allele A", exact: true }) + .waitFor(); + const original = await page.locator(".specimen>.plant-preview").innerHTML(); + await page + .getByRole("combobox", { name: "Pigment allele A", exact: true }) + .selectOption("G"); + await page.waitForFunction(() => + document + .querySelector(".phenotype-inspector") + .textContent.includes("green"), + ); + if (original === (await page.locator(".specimen>.plant-preview").innerHTML())) + throw Error("Allele edit did not update illustration"); + await page + .getByLabel("Golden veins · manual preview only", { exact: true }) + .check(); + await page + .getByLabel("Plant name", { exact: true }) + .fill("M3 acceptance founder"); + const savedResponse = page.waitForResponse( + (r) => + r.url().endsWith("/api/v1/plants") && r.request().method() === "POST", + ); + await page + .getByRole("button", { name: "Save designed plant", exact: true }) + .click(); + const saved = await (await savedResponse).json(); + if (saved.mutations.length || saved.generation !== 0) + throw Error("Manual preview persisted as mutation"); + await page.getByText("Saved to your collection.", { exact: false }).waitFor(); + await page.screenshot({ + path: "artifacts/m3/lab-desktop.png", + fullPage: true, + }); + await page + .getByRole("link", { name: "Inspect M3 acceptance founder →", exact: true }) + .click(); + await page + .getByRole("heading", { name: "M3 acceptance founder", exact: true }) + .waitFor(); + await page.reload(); + await page + .getByRole("heading", { name: "M3 acceptance founder", exact: true }) + .waitFor(); + await page.goto("http://localhost:8155/breed"); + await page + .getByRole("combobox", { name: "Parent A", exact: true }) + .selectOption(saved.id); + await page + .getByRole("combobox", { name: "Parent B", exact: true }) + .selectOption("10000000-0000-4000-8000-000000000002"); + const bredResponse = page.waitForResponse( + (r) => + r.url().endsWith("/api/v1/breeding") && r.request().method() === "POST", + ); + await page + .getByRole("button", { name: "Breed selected parents", exact: true }) + .click(); + const result = await (await bredResponse).json(); + if ( + result.plant.parents.join(",") !== + [saved.id, "10000000-0000-4000-8000-000000000002"].join(",") || + result.plant.generation !== 1 + ) + throw Error("Incorrect lineage"); + await page + .getByRole("heading", { name: "Velvetleaf · generation 1", exact: true }) + .waitFor(); + await page.screenshot({ + path: "artifacts/m3/breed-desktop.png", + fullPage: true, + }); + await page + .getByRole("link", { name: "Inspect child & lineage →", exact: true }) + .click(); + await page + .getByRole("heading", { name: "Velvetleaf · generation 1", exact: true }) + .waitFor(); + await page.screenshot({ + path: "artifacts/m3/lineage-desktop.png", + fullPage: true, + }); + await page.reload(); + await page + .getByRole("heading", { name: "Velvetleaf · generation 1", exact: true }) + .waitFor(); + await page + .getByRole("link", { + name: "Parent A · " + saved.id.slice(0, 8) + " →", + exact: true, + }) + .click(); + await page + .getByRole("heading", { name: "M3 acceptance founder", exact: true }) + .waitFor(); + for (const route of ["/lab", "/breed", "/plants/" + result.plant.id]) { + await page.setViewportSize({ width: 360, height: 1000 }); + await page.goto("http://localhost:8155" + route); + await page + .getByRole("status") + .filter({ hasText: "Laboratory online" }) + .waitFor(); + if ( + await page.evaluate( + () => document.documentElement.scrollWidth > innerWidth, + ) + ) + throw Error("Mobile overflow " + route); + await page.screenshot({ + path: + "artifacts/m3/" + + (route.startsWith("/plants") ? "details" : route.slice(1)) + + "-mobile.png", + fullPage: true, + }); + } + if (errors.length) throw Error(errors.join("\n")); + fs.writeFileSync( + "artifacts/m3/browser-checks.json", + JSON.stringify( + { + savedPlantId: saved.id, + childId: result.plant.id, + eventId: result.event.id, + checks: [ + "allele preview update", + "manual mutation not persisted", + "saved founder reload", + "persisted cross and generation", + "child reload", + "lineage parent link", + "360px no overflow", + ], + errors, + }, + null, + 2, + ), + ); + console.log("M3 browser acceptance passed", result.plant.id); + await browser.close(); +})().catch((e) => { + console.error(e); + process.exit(1); +});