Implement M1 deterministic genetics and validated catalog API

This commit is contained in:
Christian Johannesen 2026-10-28 10:16:06 +01:00
parent ea9adb85e6
commit df98d02077
26 changed files with 769 additions and 51 deletions

View file

@ -7,11 +7,12 @@ 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 -w @botanica/server && npm prune --omit=dev RUN npm run build:packages && npm run build -w @botanica/server && 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=build --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/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
COPY --from=build --chown=node:node /app/apps/server/migrations ./migrations COPY --from=build --chown=node:node /app/apps/server/migrations ./migrations

View file

@ -14,7 +14,9 @@
"fastify": "^5.12.7", "fastify": "^5.12.7",
"zod": "^4.0.0", "zod": "^4.0.0",
"drizzle-orm": "^0.45.4", "drizzle-orm": "^0.45.4",
"pg": "^8.16.3" "pg": "^8.16.3",
"@botanica/genetics": "0.0.0",
"@botanica/shared": "0.0.0"
}, },
"devDependencies": { "devDependencies": {
"@types/pg": "^8.15.5", "@types/pg": "^8.15.5",

View file

@ -1,3 +1,6 @@
import { ZodError } from "zod";
import { GeneticsError } from "@botanica/genetics";
import { registerGeneticsRoutes } from "./genetics-routes.js";
import Fastify from "fastify"; import Fastify from "fastify";
export function createApp( export function createApp(
checkDatabase: () => Promise<unknown>, checkDatabase: () => Promise<unknown>,
@ -9,9 +12,7 @@ export function createApp(
await checkDatabase(); await checkDatabase();
return { status: "ok", database: "connected" }; return { status: "ok", database: "connected" };
} catch { } catch {
return reply return reply.code(503).send({
.code(503)
.send({
error: { error: {
code: "DATABASE_UNAVAILABLE", code: "DATABASE_UNAVAILABLE",
message: "Database is unavailable.", message: "Database is unavailable.",
@ -19,12 +20,30 @@ export function createApp(
}); });
} }
}); });
registerGeneticsRoutes(app);
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 ZodError)
return reply
.code(400)
.send({
error: {
code: "INVALID_REQUEST",
message: "Request validation failed.",
details: error.issues.map((i) => ({
path: i.path,
message: i.message,
})),
},
});
if (error instanceof GeneticsError)
return reply
.code(error.code === "INCOMPATIBLE_SPECIES" ? 409 : 400)
.send({ error: { code: error.code, message: error.message } });
request.log.error(error); request.log.error(error);
const code = const code =
typeof error === "object" && error !== null && "statusCode" in error typeof error === "object" && error !== null && "statusCode" in error
@ -32,9 +51,7 @@ export function createApp(
: undefined; : undefined;
const status = const status =
typeof code === "number" && code >= 400 && code < 500 ? code : 500; typeof code === "number" && code >= 400 && code < 500 ? code : 500;
reply reply.code(status).send({
.code(status)
.send({
error: { error: {
code: status === 500 ? "INTERNAL_ERROR" : "INVALID_REQUEST", code: status === 500 ? "INTERNAL_ERROR" : "INVALID_REQUEST",
message: message:

View file

@ -0,0 +1,35 @@
import { it, expect } from "vitest";
import { createApp } from "./app.js";
import { exampleGenome } from "@botanica/genetics";
it("exposes versioned catalogs and rejects untrusted alleles and claims", async () => {
const app = createApp(async () => {});
try {
const catalog = await app.inject("/api/v1/catalog/species");
expect(catalog.statusCode).toBe(200);
expect(catalog.json().species[0].id).toBe("velvetleaf");
const payload = { speciesId: "velvetleaf", genome: exampleGenome };
const valid = await app.inject({
method: "POST",
url: "/api/v1/phenotype/resolve",
payload,
});
expect(valid.statusCode).toBe(200);
expect(valid.json().phenotype.pigment).toBe("purple");
for (const invalid of [
{ ...payload, genome: { ...exampleGenome, pigment: ["<script>", "G"] } },
{ ...payload, rarityScore: 100 },
{ ...payload, speciesId: "other" },
{ ...payload, genome: {} },
]) {
const response = await app.inject({
method: "POST",
url: "/api/v1/phenotype/resolve",
payload: invalid,
});
expect(response.statusCode).toBe(400);
expect(response.json().error.code).toBeDefined();
}
} finally {
await app.close();
}
});

View file

@ -0,0 +1,26 @@
import type { FastifyInstance } from "fastify";
import { catalog, resolvePhenotype, classifyRarity } from "@botanica/genetics";
import {
resolveRequestSchema,
type CatalogResponse,
type ResolveResponse,
} from "@botanica/shared";
export function registerGeneticsRoutes(app: FastifyInstance) {
app.get("/api/v1/catalog/species", async (): Promise<CatalogResponse> => ({
catalogVersion: 1,
species: catalog,
}));
app.post(
"/api/v1/phenotype/resolve",
async (request): Promise<ResolveResponse> => {
const input = resolveRequestSchema.parse(request.body);
const phenotype = resolvePhenotype(
input.speciesId,
input.genome,
input.mutations,
input.catalogVersion,
);
return { phenotype, rarity: classifyRarity(phenotype) };
},
);
}

View file

@ -1,3 +1,4 @@
import { velvetleaf } from "@botanica/genetics";
import { connectDatabase } from "./db.js"; import { connectDatabase } from "./db.js";
import { players, species } from "./schema.js"; import { players, species } from "./schema.js";
const { pool, db } = connectDatabase(process.env.DATABASE_URL ?? ""); const { pool, db } = connectDatabase(process.env.DATABASE_URL ?? "");
@ -17,19 +18,12 @@ try {
name: "Velvetleaf", name: "Velvetleaf",
compatibilityGroup: "velvetleaf", compatibilityGroup: "velvetleaf",
catalogVersion: 1, catalogVersion: 1,
config: { config: velvetleaf,
interspeciesBreeding: false,
loci: {
pigment: ["G", "P", "R", "Y"],
leafShape: ["O", "L", "S"],
leafPattern: ["S", "V", "M"],
growthHabit: ["C", "B", "T"],
flowerColor: ["W", "P", "B", "R"],
flowerDensity: ["L", "M", "H"],
},
},
}) })
.onConflictDoNothing(); .onConflictDoUpdate({
target: species.id,
set: { config: velvetleaf, catalogVersion: 1 },
});
}); });
console.log("Sandbox player and velvetleaf catalog ready"); console.log("Sandbox player and velvetleaf catalog ready");
} finally { } finally {

View file

@ -7,7 +7,7 @@ COPY packages/ packages/
RUN npm ci RUN npm ci
COPY tsconfig.base.json ./ COPY tsconfig.base.json ./
COPY apps/web/ apps/web/ COPY apps/web/ apps/web/
RUN npm run build -w @botanica/web RUN npm run build:packages && npm run build -w @botanica/web
FROM nginxinc/nginx-unprivileged:stable-alpine FROM nginxinc/nginx-unprivileged:stable-alpine
COPY apps/web/nginx.conf /etc/nginx/conf.d/default.conf COPY apps/web/nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/apps/web/dist /usr/share/nginx/html COPY --from=build /app/apps/web/dist /usr/share/nginx/html

View file

@ -51,3 +51,7 @@ Indices: plants(owner_id,created_at DESC), plants(species_id,rarity_tier), breed
- No raw SQL concatenation; no secrets in frontend build; limit payload size. - No raw SQL concatenation; no secrets in frontend build; limit payload size.
- Idempotent breeding ensures retries cannot create duplicated child plants. - Idempotent breeding ensures retries cannot create duplicated child plants.
- API response and schema types must be shared where sensible; avoid circular import chains. - API response and schema types must be shared where sensible; avoid circular import chains.
## M1 implemented examples
`GET /api/v1/catalog/species` returns `{catalogVersion:1,species:[...]}` with dominance, phenotype names, compatibility and mutation/rarity policy data. `POST /api/v1/phenotype/resolve` accepts `{speciesId:"velvetleaf",genome:<the six diploid locus pairs>,mutations:[],catalogVersion:1}` and returns `{phenotype,rarity}`. Unknown client fields (including rarity claims) and invalid alleles return a uniform 400 error. Shared request schemas/DTOs are in `packages/shared`; no breeding endpoint exists before M3.

View file

@ -57,3 +57,13 @@ Dominance order is a gameplay simplification and is **not** an assertion about r
- Invalid alleles, missing loci and incompatible species fail. - Invalid alleles, missing loci and incompatible species fail.
- Mutations never create an unknown allele and are logged. - Mutations never create an unknown allele and are logged.
- No simultaneous duplicate breeding can spend resources twice (when economy implemented); meanwhile transaction persists exactly one child per idempotency key. - No simultaneous duplicate breeding can spend resources twice (when economy implemented); meanwhile transaction persists exactly one child per idempotency key.
## Engine v1 execution details (M1)
The complete v1 catalog lives in `packages/genetics/src/catalog.ts`. Unknown versions, species, allele values, missing loci and extra genome keys are rejected. Compatibility requires a shared group plus mutual partner approval and an explicit offspring rule for different species. v1 contains only velvetleaf, so no interspecies cross is available.
Mulberry32 v1 accepts signed 32-bit integer seeds (matching PostgreSQL integer storage). Convert to unsigned bits with `>>> 0`; each draw adds `0x6D2B79F5` modulo 2^32, uses JavaScript `Math.imul`/bitwise xor and shifts, and divides its unsigned result by 2^32. No runtime-dependent floating seed hashing is used. Reference seed 1 begins `0.6270739405881613, 0.002735721180215478`.
Breeding visits loci in this fixed alphabetical order: flowerColor, flowerDensity, growthHabit, leafPattern, leafShape, pigment. At each locus draw parent A's allele, then B's allele, then each mutation check in A/B order; triggered mutations consume one additional draw selecting uniformly among the other valid alleles in catalog dominance order. Named mutations follow in alphabetical ID order (currently golden_veins only). Mutation checks consume a draw even at probability zero. The cosmetic child seed comes from a fresh Mulberry32 stream seeded with `eventSeed XOR 0x56495331`, scaled to 32 bits and interpreted as signed. Cosmetic layout uses its own fresh stream and never changes inheritance or rarity.
Rare recessive phenotypes are catalog-defined: green pigment, oval leaves, compact habit, white flowers and low flower density. Only the first two matching unusual traits contribute (+40 maximum). Patterned leaves add 20; golden veins add 25; yellow leaves combined with blue flowers add 15. Scores and explanations are derived without RNG. This is a fictional balance policy, not a claim about real trait frequency. Pure APIs return versions; persistence and any explicit stored-rarity migration belong to M3 and future policy upgrades.

View file

@ -1,7 +1,7 @@
# Implementation progress # Implementation progress
- [x] M0 — workspaces, Compose, healthcheck, migrations, responsive shell. - [x] M0 — workspaces, Compose, healthcheck, migrations, responsive shell.
- [ ] M1 — genetics contracts, deterministic PRNG, pure engine + tests. - [x] M1 — genetics contracts, deterministic PRNG, pure engine + tests.
- [ ] M2 — procedural illustrated SVG renderer, exports, visual QA. - [ ] M2 — procedural illustrated SVG renderer, exports, visual QA.
- [ ] M3 — lab editor, server persistence, breeding flow. - [ ] M3 — lab editor, server persistence, breeding flow.
- [ ] M4 — gallery, provenance, UX polish, full acceptance. - [ ] M4 — gallery, provenance, UX polish, full acceptance.
@ -38,3 +38,12 @@ Initial Docker build started before npm finished writing the lockfile and failed
No M1–M4 behavior is implemented. Domain packages intentionally establish empty module boundaries until their milestone. No ESLint was added; strict TypeScript and focused tests provide the M0 checks. This remains a local demo without authentication. No M1–M4 behavior is implemented. Domain packages intentionally establish empty module boundaries until their milestone. No ESLint was added; strict TypeScript and focused tests provide the M0 checks. This remains a local demo without authentication.
M0 gate complete. Stop here. Next: M1 genetics contracts, deterministic PRNG, inheritance engine and tests, after user instruction to proceed. M0 gate complete. Stop here. Next: M1 genetics contracts, deterministic PRNG, inheritance engine and tests, after user instruction to proceed.
## M1 — 2026-10-28
Gate complete before starting M2. Implemented versioned catalog/Zod schemas, pure phenotype resolution, approved-pair compatibility, Mulberry32 v1 inheritance, allele/named mutation logs, provenance and derived rarity policy. Added shared DTOs and catalog/resolve API routes with safe validation errors. Workspace exports now use built JS/declarations in explicit dependency order, including production Docker runtime package links. Catalog seed enriches the existing v1 row; no plant data or ancestry is modified.
Verified `npm run test` (10 passed), `npm run typecheck`, `npm run build`, `docker compose config --quiet`, `docker compose up --build -d`, `docker compose ps`, and proxied HTTP health/catalog/resolve requests. 10,000 mutation-free PG crosses: PP=2499 (24.99%), PG=5041 (50.41%), GG=2460 (24.60%), all within ±2 percentage points. Exact replay tested in a separate Node process. Tests cover all dominance pairs, invalid/missing/extra alleles, unknown versions/species, compatibility/hybrid requirements, mutation probabilities 0/1, valid replacements and rarity reasons. Live invalid-allele POST returned 400; valid POST returned resolved phenotype and rarity.
A mutation-boundary test exposed coupling between mutation draw consumption and cosmetic seed generation; cosmetic child seeds now use a separately salted stream, documented in GENETICS_SPEC. No UI changes at M1: existing shell remains available at http://localhost:8155. Persistent breeding remains M3. Continuing to M2 under the user's instruction.

14
package-lock.json generated
View file

@ -22,6 +22,8 @@
"name": "@botanica/server", "name": "@botanica/server",
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"@botanica/genetics": "0.0.0",
"@botanica/shared": "0.0.0",
"drizzle-orm": "^0.45.4", "drizzle-orm": "^0.45.4",
"fastify": "^5.12.7", "fastify": "^5.12.7",
"pg": "^8.16.3", "pg": "^8.16.3",
@ -3876,7 +3878,11 @@
}, },
"packages/genetics": { "packages/genetics": {
"name": "@botanica/genetics", "name": "@botanica/genetics",
"version": "0.0.0" "version": "0.0.0",
"dependencies": {
"@botanica/rules": "0.0.0",
"zod": "^4.6.7"
}
}, },
"packages/renderer": { "packages/renderer": {
"name": "@botanica/renderer", "name": "@botanica/renderer",
@ -3888,7 +3894,11 @@
}, },
"packages/shared": { "packages/shared": {
"name": "@botanica/shared", "name": "@botanica/shared",
"version": "0.0.0" "version": "0.0.0",
"dependencies": {
"@botanica/genetics": "0.0.0",
"zod": "^4.6.7"
}
} }
} }
} }

