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 = { 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 = { 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() }, }; }