diff --git a/guide/age-of-four/src/data/ecology.ts b/guide/age-of-four/src/data/ecology.ts new file mode 100644 index 00000000..5e63f3c9 --- /dev/null +++ b/guide/age-of-four/src/data/ecology.ts @@ -0,0 +1,342 @@ +// Biome display data, species trait definitions, and food web rules +// for ecosystem guide pages. Biome definitions sourced from biomes.json. + +// --------------------------------------------------------------------------- +// Biome display data — all 26 biomes with colors for visualization +// --------------------------------------------------------------------------- + +export interface BiomeDisplay { + id: string + name: string + color: string + category: 'aquatic' | 'tropical' | 'temperate' | 'cold' | 'elevation' | 'special' + quality_range: [number, number] + flora_climax: { canopy: number; undergrowth: number; fungi: number } + fauna_capacity: number + food_web_tier: string + temp_range: [number, number] + moisture_range: [number, number] +} + +export const ALL_BIOMES: BiomeDisplay[] = [ + // Aquatic + { id: 'deep_ocean', name: 'Deep Ocean', color: '#0a2472', category: 'aquatic', quality_range: [1,4], flora_climax: { canopy: 0, undergrowth: 0, fungi: 0 }, fauna_capacity: 8, food_web_tier: 'pelagic', temp_range: [0,1], moisture_range: [0,1] }, + { id: 'shallow_ocean', name: 'Shallow Ocean', color: '#1a5276', category: 'aquatic', quality_range: [1,5], flora_climax: { canopy: 0, undergrowth: 0.3, fungi: 0 }, fauna_capacity: 12, food_web_tier: 'neritic', temp_range: [0,1], moisture_range: [0,1] }, + { id: 'coral_reef', name: 'Coral Reef', color: '#e74c8b', category: 'aquatic', quality_range: [1,5], flora_climax: { canopy: 0, undergrowth: 0.6, fungi: 0.1 }, fauna_capacity: 20, food_web_tier: 'reef', temp_range: [0.55,1], moisture_range: [0,1] }, + { id: 'estuary', name: 'Estuary', color: '#5b8c85', category: 'aquatic', quality_range: [1,4], flora_climax: { canopy: 0, undergrowth: 0.5, fungi: 0.05 }, fauna_capacity: 14, food_web_tier: 'estuarine', temp_range: [0.2,0.8], moisture_range: [0.6,1] }, + { id: 'lake', name: 'Lake', color: '#3498db', category: 'aquatic', quality_range: [1,4], flora_climax: { canopy: 0, undergrowth: 0.3, fungi: 0.02 }, fauna_capacity: 10, food_web_tier: 'lacustrine', temp_range: [0,1], moisture_range: [0,1] }, + { id: 'pond', name: 'Pond', color: '#76b5c5', category: 'aquatic', quality_range: [1,2], flora_climax: { canopy: 0, undergrowth: 0.15, fungi: 0.01 }, fauna_capacity: 3, food_web_tier: 'lentic', temp_range: [0,1], moisture_range: [0,1] }, + { id: 'river', name: 'River', color: '#2980b9', category: 'aquatic', quality_range: [1,3], flora_climax: { canopy: 0, undergrowth: 0.2, fungi: 0.01 }, fauna_capacity: 6, food_web_tier: 'lotic', temp_range: [0,1], moisture_range: [0,1] }, + { id: 'mangrove', name: 'Mangrove', color: '#4a7c59', category: 'aquatic', quality_range: [1,4], flora_climax: { canopy: 0.6, undergrowth: 0.5, fungi: 0.15 },fauna_capacity: 14, food_web_tier: 'mangrove', temp_range: [0.55,1], moisture_range: [0.7,1] }, + // Tropical + { id: 'tropical_rainforest', name: 'Tropical Rainforest', color: '#0b6623', category: 'tropical', quality_range: [1,5], flora_climax: { canopy: 0.95, undergrowth: 0.7, fungi: 0.4 }, fauna_capacity: 25, food_web_tier: 'tropical_forest', temp_range: [0.65,1], moisture_range: [0.7,1] }, + { id: 'tropical_dry_forest', name: 'Tropical Dry Forest', color: '#6b8e23', category: 'tropical', quality_range: [1,4], flora_climax: { canopy: 0.65, undergrowth: 0.5, fungi: 0.2 }, fauna_capacity: 16, food_web_tier: 'tropical_forest', temp_range: [0.55,1], moisture_range: [0.4,0.7] }, + { id: 'savanna', name: 'Savanna', color: '#c4a747', category: 'tropical', quality_range: [1,3], flora_climax: { canopy: 0.15, undergrowth: 0.45, fungi: 0.05 },fauna_capacity: 12, food_web_tier: 'grassland', temp_range: [0.55,1], moisture_range: [0.2,0.4] }, + { id: 'desert', name: 'Desert', color: '#d4a76a', category: 'tropical', quality_range: [1,3], flora_climax: { canopy: 0, undergrowth: 0.08, fungi: 0.01 }, fauna_capacity: 4, food_web_tier: 'arid', temp_range: [0.55,1], moisture_range: [0,0.15] }, + // Temperate + { id: 'temperate_forest', name: 'Temperate Forest', color: '#2e7d32', category: 'temperate', quality_range: [1,5], flora_climax: { canopy: 0.85, undergrowth: 0.6, fungi: 0.35 }, fauna_capacity: 18, food_web_tier: 'temperate_forest', temp_range: [0.25,0.55], moisture_range: [0.5,1] }, + { id: 'temperate_grassland', name: 'Temperate Grassland', color: '#a8b820', category: 'temperate', quality_range: [1,4], flora_climax: { canopy: 0.05, undergrowth: 0.55, fungi: 0.1 },fauna_capacity: 14, food_web_tier: 'grassland', temp_range: [0.25,0.55], moisture_range: [0.3,0.5] }, + { id: 'chaparral', name: 'Chaparral', color: '#9e7c0c', category: 'temperate', quality_range: [1,3], flora_climax: { canopy: 0.1, undergrowth: 0.35, fungi: 0.05 },fauna_capacity: 8, food_web_tier: 'scrubland', temp_range: [0.25,0.55], moisture_range: [0.15,0.35] }, + { id: 'swamp', name: 'Swamp', color: '#4a6741', category: 'temperate', quality_range: [1,4], flora_climax: { canopy: 0.5, undergrowth: 0.6, fungi: 0.45 },fauna_capacity: 15, food_web_tier: 'wetland', temp_range: [0.35,0.7], moisture_range: [0.8,1] }, + { id: 'bog', name: 'Bog', color: '#5c4033', category: 'temperate', quality_range: [1,3], flora_climax: { canopy: 0.05, undergrowth: 0.3, fungi: 0.2 },fauna_capacity: 6, food_web_tier: 'wetland', temp_range: [0.1,0.4], moisture_range: [0.7,1] }, + // Cold + { id: 'boreal_forest', name: 'Boreal Forest', color: '#1b4332', category: 'cold', quality_range: [1,4], flora_climax: { canopy: 0.7, undergrowth: 0.35, fungi: 0.3 }, fauna_capacity: 12, food_web_tier: 'boreal', temp_range: [0.1,0.3], moisture_range: [0.35,1] }, + { id: 'tundra', name: 'Tundra', color: '#b0c4de', category: 'cold', quality_range: [1,3], flora_climax: { canopy: 0, undergrowth: 0.15, fungi: 0.05 }, fauna_capacity: 5, food_web_tier: 'tundra', temp_range: [0.05,0.15],moisture_range: [0,0.5] }, + { id: 'polar_desert', name: 'Polar Desert', color: '#e8e8f0', category: 'cold', quality_range: [1,2], flora_climax: { canopy: 0, undergrowth: 0.02, fungi: 0 }, fauna_capacity: 2, food_web_tier: 'polar', temp_range: [0,0.05], moisture_range: [0,0.2] }, + // Elevation + { id: 'montane_forest', name: 'Montane Forest', color: '#2d5a27', category: 'elevation', quality_range: [1,4], flora_climax: { canopy: 0.75, undergrowth: 0.45, fungi: 0.25 }, fauna_capacity: 14, food_web_tier: 'montane', temp_range: [0.15,0.45], moisture_range: [0.4,1] }, + { id: 'cloud_forest', name: 'Cloud Forest', color: '#5e8c6a', category: 'elevation', quality_range: [1,5], flora_climax: { canopy: 0.8, undergrowth: 0.65, fungi: 0.5 }, fauna_capacity: 20, food_web_tier: 'cloud_forest', temp_range: [0.2,0.45], moisture_range: [0.7,1] }, + { id: 'alpine_meadow', name: 'Alpine Meadow', color: '#90ee90', category: 'elevation', quality_range: [1,3], flora_climax: { canopy: 0, undergrowth: 0.3, fungi: 0.08 }, fauna_capacity: 6, food_web_tier: 'alpine', temp_range: [0.05,0.25], moisture_range: [0.3,0.7] }, + { id: 'alpine_tundra', name: 'Alpine Tundra', color: '#c8d6e5', category: 'elevation', quality_range: [1,2], flora_climax: { canopy: 0, undergrowth: 0.08, fungi: 0.02 }, fauna_capacity: 3, food_web_tier: 'alpine', temp_range: [0,0.15], moisture_range: [0,0.4] }, + { id: 'permanent_ice', name: 'Permanent Ice', color: '#f0f8ff', category: 'elevation', quality_range: [1,1], flora_climax: { canopy: 0, undergrowth: 0.01, fungi: 0 }, fauna_capacity: 1, food_web_tier: 'glacial', temp_range: [0,0.1], moisture_range: [0,1] }, + // Special + { id: 'subterranean', name: 'Subterranean', color: '#483d8b', category: 'special', quality_range: [1,4], flora_climax: { canopy: 0, undergrowth: 0.1, fungi: 0.6 }, fauna_capacity: 8, food_web_tier: 'cave', temp_range: [0.1,0.5], moisture_range: [0.2,0.8] }, +] + +export const BIOME_MAP = new Map(ALL_BIOMES.map(b => [b.id, b])) + +export const BIOME_CATEGORIES = [ + { id: 'aquatic', label: 'Aquatic', color: '#2980b9' }, + { id: 'tropical', label: 'Tropical', color: '#27ae60' }, + { id: 'temperate', label: 'Temperate', color: '#8e7cc3' }, + { id: 'cold', label: 'Cold', color: '#85c1e9' }, + { id: 'elevation', label: 'Elevation', color: '#7f8c8d' }, + { id: 'special', label: 'Special', color: '#9b59b6' }, +] as const + +// --------------------------------------------------------------------------- +// Trait definitions (from trait_definitions.json) +// --------------------------------------------------------------------------- + +export const TRAIT_CATEGORIES = { + size: ['tiny', 'small', 'medium', 'large', 'huge'], + diet: ['producer', 'herbivore', 'omnivore', 'carnivore', 'detritivore', 'filter_feeder'], + habitat: ['aquatic', 'terrestrial', 'amphibious', 'aerial', 'subterranean', 'arboreal'], + locomotion: ['sessile', 'walking', 'swimming', 'flying', 'burrowing', 'climbing', 'slithering'], + reproduction: ['r_strategy', 'k_strategy'], + thermal: ['cold_blooded', 'warm_blooded'], + social: ['solitary', 'pack', 'herd', 'swarm', 'colony'], +} as const + +export type TraitCategory = keyof typeof TRAIT_CATEGORIES + +export const TRAIT_LABELS: Record = { + tiny: 'Tiny', small: 'Small', medium: 'Medium', large: 'Large', huge: 'Huge', + producer: 'Producer', herbivore: 'Herbivore', omnivore: 'Omnivore', + carnivore: 'Carnivore', detritivore: 'Detritivore', filter_feeder: 'Filter Feeder', + aquatic: 'Aquatic', terrestrial: 'Terrestrial', amphibious: 'Amphibious', + aerial: 'Aerial', subterranean: 'Subterranean', arboreal: 'Arboreal', + sessile: 'Sessile', walking: 'Walking', swimming: 'Swimming', + flying: 'Flying', burrowing: 'Burrowing', climbing: 'Climbing', slithering: 'Slithering', + r_strategy: 'r-Strategy', k_strategy: 'K-Strategy', + cold_blooded: 'Cold-Blooded', warm_blooded: 'Warm-Blooded', + solitary: 'Solitary', pack: 'Pack', herd: 'Herd', swarm: 'Swarm', colony: 'Colony', +} + +// --------------------------------------------------------------------------- +// Quality tier display +// --------------------------------------------------------------------------- + +export const QUALITY_COLORS: Record = { + 1: '#666666', // Q1 Prolific (grey) + 2: '#cccccc', // Q2 Common (white) + 3: '#4080e0', // Q3 Rare (blue) + 4: '#9040cc', // Q4 Legendary (purple) + 5: '#e07020', // Q5 Epic (orange) +} + +export const QUALITY_LABELS: Record = { + 1: 'Prolific', 2: 'Common', 3: 'Rare', 4: 'Legendary', 5: 'Epic', +} + +// --------------------------------------------------------------------------- +// Species generation (deterministic from seed + biome traits) +// Uses the same algorithm as GDScript SpeciesGenerator +// --------------------------------------------------------------------------- + +export interface GeneratedSpecies { + id: string + name: string + biome_id: string + traits: { + size: string + diet: string + habitat: string + locomotion: string + reproduction: string + thermal: string + social: string + } + quality_range: [number, number] + growth_rate: number + food_web_role: 'producer' | 'herbivore' | 'predator' +} + +/** Simple deterministic hash for species generation. */ +function speciesHash(seed: number, a: number, b: number): number { + let h = seed * 374761393 + a * 668265263 + b * 2147483647 + h = (h ^ (h >>> 13)) * 1274126177 + h = h ^ (h >>> 16) + return (h >>> 0) / 4294967296 +} + +/** Weighted random selection from a {value: weight} map. */ +function weightedPick(weights: Record, rng: number): string { + const entries = Object.entries(weights) + const total = entries.reduce((s, [, w]) => s + w, 0) + let acc = 0 + const target = rng * total + for (const [value, weight] of entries) { + acc += weight + if (target <= acc) return value + } + return entries[entries.length - 1][0] +} + +// Trait constraint checking (from trait_constraints.json) +const INVALID_PAIRS: Array<[string, string]> = [ + ['aquatic', 'burrowing'], ['sessile', 'carnivore'], ['aerial', 'huge'], + ['subterranean', 'aerial'], ['filter_feeder', 'terrestrial'], + ['sessile', 'walking'], ['sessile', 'flying'], ['sessile', 'swimming'], + ['arboreal', 'aquatic'], ['swarm', 'huge'], +] + +function isValidTraitCombo(traits: Record): boolean { + const vals = new Set(Object.values(traits)) + for (const [a, b] of INVALID_PAIRS) { + if (vals.has(a) && vals.has(b)) return false + } + if (vals.has('sessile') && !vals.has('colony') && !vals.has('solitary')) return false + return true +} + +const SIZE_QUALITY_BASE: Record = { tiny: 1, small: 2, medium: 3, large: 4, huge: 5 } + +function computeQualityRange(traits: Record): [number, number] { + const base = SIZE_QUALITY_BASE[traits.size] ?? 3 + let mod = 0 + if (traits.reproduction === 'r_strategy') mod -= 1 + if (traits.diet === 'detritivore') mod -= 1 + if (traits.diet === 'carnivore') mod += 1 + const q = Math.max(1, Math.min(5, base + mod)) + return [Math.max(1, q - 1), Math.min(5, q + 1)] +} + +function computeGrowthRate(traits: Record): number { + let rate = 0.1 + if (traits.reproduction === 'r_strategy') rate *= 2.0 + if (traits.size === 'tiny' || traits.size === 'small') rate *= 1.5 + if (traits.size === 'huge') rate *= 0.5 + if (traits.social === 'herd' || traits.social === 'swarm') rate *= 1.2 + return rate +} + +function getFoodWebRole(diet: string): 'producer' | 'herbivore' | 'predator' { + if (diet === 'producer') return 'producer' + if (diet === 'herbivore' || diet === 'detritivore' || diet === 'filter_feeder') return 'herbivore' + return 'predator' +} + +// Flavor name generation (simplified from flavor.json) +const BIOME_PREFIXES: Record = { + deep_ocean: ['Abyssal', 'Trench', 'Deep', 'Ancient', 'Void'], + shallow_ocean: ['Salt', 'Tide', 'Shore', 'Brine', 'Coral'], + coral_reef: ['Reef', 'Pearl', 'Bright', 'Shell', 'Surge'], + estuary: ['Brackish', 'Marsh', 'Delta', 'Silt', 'Murk'], + lake: ['Still', 'Mire', 'Reed', 'Murk', 'Silver'], + pond: ['Stagnant', 'Puddle', 'Scum', 'Shallow', 'Murk'], + river: ['Current', 'Rapid', 'Eddy', 'Stone', 'Rush'], + mangrove: ['Root', 'Tangle', 'Brackish', 'Stilted', 'Brine'], + tropical_rainforest: ['Rot', 'Canopy', 'Vine', 'Emerald', 'Fang'], + tropical_dry_forest: ['Thorn', 'Dry', 'Rust', 'Ochre', 'Husk'], + savanna: ['Sun', 'Dust', 'Plains', 'Ember', 'Dry'], + desert: ['Sand', 'Dune', 'Sun', 'Ember', 'Bleach'], + temperate_forest: ['Iron', 'Moss', 'Bark', 'Grey', 'Amber'], + temperate_grassland: ['Pale', 'Wind', 'Dust', 'Gold', 'Steppe'], + chaparral: ['Brush', 'Dry', 'Thorn', 'Smoke', 'Amber'], + swamp: ['Bog', 'Rot', 'Murk', 'Bile', 'Fen'], + bog: ['Peat', 'Mist', 'Sour', 'Dank', 'Haze'], + boreal_forest: ['Frost', 'Pine', 'Snow', 'Ash', 'Cold'], + tundra: ['Frost', 'Winter', 'Howling', 'White', 'Bitter'], + polar_desert: ['Ice', 'Pale', 'Frozen', 'Null', 'Void'], + montane_forest: ['Stone', 'Ridge', 'Cloud', 'Iron', 'Crag'], + cloud_forest: ['Mist', 'Veil', 'Drip', 'Shroud', 'Dew'], + alpine_meadow: ['Peak', 'Wind', 'Rime', 'Crest', 'Cold'], + alpine_tundra: ['Shard', 'Frost', 'Bare', 'Gale', 'Scree'], + permanent_ice: ['Glacier', 'Null', 'White', 'Eternal', 'Rime'], + subterranean: ['Pale', 'Crystal', 'Cave', 'Blind', 'Hollow'], +} + +const SIZE_NOUNS: Record = { + tiny: ['Mite', 'Crawler', 'Wriggler', 'Speck', 'Gnat'], + small: ['Runner', 'Scurrier', 'Prowler', 'Skitter', 'Dart'], + medium: ['Stalker', 'Strider', 'Hunter', 'Watcher', 'Roamer'], + large: ['Beast', 'Brute', 'Prowler', 'Hulk', 'Titan'], + huge: ['Leviathan', 'Colossus', 'Behemoth', 'Monolith', 'Ancient'], +} + +function generateSpeciesName(biome: string, traits: Record, idx: number): string { + const prefixes = BIOME_PREFIXES[biome] ?? ['Wild'] + const nouns = SIZE_NOUNS[traits.size] ?? ['Creature'] + const prefix = prefixes[idx % prefixes.length] + const noun = nouns[(idx + 2) % nouns.length] + return `${prefix} ${noun}` +} + +/** + * Generate species for a biome using deterministic seeded RNG. + * Produces `count` valid species per biome. + */ +export function generateSpeciesForBiome( + biome_id: string, + weights: Record>, + seed: number, + count: number, +): GeneratedSpecies[] { + const species: GeneratedSpecies[] = [] + let attempt = 0 + let speciesIdx = 0 + + while (species.length < count && attempt < count * 10) { + const traits: Record = {} + const cats: Array<[string, readonly string[]]> = Object.entries(TRAIT_CATEGORIES) + + for (let c = 0; c < cats.length; c++) { + const [catName] = cats[c] + const catWeights = weights[catName] + if (catWeights) { + const rng = speciesHash(seed, attempt, c) + traits[catName] = weightedPick(catWeights, rng) + } else { + const values = cats[c][1] + const rng = speciesHash(seed, attempt, c) + traits[catName] = values[Math.floor(rng * values.length)] + } + } + + attempt++ + + if (!isValidTraitCombo(traits)) continue + + const qualityRange = computeQualityRange(traits) + const growthRate = computeGrowthRate(traits) + const name = generateSpeciesName(biome_id, traits, speciesIdx) + const role = getFoodWebRole(traits.diet) + + species.push({ + id: `${biome_id}_${speciesIdx}`, + name, + biome_id, + traits: traits as GeneratedSpecies['traits'], + quality_range: qualityRange, + growth_rate: growthRate, + food_web_role: role, + }) + speciesIdx++ + } + + return species +} + +// --------------------------------------------------------------------------- +// Food web edge computation +// --------------------------------------------------------------------------- + +export interface FoodWebEdge { + predator_id: string + prey_id: string +} + +const SIZE_ORDER = ['tiny', 'small', 'medium', 'large', 'huge'] + +/** Carnivores eat strictly smaller fauna. Pack social grants +1 size tier. */ +export function computeFoodWeb(species: GeneratedSpecies[]): FoodWebEdge[] { + const edges: FoodWebEdge[] = [] + + const predators = species.filter(s => + s.traits.diet === 'carnivore' || s.traits.diet === 'omnivore' + ) + + for (const pred of predators) { + const predSizeIdx = SIZE_ORDER.indexOf(pred.traits.size) + const effectiveSize = pred.traits.social === 'pack' ? predSizeIdx + 1 : predSizeIdx + + for (const prey of species) { + if (pred.id === prey.id) continue + const preySizeIdx = SIZE_ORDER.indexOf(prey.traits.size) + if (pred.traits.diet === 'omnivore' && prey.food_web_role === 'producer') { + edges.push({ predator_id: pred.id, prey_id: prey.id }) + } else if (preySizeIdx < effectiveSize) { + edges.push({ predator_id: pred.id, prey_id: prey.id }) + } + } + } + + return edges +} + +// --------------------------------------------------------------------------- +// Biome trait weight data (imported from JSON at build time) +// --------------------------------------------------------------------------- + +export type BiomeTraitWeights = Record>>