View file

@ -8,11 +8,14 @@
], ],
"scripts": { "scripts": {
"dev": "concurrently \"npm run dev -w @botanica/server\" \"npm run dev -w @botanica/web\"", "dev": "concurrently \"npm run dev -w @botanica/server\" \"npm run dev -w @botanica/web\"",
"build": "npm run build --workspaces --if-present", "build": "npm run build:packages && npm run build -w @botanica/server && npm run build -w @botanica/web",
"typecheck": "npm run typecheck --workspaces --if-present", "typecheck": "npm run build:packages && npm run typecheck --workspaces --if-present",
"test": "vitest run", "test": "vitest run",
"db:migrate": "npm run db:migrate -w @botanica/server", "db:migrate": "npm run db:migrate -w @botanica/server",
"db:seed": "npm run db:seed -w @botanica/server" "db:seed": "npm run db:seed -w @botanica/server",
"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"
}, },
"devDependencies": { "devDependencies": {
"typescript": "~5.9.3", "typescript": "~5.9.3",

View file

@ -3,9 +3,18 @@
"version": "0.0.0", "version": "0.0.0",
"private": true, "private": true,
"type": "module", "type": "module",
"exports": "./src/index.ts", "exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"scripts": { "scripts": {
"build": "tsc -p tsconfig.json", "build": "tsc -p tsconfig.json",
"typecheck": "tsc -p tsconfig.json --noEmit" "typecheck": "tsc -p tsconfig.json --noEmit"
},
"dependencies": {
"@botanica/rules": "0.0.0",
"zod": "^4.6.7"
} }
} }

