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:
775
tests/creatures.test.ts
Normal file
775
tests/creatures.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user