feat(creatures): add creature types, data, ECS components, and core systems

Phase 5.1-5.5: Creatures & Ecology foundation
- 3 species data (Crystallid, Acidophile, Reagent) with real chemistry
- ECS components: Creature, AI, Metabolism, LifeCycle
- AI FSM system: idle → wander → feed → flee → attack
- Metabolism: energy drain, feeding from resources, starvation damage
- Life cycle: egg → youth → mature → aging → natural death
- Population dynamics: counting, reproduction, initial spawning
- Species registry with numeric/string ID lookup
- 51 tests passing (272 total)

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Денис Шкабатур
2026-02-12 14:13:26 +03:00
parent 7e46d1ed1d
commit 324be5e643
10 changed files with 1965 additions and 0 deletions

346
src/creatures/ai.ts Normal file
View File

@@ -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<number, SpeciesData>,
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;
}
}
}
}

115
src/creatures/factory.ts Normal file
View File

@@ -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,
};
}

14
src/creatures/index.ts Normal file
View File

@@ -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';

152
src/creatures/lifecycle.ts Normal file
View File

@@ -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<number, SpeciesData>,
): 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;
}

124
src/creatures/metabolism.ts Normal file
View File

@@ -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<number, number>();
/**
* 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<number, ResourceInfo>,
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();
}

170
src/creatures/population.ts Normal file
View File

@@ -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<number, number> {
const counts = new Map<number, number>();
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<number, CreatureInfo> {
const creatureData = new Map<number, CreatureInfo>();
const tileSize = biome.tileSize;
// Build tile name → id map
const tileNameToId = new Map<string, number>();
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<number>();
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, CreatureInfo>,
): 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;
}

126
src/creatures/types.ts Normal file
View File

@@ -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<string, SpeciesData>();
private byNumericId = new Map<SpeciesId, SpeciesData>();
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;
}
}

110
src/data/creatures.json Normal file
View File

@@ -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"]
}
]

View File

@@ -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
};

775
tests/creatures.test.ts Normal file
View File

@@ -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<number, SpeciesData> {
const map = new Map<number, SpeciesData>();
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<number, { itemId: string; tileX: number; tileY: number }>();
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<number, CreatureInfo>();
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<number, CreatureInfo>();
// 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<number, CreatureInfo>();
// 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);
});
});