View file

@ -0,0 +1,112 @@
import { z } from "zod";
export const CATALOG_VERSION = 1 as const;
export const ENGINE_VERSION = 1 as const;
export const loci = [
"flowerColor",
"flowerDensity",
"growthHabit",
"leafPattern",
"leafShape",
"pigment",
] as const;
export type Locus = (typeof loci)[number];
const pair = <T extends readonly [string, ...string[]]>(values: T) =>
z.tuple([z.enum(values), z.enum(values)]);
export const genomeSchema = z.strictObject({
pigment: pair(["G", "P", "R", "Y"]),
leafShape: pair(["O", "L", "S"]),
leafPattern: pair(["S", "V", "M"]),
growthHabit: pair(["C", "B", "T"]),
flowerColor: pair(["W", "P", "B", "R"]),
flowerDensity: pair(["L", "M", "H"]),
});
export type Genome = z.infer<typeof genomeSchema>;
export const mutationSchema = z.array(z.literal("golden_veins")).max(1);
export type NamedMutation = "golden_veins";
export const seedSchema = z.number().int().min(-2147483648).max(2147483647);
export const phenotypeSchema = z.strictObject({
speciesId: z.literal("velvetleaf"),
catalogVersion: z.literal(1),
pigment: z.enum(["green", "purple", "red", "yellow"]),
leafShape: z.enum(["oval", "lobed", "pointed"]),
leafPattern: z.enum(["solid", "variegated", "marbled"]),
growthHabit: z.enum(["compact", "bushy", "trailing"]),
flowerColor: z.enum(["white", "pink", "blue", "red"]),
flowerDensity: z.enum(["low", "medium", "high"]),
mutations: mutationSchema,
});
export type Phenotype = z.infer<typeof phenotypeSchema>;
export const velvetleaf = {
id: "velvetleaf",
name: "Velvetleaf",
compatibilityGroup: "velvetleaf",
catalogVersion: CATALOG_VERSION,
allowedPartners: [] as string[],
hybridOffspring: {} as Record<string, string>,
loci: {
pigment: {
dominance: ["P", "R", "Y", "G"],
labels: { G: "green", P: "purple", R: "red", Y: "yellow" },
},
leafShape: {
dominance: ["L", "S", "O"],
labels: { O: "oval", L: "lobed", S: "pointed" },
},
leafPattern: {
dominance: ["V", "M", "S"],
labels: { S: "solid", V: "variegated", M: "marbled" },
},
growthHabit: {
dominance: ["T", "B", "C"],
labels: { C: "compact", B: "bushy", T: "trailing" },
},
flowerColor: {
dominance: ["B", "R", "P", "W"],
labels: { W: "white", P: "pink", B: "blue", R: "red" },
},
flowerDensity: {
dominance: ["H", "M", "L"],
labels: { L: "low", M: "medium", H: "high" },
},
},
mutationRates: { allele: 0.005, golden_veins: 0.001 },
rarityPolicy: {
unusual: {
pigment: ["green"],
leafShape: ["oval"],
growthHabit: ["compact"],
flowerColor: ["white"],
flowerDensity: ["low"],
},
patterns: ["variegated", "marbled"],
combination: { pigment: "yellow", flowerColor: "blue" },
},
} as const;
export const catalog = [velvetleaf] as const;
export const exampleGenome: Genome = {
pigment: ["P", "G"],
leafShape: ["L", "O"],
leafPattern: ["V", "S"],
growthHabit: ["B", "C"],
flowerColor: ["R", "W"],
flowerDensity: ["H", "M"],
};
export class GeneticsError extends Error {
constructor(
public code: string,
message: string,
) {
super(message);
this.name = "GeneticsError";
}
}
export function getSpecies(id: string, version: number = 1) {
if (version !== 1)
throw new GeneticsError(
"UNSUPPORTED_VERSION",
"Unsupported catalog version.",
);
const species = catalog.find((s) => s.id === id);
if (!species) throw new GeneticsError("UNKNOWN_SPECIES", "Unknown species.");
return species;
}

