weed-sim / src /shared /breeder.ts
Yufok1
Real-time growth: growTime days become wall-clock seconds
6212a21
Raw
History Blame Contribute Delete
12.3 kB
import { generateName } from './nameGenerator';
import { generatePhenotype } from './phenotype';
import { newId, randomChoice, type RandomSource } from './rng';
import { canAttempt, cloneAlleles, cloneStabilities, findSeed, nowIso, reexpressSeed } from './state';
import type { Alleles, ColorAlleles, ColorTraitKey, GameState, Hsv, NumericTraitKey, PlantType, SeedProfile, Stabilities } from './types';
const NUMERIC_TRAITS: NumericTraitKey[] = ['THC', 'CBD', 'Yield', 'GrowTime'];
const COLOR_TRAITS: ColorTraitKey[] = ['LeafColor', 'BudColor'];
const NUMERIC_LIMITS: Record<NumericTraitKey, [number, number]> = {
THC: [0, 60],
CBD: [0, 20],
Yield: [25, 180],
GrowTime: [35, 96],
};
function clamp(value: number, min: number, max: number): number {
return Math.max(min, Math.min(max, value));
}
function clamp01(value: number): number {
return clamp(value, 0, 1);
}
function wrapHue(hue: number): number {
return ((hue % 360) + 360) % 360;
}
function jitter(rng: RandomSource): number {
return (rng() + rng() + rng()) / 3 - 0.5;
}
function lowDominates(trait: NumericTraitKey): boolean {
return trait === 'GrowTime';
}
function dominantNumeric(values: [number, number], trait: NumericTraitKey): number {
return lowDominates(trait) ? Math.min(...values) : Math.max(...values);
}
function recessiveNumeric(values: [number, number], trait: NumericTraitKey): number {
return lowDominates(trait) ? Math.max(...values) : Math.min(...values);
}
function selectNumericAllele(values: [number, number], stability: number, trait: NumericTraitKey, rng: RandomSource): number {
const boundedStability = clamp01(stability);
const dominant = dominantNumeric(values, trait);
const recessive = recessiveNumeric(values, trait);
const dominantChance = 0.5 + boundedStability * 0.43;
const inherited = rng() < dominantChance ? dominant : recessive;
const volatility = 1 - boundedStability;
const [min, max] = NUMERIC_LIMITS[trait];
const driftScale: Record<NumericTraitKey, number> = { THC: 1.7, CBD: 0.8, Yield: 8.5, GrowTime: 5.2 };
const rareSurge = trait === 'THC' && inherited >= 28 && rng() < 0.05 + volatility * 0.13;
const directionBias = rareSurge ? 0.45 : 0;
const drift = (jitter(rng) + directionBias) * (0.08 + volatility * driftScale[trait]);
return Math.round(clamp(inherited + drift, min, max) * 100) / 100;
}
function inheritTraitStability(left: number, right: number, rng: RandomSource): number {
const leftPower = 0.1 + clamp01(left) ** 1.45;
const rightPower = 0.1 + clamp01(right) ** 1.45;
const anchor = rng() < leftPower / (leftPower + rightPower) ? clamp01(left) : clamp01(right);
const partner = anchor === clamp01(left) ? clamp01(right) : clamp01(left);
const volatility = 1 - anchor;
const inherited = anchor * 0.78 + partner * 0.22 + jitter(rng) * (0.04 + volatility * 0.18);
return Math.round(clamp01(inherited) * 1000) / 1000;
}
export function inheritStabilities(left: Stabilities, right: Stabilities, rng: RandomSource = Math.random): Stabilities {
return {
THC: inheritTraitStability(left.THC ?? 0.5, right.THC ?? 0.5, rng),
CBD: inheritTraitStability(left.CBD ?? 0.5, right.CBD ?? 0.5, rng),
Yield: inheritTraitStability(left.Yield ?? 0.5, right.Yield ?? 0.5, rng),
GrowTime: inheritTraitStability(left.GrowTime ?? 0.5, right.GrowTime ?? 0.5, rng),
LeafColor: inheritTraitStability(left.LeafColor ?? 0.5, right.LeafColor ?? 0.5, rng),
BudColor: inheritTraitStability(left.BudColor ?? 0.5, right.BudColor ?? 0.5, rng),
};
}
function colorStrength(hsv: Hsv): number {
return hsv[1] * 0.68 + hsv[2] * 0.32;
}
function colorHsvAt(alleles: ColorAlleles, index: 0 | 1): Hsv {
return [wrapHue(alleles.Hue[index]), clamp01(alleles.Saturation[index]), clamp01(alleles.Value[index])];
}
function hueDistance(left: number, right: number): number {
return Math.abs((((left - right) % 360) + 540) % 360 - 180);
}
function colorAlleleContrast(alleles: ColorAlleles): number {
return hueDistance(alleles.Hue[0], alleles.Hue[1]);
}
function colorAlleleBalance(alleles: ColorAlleles): number {
const left = colorStrength(colorHsvAt(alleles, 0));
const right = colorStrength(colorHsvAt(alleles, 1));
return Math.min(left, right) / Math.max(0.001, Math.max(left, right));
}
function injectDistantHueVolatility(baseStability: number, alleles: ColorAlleles, rng: RandomSource): number {
const base = clamp01(baseStability);
const contrast = colorAlleleContrast(alleles);
const balance = colorAlleleBalance(alleles);
const contrastPressure = clamp((contrast - 55) / 125, 0, 1);
const balancePressure = clamp((balance - 0.38) / 0.62, 0, 1);
const crossPressure = contrastPressure * (0.62 + balancePressure * 0.38);
if (crossPressure <= 0.02) return Math.round(base * 1000) / 1000;
const distanceDrop = crossPressure * 0.3;
const wildChance = 0.035 + crossPressure * 0.12 + (1 - base) * 0.05;
const wildDrop = rng() < wildChance ? (0.07 + rng() * 0.16) * crossPressure : 0;
const floor = crossPressure > 0.86 && balance > 0.55 ? 0.28 : 0.38;
return Math.round(clamp(base - distanceDrop - wildDrop, floor, 0.98) * 1000) / 1000;
}
function selectColorAllele(alleles: ColorAlleles, stability: number, rng: RandomSource): Hsv {
const boundedStability = clamp01(stability);
const first = colorHsvAt(alleles, 0);
const second = colorHsvAt(alleles, 1);
const firstStrength = colorStrength(first);
const secondStrength = colorStrength(second);
const dominant = firstStrength >= secondStrength ? first : second;
const recessive = dominant === first ? second : first;
const dominantChance = 0.48 + boundedStability * 0.45;
const inherited = rng() < dominantChance ? dominant : recessive;
const volatility = 1 - boundedStability;
return [
wrapHue(inherited[0] + jitter(rng) * (2 + volatility * 54)),
clamp01(inherited[1] + jitter(rng) * volatility * 0.24),
clamp01(inherited[2] + jitter(rng) * volatility * 0.2),
];
}
function colorAllelesFromHsv(left: Hsv, right: Hsv): ColorAlleles {
return {
Hue: [Math.round(left[0] * 10) / 10, Math.round(right[0] * 10) / 10],
Saturation: [Math.round(left[1] * 1000) / 1000, Math.round(right[1] * 1000) / 1000],
Value: [Math.round(left[2] * 1000) / 1000, Math.round(right[2] * 1000) / 1000],
};
}
export function recombineProfiles(parentOne: SeedProfile, parentTwo: SeedProfile, rng: RandomSource = Math.random): { alleles: Alleles; stabilities: Stabilities } {
const stabilities = inheritStabilities(parentOne.stabilities, parentTwo.stabilities, rng);
const alleles = {} as Alleles;
for (const trait of NUMERIC_TRAITS) {
alleles[trait] = [
selectNumericAllele(parentOne.alleles[trait], parentOne.stabilities[trait], trait, rng),
selectNumericAllele(parentTwo.alleles[trait], parentTwo.stabilities[trait], trait, rng),
];
}
for (const trait of COLOR_TRAITS) {
alleles[trait] = colorAllelesFromHsv(
selectColorAllele(parentOne.alleles[trait], parentOne.stabilities[trait], rng),
selectColorAllele(parentTwo.alleles[trait], parentTwo.stabilities[trait], rng)
);
stabilities[trait] = injectDistantHueVolatility(stabilities[trait], alleles[trait], rng);
}
return { alleles, stabilities };
}
export function recombine(parentOne: Alleles, parentTwo: Alleles, rng: RandomSource = Math.random): Alleles {
return {
THC: [randomChoice(parentOne.THC, rng), randomChoice(parentTwo.THC, rng)],
CBD: [randomChoice(parentOne.CBD, rng), randomChoice(parentTwo.CBD, rng)],
Yield: [randomChoice(parentOne.Yield, rng), randomChoice(parentTwo.Yield, rng)],
GrowTime: [randomChoice(parentOne.GrowTime, rng), randomChoice(parentTwo.GrowTime, rng)],
LeafColor: colorAllelesFromHsv(selectColorAllele(parentOne.LeafColor, 0.5, rng), selectColorAllele(parentTwo.LeafColor, 0.5, rng)),
BudColor: colorAllelesFromHsv(selectColorAllele(parentOne.BudColor, 0.5, rng), selectColorAllele(parentTwo.BudColor, 0.5, rng)),
};
}
function buildSeed(input: {
strainName: string;
type: PlantType;
alleles: Alleles;
stabilities: Stabilities;
baseImageName: string;
lineage: [string | null, string | null];
description: string;
isStarter?: boolean;
}): SeedProfile {
const phenotype = generatePhenotype(input.alleles, input.stabilities);
return {
seedId: newId('seed'),
strainName: input.strainName,
type: input.type,
alleles: cloneAlleles(input.alleles),
stabilities: cloneStabilities(input.stabilities),
thc: Math.round(phenotype.thc * 10) / 10,
cbd: Math.round(phenotype.cbd * 10) / 10,
yield: Math.round(phenotype.yield * 10) / 10,
growTime: Math.round(phenotype.growTime * 10) / 10,
budColor: phenotype.budColorRgb,
leafColor: phenotype.leafColorRgb,
budPalette: phenotype.budPaletteRgb,
leafPalette: phenotype.leafPaletteRgb,
budPattern: phenotype.budPattern,
leafPattern: phenotype.leafPattern,
baseImageName: input.baseImageName,
growthStage: 'SEED',
lineage: input.lineage,
description: input.description,
quantity: 1,
attemptsUsed: 0,
maxAttempts: 2,
isStarter: input.isStarter ?? false,
};
}
export function breedProfiles(parentOne: SeedProfile, parentTwo: SeedProfile, rng: RandomSource = Math.random): SeedProfile {
if (parentOne.growthStage !== 'MATURE' || parentTwo.growthStage !== 'MATURE') {
throw new Error('Both parents must be fully mature before breeding');
}
if (!canAttempt(parentOne) || !canAttempt(parentTwo)) {
throw new Error('One or both parents have reached max breeding/cloning attempts');
}
const { alleles, stabilities } = recombineProfiles(parentOne, parentTwo, rng);
return buildSeed({
strainName: generateName('Hybrid', rng),
type: 'Hybrid',
alleles,
stabilities,
baseImageName: randomChoice([parentOne.baseImageName, parentTwo.baseImageName], rng),
lineage: [parentOne.seedId, parentTwo.seedId],
description: `Child of ${parentOne.strainName} and ${parentTwo.strainName}`,
});
}
export function cloneProfile(parent: SeedProfile): SeedProfile {
if (parent.growthStage !== 'MATURE') {
throw new Error(`${parent.strainName} must be fully mature before cloning`);
}
if (!canAttempt(parent)) {
throw new Error(`${parent.strainName} has reached max breeding/cloning attempts`);
}
const seedId = newId('seed');
return reexpressSeed({
...parent,
seedId,
strainName: `${parent.strainName} Clone ${seedId.slice(-4)}`,
alleles: cloneAlleles(parent.alleles),
stabilities: cloneStabilities(parent.stabilities),
growthStage: 'SEED',
maturesAt: null,
lineage: [parent.seedId, null],
description: `Clone of ${parent.strainName}`,
quantity: 1,
attemptsUsed: 0,
maxAttempts: parent.maxAttempts,
isStarter: false,
});
}
export function breedInState(
state: GameState,
parentOneId: string,
parentTwoId: string,
rng: RandomSource = Math.random
): { state: GameState; offspring: SeedProfile } {
const parentOne = findSeed(state, parentOneId);
const parentTwo = findSeed(state, parentTwoId);
if (!parentOne || !parentTwo) {
throw new Error('Parents not found');
}
let offspring = breedProfiles(parentOne, parentTwo, rng);
const takenNames = new Set(state.seeds.map((seed) => seed.strainName));
if (takenNames.has(offspring.strainName)) {
offspring = { ...offspring, strainName: `${offspring.strainName} ${offspring.seedId.slice(-4)}` };
}
const seeds = state.seeds.map((seed) => {
if (seed.seedId === parentOne.seedId || seed.seedId === parentTwo.seedId) {
return { ...seed, attemptsUsed: seed.attemptsUsed + 1 };
}
return seed;
});
return {
offspring,
state: { ...state, seeds: [...seeds, offspring], updatedAt: nowIso() },
};
}
export function cloneInState(state: GameState, seedId: string): { state: GameState; clone: SeedProfile } {
const parent = findSeed(state, seedId);
if (!parent) {
throw new Error('Seed not found');
}
const clone = cloneProfile(parent);
const seeds = state.seeds.map((seed) =>
seed.seedId === parent.seedId ? { ...seed, attemptsUsed: seed.attemptsUsed + 1 } : seed
);
return {
clone,
state: { ...state, seeds: [...seeds, clone], updatedAt: nowIso() },
};
}