Files
synthesis/tests/creatures.test.ts
Денис Шкабатур 6ba0746bb9 phase 9: biome expansion — 3 biomes, 40 elements, 119 reactions, 9 species
Expand beyond vertical slice with two new biomes and massive chemistry expansion:

Chemistry: +20 real elements (Li→U), +39 compounds (acids/salts/oxides/organics),
+85 reactions (Haber process, thermite variants, smelting, fermentation, etc.)

Biomes: Kinetic Mountains (physics/mechanics themed) and Verdant Forests
(biology/ecology themed), each with 8 tile types and unique generation rules.

Creatures: 6 new species — Pendulums/Mechanoids/Resonators (mountains),
Symbiotes/Mimics/Spore-bearers (forests). Species filtered by biome.

Infrastructure: CradleScene biome selector UI, generic world generator
(tile lookup by property instead of hardcoded names), actinide element category.

487 tests passing (32 new).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-12 17:27:15 +03:00

796 lines
27 KiB
TypeScript

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 9 species from JSON (3 per biome)', () => {
expect(allSpecies).toHaveLength(9);
});
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(9);
});
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(9);
});
it('can look up new Phase 9 species', () => {
expect(registry.get('pendulum')?.biome).toBe('kinetic-mountains');
expect(registry.get('mechanoid')?.biome).toBe('kinetic-mountains');
expect(registry.get('resonator')?.biome).toBe('kinetic-mountains');
expect(registry.get('symbiote')?.biome).toBe('verdant-forests');
expect(registry.get('mimic')?.biome).toBe('verdant-forests');
expect(registry.get('spore-bearer')?.biome).toBe('verdant-forests');
});
it('each biome has exactly 3 species', () => {
const all = registry.getAll();
const byBiome = new Map<string, number>();
for (const s of all) {
byBiome.set(s.biome, (byBiome.get(s.biome) ?? 0) + 1);
}
expect(byBiome.get('catalytic-wastes')).toBe(3);
expect(byBiome.get('kinetic-mountains')).toBe(3);
expect(byBiome.get('verdant-forests')).toBe(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);
});
});