View file

@ -0,0 +1,186 @@
import { describe, it, expect } from "vitest";
import { execFileSync } from "node:child_process";
import {
breed,
canBreed,
checkCompatibility,
classifyRarity,
createRng,
exampleGenome,
loci,
resolvePhenotype,
validateGenome,
velvetleaf,
type BreedingParent,
} from "./index.js";
const a: BreedingParent = {
id: "a",
speciesId: "velvetleaf",
genome: exampleGenome,
generation: 2,
genomeVersion: 1,
};
const b: BreedingParent = { ...a, id: "b", generation: 4 };
const off = { alleleMutationRate: 0, namedMutationRate: 0 };
describe("genetics v1", () => {
it("replays Mulberry32 bit-identically, including a separate Node process", () => {
const rng = createRng(1);
expect([rng(), rng()]).toEqual([0.6270739405881613, 0.002735721180215478]);
const first = breed(a, b, -81);
expect(breed(a, b, -81)).toEqual(first);
const script = `import {breed} from '@botanica/genetics';console.log(JSON.stringify(breed(${JSON.stringify(a)},${JSON.stringify(b)},-81)))`;
expect(
JSON.parse(
execFileSync(process.execPath, ["--input-type=module", "-e", script], {
encoding: "utf8",
}),
),
).toEqual(first);
expect(first.parents).toEqual(["a", "b"]);
expect(first.generation).toBe(5);
expect(first.engineVersion).toBe(1);
});
it("produces the 1:2:1 PG cross over 10,000 independent seeds", () => {
const counts = { PP: 0, PG: 0, GG: 0 };
for (let seed = 0; seed < 10000; seed++) {
const pair = breed(a, b, seed, 1, off).genome.pigment;
const key = pair[0] === pair[1] ? pair.join("") : "PG";
counts[key as keyof typeof counts]++;
}
console.log("M1 PG cross (n=10000):", counts);
expect(Math.abs(counts.PP / 10000 - 0.25)).toBeLessThanOrEqual(0.02);
expect(Math.abs(counts.PG / 10000 - 0.5)).toBeLessThanOrEqual(0.02);
expect(Math.abs(counts.GG / 10000 - 0.25)).toBeLessThanOrEqual(0.02);
});
it("honors dominance at every locus and rejects incomplete or untrusted genomes", () => {
expect(resolvePhenotype("velvetleaf", exampleGenome)).toMatchObject({
pigment: "purple",
leafShape: "lobed",
leafPattern: "variegated",
growthHabit: "bushy",
flowerColor: "red",
flowerDensity: "high",
});
for (const locus of loci) {
const { dominance, labels } = velvetleaf.loci[locus];
for (const left of dominance)
for (const right of dominance) {
const genome = { ...exampleGenome, [locus]: [left, right] };
const winner = dominance.find((v) => v === left || v === right)!;
expect(resolvePhenotype("velvetleaf", genome)[locus]).toBe(
(labels as Record<string, string>)[winner],
);
}
}
expect(() =>
validateGenome("velvetleaf", { ...exampleGenome, pigment: ["X", "G"] }),
).toThrow();
expect(() =>
validateGenome("velvetleaf", { pigment: ["P", "G"] }),
).toThrow();
expect(() =>
validateGenome("velvetleaf", { ...exampleGenome, rarity: "Mythic" }),
).toThrow();
expect(() =>
resolvePhenotype("velvetleaf", exampleGenome, ["unknown"]),
).toThrow();
expect(() =>
resolvePhenotype("velvetleaf", exampleGenome, [], 2),
).toThrow();
});
it("requires compatibility groups, mutual approval and an explicit hybrid rule", () => {
expect(canBreed("velvetleaf", "velvetleaf").compatible).toBe(true);
const other = { ...velvetleaf, id: "other" };
expect(checkCompatibility(velvetleaf, other)).toMatchObject({
compatible: false,
code: "INCOMPATIBLE_SPECIES",
});
expect(
checkCompatibility(
{
...velvetleaf,
allowedPartners: ["other"],
hybridOffspring: { other: "hybrid" },
},
{ ...other, allowedPartners: ["velvetleaf"] },
),
).toMatchObject({ compatible: true, offspringSpeciesId: "hybrid" });
expect(
checkCompatibility(
{
...velvetleaf,
allowedPartners: ["other"],
hybridOffspring: { other: "hybrid" },
},
{
...other,
compatibilityGroup: "alien",
allowedPartners: ["velvetleaf"],
},
),
).toMatchObject({ compatible: false });
expect(() => breed(a, { ...b, speciesId: "unknown" }, 1)).toThrow();
});
it("has exact zero/one mutation boundaries and logs only different valid alleles", () => {
const before = JSON.stringify(a);
const clean = breed(a, b, 42, 1, off);
expect(clean.mutationEvents).toEqual([]);
const changed = breed(a, b, 42, 1, {
alleleMutationRate: 1,
namedMutationRate: 1,
});
expect(changed.mutationEvents).toHaveLength(13);
expect(changed.mutations).toEqual(["golden_veins"]);
validateGenome("velvetleaf", changed.genome);
for (const event of changed.mutationEvents.filter(
(e) => e.type === "allele",
))
expect(event.from).not.toBe(event.to);
expect(changed.visualSeed).toBe(clean.visualSeed);
expect(JSON.stringify(a)).toBe(before);
expect(() => breed(a, b, 0, 1, { alleleMutationRate: 1.01 })).toThrow();
expect(() => breed(a, b, 2147483648)).toThrow();
});
it("derives rarity from explicit catalog traits with capped unusual contributions", () => {
const base = resolvePhenotype("velvetleaf", {
pigment: ["P", "P"],
leafShape: ["L", "L"],
leafPattern: ["S", "S"],
growthHabit: ["T", "T"],
flowerColor: ["B", "B"],
flowerDensity: ["H", "H"],
});
expect(classifyRarity(base)).toMatchObject({
score: 0,
tier: "Common",
rarityPolicyVersion: 1,
});
expect(classifyRarity({ ...base, leafPattern: "marbled" })).toMatchObject({
score: 20,
tier: "Uncommon",
});
expect(
classifyRarity({ ...base, mutations: ["golden_veins"] }),
).toMatchObject({ score: 25, tier: "Rare" });
expect(
classifyRarity({
...base,
pigment: "yellow",
leafShape: "oval",
growthHabit: "compact",
leafPattern: "variegated",
mutations: ["golden_veins"],
}),
).toMatchObject({ score: 100, tier: "Mythic" });
expect(
classifyRarity({
...base,
pigment: "green",
leafShape: "oval",
growthHabit: "compact",
flowerDensity: "low",
flowerColor: "white",
}).score,
).toBe(40);
});
});

