diff --git a/src/creatures/ai.ts b/src/creatures/ai.ts new file mode 100644 index 0000000..12238e1 --- /dev/null +++ b/src/creatures/ai.ts @@ -0,0 +1,346 @@ +/** + * Creature AI System — Finite State Machine + * + * States: Idle → Wander → Feed → Flee → Attack + * Transitions based on hunger, threats, territory. + * + * Pure function system — reads/writes ECS components only. + */ + +import { query } from 'bitecs'; +import type { World } from '../ecs/world'; +import { + Position, + Velocity, + Health, + Creature, + AI, + Metabolism, + LifeCycle, + PlayerTag, + Resource, +} from '../ecs/components'; +import { AIState, LifeStage, SpeciesId } from './types'; +import type { SpeciesData } from './types'; + +/** Seeded pseudo-random for AI decisions (deterministic per frame) */ +function aiRandom(eid: number, tick: number): number { + let h = ((eid * 2654435761) ^ (tick * 2246822507)) >>> 0; + h = Math.imul(h ^ (h >>> 16), 0x45d9f3b); + h = Math.imul(h ^ (h >>> 16), 0x45d9f3b); + return ((h ^ (h >>> 16)) >>> 0) / 4294967296; +} + +/** Calculate squared distance between two entities */ +function distSq(ax: number, ay: number, bx: number, by: number): number { + const dx = ax - bx; + const dy = ay - by; + return dx * dx + dy * dy; +} + +/** Set velocity toward a target position at given speed */ +function moveToward( + eid: number, + targetX: number, + targetY: number, + speed: number, +): void { + const dx = targetX - Position.x[eid]; + const dy = targetY - Position.y[eid]; + const dist = Math.sqrt(dx * dx + dy * dy); + if (dist < 1) { + Velocity.vx[eid] = 0; + Velocity.vy[eid] = 0; + return; + } + Velocity.vx[eid] = (dx / dist) * speed; + Velocity.vy[eid] = (dy / dist) * speed; +} + +/** Set velocity away from a position at given speed */ +function moveAway( + eid: number, + fromX: number, + fromY: number, + speed: number, +): void { + const dx = Position.x[eid] - fromX; + const dy = Position.y[eid] - fromY; + const dist = Math.sqrt(dx * dx + dy * dy); + if (dist < 1) { + // Random direction when exactly on top + Velocity.vx[eid] = speed; + Velocity.vy[eid] = 0; + return; + } + Velocity.vx[eid] = (dx / dist) * speed; + Velocity.vy[eid] = (dy / dist) * speed; +} + +/** + * Find the nearest entity matching a predicate. + * @returns [eid, distanceSquared] or [-1, Infinity] if none found + */ +function findNearest( + world: World, + fromX: number, + fromY: number, + candidates: number[], + maxDistSq: number, +): [number, number] { + let bestEid = -1; + let bestDist = Infinity; + + for (const eid of candidates) { + const d = distSq(fromX, fromY, Position.x[eid], Position.y[eid]); + if (d < bestDist && d <= maxDistSq) { + bestDist = d; + bestEid = eid; + } + } + + return [bestEid, bestDist]; +} + +/** + * AI System — runs FSM transitions and actions for all creatures. + * + * @param speciesLookup - map from SpeciesId to species stats + * @param tick - current game tick (for deterministic randomness) + */ +export function aiSystem( + world: World, + deltaMs: number, + speciesLookup: Map, + tick: number, +): void { + const dt = deltaMs / 1000; + const creatures = query(world, [Creature, AI, Position, Velocity, Metabolism, LifeCycle]); + + // Gather player positions for flee calculations + const players = query(world, [PlayerTag, Position]); + const playerPositions = players.map(eid => ({ + x: Position.x[eid], + y: Position.y[eid], + eid, + })); + + // Gather resource positions for feeding + const resources = query(world, [Resource, Position]); + + for (const eid of creatures) { + // Eggs don't act + if (LifeCycle.stage[eid] === LifeStage.Egg) { + Velocity.vx[eid] = 0; + Velocity.vy[eid] = 0; + continue; + } + + const species = speciesLookup.get(Creature.speciesId[eid]); + if (!species) continue; + + const speed = LifeCycle.stage[eid] === LifeStage.Youth + ? species.speed * 0.7 + : LifeCycle.stage[eid] === LifeStage.Aging + ? species.speed * 0.5 + : species.speed; + + // Reduce attack cooldown + if (AI.attackCooldown[eid] > 0) { + AI.attackCooldown[eid] -= deltaMs; + } + + // Reduce state timer + AI.stateTimer[eid] -= deltaMs; + + // ─── Check for threats (player proximity) ─── + let nearestThreatDist = Infinity; + let nearestThreatX = 0; + let nearestThreatY = 0; + + for (const p of playerPositions) { + const d = distSq(Position.x[eid], Position.y[eid], p.x, p.y); + if (d < nearestThreatDist) { + nearestThreatDist = d; + nearestThreatX = p.x; + nearestThreatY = p.y; + } + } + + const fleeRadiusSq = species.fleeRadius * species.fleeRadius; + const shouldFlee = nearestThreatDist < fleeRadiusSq + && species.speciesId !== SpeciesId.Reagent; // Reagents don't flee + + // ─── Check hunger ─── + const hungerFraction = Metabolism.energy[eid] / Metabolism.energyMax[eid]; + const isHungry = hungerFraction < Metabolism.hungerThreshold[eid]; + + // ─── Check for prey (Reagents only) ─── + const aggrRadiusSq = species.aggressionRadius * species.aggressionRadius; + let nearestPreyEid = -1; + let nearestPreyDist = Infinity; + + if (species.speciesId === SpeciesId.Reagent) { + // Reagents hunt other creatures (non-Reagent) + for (const other of creatures) { + if (other === eid) continue; + if (Creature.speciesId[other] === SpeciesId.Reagent) continue; + if (LifeCycle.stage[other] === LifeStage.Egg) continue; + + const d = distSq(Position.x[eid], Position.y[eid], Position.x[other], Position.y[other]); + if (d < nearestPreyDist && d <= aggrRadiusSq) { + nearestPreyDist = d; + nearestPreyEid = other; + } + } + } + + // ─── State transitions ─── + const currentState = AI.state[eid]; + + if (shouldFlee && currentState !== AIState.Flee) { + // Threat detected → flee + AI.state[eid] = AIState.Flee; + AI.stateTimer[eid] = 2000; + } else if (nearestPreyEid >= 0 && isHungry && currentState !== AIState.Attack) { + // Prey spotted and hungry → attack + AI.state[eid] = AIState.Attack; + AI.targetEid[eid] = nearestPreyEid; + AI.stateTimer[eid] = 5000; + } else if (isHungry && currentState !== AIState.Feed && currentState !== AIState.Flee && currentState !== AIState.Attack) { + // Hungry → feed (but don't interrupt active attack) + AI.state[eid] = AIState.Feed; + AI.stateTimer[eid] = 8000; + } else if (AI.stateTimer[eid] <= 0) { + // Timer expired → cycle between idle and wander + if (currentState === AIState.Idle) { + AI.state[eid] = AIState.Wander; + AI.stateTimer[eid] = 3000 + aiRandom(eid, tick) * 4000; + } else if (currentState === AIState.Wander) { + AI.state[eid] = AIState.Idle; + AI.stateTimer[eid] = 1000 + aiRandom(eid, tick) * 2000; + } else if (currentState === AIState.Feed) { + // Couldn't find food → wander + AI.state[eid] = AIState.Wander; + AI.stateTimer[eid] = 3000; + } else if (currentState === AIState.Flee) { + AI.state[eid] = AIState.Wander; + AI.stateTimer[eid] = 2000; + } else if (currentState === AIState.Attack) { + AI.state[eid] = AIState.Wander; + AI.stateTimer[eid] = 2000; + } + } + + // ─── Execute current state behavior ─── + switch (AI.state[eid]) { + case AIState.Idle: { + Velocity.vx[eid] = 0; + Velocity.vy[eid] = 0; + break; + } + + case AIState.Wander: { + // Random direction, clamped to wander radius from home + const homeDist = Math.sqrt(distSq( + Position.x[eid], Position.y[eid], + AI.homeX[eid], AI.homeY[eid], + )); + + if (homeDist > species.wanderRadius) { + // Too far from home → head back + moveToward(eid, AI.homeX[eid], AI.homeY[eid], speed * 0.6); + } else { + // Pick new random direction periodically + const phase = Math.floor(AI.stateTimer[eid] / 1000); + const angle = aiRandom(eid, tick + phase) * Math.PI * 2; + Velocity.vx[eid] = Math.cos(angle) * speed * 0.5; + Velocity.vy[eid] = Math.sin(angle) * speed * 0.5; + } + break; + } + + case AIState.Feed: { + if (species.diet === 'mineral') { + // Find nearest resource entity + const [nearResEid, nearResDist] = findNearest( + world, + Position.x[eid], Position.y[eid], + [...resources], + (species.wanderRadius * 2) ** 2, + ); + + if (nearResEid >= 0) { + const feedRangeSq = 30 * 30; + if (nearResDist < feedRangeSq) { + // Close enough — feed (handled by metabolism system) + Velocity.vx[eid] = 0; + Velocity.vy[eid] = 0; + } else { + moveToward(eid, Position.x[nearResEid], Position.y[nearResEid], speed); + } + } else { + // No resources found → wander to look for food + const angle = aiRandom(eid, tick) * Math.PI * 2; + Velocity.vx[eid] = Math.cos(angle) * speed * 0.5; + Velocity.vy[eid] = Math.sin(angle) * speed * 0.5; + } + } else if (species.diet === 'creature') { + // Predator — same as attack but passive searching + const angle = aiRandom(eid, tick) * Math.PI * 2; + Velocity.vx[eid] = Math.cos(angle) * speed * 0.7; + Velocity.vy[eid] = Math.sin(angle) * speed * 0.7; + } + break; + } + + case AIState.Flee: { + moveAway(eid, nearestThreatX, nearestThreatY, speed * 1.3); + break; + } + + case AIState.Attack: { + const target = AI.targetEid[eid]; + if (target < 0) { + AI.state[eid] = AIState.Wander; + AI.stateTimer[eid] = 2000; + break; + } + + const targetDist = distSq( + Position.x[eid], Position.y[eid], + Position.x[target], Position.y[target], + ); + const attackRangeSq = species.attackRange * species.attackRange; + + if (targetDist <= attackRangeSq) { + // In range — deal damage if cooldown ready + Velocity.vx[eid] = 0; + Velocity.vy[eid] = 0; + if (AI.attackCooldown[eid] <= 0) { + const dmg = LifeCycle.stage[eid] === LifeStage.Youth + ? species.damage * 0.5 + : species.damage; + Health.current[target] -= dmg; + AI.attackCooldown[eid] = species.attackCooldown; + + // Gain energy from kill if target dies + if (Health.current[target] <= 0) { + Metabolism.energy[eid] = Math.min( + Metabolism.energy[eid] + Metabolism.feedAmount[eid], + Metabolism.energyMax[eid], + ); + AI.state[eid] = AIState.Wander; + AI.stateTimer[eid] = 3000; + AI.targetEid[eid] = -1; + } + } + } else { + // Chase target + moveToward(eid, Position.x[target], Position.y[target], speed); + } + break; + } + } + } +} diff --git a/src/creatures/factory.ts b/src/creatures/factory.ts new file mode 100644 index 0000000..2d6d892 --- /dev/null +++ b/src/creatures/factory.ts @@ -0,0 +1,115 @@ +/** + * Creature Factory — creates creature entities in the ECS world + * + * Each creature gets: Position, Velocity, Health, SpriteRef, + * Creature, AI, Metabolism, LifeCycle components. + */ + +import { addEntity, addComponent } from 'bitecs'; +import type { World } from '../ecs/world'; +import { + Position, + Velocity, + Health, + SpriteRef, + Creature, + AI, + Metabolism, + LifeCycle, +} from '../ecs/components'; +import { AIState, LifeStage } from './types'; +import type { SpeciesData, CreatureInfo } from './types'; + +/** + * Create a creature entity from species data at a given position. + * Starts at the Egg stage (immobile). + * + * @returns entity ID + */ +export function createCreatureEntity( + world: World, + species: SpeciesData, + x: number, + y: number, + startStage: LifeStage = LifeStage.Egg, +): number { + const eid = addEntity(world); + + // Position + addComponent(world, eid, Position); + Position.x[eid] = x; + Position.y[eid] = y; + + // Velocity (starts at 0 — eggs don't move) + addComponent(world, eid, Velocity); + Velocity.vx[eid] = 0; + Velocity.vy[eid] = 0; + + // Health — scaled by life stage + addComponent(world, eid, Health); + const healthScale = startStage === LifeStage.Egg ? 0.3 + : startStage === LifeStage.Youth ? 0.6 + : startStage === LifeStage.Aging ? 0.7 + : 1.0; + const hp = Math.round(species.health * healthScale); + Health.current[eid] = hp; + Health.max[eid] = hp; + + // Sprite — color from species, radius by stage + addComponent(world, eid, SpriteRef); + SpriteRef.color[eid] = parseInt(species.color.replace('#', ''), 16); + SpriteRef.radius[eid] = startStage <= LifeStage.Youth + ? species.radiusYouth + : species.radius; + + // Creature tag + addComponent(world, eid, Creature); + Creature.speciesId[eid] = species.speciesId; + + // AI — starts idle + addComponent(world, eid, AI); + AI.state[eid] = startStage === LifeStage.Egg ? AIState.Idle : AIState.Wander; + AI.stateTimer[eid] = 2000; // 2s initial timer + AI.targetEid[eid] = -1; + AI.homeX[eid] = x; + AI.homeY[eid] = y; + AI.attackCooldown[eid] = 0; + + // Metabolism + addComponent(world, eid, Metabolism); + Metabolism.energy[eid] = species.energyMax * 0.7; // start 70% full + Metabolism.energyMax[eid] = species.energyMax; + Metabolism.drainRate[eid] = species.energyDrainPerSecond; + Metabolism.feedAmount[eid] = species.energyPerFeed; + Metabolism.hungerThreshold[eid] = species.hungerThreshold; + + // Life Cycle + addComponent(world, eid, LifeCycle); + LifeCycle.stage[eid] = startStage; + LifeCycle.age[eid] = 0; + + // Set stage timer based on starting stage + const stageTimers = [ + species.eggDuration, + species.youthDuration, + species.matureDuration, + species.agingDuration, + ]; + LifeCycle.stageTimer[eid] = stageTimers[startStage]; + + return eid; +} + +/** + * Get runtime creature info for string-based lookups. + * Used by the creature data map (similar to resourceData pattern). + */ +export function getCreatureInfo( + eid: number, + speciesDataId: string, +): CreatureInfo { + return { + speciesId: Creature.speciesId[eid], + speciesDataId, + }; +} diff --git a/src/creatures/index.ts b/src/creatures/index.ts new file mode 100644 index 0000000..acc4fb5 --- /dev/null +++ b/src/creatures/index.ts @@ -0,0 +1,14 @@ +/** + * Creatures module — re-exports for clean imports + */ + +export { SpeciesId, AIState, LifeStage, SpeciesRegistry } from './types'; +export type { SpeciesData, CreatureInfo, DietType } from './types'; + +export { createCreatureEntity, getCreatureInfo } from './factory'; + +export { aiSystem } from './ai'; +export { metabolismSystem, clearMetabolismTracking, resetMetabolismTracking } from './metabolism'; +export { lifeCycleSystem } from './lifecycle'; +export type { LifeCycleEvent } from './lifecycle'; +export { countPopulations, spawnInitialCreatures, reproduce } from './population'; diff --git a/src/creatures/lifecycle.ts b/src/creatures/lifecycle.ts new file mode 100644 index 0000000..f521a44 --- /dev/null +++ b/src/creatures/lifecycle.ts @@ -0,0 +1,152 @@ +/** + * Life Cycle System — manages creature aging through stages + * + * Egg → Youth → Mature → Aging → Death + * + * Each stage has a timer. When it expires, creature advances to next stage. + * Stats (health, speed, size) change with each stage. + * Death at end of Aging stage returns entity ID for removal + decomposition. + */ + +import { query } from 'bitecs'; +import type { World } from '../ecs/world'; +import { + Health, + SpriteRef, + Creature, + AI, + Metabolism, + LifeCycle, +} from '../ecs/components'; +import { AIState, LifeStage } from './types'; +import type { SpeciesData } from './types'; + +/** Result of life cycle processing */ +export interface LifeCycleEvent { + type: 'stage_advance' | 'natural_death' | 'ready_to_reproduce'; + eid: number; + speciesId: number; + newStage?: LifeStage; +} + +/** + * Life cycle system — advances stages, adjusts stats, detects natural death. + * + * @returns Array of lifecycle events (stage advances, natural deaths, reproduction readiness) + */ +export function lifeCycleSystem( + world: World, + deltaMs: number, + speciesLookup: Map, +): LifeCycleEvent[] { + const events: LifeCycleEvent[] = []; + + for (const eid of query(world, [Creature, LifeCycle])) { + const species = speciesLookup.get(Creature.speciesId[eid]); + if (!species) continue; + + // Advance age + LifeCycle.age[eid] += deltaMs; + + // Decrease stage timer + LifeCycle.stageTimer[eid] -= deltaMs; + + // Check for reproduction readiness (mature with enough energy) + if (LifeCycle.stage[eid] === LifeStage.Mature) { + const energyFraction = Metabolism.energy[eid] / Metabolism.energyMax[eid]; + if (energyFraction > 0.8) { + events.push({ + type: 'ready_to_reproduce', + eid, + speciesId: Creature.speciesId[eid], + }); + } + } + + // Stage transition when timer expires + if (LifeCycle.stageTimer[eid] <= 0) { + const currentStage = LifeCycle.stage[eid] as LifeStage; + + switch (currentStage) { + case LifeStage.Egg: { + // Hatch → Youth + LifeCycle.stage[eid] = LifeStage.Youth; + LifeCycle.stageTimer[eid] = species.youthDuration; + + // Update visuals — still small + SpriteRef.radius[eid] = species.radiusYouth; + + // Enable AI + AI.state[eid] = AIState.Wander; + AI.stateTimer[eid] = 2000; + + // Scale health up + const youthHp = Math.round(species.health * 0.6); + Health.current[eid] = youthHp; + Health.max[eid] = youthHp; + + events.push({ + type: 'stage_advance', + eid, + speciesId: Creature.speciesId[eid], + newStage: LifeStage.Youth, + }); + break; + } + + case LifeStage.Youth: { + // Grow → Mature + LifeCycle.stage[eid] = LifeStage.Mature; + LifeCycle.stageTimer[eid] = species.matureDuration; + + // Full size and health + SpriteRef.radius[eid] = species.radius; + Health.current[eid] = species.health; + Health.max[eid] = species.health; + + events.push({ + type: 'stage_advance', + eid, + speciesId: Creature.speciesId[eid], + newStage: LifeStage.Mature, + }); + break; + } + + case LifeStage.Mature: { + // Age → Aging + LifeCycle.stage[eid] = LifeStage.Aging; + LifeCycle.stageTimer[eid] = species.agingDuration; + + // Reduced stats + const agingHp = Math.round(species.health * 0.7); + Health.max[eid] = agingHp; + if (Health.current[eid] > agingHp) { + Health.current[eid] = agingHp; + } + + events.push({ + type: 'stage_advance', + eid, + speciesId: Creature.speciesId[eid], + newStage: LifeStage.Aging, + }); + break; + } + + case LifeStage.Aging: { + // Natural death + Health.current[eid] = 0; + events.push({ + type: 'natural_death', + eid, + speciesId: Creature.speciesId[eid], + }); + break; + } + } + } + } + + return events; +} diff --git a/src/creatures/metabolism.ts b/src/creatures/metabolism.ts new file mode 100644 index 0000000..3486678 --- /dev/null +++ b/src/creatures/metabolism.ts @@ -0,0 +1,124 @@ +/** + * Metabolism System — energy drain, feeding, starvation + * + * Every creature loses energy over time. Feeding near resources restores energy. + * Starvation (energy = 0) deals damage over time. + */ + +import { query } from 'bitecs'; +import type { World } from '../ecs/world'; +import { + Position, + Health, + Creature, + AI, + Metabolism, + LifeCycle, + Resource, +} from '../ecs/components'; +import { AIState, LifeStage } from './types'; +import type { ResourceInfo } from '../player/interaction'; + +/** Damage per second when starving (energy = 0) */ +const STARVATION_DAMAGE_PER_SEC = 5; + +/** Feed range squared (pixels) — how close creature must be to resource */ +const FEED_RANGE_SQ = 35 * 35; + +/** Minimum time between feeds (ms) */ +const FEED_COOLDOWN_MS = 2000; + +/** Tracking last feed time per creature to prevent instant depletion */ +const lastFeedTime = new Map(); + +/** + * Metabolism system — drains energy, handles feeding from resources, starvation damage. + * + * @param resourceData - map of resource entity ID → info (for depletion tracking) + * @param elapsed - total elapsed time in ms (for feed cooldown) + * @returns Array of [creatureEid, elementNumber] pairs for excretion events + */ +export function metabolismSystem( + world: World, + deltaMs: number, + resourceData: Map, + elapsed: number, +): Array<[number, number]> { + const dt = deltaMs / 1000; + const excretions: Array<[number, number]> = []; + + const creatures = query(world, [Creature, Metabolism, Position, LifeCycle]); + const resources = query(world, [Resource, Position]); + + for (const eid of creatures) { + // Eggs don't metabolize + if (LifeCycle.stage[eid] === LifeStage.Egg) continue; + + // ─── Energy drain ─── + const drainMultiplier = LifeCycle.stage[eid] === LifeStage.Youth ? 0.7 + : LifeCycle.stage[eid] === LifeStage.Aging ? 1.3 + : 1.0; + + Metabolism.energy[eid] -= Metabolism.drainRate[eid] * dt * drainMultiplier; + + // ─── Feeding (if in Feed state and near resource) ─── + if (AI.state[eid] === AIState.Feed || AI.state[eid] === AIState.Idle) { + const lastFed = lastFeedTime.get(eid) ?? 0; + if (elapsed - lastFed >= FEED_COOLDOWN_MS) { + for (const resEid of resources) { + if (Resource.quantity[resEid] <= 0) continue; + + const dx = Position.x[eid] - Position.x[resEid]; + const dy = Position.y[eid] - Position.y[resEid]; + const dSq = dx * dx + dy * dy; + + if (dSq <= FEED_RANGE_SQ) { + // Feed! + Metabolism.energy[eid] = Math.min( + Metabolism.energy[eid] + Metabolism.feedAmount[eid], + Metabolism.energyMax[eid], + ); + Resource.quantity[resEid] -= 1; + lastFeedTime.set(eid, elapsed); + + // Excretion event (species excretes element after feeding) + const speciesId = Creature.speciesId[eid]; + // Excretion element is stored in species data, we pass speciesId + // and let the caller resolve what element is excreted + excretions.push([eid, speciesId]); + + // Exit Feed state if energy is above threshold + const fraction = Metabolism.energy[eid] / Metabolism.energyMax[eid]; + if (fraction >= Metabolism.hungerThreshold[eid] * 1.5) { + AI.state[eid] = AIState.Wander; + AI.stateTimer[eid] = 3000; + } + break; // one feed per tick + } + } + } + } + + // ─── Clamp energy ─── + if (Metabolism.energy[eid] < 0) { + Metabolism.energy[eid] = 0; + } + + // ─── Starvation damage ─── + if (Metabolism.energy[eid] <= 0) { + Health.current[eid] -= STARVATION_DAMAGE_PER_SEC * dt; + } + } + + return excretions; +} + +/** Clear tracking data for a removed creature */ +export function clearMetabolismTracking(eid: number): void { + lastFeedTime.delete(eid); +} + +/** Reset all metabolism tracking (for tests) */ +export function resetMetabolismTracking(): void { + lastFeedTime.clear(); +} diff --git a/src/creatures/population.ts b/src/creatures/population.ts new file mode 100644 index 0000000..0506b45 --- /dev/null +++ b/src/creatures/population.ts @@ -0,0 +1,170 @@ +/** + * Population Dynamics — spawning, reproduction, population caps + * + * Simplified Lotka-Volterra inspired system: + * - Reproduction when mature creatures have high energy + * - Population caps per species prevent explosion + * - Initial spawning on preferred tiles + */ + +import { query } from 'bitecs'; +import type { World } from '../ecs/world'; +import { Position, Creature, LifeCycle } from '../ecs/components'; +import { LifeStage, SpeciesId } from './types'; +import type { SpeciesData, CreatureInfo } from './types'; +import { createCreatureEntity } from './factory'; +import type { TileGrid, BiomeData } from '../world/types'; + +/** Simple seeded hash for deterministic spawn placement */ +function spawnHash(x: number, y: number, seed: number, salt: number): number { + return (((x * 48611) ^ (y * 29423) ^ (seed * 61379) ^ (salt * 73757)) >>> 0) / 4294967296; +} + +/** Count living creatures per species */ +export function countPopulations(world: World): Map { + const counts = new Map(); + for (const eid of query(world, [Creature])) { + const sid = Creature.speciesId[eid]; + counts.set(sid, (counts.get(sid) ?? 0) + 1); + } + return counts; +} + +/** + * Spawn initial creatures across the map. + * Places creatures on preferred tiles using seeded randomness. + * + * @returns Map of entity ID → CreatureInfo + */ +export function spawnInitialCreatures( + world: World, + grid: TileGrid, + biome: BiomeData, + seed: number, + allSpecies: SpeciesData[], +): Map { + const creatureData = new Map(); + const tileSize = biome.tileSize; + + // Build tile name → id map + const tileNameToId = new Map(); + for (const tile of biome.tiles) { + tileNameToId.set(tile.name, tile.id); + } + + for (const species of allSpecies) { + // Find preferred tile IDs + const preferredIds = new Set(); + for (const name of species.preferredTiles) { + const id = tileNameToId.get(name); + if (id !== undefined) preferredIds.add(id); + } + + // Collect candidate positions + const candidates: Array<{ x: number; y: number }> = []; + for (let y = 0; y < grid.length; y++) { + for (let x = 0; x < grid[y].length; x++) { + if (preferredIds.has(grid[y][x])) { + candidates.push({ x, y }); + } + } + } + + // Spawn based on weight — deterministic selection + const targetCount = Math.min( + Math.round(species.maxPopulation * species.spawnWeight), + species.maxPopulation, + ); + + let spawned = 0; + for (let i = 0; i < candidates.length && spawned < targetCount; i++) { + const { x, y } = candidates[i]; + const roll = spawnHash(x, y, seed, species.speciesId); + + // Probabilistic placement — spread creatures out + if (roll < species.spawnWeight * 0.05) { + const px = x * tileSize + tileSize / 2; + const py = y * tileSize + tileSize / 2; + + // Start mature for initial population + const eid = createCreatureEntity(world, species, px, py, LifeStage.Mature); + creatureData.set(eid, { + speciesId: species.speciesId, + speciesDataId: species.id, + }); + spawned++; + } + } + + // Fallback: if not enough spawned, place remaining at random candidates + if (spawned < targetCount && candidates.length > 0) { + for (let i = 0; spawned < targetCount && i < 1000; i++) { + const idx = Math.floor(spawnHash(i, spawned, seed, species.speciesId + 100) * candidates.length); + const { x, y } = candidates[idx]; + const px = x * tileSize + tileSize / 2; + const py = y * tileSize + tileSize / 2; + + const eid = createCreatureEntity(world, species, px, py, LifeStage.Mature); + creatureData.set(eid, { + speciesId: species.speciesId, + speciesDataId: species.id, + }); + spawned++; + } + } + } + + return creatureData; +} + +/** + * Handle reproduction — create offspring near parent. + * Called when lifecycle system reports 'ready_to_reproduce' events. + * + * @param parentEid - entity ID of the reproducing creature + * @param species - species data + * @param currentPopulation - current count of this species + * @returns Array of new creature entity IDs (empty if population cap reached) + */ +export function reproduce( + world: World, + parentEid: number, + species: SpeciesData, + currentPopulation: number, + creatureData: Map, +): number[] { + if (currentPopulation >= species.maxPopulation) { + return []; + } + + const newEids: number[] = []; + const maxOffspring = Math.min( + species.offspringCount, + species.maxPopulation - currentPopulation, + ); + + const parentX = Position.x[parentEid]; + const parentY = Position.y[parentEid]; + + for (let i = 0; i < maxOffspring; i++) { + // Place egg near parent with slight offset + const angle = (i / maxOffspring) * Math.PI * 2; + const offsetX = Math.cos(angle) * 20; + const offsetY = Math.sin(angle) * 20; + + const eid = createCreatureEntity( + world, species, + parentX + offsetX, + parentY + offsetY, + LifeStage.Egg, + ); + + creatureData.set(eid, { + speciesId: species.speciesId, + speciesDataId: species.id, + }); + newEids.push(eid); + } + + return newEids; +} diff --git a/src/creatures/types.ts b/src/creatures/types.ts new file mode 100644 index 0000000..46f6daf --- /dev/null +++ b/src/creatures/types.ts @@ -0,0 +1,126 @@ +/** + * Creature Types — data definitions for the creature/ecology system + * + * Three species in Catalytic Wastes: + * - Crystallids: slow, sturdy, eat minerals, excrete silicon + * - Acidophiles: medium speed, eat minerals, excrete acid + * - Reagents: fast, paired, predatory, explosive + */ + +/** Species identifier (stored as numeric ID in ECS components) */ +export enum SpeciesId { + Crystallid = 0, + Acidophile = 1, + Reagent = 2, +} + +/** AI behavior state (FSM) */ +export enum AIState { + Idle = 0, + Wander = 1, + Feed = 2, + Flee = 3, + Attack = 4, +} + +/** Life cycle stage */ +export enum LifeStage { + Egg = 0, + Youth = 1, + Mature = 2, + Aging = 3, +} + +/** Diet type — what a creature can feed on */ +export type DietType = 'mineral' | 'acid' | 'creature'; + +/** Species definition loaded from creatures.json */ +export interface SpeciesData { + id: string; + name: string; + nameRu: string; + speciesId: SpeciesId; + description: string; + descriptionRu: string; + + /** Visual */ + color: string; // hex color string + radius: number; // base radius in pixels + radiusYouth: number; // smaller when young + + /** Stats */ + health: number; + speed: number; // pixels per second + damage: number; // damage per attack + armor: number; // damage reduction (0-1) + + /** Metabolism */ + diet: DietType; + dietTiles: string[]; // tile names this species feeds on + excretionElement: number; // atomic number of excreted element (0 = none) + energyMax: number; // max energy capacity + energyPerFeed: number; // energy gained per feeding + energyDrainPerSecond: number; // passive energy loss rate + hungerThreshold: number; // below this → seek food (0-1 fraction of max) + + /** Behavior */ + aggressionRadius: number; // distance to detect threats + fleeRadius: number; // distance to start fleeing + wanderRadius: number; // max wander distance from home + attackRange: number; // melee attack range + attackCooldown: number; // ms between attacks + + /** Life cycle durations (ms) */ + eggDuration: number; + youthDuration: number; + matureDuration: number; + agingDuration: number; + + /** Reproduction */ + reproductionEnergy: number; // energy cost to reproduce + offspringCount: number; // eggs per reproduction event + + /** Population */ + maxPopulation: number; // per-species cap + spawnWeight: number; // relative spawn density (0-1) + preferredTiles: string[]; // tiles where this species spawns +} + +/** Runtime creature state (non-ECS, for string data lookup) */ +export interface CreatureInfo { + speciesId: SpeciesId; + speciesDataId: string; // key into species registry +} + +/** Creature species registry */ +export class SpeciesRegistry { + private byId = new Map(); + private byNumericId = new Map(); + + constructor(data: SpeciesData[]) { + for (const species of data) { + this.byId.set(species.id, species); + this.byNumericId.set(species.speciesId, species); + } + } + + /** Get species by string ID */ + get(id: string): SpeciesData | undefined { + return this.byId.get(id); + } + + /** Get species by numeric ID (from ECS component) */ + getByNumericId(id: SpeciesId): SpeciesData | undefined { + return this.byNumericId.get(id); + } + + /** Get all species */ + getAll(): SpeciesData[] { + return [...this.byId.values()]; + } + + /** Get species count */ + get count(): number { + return this.byId.size; + } +} diff --git a/src/data/creatures.json b/src/data/creatures.json new file mode 100644 index 0000000..8b259ce --- /dev/null +++ b/src/data/creatures.json @@ -0,0 +1,110 @@ +[ + { + "id": "crystallid", + "name": "Crystallid", + "nameRu": "Кристаллид", + "speciesId": 0, + "description": "Slow, armored creature that feeds on mineral deposits. Its silicon-rich body refracts light into prismatic patterns. When killed, it shatters into valuable crystalline fragments.", + "descriptionRu": "Медленное бронированное существо, питающееся минеральными отложениями. Его кремниевое тело преломляет свет в призматические узоры. При гибели рассыпается на ценные кристаллические осколки.", + "color": "#88ccff", + "radius": 10, + "radiusYouth": 6, + "health": 120, + "speed": 30, + "damage": 8, + "armor": 0.3, + "diet": "mineral", + "dietTiles": ["mineral-vein", "crystal-formation"], + "excretionElement": 14, + "energyMax": 100, + "energyPerFeed": 25, + "energyDrainPerSecond": 1.5, + "hungerThreshold": 0.4, + "aggressionRadius": 0, + "fleeRadius": 80, + "wanderRadius": 200, + "attackRange": 20, + "attackCooldown": 2000, + "eggDuration": 8000, + "youthDuration": 15000, + "matureDuration": 60000, + "agingDuration": 20000, + "reproductionEnergy": 60, + "offspringCount": 2, + "maxPopulation": 12, + "spawnWeight": 0.4, + "preferredTiles": ["ground", "scorched-earth"] + }, + { + "id": "acidophile", + "name": "Acidophile", + "nameRu": "Ацидофил", + "speciesId": 1, + "description": "Mid-sized creature that thrives in acidic environments. Consumes mineral deposits and excretes hydrochloric acid, gradually reshaping the landscape. Territorial but not predatory.", + "descriptionRu": "Среднеразмерное существо, процветающее в кислотной среде. Поглощает минералы и выделяет соляную кислоту, постепенно изменяя ландшафт. Территориальное, но не хищное.", + "color": "#44ff44", + "radius": 8, + "radiusYouth": 5, + "health": 80, + "speed": 50, + "damage": 12, + "armor": 0.1, + "diet": "mineral", + "dietTiles": ["mineral-vein", "geyser"], + "excretionElement": 17, + "energyMax": 80, + "energyPerFeed": 20, + "energyDrainPerSecond": 2.0, + "hungerThreshold": 0.5, + "aggressionRadius": 100, + "fleeRadius": 120, + "wanderRadius": 250, + "attackRange": 24, + "attackCooldown": 1500, + "eggDuration": 6000, + "youthDuration": 12000, + "matureDuration": 45000, + "agingDuration": 15000, + "reproductionEnergy": 50, + "offspringCount": 3, + "maxPopulation": 15, + "spawnWeight": 0.35, + "preferredTiles": ["acid-shallow", "ground"] + }, + { + "id": "reagent", + "name": "Reagent", + "nameRu": "Реагент", + "speciesId": 2, + "description": "Fast, aggressive predator that hunts in pairs. Contains unstable chemical compounds — when two Reagents collide, they trigger an exothermic reaction. Feeds on other creatures.", + "descriptionRu": "Быстрый агрессивный хищник, охотящийся парами. Содержит нестабильные химические соединения — при столкновении двух Реагентов происходит экзотермическая реакция. Питается другими существами.", + "color": "#ff4444", + "radius": 7, + "radiusYouth": 4, + "health": 60, + "speed": 80, + "damage": 20, + "armor": 0.0, + "diet": "creature", + "dietTiles": [], + "excretionElement": 0, + "energyMax": 60, + "energyPerFeed": 30, + "energyDrainPerSecond": 3.0, + "hungerThreshold": 0.6, + "aggressionRadius": 150, + "fleeRadius": 60, + "wanderRadius": 300, + "attackRange": 18, + "attackCooldown": 1000, + "eggDuration": 5000, + "youthDuration": 10000, + "matureDuration": 35000, + "agingDuration": 12000, + "reproductionEnergy": 40, + "offspringCount": 1, + "maxPopulation": 8, + "spawnWeight": 0.25, + "preferredTiles": ["scorched-earth", "ground"] + } +] diff --git a/src/ecs/components.ts b/src/ecs/components.ts index 9d66ec1..2a98344 100644 --- a/src/ecs/components.ts +++ b/src/ecs/components.ts @@ -50,3 +50,36 @@ export const Resource = { export const Projectile = { lifetime: [] as number[], // remaining lifetime in ms (removed at 0) }; + +// ─── Creature Components ───────────────────────────────────────── + +/** Tag + species identifier for creatures */ +export const Creature = { + speciesId: [] as number[], // SpeciesId enum value (0=Crystallid, 1=Acidophile, 2=Reagent) +}; + +/** AI finite state machine */ +export const AI = { + state: [] as number[], // AIState enum value + stateTimer: [] as number[], // ms remaining in current state + targetEid: [] as number[], // entity being pursued/fled (-1 = none) + homeX: [] as number[], // spawn/home position X + homeY: [] as number[], // spawn/home position Y + attackCooldown: [] as number[], // ms until next attack allowed +}; + +/** Creature metabolism — energy, hunger, feeding */ +export const Metabolism = { + energy: [] as number[], // current energy (0 = starving) + energyMax: [] as number[], // maximum energy capacity + drainRate: [] as number[], // energy lost per second + feedAmount: [] as number[], // energy gained per feeding action + hungerThreshold: [] as number[], // fraction (0-1) below which creature seeks food +}; + +/** Life cycle stage tracking */ +export const LifeCycle = { + stage: [] as number[], // LifeStage enum value (0=egg, 1=youth, 2=mature, 3=aging) + stageTimer: [] as number[], // ms remaining in current stage + age: [] as number[], // total age in ms +}; diff --git a/tests/creatures.test.ts b/tests/creatures.test.ts new file mode 100644 index 0000000..5050b82 --- /dev/null +++ b/tests/creatures.test.ts @@ -0,0 +1,775 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { createWorld, addEntity, addComponent, query } from 'bitecs'; +import type { World } from '../src/ecs/world'; +import { + Position, + Velocity, + Health, + SpriteRef, + Creature, + AI, + Metabolism, + LifeCycle, + Resource, + PlayerTag, +} from '../src/ecs/components'; +import { + SpeciesId, + AIState, + LifeStage, + SpeciesRegistry, +} from '../src/creatures/types'; +import type { SpeciesData, CreatureInfo } from '../src/creatures/types'; +import { createCreatureEntity } from '../src/creatures/factory'; +import { aiSystem } from '../src/creatures/ai'; +import { + metabolismSystem, + resetMetabolismTracking, +} from '../src/creatures/metabolism'; +import { lifeCycleSystem } from '../src/creatures/lifecycle'; +import { countPopulations, reproduce } from '../src/creatures/population'; +import speciesDataArray from '../src/data/creatures.json'; + +// ─── Test Helpers ──────────────────────────────────────────────── + +const allSpecies = speciesDataArray as SpeciesData[]; + +function getSpecies(id: string): SpeciesData { + const s = allSpecies.find(s => s.id === id); + if (!s) throw new Error(`Species not found: ${id}`); + return s; +} + +function buildSpeciesLookup(): Map { + const map = new Map(); + for (const s of allSpecies) { + map.set(s.speciesId, s); + } + return map; +} + +function createResourceEntity(world: World, x: number, y: number, qty: number): number { + const eid = addEntity(world); + addComponent(world, eid, Position); + addComponent(world, eid, Resource); + Position.x[eid] = x; + Position.y[eid] = y; + Resource.quantity[eid] = qty; + Resource.interactRange[eid] = 40; + return eid; +} + +function createPlayerEntity(world: World, x: number, y: number): number { + const eid = addEntity(world); + addComponent(world, eid, Position); + addComponent(world, eid, PlayerTag); + Position.x[eid] = x; + Position.y[eid] = y; + return eid; +} + +// ─── Species Data ──────────────────────────────────────────────── + +describe('Species Data', () => { + it('loads 3 species from JSON', () => { + expect(allSpecies).toHaveLength(3); + }); + + it('has Crystallid with correct properties', () => { + const c = getSpecies('crystallid'); + expect(c.speciesId).toBe(SpeciesId.Crystallid); + expect(c.health).toBe(120); + expect(c.speed).toBe(30); + expect(c.armor).toBe(0.3); + expect(c.diet).toBe('mineral'); + expect(c.excretionElement).toBe(14); // Si + }); + + it('has Acidophile with correct properties', () => { + const a = getSpecies('acidophile'); + expect(a.speciesId).toBe(SpeciesId.Acidophile); + expect(a.health).toBe(80); + expect(a.speed).toBe(50); + expect(a.diet).toBe('mineral'); + expect(a.excretionElement).toBe(17); // Cl + }); + + it('has Reagent as predator', () => { + const r = getSpecies('reagent'); + expect(r.speciesId).toBe(SpeciesId.Reagent); + expect(r.speed).toBe(80); + expect(r.diet).toBe('creature'); + expect(r.damage).toBe(20); + }); + + it('all species have valid life cycle durations', () => { + for (const species of allSpecies) { + expect(species.eggDuration).toBeGreaterThan(0); + expect(species.youthDuration).toBeGreaterThan(0); + expect(species.matureDuration).toBeGreaterThan(0); + expect(species.agingDuration).toBeGreaterThan(0); + } + }); + + it('all species have valid color hex strings', () => { + for (const species of allSpecies) { + expect(species.color).toMatch(/^#[0-9a-fA-F]{6}$/); + } + }); +}); + +// ─── Species Registry ──────────────────────────────────────────── + +describe('Species Registry', () => { + let registry: SpeciesRegistry; + + beforeEach(() => { + registry = new SpeciesRegistry(allSpecies); + }); + + it('has correct count', () => { + expect(registry.count).toBe(3); + }); + + it('looks up by string ID', () => { + const c = registry.get('crystallid'); + expect(c?.name).toBe('Crystallid'); + }); + + it('looks up by numeric ID', () => { + const a = registry.getByNumericId(SpeciesId.Acidophile); + expect(a?.id).toBe('acidophile'); + }); + + it('returns undefined for unknown ID', () => { + expect(registry.get('nonexistent')).toBeUndefined(); + expect(registry.getByNumericId(99 as SpeciesId)).toBeUndefined(); + }); + + it('returns all species', () => { + const all = registry.getAll(); + expect(all).toHaveLength(3); + }); +}); + +// ─── Creature Factory ──────────────────────────────────────────── + +describe('Creature Factory', () => { + let world: World; + + beforeEach(() => { + world = createWorld(); + }); + + it('creates creature with all components', () => { + const species = getSpecies('crystallid'); + const eid = createCreatureEntity(world, species, 100, 200); + + expect(Position.x[eid]).toBe(100); + expect(Position.y[eid]).toBe(200); + expect(Creature.speciesId[eid]).toBe(SpeciesId.Crystallid); + expect(LifeCycle.stage[eid]).toBe(LifeStage.Egg); + expect(AI.state[eid]).toBe(AIState.Idle); + expect(Metabolism.energyMax[eid]).toBe(species.energyMax); + }); + + it('starts as egg with reduced health', () => { + const species = getSpecies('crystallid'); + const eid = createCreatureEntity(world, species, 0, 0, LifeStage.Egg); + + expect(Health.current[eid]).toBe(Math.round(species.health * 0.3)); + expect(LifeCycle.stage[eid]).toBe(LifeStage.Egg); + expect(SpriteRef.radius[eid]).toBe(species.radiusYouth); + }); + + it('creates mature creature with full stats', () => { + const species = getSpecies('acidophile'); + const eid = createCreatureEntity(world, species, 50, 50, LifeStage.Mature); + + expect(Health.current[eid]).toBe(species.health); + expect(Health.max[eid]).toBe(species.health); + expect(LifeCycle.stage[eid]).toBe(LifeStage.Mature); + expect(SpriteRef.radius[eid]).toBe(species.radius); + expect(AI.state[eid]).toBe(AIState.Wander); // mature starts wandering + }); + + it('sets home position to spawn position', () => { + const species = getSpecies('reagent'); + const eid = createCreatureEntity(world, species, 300, 400); + + expect(AI.homeX[eid]).toBe(300); + expect(AI.homeY[eid]).toBe(400); + }); + + it('sets metabolism from species data', () => { + const species = getSpecies('reagent'); + const eid = createCreatureEntity(world, species, 0, 0, LifeStage.Mature); + + expect(Metabolism.energyMax[eid]).toBe(species.energyMax); + expect(Metabolism.drainRate[eid]).toBe(species.energyDrainPerSecond); + expect(Metabolism.feedAmount[eid]).toBe(species.energyPerFeed); + expect(Metabolism.energy[eid]).toBe(species.energyMax * 0.7); + }); + + it('queries creature entities correctly', () => { + const s1 = getSpecies('crystallid'); + const s2 = getSpecies('reagent'); + const e1 = createCreatureEntity(world, s1, 0, 0); + const e2 = createCreatureEntity(world, s2, 100, 100); + + const creatures = [...query(world, [Creature])]; + expect(creatures).toContain(e1); + expect(creatures).toContain(e2); + expect(creatures).toHaveLength(2); + }); +}); + +// ─── AI System ─────────────────────────────────────────────────── + +describe('AI System', () => { + let world: World; + const speciesLookup = buildSpeciesLookup(); + + beforeEach(() => { + world = createWorld(); + }); + + it('eggs do not move', () => { + const species = getSpecies('crystallid'); + const eid = createCreatureEntity(world, species, 100, 100, LifeStage.Egg); + + aiSystem(world, 16, speciesLookup, 1); + + expect(Velocity.vx[eid]).toBe(0); + expect(Velocity.vy[eid]).toBe(0); + }); + + it('idle creatures have zero velocity', () => { + const species = getSpecies('crystallid'); + const eid = createCreatureEntity(world, species, 100, 100, LifeStage.Mature); + AI.state[eid] = AIState.Idle; + AI.stateTimer[eid] = 5000; + + aiSystem(world, 16, speciesLookup, 1); + + expect(Velocity.vx[eid]).toBe(0); + expect(Velocity.vy[eid]).toBe(0); + }); + + it('wandering creatures have non-zero velocity', () => { + const species = getSpecies('crystallid'); + const eid = createCreatureEntity(world, species, 100, 100, LifeStage.Mature); + AI.state[eid] = AIState.Wander; + AI.stateTimer[eid] = 5000; + + aiSystem(world, 16, speciesLookup, 1); + + const speed = Math.sqrt(Velocity.vx[eid] ** 2 + Velocity.vy[eid] ** 2); + expect(speed).toBeGreaterThan(0); + }); + + it('creature flees from nearby player', () => { + const species = getSpecies('crystallid'); // fleeRadius = 80 + const eid = createCreatureEntity(world, species, 100, 100, LifeStage.Mature); + AI.state[eid] = AIState.Wander; + AI.stateTimer[eid] = 5000; + + // Place player within flee radius + createPlayerEntity(world, 130, 100); // 30px away, within 80 + + aiSystem(world, 16, speciesLookup, 1); + + expect(AI.state[eid]).toBe(AIState.Flee); + }); + + it('Reagents do not flee from player', () => { + const species = getSpecies('reagent'); + const eid = createCreatureEntity(world, species, 100, 100, LifeStage.Mature); + AI.state[eid] = AIState.Wander; + AI.stateTimer[eid] = 5000; + + createPlayerEntity(world, 110, 100); // very close + + aiSystem(world, 16, speciesLookup, 1); + + expect(AI.state[eid]).not.toBe(AIState.Flee); + }); + + it('hungry creature enters Feed state', () => { + const species = getSpecies('crystallid'); + const eid = createCreatureEntity(world, species, 100, 100, LifeStage.Mature); + AI.state[eid] = AIState.Wander; + AI.stateTimer[eid] = 5000; + + // Set energy below hunger threshold + Metabolism.energy[eid] = species.energyMax * 0.2; // well below 0.4 threshold + + aiSystem(world, 16, speciesLookup, 1); + + expect(AI.state[eid]).toBe(AIState.Feed); + }); + + it('Reagent attacks nearby non-Reagent creature when hungry', () => { + const reagent = getSpecies('reagent'); + const crystallid = getSpecies('crystallid'); + + const predator = createCreatureEntity(world, reagent, 100, 100, LifeStage.Mature); + const prey = createCreatureEntity(world, crystallid, 120, 100, LifeStage.Mature); // 20px away, within aggressionRadius 150 + + // Make predator hungry + Metabolism.energy[predator] = reagent.energyMax * 0.3; + AI.state[predator] = AIState.Wander; + AI.stateTimer[predator] = 5000; + + aiSystem(world, 16, speciesLookup, 1); + + expect(AI.state[predator]).toBe(AIState.Attack); + expect(AI.targetEid[predator]).toBe(prey); + }); + + it('attacking creature deals damage when in range', () => { + const reagent = getSpecies('reagent'); + const crystallid = getSpecies('crystallid'); + + const predator = createCreatureEntity(world, reagent, 100, 100, LifeStage.Mature); + const prey = createCreatureEntity(world, crystallid, 110, 100, LifeStage.Mature); // 10px, within attackRange 18 + + const preyHpBefore = Health.current[prey]; + + AI.state[predator] = AIState.Attack; + AI.targetEid[predator] = prey; + AI.stateTimer[predator] = 5000; + AI.attackCooldown[predator] = 0; + Metabolism.energy[predator] = reagent.energyMax * 0.3; + + aiSystem(world, 16, speciesLookup, 1); + + expect(Health.current[prey]).toBeLessThan(preyHpBefore); + }); + + it('state transitions on timer expiry (idle → wander)', () => { + const species = getSpecies('crystallid'); + const eid = createCreatureEntity(world, species, 100, 100, LifeStage.Mature); + AI.state[eid] = AIState.Idle; + AI.stateTimer[eid] = 10; // almost expired + + aiSystem(world, 20, speciesLookup, 1); // 20ms > 10ms timer + + expect(AI.state[eid]).toBe(AIState.Wander); + }); + + it('wander → idle on timer expiry', () => { + const species = getSpecies('crystallid'); + const eid = createCreatureEntity(world, species, 100, 100, LifeStage.Mature); + AI.state[eid] = AIState.Wander; + AI.stateTimer[eid] = 10; + + aiSystem(world, 20, speciesLookup, 1); + + expect(AI.state[eid]).toBe(AIState.Idle); + }); + + it('creature returns to home when too far', () => { + const species = getSpecies('crystallid'); // wanderRadius = 200 + const eid = createCreatureEntity(world, species, 100, 100, LifeStage.Mature); + AI.homeX[eid] = 100; + AI.homeY[eid] = 100; + + // Move creature far from home + Position.x[eid] = 500; // 400px away, beyond wanderRadius 200 + Position.y[eid] = 100; + + AI.state[eid] = AIState.Wander; + AI.stateTimer[eid] = 5000; + + aiSystem(world, 16, speciesLookup, 1); + + // Should be moving toward home (negative vx toward 100) + expect(Velocity.vx[eid]).toBeLessThan(0); + }); +}); + +// ─── Metabolism System ─────────────────────────────────────────── + +describe('Metabolism System', () => { + let world: World; + const emptyResourceData = new Map(); + + beforeEach(() => { + world = createWorld(); + resetMetabolismTracking(); + }); + + it('drains energy over time', () => { + const species = getSpecies('crystallid'); + const eid = createCreatureEntity(world, species, 100, 100, LifeStage.Mature); + const energyBefore = Metabolism.energy[eid]; + + metabolismSystem(world, 1000, emptyResourceData, 1000); // 1 second + + expect(Metabolism.energy[eid]).toBeLessThan(energyBefore); + expect(Metabolism.energy[eid]).toBeCloseTo( + energyBefore - species.energyDrainPerSecond, + 1, + ); + }); + + it('eggs do not metabolize', () => { + const species = getSpecies('crystallid'); + const eid = createCreatureEntity(world, species, 100, 100, LifeStage.Egg); + const energyBefore = Metabolism.energy[eid]; + + metabolismSystem(world, 1000, emptyResourceData, 1000); + + expect(Metabolism.energy[eid]).toBe(energyBefore); + }); + + it('starvation deals damage when energy = 0', () => { + const species = getSpecies('crystallid'); + const eid = createCreatureEntity(world, species, 100, 100, LifeStage.Mature); + Metabolism.energy[eid] = 0; + const hpBefore = Health.current[eid]; + + metabolismSystem(world, 1000, emptyResourceData, 1000); + + expect(Health.current[eid]).toBeLessThan(hpBefore); + }); + + it('creature feeds from nearby resource', () => { + const species = getSpecies('crystallid'); + const eid = createCreatureEntity(world, species, 100, 100, LifeStage.Mature); + AI.state[eid] = AIState.Feed; + Metabolism.energy[eid] = 20; // low energy + + // Place resource next to creature + const resEid = createResourceEntity(world, 110, 100, 5); + + metabolismSystem(world, 16, emptyResourceData, 5000); + + // Should have gained energy + expect(Metabolism.energy[eid]).toBeGreaterThan(20); + // Resource should be depleted by 1 + expect(Resource.quantity[resEid]).toBe(4); + }); + + it('returns excretion events after feeding', () => { + const species = getSpecies('crystallid'); + const eid = createCreatureEntity(world, species, 100, 100, LifeStage.Mature); + AI.state[eid] = AIState.Feed; + Metabolism.energy[eid] = 20; + + createResourceEntity(world, 110, 100, 5); + + const excretions = metabolismSystem(world, 16, emptyResourceData, 5000); + + expect(excretions.length).toBeGreaterThan(0); + expect(excretions[0][0]).toBe(eid); // creature eid + expect(excretions[0][1]).toBe(SpeciesId.Crystallid); + }); + + it('energy does not exceed max', () => { + const species = getSpecies('crystallid'); + const eid = createCreatureEntity(world, species, 100, 100, LifeStage.Mature); + AI.state[eid] = AIState.Feed; + Metabolism.energy[eid] = species.energyMax - 1; // almost full + + createResourceEntity(world, 110, 100, 5); + + metabolismSystem(world, 16, emptyResourceData, 5000); + + expect(Metabolism.energy[eid]).toBe(species.energyMax); + }); + + it('youth drains energy slower (0.7x)', () => { + const species = getSpecies('crystallid'); + const eid = createCreatureEntity(world, species, 100, 100, LifeStage.Youth); + const energyBefore = Metabolism.energy[eid]; + + metabolismSystem(world, 1000, emptyResourceData, 1000); + + const drained = energyBefore - Metabolism.energy[eid]; + expect(drained).toBeCloseTo(species.energyDrainPerSecond * 0.7, 1); + }); + + it('aging drains energy faster (1.3x)', () => { + const species = getSpecies('crystallid'); + const eid = createCreatureEntity(world, species, 100, 100, LifeStage.Aging); + const energyBefore = Metabolism.energy[eid]; + + metabolismSystem(world, 1000, emptyResourceData, 1000); + + const drained = energyBefore - Metabolism.energy[eid]; + expect(drained).toBeCloseTo(species.energyDrainPerSecond * 1.3, 1); + }); +}); + +// ─── Life Cycle System ─────────────────────────────────────────── + +describe('Life Cycle System', () => { + let world: World; + const speciesLookup = buildSpeciesLookup(); + + beforeEach(() => { + world = createWorld(); + }); + + it('advances age over time', () => { + const species = getSpecies('crystallid'); + const eid = createCreatureEntity(world, species, 100, 100, LifeStage.Mature); + + lifeCycleSystem(world, 1000, speciesLookup); + + expect(LifeCycle.age[eid]).toBe(1000); + }); + + it('egg hatches to youth when timer expires', () => { + const species = getSpecies('crystallid'); + const eid = createCreatureEntity(world, species, 100, 100, LifeStage.Egg); + + // Fast-forward past egg duration + const events = lifeCycleSystem(world, species.eggDuration + 100, speciesLookup); + + expect(LifeCycle.stage[eid]).toBe(LifeStage.Youth); + expect(events.some(e => e.type === 'stage_advance' && e.newStage === LifeStage.Youth)).toBe(true); + }); + + it('youth grows to mature', () => { + const species = getSpecies('crystallid'); + const eid = createCreatureEntity(world, species, 100, 100, LifeStage.Youth); + + const events = lifeCycleSystem(world, species.youthDuration + 100, speciesLookup); + + expect(LifeCycle.stage[eid]).toBe(LifeStage.Mature); + expect(Health.current[eid]).toBe(species.health); // full health + expect(SpriteRef.radius[eid]).toBe(species.radius); // full size + expect(events.some(e => e.type === 'stage_advance' && e.newStage === LifeStage.Mature)).toBe(true); + }); + + it('mature ages to aging', () => { + const species = getSpecies('crystallid'); + const eid = createCreatureEntity(world, species, 100, 100, LifeStage.Mature); + + const events = lifeCycleSystem(world, species.matureDuration + 100, speciesLookup); + + expect(LifeCycle.stage[eid]).toBe(LifeStage.Aging); + expect(Health.max[eid]).toBe(Math.round(species.health * 0.7)); // reduced + expect(events.some(e => e.type === 'stage_advance' && e.newStage === LifeStage.Aging)).toBe(true); + }); + + it('aging leads to natural death', () => { + const species = getSpecies('crystallid'); + const eid = createCreatureEntity(world, species, 100, 100, LifeStage.Aging); + + const events = lifeCycleSystem(world, species.agingDuration + 100, speciesLookup); + + expect(Health.current[eid]).toBe(0); + expect(events.some(e => e.type === 'natural_death')).toBe(true); + }); + + it('reports ready_to_reproduce for mature creatures with high energy', () => { + const species = getSpecies('crystallid'); + const eid = createCreatureEntity(world, species, 100, 100, LifeStage.Mature); + Metabolism.energy[eid] = species.energyMax * 0.9; // above 0.8 threshold + + // Don't let timer expire + LifeCycle.stageTimer[eid] = 99999; + + const events = lifeCycleSystem(world, 100, speciesLookup); + + expect(events.some(e => e.type === 'ready_to_reproduce' && e.eid === eid)).toBe(true); + }); + + it('does not report reproduction for youth', () => { + const species = getSpecies('crystallid'); + const eid = createCreatureEntity(world, species, 100, 100, LifeStage.Youth); + Metabolism.energy[eid] = species.energyMax; // full energy but youth + + LifeCycle.stageTimer[eid] = 99999; + + const events = lifeCycleSystem(world, 100, speciesLookup); + + expect(events.some(e => e.type === 'ready_to_reproduce' && e.eid === eid)).toBe(false); + }); + + it('full lifecycle: egg → youth → mature → aging → death', () => { + const species = getSpecies('acidophile'); + const eid = createCreatureEntity(world, species, 100, 100, LifeStage.Egg); + + // Egg → Youth + lifeCycleSystem(world, species.eggDuration + 1, speciesLookup); + expect(LifeCycle.stage[eid]).toBe(LifeStage.Youth); + + // Youth → Mature + lifeCycleSystem(world, species.youthDuration + 1, speciesLookup); + expect(LifeCycle.stage[eid]).toBe(LifeStage.Mature); + + // Mature → Aging + lifeCycleSystem(world, species.matureDuration + 1, speciesLookup); + expect(LifeCycle.stage[eid]).toBe(LifeStage.Aging); + + // Aging → Death + const events = lifeCycleSystem(world, species.agingDuration + 1, speciesLookup); + expect(Health.current[eid]).toBe(0); + expect(events.some(e => e.type === 'natural_death')).toBe(true); + }); +}); + +// ─── Population Dynamics ───────────────────────────────────────── + +describe('Population Dynamics', () => { + let world: World; + + beforeEach(() => { + world = createWorld(); + }); + + it('counts populations correctly', () => { + const s1 = getSpecies('crystallid'); + const s2 = getSpecies('reagent'); + + createCreatureEntity(world, s1, 0, 0, LifeStage.Mature); + createCreatureEntity(world, s1, 100, 0, LifeStage.Mature); + createCreatureEntity(world, s2, 200, 0, LifeStage.Mature); + + const counts = countPopulations(world); + + expect(counts.get(SpeciesId.Crystallid)).toBe(2); + expect(counts.get(SpeciesId.Reagent)).toBe(1); + expect(counts.get(SpeciesId.Acidophile)).toBeUndefined(); + }); + + it('reproduce creates offspring near parent', () => { + const species = getSpecies('crystallid'); // offspringCount = 2 + const parent = createCreatureEntity(world, species, 200, 200, LifeStage.Mature); + + const creatureData = new Map(); + const newEids = reproduce(world, parent, species, 1, creatureData); + + expect(newEids).toHaveLength(2); + + for (const eid of newEids) { + // Offspring should be eggs near parent + expect(LifeCycle.stage[eid]).toBe(LifeStage.Egg); + const dx = Math.abs(Position.x[eid] - 200); + const dy = Math.abs(Position.y[eid] - 200); + expect(dx).toBeLessThanOrEqual(25); + expect(dy).toBeLessThanOrEqual(25); + + // Should be tracked in creature data + expect(creatureData.has(eid)).toBe(true); + } + }); + + it('reproduce respects population cap', () => { + const species = getSpecies('reagent'); // maxPopulation = 8, offspringCount = 1 + const parent = createCreatureEntity(world, species, 100, 100, LifeStage.Mature); + + const creatureData = new Map(); + + // At max population + const newEids = reproduce(world, parent, species, 8, creatureData); + expect(newEids).toHaveLength(0); + }); + + it('reproduce limits offspring to not exceed cap', () => { + const species = getSpecies('crystallid'); // maxPopulation = 12, offspringCount = 2 + const parent = createCreatureEntity(world, species, 100, 100, LifeStage.Mature); + + const creatureData = new Map(); + + // Population at 11, only room for 1 offspring + const newEids = reproduce(world, parent, species, 11, creatureData); + expect(newEids).toHaveLength(1); + }); +}); + +// ─── Integration ───────────────────────────────────────────────── + +describe('Creature Integration', () => { + let world: World; + const speciesLookup = buildSpeciesLookup(); + + beforeEach(() => { + world = createWorld(); + resetMetabolismTracking(); + }); + + it('creature lifecycle: hatch → feed → reproduce → age → die', () => { + const species = getSpecies('acidophile'); + const eid = createCreatureEntity(world, species, 200, 200, LifeStage.Egg); + + // 1. Hatch + lifeCycleSystem(world, species.eggDuration + 1, speciesLookup); + expect(LifeCycle.stage[eid]).toBe(LifeStage.Youth); + + // 2. Grow to mature + lifeCycleSystem(world, species.youthDuration + 1, speciesLookup); + expect(LifeCycle.stage[eid]).toBe(LifeStage.Mature); + + // 3. AI works — creature wanders + aiSystem(world, 16, speciesLookup, 1); + const speed = Math.sqrt(Velocity.vx[eid] ** 2 + Velocity.vy[eid] ** 2); + expect(speed).toBeGreaterThan(0); + + // 4. Metabolism drains energy + const energyBefore = Metabolism.energy[eid]; + metabolismSystem(world, 1000, new Map(), 10000); + expect(Metabolism.energy[eid]).toBeLessThan(energyBefore); + + // 5. Age to aging + lifeCycleSystem(world, species.matureDuration + 1, speciesLookup); + expect(LifeCycle.stage[eid]).toBe(LifeStage.Aging); + + // 6. Natural death + const events = lifeCycleSystem(world, species.agingDuration + 1, speciesLookup); + expect(events.some(e => e.type === 'natural_death')).toBe(true); + }); + + it('predator-prey interaction: reagent kills crystallid', () => { + const reagent = getSpecies('reagent'); + const crystallid = getSpecies('crystallid'); + + const pred = createCreatureEntity(world, reagent, 100, 100, LifeStage.Mature); + const prey = createCreatureEntity(world, crystallid, 110, 100, LifeStage.Mature); + + // Set predator to attack + AI.state[pred] = AIState.Attack; + AI.targetEid[pred] = prey; + AI.stateTimer[pred] = 10000; + AI.attackCooldown[pred] = 0; + Metabolism.energy[pred] = reagent.energyMax * 0.3; + + const initialHp = Health.current[prey]; + + // Simulate several attack cycles + for (let i = 0; i < 20; i++) { + aiSystem(world, 1000, speciesLookup, i); + AI.attackCooldown[pred] = 0; // reset cooldown for test + } + + expect(Health.current[prey]).toBeLessThanOrEqual(0); + }); + + it('creature ecosystem with multiple species runs without errors', () => { + const species = allSpecies; + + // Spawn several of each + for (const s of species) { + for (let i = 0; i < 3; i++) { + createCreatureEntity(world, s, 100 + i * 50, 100 + s.speciesId * 100, LifeStage.Mature); + } + } + + // Run 100 ticks + for (let tick = 0; tick < 100; tick++) { + aiSystem(world, 16, speciesLookup, tick); + metabolismSystem(world, 16, new Map(), tick * 16); + lifeCycleSystem(world, 16, speciesLookup); + } + + // Should still have creatures alive + const counts = countPopulations(world); + const total = [...counts.values()].reduce((a, b) => a + b, 0); + expect(total).toBeGreaterThan(0); + }); +});