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>
796 lines
27 KiB
TypeScript
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);
|
|
});
|
|
});
|