View file

@ -1 +1,191 @@
export {}; import { z } from "zod";
import { scoreRarity } from "@botanica/rules";
import {
CATALOG_VERSION,
ENGINE_VERSION,
GeneticsError,
getSpecies,
genomeSchema,
mutationSchema,
phenotypeSchema,
seedSchema,
loci,
type Genome,
type Locus,
type NamedMutation,
type Phenotype,
} from "./catalog.js";
import { createRng } from "./rng.js";
export * from "./catalog.js";
export * from "./rng.js";
export function validateGenome(
speciesId: string,
input: unknown,
catalogVersion: number = 1,
): Genome {
getSpecies(speciesId, catalogVersion);
return genomeSchema.parse(input);
}
export function resolvePhenotype(
speciesId: string,
input: unknown,
mutations: unknown = [],
catalogVersion: number = 1,
): Phenotype {
const species = getSpecies(speciesId, catalogVersion),
genome = validateGenome(speciesId, input, catalogVersion);
const traits: Record<string, string> = {};
for (const locus of loci) {
const def = species.loci[locus];
const winner = def.dominance.find((allele) =>
(genome[locus] as string[]).includes(allele),
)!;
traits[locus] = (def.labels as Record<string, string>)[winner];
}
return phenotypeSchema.parse({
...traits,
speciesId,
catalogVersion,
mutations: mutationSchema.parse(mutations),
});
}
export function classifyRarity(phenotype: Phenotype) {
const p = phenotypeSchema.parse(phenotype);
const policy = getSpecies(p.speciesId, p.catalogVersion).rarityPolicy;
return scoreRarity(
{
pigment: p.pigment,
leafShape: p.leafShape,
leafPattern: p.leafPattern,
growthHabit: p.growthHabit,
flowerColor: p.flowerColor,
flowerDensity: p.flowerDensity,
},
p.mutations,
policy,
);
}
export interface CompatibilitySpecies {
id: string;
compatibilityGroup: string;
allowedPartners: readonly string[];
hybridOffspring: Readonly<Record<string, string>>;
}
export function checkCompatibility(
left: CompatibilitySpecies,
right: CompatibilitySpecies,
) {
if (left.id === right.id)
return { compatible: true as const, offspringSpeciesId: left.id };
const hybrid = left.hybridOffspring[right.id];
if (
left.compatibilityGroup === right.compatibilityGroup &&
left.allowedPartners.includes(right.id) &&
right.allowedPartners.includes(left.id) &&
hybrid
)
return { compatible: true as const, offspringSpeciesId: hybrid };
return {
compatible: false as const,
code: "INCOMPATIBLE_SPECIES",
message: "No approved hybrid rule exists for this pair.",
};
}
export function canBreed(a: string, b: string, catalogVersion: number = 1) {
return checkCompatibility(
getSpecies(a, catalogVersion),
getSpecies(b, catalogVersion),
);
}
export interface BreedingParent {
id: string;
speciesId: string;
genome: Genome;
generation: number;
genomeVersion: number;
}
export interface MutationEvent {
type: "allele" | "named";
locus?: Locus;
parent?: "A" | "B";
from?: string;
to: string;
}
const parentSchema = z.strictObject({
id: z.string().min(1),
speciesId: z.string().min(1),
genome: genomeSchema,
generation: z.number().int().nonnegative(),
genomeVersion: z.literal(1),
});
const optionsSchema = z.strictObject({
alleleMutationRate: z.number().min(0).max(1).optional(),
namedMutationRate: z.number().min(0).max(1).optional(),
});
export function breed(
parentA: BreedingParent,
parentB: BreedingParent,
seed: number,
catalogVersion: number = 1,
options: { alleleMutationRate?: number; namedMutationRate?: number } = {},
) {
const a = parentSchema.parse(parentA),
b = parentSchema.parse(parentB);
seedSchema.parse(seed);
optionsSchema.parse(options);
const compatibility = canBreed(a.speciesId, b.speciesId, catalogVersion);
if (!compatibility.compatible)
throw new GeneticsError(compatibility.code, compatibility.message);
const species = getSpecies(compatibility.offspringSpeciesId, catalogVersion),
rng = createRng(seed),
mutationEvents: MutationEvent[] = [];
const alleleRate = options.alleleMutationRate ?? species.mutationRates.allele,
namedRate = options.namedMutationRate ?? species.mutationRates.golden_veins;
const genome = structuredClone(a.genome);
for (const locus of loci) {
const inherited: string[] = [
a.genome[locus][Math.floor(rng() * 2)],
b.genome[locus][Math.floor(rng() * 2)],
];
for (let index = 0; index < 2; index++)
if (rng() < alleleRate) {
const alternatives = species.loci[locus].dominance.filter(
(allele) => allele !== inherited[index],
);
const from = inherited[index];
inherited[index] =
alternatives[Math.floor(rng() * alternatives.length)];
mutationEvents.push({
type: "allele",
locus,
parent: index === 0 ? "A" : "B",
from,
to: inherited[index],
});
}
(genome[locus] as [string, string]) = [inherited[0], inherited[1]];
}
const mutations: NamedMutation[] = [];
if (rng() < namedRate) {
mutations.push("golden_veins");
mutationEvents.push({ type: "named", to: "golden_veins" });
}
const visualSeed =
Math.floor(createRng(seed ^ 0x56495331)() * 4294967296) | 0,
phenotype = resolvePhenotype(species.id, genome, mutations, catalogVersion);
return {
speciesId: species.id,
genome,
mutations,
phenotype,
rarity: classifyRarity(phenotype),
visualSeed,
parents: [a.id, b.id] as [string, string],
generation: Math.max(a.generation, b.generation) + 1,
genomeVersion: CATALOG_VERSION,
engineVersion: ENGINE_VERSION,
catalogVersion,
rngSeed: seed,
mutationEvents,
};
}

