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