import { describe, it, expect, beforeEach } from 'vitest'; import { createWorld, addEntity, addComponent, query } from 'bitecs'; import type { World } from '../src/ecs/world'; import { Position, Health, Creature, AI, Metabolism, LifeCycle, Projectile, PlayerTag, SpriteRef, Velocity, } from '../src/ecs/components'; import { SpeciesId, AIState, LifeStage } from '../src/creatures/types'; import type { SpeciesData, CreatureInfo } from '../src/creatures/types'; import { createCreatureEntity } from '../src/creatures/factory'; import { creatureProjectileSystem, getObservableCreatures, creatureAttackPlayerSystem, } from '../src/creatures/interaction'; import type { ProjectileData } from '../src/player/projectile'; import speciesDataArray from '../src/data/creatures.json'; // ─── Helpers ───────────────────────────────────────────────────── const allSpecies = speciesDataArray as SpeciesData[]; function getSpecies(id: string): SpeciesData { const s = allSpecies.find(s => s.id === id); if (!s) throw new Error(`Species not found: ${id}`); return s; } function buildSpeciesLookup(): Map { const map = new Map(); for (const s of allSpecies) map.set(s.speciesId, s); return map; } function createProjectile(world: World, x: number, y: number, projData: Map): number { const eid = addEntity(world); addComponent(world, eid, Position); addComponent(world, eid, Projectile); addComponent(world, eid, SpriteRef); addComponent(world, eid, Velocity); Position.x[eid] = x; Position.y[eid] = y; Projectile.lifetime[eid] = 2000; SpriteRef.color[eid] = 0xffffff; SpriteRef.radius[eid] = 5; projData.set(eid, { itemId: 'Na' }); return eid; } function createPlayer(world: World, x: number, y: number): number { const eid = addEntity(world); addComponent(world, eid, Position); addComponent(world, eid, PlayerTag); addComponent(world, eid, Health); Position.x[eid] = x; Position.y[eid] = y; Health.current[eid] = 100; Health.max[eid] = 100; return eid; } // ─── Projectile-Creature Collision ─────────────────────────────── describe('Projectile-Creature Collision', () => { let world: World; const speciesLookup = buildSpeciesLookup(); beforeEach(() => { world = createWorld(); }); it('projectile hits creature within range', () => { const species = getSpecies('crystallid'); const creature = createCreatureEntity(world, species, 100, 100, LifeStage.Mature); const hpBefore = Health.current[creature]; const projData = new Map(); createProjectile(world, 105, 100, projData); // 5px away, within 20px hit radius const hits = creatureProjectileSystem(world, projData, speciesLookup); expect(hits).toHaveLength(1); expect(hits[0].creatureEid).toBe(creature); expect(hits[0].killed).toBe(false); expect(Health.current[creature]).toBeLessThan(hpBefore); }); it('projectile is removed after hitting creature', () => { const species = getSpecies('crystallid'); createCreatureEntity(world, species, 100, 100, LifeStage.Mature); const projData = new Map(); const projEid = createProjectile(world, 105, 100, projData); creatureProjectileSystem(world, projData, speciesLookup); // Projectile should be removed expect(projData.has(projEid)).toBe(false); expect([...query(world, [Projectile])]).not.toContain(projEid); }); it('armor reduces damage (Crystallid has 0.3 armor)', () => { const species = getSpecies('crystallid'); // armor = 0.3 const creature = createCreatureEntity(world, species, 100, 100, LifeStage.Mature); const projData = new Map(); createProjectile(world, 105, 100, projData); const hits = creatureProjectileSystem(world, projData, speciesLookup); // Base damage 15, armor 0.3 → 15 * 0.7 = 10.5 → rounded to 11 expect(hits[0].damage).toBe(11); }); it('Reagent takes full damage (0 armor)', () => { const species = getSpecies('reagent'); // armor = 0 const creature = createCreatureEntity(world, species, 100, 100, LifeStage.Mature); const projData = new Map(); createProjectile(world, 105, 100, projData); const hits = creatureProjectileSystem(world, projData, speciesLookup); expect(hits[0].damage).toBe(15); }); it('projectile misses creature outside range', () => { const species = getSpecies('crystallid'); createCreatureEntity(world, species, 100, 100, LifeStage.Mature); const projData = new Map(); createProjectile(world, 200, 200, projData); // far away const hits = creatureProjectileSystem(world, projData, speciesLookup); expect(hits).toHaveLength(0); }); it('passive creature flees after being hit', () => { const species = getSpecies('crystallid'); // aggressionRadius = 0 → passive const creature = createCreatureEntity(world, species, 100, 100, LifeStage.Mature); const projData = new Map(); createProjectile(world, 105, 100, projData); creatureProjectileSystem(world, projData, speciesLookup); expect(AI.state[creature]).toBe(AIState.Flee); }); it('aggressive creature attacks player after being hit', () => { const species = getSpecies('acidophile'); // aggressionRadius = 100 → aggressive const creature = createCreatureEntity(world, species, 100, 100, LifeStage.Mature); const player = createPlayer(world, 150, 100); const projData = new Map(); createProjectile(world, 105, 100, projData); creatureProjectileSystem(world, projData, speciesLookup); expect(AI.state[creature]).toBe(AIState.Attack); expect(AI.targetEid[creature]).toBe(player); }); it('killing a creature reports killed=true', () => { const species = getSpecies('reagent'); // 60 hp const creature = createCreatureEntity(world, species, 100, 100, LifeStage.Mature); Health.current[creature] = 10; // nearly dead const projData = new Map(); createProjectile(world, 105, 100, projData); const hits = creatureProjectileSystem(world, projData, speciesLookup); expect(hits[0].killed).toBe(true); }); }); // ─── Creature Observation ──────────────────────────────────────── describe('Creature Observation', () => { let world: World; beforeEach(() => { world = createWorld(); }); it('detects creature near player', () => { const species = getSpecies('crystallid'); createCreatureEntity(world, species, 100, 100, LifeStage.Mature); createPlayer(world, 120, 100); // 20px away, within 60px observe range const observations = getObservableCreatures(world); expect(observations).toHaveLength(1); expect(observations[0].speciesId).toBe(SpeciesId.Crystallid); expect(observations[0].stage).toBe(LifeStage.Mature); }); it('does not detect distant creature', () => { const species = getSpecies('crystallid'); createCreatureEntity(world, species, 500, 500, LifeStage.Mature); createPlayer(world, 100, 100); const observations = getObservableCreatures(world); expect(observations).toHaveLength(0); }); it('returns health and energy percentages', () => { const species = getSpecies('crystallid'); const eid = createCreatureEntity(world, species, 100, 100, LifeStage.Mature); Health.current[eid] = 60; // 50% of 120 Metabolism.energy[eid] = 50; // 50% of 100 createPlayer(world, 110, 100); const observations = getObservableCreatures(world); expect(observations[0].healthPercent).toBe(50); expect(observations[0].energyPercent).toBe(50); }); it('returns empty array when no player', () => { const species = getSpecies('crystallid'); createCreatureEntity(world, species, 100, 100, LifeStage.Mature); const observations = getObservableCreatures(world); expect(observations).toHaveLength(0); }); }); // ─── Creature Attacks Player ───────────────────────────────────── describe('Creature Attacks Player', () => { let world: World; const speciesLookup = buildSpeciesLookup(); beforeEach(() => { world = createWorld(); }); it('attacking creature damages player in range', () => { const species = getSpecies('reagent'); // attackRange = 18, damage = 20 const creature = createCreatureEntity(world, species, 100, 100, LifeStage.Mature); const player = createPlayer(world, 110, 100); // 10px away AI.state[creature] = AIState.Attack; AI.targetEid[creature] = player; AI.attackCooldown[creature] = 0; const damage = creatureAttackPlayerSystem(world, speciesLookup); expect(damage).toBe(20); expect(Health.current[player]).toBe(80); }); it('no damage if creature is out of range', () => { const species = getSpecies('reagent'); const creature = createCreatureEntity(world, species, 100, 100, LifeStage.Mature); const player = createPlayer(world, 200, 100); // 100px, beyond attackRange 18 AI.state[creature] = AIState.Attack; AI.targetEid[creature] = player; AI.attackCooldown[creature] = 0; const damage = creatureAttackPlayerSystem(world, speciesLookup); expect(damage).toBe(0); }); it('attack cooldown prevents rapid attacks', () => { const species = getSpecies('reagent'); const creature = createCreatureEntity(world, species, 100, 100, LifeStage.Mature); const player = createPlayer(world, 110, 100); AI.state[creature] = AIState.Attack; AI.targetEid[creature] = player; AI.attackCooldown[creature] = 500; // on cooldown const damage = creatureAttackPlayerSystem(world, speciesLookup); expect(damage).toBe(0); }); it('non-attacking creature does not damage player', () => { const species = getSpecies('crystallid'); const creature = createCreatureEntity(world, species, 100, 100, LifeStage.Mature); const player = createPlayer(world, 110, 100); AI.state[creature] = AIState.Wander; // not attacking AI.attackCooldown[creature] = 0; const damage = creatureAttackPlayerSystem(world, speciesLookup); expect(damage).toBe(0); }); });