View file

@ -0,0 +1,12 @@
import { seedSchema } from "./catalog.js";
/** Mulberry32 v1: signed int32 seed bits; uint32 add/imul/xor; [0,1) output. */
export function createRng(seed: number) {
let state = seedSchema.parse(seed) >>> 0;
return () => {
state = (state + 0x6d2b79f5) >>> 0;
let t = state;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}

View file

@ -5,5 +5,6 @@
"rootDir": "src", "rootDir": "src",
"declaration": true "declaration": true
}, },
"include": ["src"] "include": ["src"],
"exclude": ["src/**/*.test.ts"]
} }

View file

@ -3,7 +3,12 @@
"version": "0.0.0", "version": "0.0.0",
"private": true, "private": true,
"type": "module", "type": "module",
"exports": "./src/index.ts", "exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"scripts": { "scripts": {
"build": "tsc -p tsconfig.json", "build": "tsc -p tsconfig.json",
"typecheck": "tsc -p tsconfig.json --noEmit" "typecheck": "tsc -p tsconfig.json --noEmit"

View file

@ -5,5 +5,6 @@
"rootDir": "src", "rootDir": "src",
"declaration": true "declaration": true
}, },
"include": ["src"] "include": ["src"],
"exclude": ["src/**/*.test.ts"]
} }

