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
|
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
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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,22 +12,38 @@ 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)
|
error: {
|
||||||
.send({
|
code: "DATABASE_UNAVAILABLE",
|
||||||
error: {
|
message: "Database is unavailable.",
|
||||||
code: "DATABASE_UNAVAILABLE",
|
},
|
||||||
message: "Database is unavailable.",
|
});
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
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,17 +51,15 @@ 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)
|
error: {
|
||||||
.send({
|
code: status === 500 ? "INTERNAL_ERROR" : "INVALID_REQUEST",
|
||||||
error: {
|
message:
|
||||||
code: status === 500 ? "INTERNAL_ERROR" : "INVALID_REQUEST",
|
status === 500
|
||||||
message:
|
? "An unexpected error occurred."
|
||||||
status === 500
|
: "Request could not be processed.",
|
||||||
? "An unexpected error occurred."
|
},
|
||||||
: "Request could not be processed.",
|
});
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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 { 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 {
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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
14
package-lock.json
generated
|
|
@ -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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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",
|
"rootDir": "src",
|
||||||
"declaration": true
|
"declaration": true
|
||||||
},
|
},
|
||||||
"include": ["src"]
|
"include": ["src"],
|
||||||
|
"exclude": ["src/**/*.test.ts"]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -5,5 +5,6 @@
|
||||||
"rootDir": "src",
|
"rootDir": "src",
|
||||||
"declaration": true
|
"declaration": true
|
||||||
},
|
},
|
||||||
"include": ["src"]
|
"include": ["src"],
|
||||||
|
"exclude": ["src/**/*.test.ts"]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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",
|
"rootDir": "src",
|
||||||
"declaration": true
|
"declaration": true
|
||||||
},
|
},
|
||||||
"include": ["src"]
|
"include": ["src"],
|
||||||
|
"exclude": ["src/**/*.test.ts"]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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",
|
"rootDir": "src",
|
||||||
"declaration": true
|
"declaration": true
|
||||||
},
|
},
|
||||||
"include": ["src"]
|
"include": ["src"],
|
||||||
|
"exclude": ["src/**/*.test.ts"]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue