weed-sim / src /shared /phenotype.ts
Yufok1
Checkpoint Devvit v0.0.24
1a343cd
Raw
History Blame Contribute Delete
8.14 kB
import { hsvToRgb, rgbToHsv } from './color';
import type { Alleles, ColorAlleles, ColorPattern, Hsv, Rgb, Stabilities } from './types';
const DOMINANT_HUE_ZONES: Array<[number, number]> = [
[0, 20],
[100, 140],
[220, 260],
];
const THC_SOFT_CAP = 50;
const THC_SOFT_THRESHOLD = 31;
export type BloomScoreBreakdown = {
score: number;
colorCount: number;
hueSpread: number;
averageSaturation: number;
averageValue: number;
muddyStops: number;
pattern: ColorPattern;
};
export type ColorExpression = {
rgb: Rgb;
hsv: Hsv;
palette: Rgb[];
hsvPalette: Hsv[];
pattern: ColorPattern;
contrast: number;
};
export type Phenotype = {
thc: number;
cbd: number;
yield: number;
growTime: number;
budColorRgb: Rgb;
leafColorRgb: Rgb;
budColorHsv: Hsv;
leafColorHsv: Hsv;
budPaletteRgb: Rgb[];
leafPaletteRgb: Rgb[];
budPattern: ColorPattern;
leafPattern: ColorPattern;
};
export function isPrimaryHue(hue: number): boolean {
return DOMINANT_HUE_ZONES.some(([low, high]) => hue >= low && hue <= high);
}
function clamp01(value: number): number {
return Math.max(0, Math.min(1, value));
}
function roundOne(value: number): number {
return Math.round(value * 10) / 10;
}
function wrapHue(hue: number): number {
return ((hue % 360) + 360) % 360;
}
function dominanceExpression(values: [number, number], stability = 0.5, lowDominates = false): number {
const boundedStability = clamp01(stability);
const dominant = lowDominates ? Math.min(...values) : Math.max(...values);
const recessive = lowDominates ? Math.max(...values) : Math.min(...values);
const dominanceWeight = 0.58 + boundedStability * 0.34;
return dominant * dominanceWeight + recessive * (1 - dominanceWeight);
}
function hueDistance(left: number, right: number): number {
const distance = Math.abs((((left - right) % 360) + 540) % 360 - 180);
return roundOne(distance);
}
function mixHue(left: number, right: number, weightRight: number): number {
const delta = (((right - left) % 360) + 540) % 360 - 180;
return wrapHue(left + delta * weightRight);
}
function uniqueHsvPalette(colors: Hsv[]): Hsv[] {
const seen = new Set<string>();
const unique: Hsv[] = [];
for (const color of colors) {
const key = `${Math.round(color[0] / 8)}_${Math.round(color[1] * 10)}_${Math.round(color[2] * 10)}`;
if (!seen.has(key)) {
seen.add(key);
unique.push(color);
}
}
return unique.slice(0, 6);
}
function mean(values: number[]): number {
return values.length ? values.reduce((sum, value) => sum + value, 0) / values.length : 0;
}
function hueSpread(hues: number[]): number {
if (hues.length <= 1) return 0;
let maxDistance = 0;
for (let left = 0; left < hues.length; left += 1) {
for (let right = left + 1; right < hues.length; right += 1) {
const leftHue = hues[left];
const rightHue = hues[right];
if (leftHue === undefined || rightHue === undefined) continue;
maxDistance = Math.max(maxDistance, hueDistance(leftHue, rightHue));
}
}
return maxDistance;
}
export function bloomScoreFromPalette(palette: Rgb[], pattern: ColorPattern = 'solid'): BloomScoreBreakdown {
const hsvStops = uniqueHsvPalette(palette.map((color) => rgbToHsv(color)));
if (!hsvStops.length) {
return { score: 0, colorCount: 0, hueSpread: 0, averageSaturation: 0, averageValue: 0, muddyStops: 0, pattern };
}
const colorCount = hsvStops.length;
const spread = hueSpread(hsvStops.map((hsv) => hsv[0]));
const averageSaturation = mean(hsvStops.map((hsv) => hsv[1]));
const averageValue = mean(hsvStops.map((hsv) => hsv[2]));
const muddyStops = hsvStops.filter((hsv) => hsv[1] < 0.34 || hsv[2] < 0.28).length;
const patternBonus: Record<ColorPattern, number> = { solid: 0, speckled: 4, split: 8, rimmed: 10, mosaic: 16, polycolor: 22 };
const zoneScore = Math.min(1, Math.max(0, (colorCount - 1) / 5)) * 24;
const spreadScore = (spread / 180) * 22;
const colorQuality = averageSaturation * 34 + averageValue * 10;
const muddyPenalty = (muddyStops / colorCount) * 18;
const score = Math.max(0, Math.min(100, colorQuality + zoneScore + spreadScore + patternBonus[pattern] - muddyPenalty));
return {
score: Math.round(score * 10) / 10,
colorCount,
hueSpread: Math.round(spread * 10) / 10,
averageSaturation: Math.round(averageSaturation * 1000) / 1000,
averageValue: Math.round(averageValue * 1000) / 1000,
muddyStops,
pattern,
};
}
export function expressThcPotential(values: [number, number], stability = 0.5): number {
const raw = dominanceExpression(values, stability);
if (raw <= THC_SOFT_THRESHOLD) return raw;
const pressure = 0.9 + clamp01(stability) * 0.35;
const excess = raw - THC_SOFT_THRESHOLD;
return THC_SOFT_THRESHOLD + (THC_SOFT_CAP - THC_SOFT_THRESHOLD) * (1 - Math.exp(-(excess * pressure) / 12));
}
export function expressColorAlleles(hsvAlleles: ColorAlleles, stability = 1): ColorExpression {
const boundedStability = clamp01(stability);
const volatility = 1 - boundedStability;
const hsv0: Hsv = [wrapHue(hsvAlleles.Hue[0]), clamp01(hsvAlleles.Saturation[0]), clamp01(hsvAlleles.Value[0])];
const hsv1: Hsv = [wrapHue(hsvAlleles.Hue[1]), clamp01(hsvAlleles.Saturation[1]), clamp01(hsvAlleles.Value[1])];
const strength = (hsv: Hsv) => (hsv[1] * 0.68 + hsv[2] * 0.32) * (1 + boundedStability);
const strength0 = strength(hsv0) + (isPrimaryHue(hsv0[0]) ? 0.08 : 0);
const strength1 = strength(hsv1) + (isPrimaryHue(hsv1[0]) ? 0.08 : 0);
const primary = strength0 >= strength1 ? hsv0 : hsv1;
const secondary = primary === hsv0 ? hsv1 : hsv0;
const contrast = hueDistance(hsv0[0], hsv1[0]);
const balance = Math.min(strength0, strength1) / Math.max(0.001, Math.max(strength0, strength1));
const reveal = contrast / 180 * 0.42 + balance * 0.32 + volatility * 0.42;
const visibleColors = Math.max(1, Math.min(6, Math.ceil(reveal * 6 - boundedStability * 1.25)));
const colors: Hsv[] = [primary];
if (visibleColors >= 2) colors.push(secondary);
if (visibleColors >= 3) colors.push([mixHue(primary[0], secondary[0], 0.33), Math.max(primary[1], secondary[1]) * 0.95, Math.min(1, Math.max(primary[2], secondary[2]) * 1.04)]);
if (visibleColors >= 4) colors.push([mixHue(primary[0], secondary[0], 0.66), Math.min(1, secondary[1] + volatility * 0.14), Math.min(1, secondary[2] + volatility * 0.1)]);
if (visibleColors >= 5) colors.push([wrapHue(primary[0] + 145 + volatility * 35), Math.min(1, primary[1] * 0.86 + volatility * 0.18), Math.min(1, primary[2] * 0.92 + 0.08)]);
if (visibleColors >= 6) colors.push([wrapHue(secondary[0] - 120 - volatility * 24), Math.min(1, secondary[1] * 0.9 + 0.08), Math.max(0.2, secondary[2] * 0.72)]);
const hsvPalette = uniqueHsvPalette(colors);
const pattern: ColorPattern = (() => {
if (hsvPalette.length <= 1) return 'solid';
if (hsvPalette.length >= 6) return 'polycolor';
if (hsvPalette.length >= 4) return 'mosaic';
if (contrast >= 128 && boundedStability >= 0.38) return 'rimmed';
if (contrast >= 72) return 'split';
return 'speckled';
})();
return {
rgb: hsvToRgb(primary[0], primary[1], primary[2]),
hsv: primary,
palette: hsvPalette.map((hsv) => hsvToRgb(hsv[0], hsv[1], hsv[2])),
hsvPalette,
pattern,
contrast,
};
}
export function generatePhenotype(alleles: Alleles, stabilities: Stabilities): Phenotype {
const budColor = expressColorAlleles(alleles.BudColor, stabilities.BudColor);
const leafColor = expressColorAlleles(alleles.LeafColor, stabilities.LeafColor);
return {
thc: expressThcPotential(alleles.THC, stabilities.THC),
cbd: dominanceExpression(alleles.CBD, stabilities.CBD),
yield: dominanceExpression(alleles.Yield, stabilities.Yield),
growTime: dominanceExpression(alleles.GrowTime, stabilities.GrowTime, true),
budColorRgb: budColor.rgb,
leafColorRgb: leafColor.rgb,
budColorHsv: budColor.hsv,
leafColorHsv: leafColor.hsv,
budPaletteRgb: budColor.palette,
leafPaletteRgb: leafColor.palette,
budPattern: budColor.pattern,
leafPattern: leafColor.pattern,
};
}