View file

@ -3,7 +3,12 @@
"version": "0.0.0", "version": "0.0.0",
"private": true, "private": true,
"type": "module", "type": "module",
"exports": "./src/index.ts", "exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"scripts": { "scripts": {
"build": "tsc -p tsconfig.json", "build": "tsc -p tsconfig.json",
"typecheck": "tsc -p tsconfig.json --noEmit" "typecheck": "tsc -p tsconfig.json --noEmit"

View file

@ -1 +1,54 @@
export {}; export const RARITY_POLICY_VERSION = 1 as const;
export const rarityTiers = [
"Common",
"Uncommon",
"Rare",
"Epic",
"Legendary",
"Mythic",
] as const;
export type RarityTier = (typeof rarityTiers)[number];
export interface RarityPolicy {
unusual: Record<string, readonly string[]>;
patterns: readonly string[];
combination: Record<string, string>;
}
export function scoreRarity(
traits: Record<string, string>,
mutations: readonly string[],
policy: RarityPolicy,
) {
const reasons: { label: string; points: number }[] = [];
const unusual = Object.entries(policy.unusual).filter(([locus, values]) =>
values.includes(traits[locus]),
);
for (const [locus] of unusual.slice(0, 2))
reasons.push({ label: "Unusual recessive " + locus, points: 20 });
if (policy.patterns.includes(traits.leafPattern))
reasons.push({ label: "Patterned foliage", points: 20 });
if (mutations.includes("golden_veins"))
reasons.push({ label: "Golden veins", points: 25 });
if (
Object.entries(policy.combination).every(
([locus, value]) => traits[locus] === value,
)
)
reasons.push({ label: "Uncommon trait combination", points: 15 });
const score = Math.min(
100,
reasons.reduce((sum, r) => sum + r.points, 0),
);
const tier: RarityTier =
score >= 85
? "Mythic"
: score >= 65
? "Legendary"
: score >= 45
? "Epic"
: score >= 25
? "Rare"
: score >= 10
? "Uncommon"
: "Common";
return { score, tier, rarityPolicyVersion: RARITY_POLICY_VERSION, reasons };
}

