Implement M1 deterministic genetics and validated catalog API
This commit is contained in:
parent
ea9adb85e6
commit
df98d02077
26 changed files with 769 additions and 51 deletions
|
|
@ -7,11 +7,12 @@ COPY packages/ packages/
|
|||
RUN npm ci
|
||||
COPY tsconfig.base.json ./
|
||||
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
|
||||
ENV NODE_ENV=production
|
||||
WORKDIR /app
|
||||
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/dist ./dist
|
||||
COPY --from=build --chown=node:node /app/apps/server/migrations ./migrations
|
||||
|
|
|
|||
|
|
@ -14,7 +14,9 @@
|
|||
"fastify": "^5.12.7",
|
||||
"zod": "^4.0.0",
|
||||
"drizzle-orm": "^0.45.4",
|
||||
"pg": "^8.16.3"
|
||||
"pg": "^8.16.3",
|
||||
"@botanica/genetics": "0.0.0",
|
||||
"@botanica/shared": "0.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/pg": "^8.15.5",
|
||||
|
|
|
|||
|
|
@ -1,3 +1,6 @@
|
|||
import { ZodError } from "zod";
|
||||
import { GeneticsError } from "@botanica/genetics";
|
||||
import { registerGeneticsRoutes } from "./genetics-routes.js";
|
||||
import Fastify from "fastify";
|
||||
export function createApp(
|
||||
checkDatabase: () => Promise<unknown>,
|
||||
|
|
@ -9,9 +12,7 @@ export function createApp(
|
|||
await checkDatabase();
|
||||
return { status: "ok", database: "connected" };
|
||||
} catch {
|
||||
return reply
|
||||
.code(503)
|
||||
.send({
|
||||
return reply.code(503).send({
|
||||
error: {
|
||||
code: "DATABASE_UNAVAILABLE",
|
||||
message: "Database is unavailable.",
|
||||
|
|
@ -19,12 +20,30 @@ export function createApp(
|
|||
});
|
||||
}
|
||||
});
|
||||
registerGeneticsRoutes(app);
|
||||
app.setNotFoundHandler((_request, reply) =>
|
||||
reply
|
||||
.code(404)
|
||||
.send({ error: { code: "NOT_FOUND", message: "Route not found." } }),
|
||||
);
|
||||
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);
|
||||
const code =
|
||||
typeof error === "object" && error !== null && "statusCode" in error
|
||||
|
|
@ -32,9 +51,7 @@ export function createApp(
|
|||
: undefined;
|
||||
const status =
|
||||
typeof code === "number" && code >= 400 && code < 500 ? code : 500;
|
||||
reply
|
||||
.code(status)
|
||||
.send({
|
||||
reply.code(status).send({
|
||||
error: {
|
||||
code: status === 500 ? "INTERNAL_ERROR" : "INVALID_REQUEST",
|
||||
message:
|
||||
|
|
|
|||
35
apps/server/src/genetics-routes.test.ts
Normal file
35
apps/server/src/genetics-routes.test.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
26
apps/server/src/genetics-routes.ts
Normal file
26
apps/server/src/genetics-routes.ts
Normal 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) };
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
import { velvetleaf } from "@botanica/genetics";
|
||||
import { connectDatabase } from "./db.js";
|
||||
import { players, species } from "./schema.js";
|
||||
const { pool, db } = connectDatabase(process.env.DATABASE_URL ?? "");
|
||||
|
|
@ -17,19 +18,12 @@ try {
|
|||
name: "Velvetleaf",
|
||||
compatibilityGroup: "velvetleaf",
|
||||
catalogVersion: 1,
|
||||
config: {
|
||||
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"],
|
||||
},
|
||||
},
|
||||
config: velvetleaf,
|
||||
})
|
||||
.onConflictDoNothing();
|
||||
.onConflictDoUpdate({
|
||||
target: species.id,
|
||||
set: { config: velvetleaf, catalogVersion: 1 },
|
||||
});
|
||||
});
|
||||
console.log("Sandbox player and velvetleaf catalog ready");
|
||||
} finally {
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ COPY packages/ packages/
|
|||
RUN npm ci
|
||||
COPY tsconfig.base.json ./
|
||||
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
|
||||
COPY apps/web/nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY --from=build /app/apps/web/dist /usr/share/nginx/html
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
- Idempotent breeding ensures retries cannot create duplicated child plants.
|
||||
- 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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
- 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.
|
||||
|
||||
## 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.
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
# Implementation progress
|
||||
|
||||
- [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.
|
||||
- [ ] M3 — lab editor, server persistence, breeding flow.
|
||||
- [ ] 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.
|
||||
|
||||
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
14
package-lock.json
generated
|
|
@ -22,6 +22,8 @@
|
|||
"name": "@botanica/server",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@botanica/genetics": "0.0.0",
|
||||
"@botanica/shared": "0.0.0",
|
||||
"drizzle-orm": "^0.45.4",
|
||||
"fastify": "^5.12.7",
|
||||
"pg": "^8.16.3",
|
||||
|
|
@ -3876,7 +3878,11 @@
|
|||
},
|
||||
"packages/genetics": {
|
||||
"name": "@botanica/genetics",
|
||||
"version": "0.0.0"
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@botanica/rules": "0.0.0",
|
||||
"zod": "^4.6.7"
|
||||
}
|
||||
},
|
||||
"packages/renderer": {
|
||||
"name": "@botanica/renderer",
|
||||
|
|
@ -3888,7 +3894,11 @@
|
|||
},
|
||||
"packages/shared": {
|
||||
"name": "@botanica/shared",
|
||||
"version": "0.0.0"
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@botanica/genetics": "0.0.0",
|
||||
"zod": "^4.6.7"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,11 +8,14 @@
|
|||
],
|
||||
"scripts": {
|
||||
"dev": "concurrently \"npm run dev -w @botanica/server\" \"npm run dev -w @botanica/web\"",
|
||||
"build": "npm run build --workspaces --if-present",
|
||||
"typecheck": "npm run typecheck --workspaces --if-present",
|
||||
"build": "npm run build:packages && npm run build -w @botanica/server && npm run build -w @botanica/web",
|
||||
"typecheck": "npm run build:packages && npm run typecheck --workspaces --if-present",
|
||||
"test": "vitest run",
|
||||
"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": {
|
||||
"typescript": "~5.9.3",
|
||||
|
|
|
|||
|
|
@ -3,9 +3,18 @@
|
|||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": "./src/index.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@botanica/rules": "0.0.0",
|
||||
"zod": "^4.6.7"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
112
packages/genetics/src/catalog.ts
Normal file
112
packages/genetics/src/catalog.ts
Normal 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;
|
||||
}
|
||||
186
packages/genetics/src/genetics.test.ts
Normal file
186
packages/genetics/src/genetics.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
12
packages/genetics/src/rng.ts
Normal file
12
packages/genetics/src/rng.ts
Normal 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;
|
||||
};
|
||||
}
|
||||
|
|
@ -5,5 +5,6 @@
|
|||
"rootDir": "src",
|
||||
"declaration": true
|
||||
},
|
||||
"include": ["src"]
|
||||
"include": ["src"],
|
||||
"exclude": ["src/**/*.test.ts"]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,12 @@
|
|||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": "./src/index.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||
|
|
|
|||
|
|
@ -5,5 +5,6 @@
|
|||
"rootDir": "src",
|
||||
"declaration": true
|
||||
},
|
||||
"include": ["src"]
|
||||
"include": ["src"],
|
||||
"exclude": ["src/**/*.test.ts"]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,12 @@
|
|||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": "./src/index.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,5 +5,6 @@
|
|||
"rootDir": "src",
|
||||
"declaration": true
|
||||
},
|
||||
"include": ["src"]
|
||||
"include": ["src"],
|
||||
"exclude": ["src/**/*.test.ts"]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,9 +3,18 @@
|
|||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": "./src/index.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@botanica/genetics": "0.0.0",
|
||||
"zod": "^4.6.7"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,5 +5,6 @@
|
|||
"rootDir": "src",
|
||||
"declaration": true
|
||||
},
|
||||
"include": ["src"]
|
||||
"include": ["src"],
|
||||
"exclude": ["src/**/*.test.ts"]
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue