Implement M3 genome editor and transactional persistent breeding

This commit is contained in:
Christian Johannesen 2026-10-28 10:47:00 +01:00
parent ca8f07a0a2
commit daa461827f
35 changed files with 2341 additions and 60 deletions

View file

@ -7,11 +7,13 @@ COPY packages/ packages/
RUN npm ci RUN npm ci
COPY tsconfig.base.json ./ COPY tsconfig.base.json ./
COPY apps/server/ apps/server/ 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 FROM node:24-alpine
ENV NODE_ENV=production ENV NODE_ENV=production
WORKDIR /app 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/packages ./packages
COPY --from=build --chown=node:node /app/apps/server/package.json ./package.json COPY --from=build --chown=node:node /app/apps/server/package.json ./package.json
COPY --from=build --chown=node:node /app/apps/server/dist ./dist COPY --from=build --chown=node:node /app/apps/server/dist ./dist

View file

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

View file

@ -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 { registerPreviewRoute } from "./preview-route.js";
import { ZodError } from "zod"; import { ZodError } from "zod";
import { GeneticsError } from "@botanica/genetics"; import { GeneticsError } from "@botanica/genetics";
@ -6,6 +9,7 @@ import Fastify from "fastify";
export function createApp( export function createApp(
checkDatabase: () => Promise<unknown>, checkDatabase: () => Promise<unknown>,
logger = false, logger = false,
pool?: Pool,
) { ) {
const app = Fastify({ logger, bodyLimit: 65536 }); const app = Fastify({ logger, bodyLimit: 65536 });
app.get("/health", async (_request, reply) => { app.get("/health", async (_request, reply) => {
@ -23,12 +27,17 @@ export function createApp(
}); });
registerGeneticsRoutes(app); registerGeneticsRoutes(app);
registerPreviewRoute(app); registerPreviewRoute(app);
if (pool) registerPlantRoutes(app, pool);
app.setNotFoundHandler((_request, reply) => app.setNotFoundHandler((_request, reply) =>
reply reply
.code(404) .code(404)
.send({ error: { code: "NOT_FOUND", message: "Route not found." } }), .send({ error: { code: "NOT_FOUND", message: "Route not found." } }),
); );
app.setErrorHandler((error, request, reply) => { 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) if (error instanceof ZodError)
return reply.code(400).send({ return reply.code(400).send({
error: { error: {

View file

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

View file

@ -0,0 +1,9 @@
export class ApiError extends Error {
constructor(
public statusCode: number,
public code: string,
message: string,
) {
super(message);
}
}

View file

@ -3,7 +3,7 @@ import { connectDatabase } from "./db.js";
import { createApp } from "./app.js"; import { createApp } from "./app.js";
const config = readConfig(process.env); const config = readConfig(process.env);
const { pool } = connectDatabase(config.DATABASE_URL); 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 () => { app.addHook("onClose", async () => {
await pool.end(); await pool.end();
}); });

View file

@ -1,35 +1,9 @@
import { readFile } from "node:fs/promises";
import { connectDatabase } from "./db.js"; import { connectDatabase } from "./db.js";
import { migrate } from "./migrations.js";
const { pool } = connectDatabase(process.env.DATABASE_URL ?? ""); const { pool } = connectDatabase(process.env.DATABASE_URL ?? "");
const client = await pool.connect();
try { try {
await client.query("BEGIN"); await migrate(pool);
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");
console.log("Migrations current"); console.log("Migrations current");
} catch (error) {
await client.query("ROLLBACK");
throw error;
} finally { } finally {
client.release();
await pool.end(); await pool.end();
} }

View file

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

View file

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

View file

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

380
apps/server/src/plants.ts Normal file
View file

@ -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<PlantRow>(
`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<PlantRow>(
"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<PageDto<PlantDto>> {
const cursor = decodeCursor(query.cursor);
const rows = await this.pool.query<PlantRow>(
`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<PageDto<BreedingEventDto>> {
const cursor = decodeCursor(query.cursor),
rows = await this.pool.query<EventRow>(
`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<BreedingResponse> {
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<EventRow>(
"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<PlantRow>(
"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<EventRow>(
"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();
}
}
}

View file

@ -27,6 +27,9 @@ export const plants = pgTable(
"plants", "plants",
{ {
id: uuid().primaryKey(), 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") ownerId: uuid("owner_id")
.notNull() .notNull()
.references(() => players.id), .references(() => players.id),

View file

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

View file

@ -1,31 +1,9 @@
import { velvetleaf } from "@botanica/genetics";
import { connectDatabase } from "./db.js"; import { connectDatabase } from "./db.js";
import { players, species } from "./schema.js"; import { seedDatabase } from "./seed-data.js";
const { pool, db } = connectDatabase(process.env.DATABASE_URL ?? ""); const { pool } = connectDatabase(process.env.DATABASE_URL ?? "");
try { try {
await db.transaction(async (tx) => { await seedDatabase(pool);
await tx console.log("Sandbox catalog and demo parents ready");
.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");
} finally { } finally {
await pool.end(); await pool.end();
} }

View file

@ -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 (
<>
<div className="export-actions">
<button disabled={busy} onClick={() => void save("svg")}>
Export SVG
</button>
<button disabled={busy} onClick={() => void save("png")}>
Export PNG
</button>
</div>
<p className="export-status" role="status">
{message}
</p>
</>
);
}

View file

@ -0,0 +1,56 @@
import { loci, type Genome, type Locus, velvetleaf } from "@botanica/genetics";
export const locusLabels: Record<Locus, string> = {
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 (
<fieldset className="genome-editor">
<legend>Diploid genotype</legend>
<p>
One allele from each parent. Dominance determines the expressed trait.
</p>
{loci.map((locus) => (
<div className="locus-editor" key={locus}>
<span>{locusLabels[locus]}</span>
{([0, 1] as const).map((index) => (
<label key={index}>
<span className="sr-only">
{locusLabels[locus]} allele {index === 0 ? "A" : "B"}
</span>
<select
value={genome[locus][index]}
onChange={(e) => {
const pair = [...genome[locus]];
pair[index] = e.target.value;
onChange({ ...genome, [locus]: pair } as Genome);
}}
>
{velvetleaf.loci[locus].dominance.map((allele) => (
<option value={allele} key={allele}>
{allele} ·{" "}
{
(velvetleaf.loci[locus].labels as Record<string, string>)[
allele
]
}
</option>
))}
</select>
</label>
))}
</div>
))}
</fieldset>
);
}

View file

@ -0,0 +1,27 @@
export function QueryMessage({
error,
retry,
loading = false,
}: {
error?: Error | null;
retry?: () => void;
loading?: boolean;
}) {
return (
<div
className={"query-message" + (error ? " error-message" : "")}
role={error ? "alert" : "status"}
>
<p>
{loading
? "Growing your view…"
: (error?.message ?? "Nothing here yet.")}
</p>
{error && retry && (
<button className="secondary" onClick={retry}>
Try again
</button>
)}
</div>
);
}

84
apps/web/src/lib/api.ts Normal file
View file

@ -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<T>(
url: string,
options: RequestInit = {},
): Promise<T> {
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<CatalogResponse>("/catalog/species"),
plant: (id: string) => request<PlantDto>("/plants/" + encodeURIComponent(id)),
plants: (query = "") =>
request<PageDto<PlantDto>>("/plants" + (query ? "?" + query : "")),
create: (input: CreatePlantRequest) =>
request<PlantDto>("/plants", {
method: "POST",
body: JSON.stringify(input),
}),
cross: (input: BreedingRequest) =>
request<BreedingResponse>("/breeding", {
method: "POST",
body: JSON.stringify(input),
}),
events: (cursor?: string) =>
request<PageDto<BreedingEventDto>>(
"/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;
}

View file

@ -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 { RenderLab } from "./pages/RenderLab";
import { RenderGallery } from "./pages/RenderGallery"; import { RenderGallery } from "./pages/RenderGallery";
import React from "react"; import React from "react";
@ -237,7 +240,9 @@ function App() {
path={`/${key}`} path={`/${key}`}
element={ element={
key === "lab" ? ( key === "lab" ? (
<RenderLab /> <Lab />
) : key === "breed" ? (
<BreedingLab />
) : key === "gallery" ? ( ) : key === "gallery" ? (
<RenderGallery /> <RenderGallery />
) : ( ) : (
@ -246,6 +251,8 @@ function App() {
} }
/> />
))} ))}
<Route path="/plants/:id" element={<PlantDetails />} />
<Route path="/studies" element={<RenderGallery />} />
<Route <Route
path="*" path="*"
element={ element={

View file

@ -0,0 +1,276 @@
import { useState, useRef } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Link } from "react-router";
import { canBreed } from "@botanica/genetics";
import { PlantPreview } from "../components/PlantPreview";
import { QueryMessage } from "../components/QueryMessage";
import { api, loadAllParents } from "../lib/api";
import type { PlantDto } from "@botanica/shared";
function Parent({
label,
id,
onChange,
plants,
}: {
label: string;
id: string;
onChange: (id: string) => void;
plants: PlantDto[];
}) {
const plant = plants.find((p) => p.id === id);
return (
<section className="parent-card">
<label>
{label}
<select value={id} onChange={(e) => onChange(e.target.value)}>
<option value="">Choose a saved plant</option>
{plants.map((p) => (
<option key={p.id} value={p.id}>
{p.name} · {p.id.slice(0, 8)}
</option>
))}
</select>
</label>
{plant ? (
<>
<PlantPreview
phenotype={plant.phenotype}
visualSeed={plant.visualSeed}
stage="flowering"
size={512}
/>
<h2>{plant.name}</h2>
<p>
{plant.phenotype.pigment} · {plant.phenotype.leafShape} ·{" "}
{plant.phenotype.leafPattern}
</p>
<span className="badge">
Generation {plant.generation} · {plant.rarity.tier}
</span>
<Link className="text-link" to={"/plants/" + plant.id}>
Inspect parent →
</Link>
</>
) : (
<div className="parent-empty">Select a parent to see its traits.</div>
)}
</section>
);
}
export function BreedingLab() {
const parents = useQuery({ queryKey: ["parents"], queryFn: loadAllParents }),
[a, setA] = useState(""),
[b, setB] = useState(""),
[cursor, setCursor] = useState<string | undefined>(),
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 (
<>
<section className="lab-heading">
<div>
<p className="eyebrow">The next generation</p>
<h1>Let their traits meet.</h1>
<p>
Choose two saved plants. Inheritance, mutations and the child's
identity are decided once by the server.
</p>
</div>
<Link className="button-link secondary" to="/lab">
Design a parent →
</Link>
</section>
{parents.isPending ? (
<QueryMessage loading />
) : parents.error ? (
<QueryMessage
error={parents.error}
retry={() => void parents.refetch()}
/>
) : !list.length ? (
<div className="empty">
<h2>No saved parents yet</h2>
<p>Design and save two plants in the laboratory.</p>
<Link className="text-link" to="/lab">
Open laboratory →
</Link>
</div>
) : (
<>
<div className="breeding-pair">
<Parent
label="Parent A"
id={a}
onChange={(id) => choose("A", id)}
plants={list}
/>
<div className="cross-symbol" aria-hidden="true">
×
</div>
<Parent
label="Parent B"
id={b}
onChange={(id) => choose("B", id)}
plants={list}
/>
</div>
<section className="cross-controls">
<p role="status">{message}</p>
<button
onClick={breed}
disabled={!compatible || cross.isPending || !!cross.data}
>
{cross.isPending
? "Crossing…"
: cross.data
? "Cross saved"
: "Breed selected parents"}
</button>
{cross.error && (
<p className="error-text" role="alert">
{cross.error.message} Retry uses the same request key.
</p>
)}
{cross.data && (
<button
className="secondary"
onClick={() => {
key.current = null;
cross.reset();
}}
>
Start another cross
</button>
)}
</section>
</>
)}
{cross.data && (
<section className="offspring-card" aria-labelledby="offspring-title">
<PlantPreview
phenotype={cross.data.plant.phenotype}
visualSeed={cross.data.plant.visualSeed}
stage="flowering"
size={128}
/>
<div>
<p className="eyebrow">A new discovery</p>
<h2 id="offspring-title">{cross.data.plant.name}</h2>
<p>
{cross.data.plant.phenotype.pigment} ·{" "}
{cross.data.plant.phenotype.leafPattern} ·{" "}
{cross.data.plant.rarity.tier}
</p>
<p>
{cross.data.event.mutationEvents.length
? cross.data.event.mutationEvents.length +
" mutation events recorded."
: "No mutations in this cross."}
</p>
<Link className="text-link" to={"/plants/" + cross.data.plant.id}>
Inspect child & lineage →
</Link>
</div>
</section>
)}
<section className="breeding-history">
<div className="section-heading">
<h2>The breeding journal</h2>
{cursor && (
<button className="secondary" onClick={() => setCursor(undefined)}>
Latest crosses
</button>
)}
</div>
{history.isPending ? (
<QueryMessage loading />
) : history.error ? (
<QueryMessage
error={history.error}
retry={() => void history.refetch()}
/>
) : history.data?.items.length ? (
<>
<ol>
{history.data.items.map((event) => (
<li key={event.id}>
<div>
<Link to={"/plants/" + event.parentAId}>
Parent {event.parentAId.slice(0, 8)}
</Link>
<span> × </span>
<Link to={"/plants/" + event.parentBId}>
Parent {event.parentBId.slice(0, 8)}
</Link>
</div>
<Link to={"/plants/" + event.childId}>View child →</Link>
<time dateTime={event.createdAt}>
{new Date(event.createdAt).toLocaleString("en-GB")}
</time>
</li>
))}
</ol>
{history.data.nextCursor && (
<button
className="secondary"
onClick={() => setCursor(history.data!.nextCursor!)}
>
Older crosses
</button>
)}
</>
) : (
<div className="empty">
<h3>The first cross is still to come</h3>
<p>Every saved offspring will leave a trace here.</p>
</div>
)}
</section>
</>
);
}

275
apps/web/src/pages/Lab.tsx Normal file
View file

@ -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<Genome>(() =>
structuredClone(exampleGenome),
),
[stage, setStage] = useState<GrowthStage>("flowering"),
[seed, setSeed] = useState(8155),
[golden, setGolden] = useState(false),
[background, setBackground] = useState<Background>("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 (
<>
<section className="lab-heading">
<div>
<p className="eyebrow">The plant laboratory</p>
<h1>Grow something unexpected.</h1>
<p>
Edit paired alleles and see their expression. Save a designed
founder, then cross saved plants in the breeding lab.
</p>
</div>
<Link className="button-link secondary" to="/breed">
Open breeding lab →
</Link>
</section>
{sourceId && source.isPending && <QueryMessage loading />}
{source.error && (
<QueryMessage
error={source.error}
retry={() => void source.refetch()}
/>
)}
<section className="render-workbench editor-workbench">
<div className="specimen">
<div className="specimen-top">
<span>VELVETLEAF / LIVE PREVIEW</span>
<span>Genome v1 · Renderer v1</span>
</div>
<PlantPreview
phenotype={phenotype}
visualSeed={seed}
stage={stage}
background={background}
/>
<div className="specimen-bottom">
<div>
<h2>{name || "Velvetleaf"}</h2>
<p>
{phenotype.pigment} · {phenotype.leafShape} ·{" "}
{phenotype.leafPattern}
</p>
</div>
<PlantPreview
phenotype={phenotype}
visualSeed={seed}
stage={stage}
size={64}
/>
</div>
<div className="phenotype-inspector">
<h3>Resolved phenotype</h3>
<dl>
{(
[
"pigment",
"leafShape",
"leafPattern",
"growthHabit",
"flowerColor",
"flowerDensity",
] as const
).map((key) => (
<div key={key}>
<dt>{key.replace(/([A-Z])/g, " $1")}</dt>
<dd>{phenotype[key]}</dd>
</div>
))}
<div>
<dt>Derived rarity</dt>
<dd>
{rarity.tier} · {rarity.score}/100
</dd>
</div>
</dl>
<p>
{rarity.reasons
.map((r) => r.label + " +" + r.points)
.join("; ") || "No unusual traits."}
</p>
<small>
Policy v1 · preview classification
{golden ? " includes a manual mutation" : ""}
</small>
</div>
</div>
<aside className="study-panel">
<h2>The genome workbench</h2>
{catalog.error ? (
<QueryMessage
error={catalog.error}
retry={() => void catalog.refetch()}
/>
) : (
<label>
Species
<select
aria-label="Species"
value="velvetleaf"
disabled={catalog.isPending}
>
{(
catalog.data?.species ?? [
{ id: "velvetleaf", name: "Velvetleaf" },
]
).map((s) => (
<option key={s.id} value={s.id}>
{s.name}
</option>
))}
</select>
</label>
)}
<GenomeEditor
genome={genome}
onChange={(value) => {
setGenome(value);
save.reset();
}}
/>
<div className="control-pair">
<label>
Growth stage
<select
value={stage}
onChange={(e) => setStage(e.target.value as GrowthStage)}
>
{stages.map((s) => (
<option key={s}>{s}</option>
))}
</select>
</label>
<label>
Visual seed
<input
type="number"
min={-2147483648}
max={2147483647}
value={seed}
onChange={(e) => {
const value = Number(e.target.value);
if (
Number.isInteger(value) &&
value >= -2147483648 &&
value <= 2147483647
)
setSeed(value);
}}
/>
</label>
</div>
<label className="checkbox">
<input
type="checkbox"
checked={golden}
onChange={(e) => setGolden(e.target.checked)}
/>
Golden veins · manual preview only
</label>
<p className="panel-note">
Manual mutation previews appear in image exports. They are not saved
or recorded as bred mutations.
</p>
<label>
Preview & export background
<select
value={background}
onChange={(e) => setBackground(e.target.value as Background)}
>
<option value="warm">Warm ivory</option>
<option value="transparent">Transparent</option>
</select>
</label>
<ExportActions
phenotype={phenotype}
visualSeed={seed}
stage={stage}
background={background}
/>
<form
className="save-plant"
onSubmit={(e) => {
e.preventDefault();
save.mutate({
speciesId: "velvetleaf",
genome,
visualSeed: seed,
name,
});
}}
>
<label>
Plant name
<input
required
maxLength={60}
value={name}
onChange={(e) => setName(e.target.value)}
/>
</label>
<button type="submit" disabled={save.isPending || !name.trim()}>
{save.isPending ? "Saving…" : "Save designed plant"}
</button>
<p className="panel-note">
Saves a new generation 0 founder. Earlier plants stay unchanged.
</p>
{save.error && (
<p role="alert" className="error-text">
{save.error.message}
</p>
)}
{save.data && (
<p role="status" className="success-text">
Saved to your collection.{" "}
<Link to={"/plants/" + save.data.id}>
Inspect {save.data.name} →
</Link>
</p>
)}
</form>
</aside>
</section>
</>
);
}

View file

@ -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 <QueryMessage loading />;
if (plant.error)
return (
<QueryMessage error={plant.error} retry={() => void plant.refetch()} />
);
const p = plant.data;
return (
<>
<section className="lab-heading">
<div>
<p className="eyebrow">A living record</p>
<h1>{p.name}</h1>
<p>
Generation {p.generation} ·{" "}
{p.creationSource === "bred"
? "Bred offspring"
: p.creationSource === "demo"
? "Demonstration plant"
: "Designed founder"}
</p>
</div>
<Link className="button-link secondary" to="/breed">
Back to breeding →
</Link>
</section>
<section className="render-workbench plant-details">
<div className="specimen">
<PlantPreview
phenotype={p.phenotype}
visualSeed={p.visualSeed}
stage="flowering"
/>
<div className="specimen-bottom">
<span>
{p.phenotype.pigment} · {p.phenotype.leafShape} ·{" "}
{p.phenotype.leafPattern}
</span>
<PlantPreview
phenotype={p.phenotype}
visualSeed={p.visualSeed}
stage="flowering"
size={64}
/>
</div>
</div>
<aside className="study-panel">
<h2>Genotype & phenotype</h2>
<dl className="genome-record">
{loci.map((locus) => (
<div key={locus}>
<dt>{locusLabels[locus]}</dt>
<dd>
<code>{p.genome[locus].join(" / ")}</code>
<span>{p.phenotype[locus]}</span>
</dd>
</div>
))}
</dl>
<h3>
{p.rarity.tier} · {p.rarity.score}/100
</h3>
<p className="panel-note">
{p.rarity.reasons
.map((r) => r.label + " +" + r.points)
.join("; ") || "No unusual traits."}{" "}
· Rarity policy v{p.rarity.rarityPolicyVersion}
</p>
<h3>Lineage</h3>
{p.parents.length ? (
<ul className="lineage-links">
{p.parents.map((parent, i) => (
<li key={parent}>
<Link to={"/plants/" + parent}>
Parent {i === 0 ? "A" : "B"} · {parent.slice(0, 8)} →
</Link>
</li>
))}
</ul>
) : (
<p className="panel-note">Founder plant. No recorded parents.</p>
)}
<p className="panel-note">
{p.mutations.length
? "Recorded modifier: " + p.mutations.join(", ")
: "No named mutations."}
</p>
<details>
<summary>Provenance</summary>
<dl className="provenance">
<dt>Plant ID</dt>
<dd>{p.id}</dd>
<dt>Created</dt>
<dd>{new Date(p.createdAt).toLocaleString("en-GB")}</dd>
<dt>Versions</dt>
<dd>
Genome {p.genomeVersion} · Renderer {p.rendererVersion}
</dd>
<dt>Visual seed</dt>
<dd>{p.visualSeed}</dd>
</dl>
</details>
<ExportActions
phenotype={p.phenotype}
visualSeed={p.visualSeed}
stage="flowering"
/>
<Link className="text-link" to={"/lab?plant=" + p.id}>
Study this genome in the lab →
</Link>
</aside>
</section>
</>
);
}

View file

@ -694,3 +694,374 @@ button:disabled {
width: 100%; 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;
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 208 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

View file

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 183 KiB

BIN
artifacts/m3/lab-mobile.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 125 KiB

31
compose.test.yaml Normal file
View file

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

View file

@ -3,7 +3,7 @@
- [x] M0 — workspaces, Compose, healthcheck, migrations, responsive shell. - [x] M0 — workspaces, Compose, healthcheck, migrations, responsive shell.
- [x] M1 — genetics contracts, deterministic PRNG, pure engine + tests. - [x] M1 — genetics contracts, deterministic PRNG, pure engine + tests.
- [x] M2 — procedural illustrated SVG renderer, exports, visual QA. - [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. - [ ] M4 — gallery, provenance, UX polish, full acceptance.
## M0 — 2026-10-28 ## 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. 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. 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.

View file

@ -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", "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", "pretest": "npm run build:packages",
"predev": "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": { "devDependencies": {
"typescript": "~5.9.3", "typescript": "~5.9.3",

View file

@ -21,3 +21,58 @@ export interface CatalogResponse {
catalogVersion: 1; catalogVersion: 1;
species: typeof catalog; 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<typeof classifyRarity>;
}
export interface PageDto<T> {
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<typeof createPlantSchema>;
export type BreedingRequest = z.infer<typeof breedingRequestSchema>;

163
scripts/verify-m3.cjs Normal file
View file

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