View file

@ -5,5 +5,6 @@
"rootDir": "src", "rootDir": "src",
"declaration": true "declaration": true
}, },
"include": ["src"] "include": ["src"],
"exclude": ["src/**/*.test.ts"]
} }

View file

@ -3,9 +3,18 @@
"version": "0.0.0", "version": "0.0.0",
"private": true, "private": true,
"type": "module", "type": "module",
"exports": "./src/index.ts", "exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"scripts": { "scripts": {
"build": "tsc -p tsconfig.json", "build": "tsc -p tsconfig.json",
"typecheck": "tsc -p tsconfig.json --noEmit" "typecheck": "tsc -p tsconfig.json --noEmit"
},
"dependencies": {
"@botanica/genetics": "0.0.0",
"zod": "^4.6.7"
} }
} }

View file

@ -1 +1,23 @@
export {}; import { z } from "zod";
import {
genomeSchema,
mutationSchema,
type Phenotype,
type catalog,
type classifyRarity,
} from "@botanica/genetics";
export const resolveRequestSchema = z.strictObject({
speciesId: z.string().min(1),
genome: genomeSchema,
mutations: mutationSchema.default([]),
catalogVersion: z.literal(1).default(1),
});
export type ResolveRequest = z.input<typeof resolveRequestSchema>;
export interface ResolveResponse {
phenotype: Phenotype;
rarity: ReturnType<typeof classifyRarity>;
}
export interface CatalogResponse {
catalogVersion: 1;
species: typeof catalog;
}

View file

@ -5,5 +5,6 @@
"rootDir": "src", "rootDir": "src",
"declaration": true "declaration": true
}, },
"include": ["src"] "include": ["src"],
"exclude": ["src/**/*.test.ts"]
} }