import { describe, it, expect, beforeEach } from 'vitest'; import { createWorld, addEntity, addComponent, query } from 'bitecs'; import { Position, Velocity, Health, SpriteRef, ChemicalComposition, } from '../src/ecs/components'; import { createGameWorld, updateTime, type World, } from '../src/ecs/world'; import { movementSystem, bounceSystem, } from '../src/ecs/systems/movement'; import { healthSystem, applyDamage, applyHealing, } from '../src/ecs/systems/health'; import { createGameEntity, removeGameEntity, } from '../src/ecs/factory'; // ─── World ────────────────────────────────────────────────────── describe('World', () => { it('creates a game world with zeroed time', () => { const gw = createGameWorld(); expect(gw.world).toBeDefined(); expect(gw.time.delta).toBe(0); expect(gw.time.elapsed).toBe(0); expect(gw.time.tick).toBe(0); }); it('updates time tracking correctly', () => { const gw = createGameWorld(); updateTime(gw, 16.67); expect(gw.time.delta).toBeCloseTo(16.67); expect(gw.time.elapsed).toBeCloseTo(16.67); expect(gw.time.tick).toBe(1); updateTime(gw, 16.67); expect(gw.time.delta).toBeCloseTo(16.67); expect(gw.time.elapsed).toBeCloseTo(33.34); expect(gw.time.tick).toBe(2); }); it('tracks varying delta times', () => { const gw = createGameWorld(); updateTime(gw, 10); updateTime(gw, 20); updateTime(gw, 30); expect(gw.time.delta).toBe(30); expect(gw.time.elapsed).toBe(60); expect(gw.time.tick).toBe(3); }); }); // ─── Components ───────────────────────────────────────────────── describe('Components', () => { let world: World; beforeEach(() => { world = createWorld(); }); it('stores Position data for entities', () => { const eid = addEntity(world); addComponent(world, eid, Position); Position.x[eid] = 100; Position.y[eid] = 200; expect(Position.x[eid]).toBe(100); expect(Position.y[eid]).toBe(200); }); it('stores Velocity data for entities', () => { const eid = addEntity(world); addComponent(world, eid, Velocity); Velocity.vx[eid] = 50; Velocity.vy[eid] = -30; expect(Velocity.vx[eid]).toBe(50); expect(Velocity.vy[eid]).toBe(-30); }); it('stores Health data for entities', () => { const eid = addEntity(world); addComponent(world, eid, Health); Health.current[eid] = 80; Health.max[eid] = 100; expect(Health.current[eid]).toBe(80); expect(Health.max[eid]).toBe(100); }); it('stores SpriteRef data for entities', () => { const eid = addEntity(world); addComponent(world, eid, SpriteRef); SpriteRef.color[eid] = 0x00ff88; SpriteRef.radius[eid] = 12; expect(SpriteRef.color[eid]).toBe(0x00ff88); expect(SpriteRef.radius[eid]).toBe(12); }); it('stores ChemicalComposition for entities', () => { const eid = addEntity(world); addComponent(world, eid, ChemicalComposition); ChemicalComposition.primaryElement[eid] = 11; // Na expect(ChemicalComposition.primaryElement[eid]).toBe(11); }); it('queries entities by single component', () => { const e1 = addEntity(world); const e2 = addEntity(world); addComponent(world, e1, Position); addComponent(world, e2, Position); const entities = [...query(world, [Position])]; expect(entities).toContain(e1); expect(entities).toContain(e2); }); it('queries entities by multiple components', () => { const moving = addEntity(world); const stationary = addEntity(world); addComponent(world, moving, Position); addComponent(world, moving, Velocity); addComponent(world, stationary, Position); const movingEntities = [...query(world, [Position, Velocity])]; expect(movingEntities).toContain(moving); expect(movingEntities).not.toContain(stationary); }); }); // ─── Movement System ──────────────────────────────────────────── describe('Movement System', () => { let world: World; beforeEach(() => { world = createWorld(); }); it('updates position by velocity * delta', () => { const eid = addEntity(world); addComponent(world, eid, Position); addComponent(world, eid, Velocity); Position.x[eid] = 100; Position.y[eid] = 200; Velocity.vx[eid] = 60; Velocity.vy[eid] = -30; movementSystem(world, 1000); // 1 second expect(Position.x[eid]).toBeCloseTo(160); expect(Position.y[eid]).toBeCloseTo(170); }); it('handles fractional delta (16.67ms ≈ 60fps)', () => { const eid = addEntity(world); addComponent(world, eid, Position); addComponent(world, eid, Velocity); Position.x[eid] = 0; Position.y[eid] = 0; Velocity.vx[eid] = 100; Velocity.vy[eid] = 100; movementSystem(world, 16.67); expect(Position.x[eid]).toBeCloseTo(1.667, 1); expect(Position.y[eid]).toBeCloseTo(1.667, 1); }); it('does not move entities without Velocity', () => { const eid = addEntity(world); addComponent(world, eid, Position); Position.x[eid] = 100; Position.y[eid] = 200; movementSystem(world, 1000); expect(Position.x[eid]).toBe(100); expect(Position.y[eid]).toBe(200); }); it('handles negative velocities', () => { const eid = addEntity(world); addComponent(world, eid, Position); addComponent(world, eid, Velocity); Position.x[eid] = 500; Position.y[eid] = 500; Velocity.vx[eid] = -100; Velocity.vy[eid] = -200; movementSystem(world, 1000); expect(Position.x[eid]).toBeCloseTo(400); expect(Position.y[eid]).toBeCloseTo(300); }); it('handles zero delta', () => { const eid = addEntity(world); addComponent(world, eid, Position); addComponent(world, eid, Velocity); Position.x[eid] = 100; Position.y[eid] = 200; Velocity.vx[eid] = 999; Velocity.vy[eid] = 999; movementSystem(world, 0); expect(Position.x[eid]).toBe(100); expect(Position.y[eid]).toBe(200); }); it('moves multiple entities independently', () => { const e1 = addEntity(world); const e2 = addEntity(world); addComponent(world, e1, Position); addComponent(world, e1, Velocity); addComponent(world, e2, Position); addComponent(world, e2, Velocity); Position.x[e1] = 0; Position.y[e1] = 0; Velocity.vx[e1] = 100; Velocity.vy[e1] = 0; Position.x[e2] = 100; Position.y[e2] = 100; Velocity.vx[e2] = 0; Velocity.vy[e2] = -50; movementSystem(world, 1000); expect(Position.x[e1]).toBeCloseTo(100); expect(Position.y[e1]).toBeCloseTo(0); expect(Position.x[e2]).toBeCloseTo(100); expect(Position.y[e2]).toBeCloseTo(50); }); }); // ─── Bounce System ────────────────────────────────────────────── describe('Bounce System', () => { let world: World; const WIDTH = 1280; const HEIGHT = 720; beforeEach(() => { world = createWorld(); }); it('bounces at left boundary (x < 0)', () => { const eid = addEntity(world); addComponent(world, eid, Position); addComponent(world, eid, Velocity); Position.x[eid] = -5; Position.y[eid] = 100; Velocity.vx[eid] = -100; Velocity.vy[eid] = 0; bounceSystem(world, WIDTH, HEIGHT); expect(Position.x[eid]).toBe(0); expect(Velocity.vx[eid]).toBe(100); }); it('bounces at right boundary (x > width)', () => { const eid = addEntity(world); addComponent(world, eid, Position); addComponent(world, eid, Velocity); Position.x[eid] = WIDTH + 5; Position.y[eid] = 100; Velocity.vx[eid] = 100; Velocity.vy[eid] = 0; bounceSystem(world, WIDTH, HEIGHT); expect(Position.x[eid]).toBe(WIDTH); expect(Velocity.vx[eid]).toBe(-100); }); it('bounces at top boundary (y < 0)', () => { const eid = addEntity(world); addComponent(world, eid, Position); addComponent(world, eid, Velocity); Position.x[eid] = 100; Position.y[eid] = -10; Velocity.vx[eid] = 0; Velocity.vy[eid] = -50; bounceSystem(world, WIDTH, HEIGHT); expect(Position.y[eid]).toBe(0); expect(Velocity.vy[eid]).toBe(50); }); it('bounces at bottom boundary (y > height)', () => { const eid = addEntity(world); addComponent(world, eid, Position); addComponent(world, eid, Velocity); Position.x[eid] = 100; Position.y[eid] = HEIGHT + 10; Velocity.vx[eid] = 0; Velocity.vy[eid] = 200; bounceSystem(world, WIDTH, HEIGHT); expect(Position.y[eid]).toBe(HEIGHT); expect(Velocity.vy[eid]).toBe(-200); }); it('does not affect entities within bounds', () => { const eid = addEntity(world); addComponent(world, eid, Position); addComponent(world, eid, Velocity); Position.x[eid] = 640; Position.y[eid] = 360; Velocity.vx[eid] = 100; Velocity.vy[eid] = -50; bounceSystem(world, WIDTH, HEIGHT); expect(Position.x[eid]).toBe(640); expect(Position.y[eid]).toBe(360); expect(Velocity.vx[eid]).toBe(100); expect(Velocity.vy[eid]).toBe(-50); }); it('handles corner bounce (both axes out of bounds)', () => { const eid = addEntity(world); addComponent(world, eid, Position); addComponent(world, eid, Velocity); Position.x[eid] = -5; Position.y[eid] = -10; Velocity.vx[eid] = -100; Velocity.vy[eid] = -200; bounceSystem(world, WIDTH, HEIGHT); expect(Position.x[eid]).toBe(0); expect(Position.y[eid]).toBe(0); expect(Velocity.vx[eid]).toBe(100); expect(Velocity.vy[eid]).toBe(200); }); }); // ─── Health System ────────────────────────────────────────────── describe('Health System', () => { let world: World; beforeEach(() => { world = createWorld(); }); it('detects entities with health = 0', () => { const eid = addEntity(world); addComponent(world, eid, Health); Health.current[eid] = 0; Health.max[eid] = 100; const dead = healthSystem(world); expect(dead).toContain(eid); }); it('detects entities with negative health', () => { const eid = addEntity(world); addComponent(world, eid, Health); Health.current[eid] = -50; Health.max[eid] = 100; const dead = healthSystem(world); expect(dead).toContain(eid); }); it('does not flag healthy entities', () => { const eid = addEntity(world); addComponent(world, eid, Health); Health.current[eid] = 50; Health.max[eid] = 100; const dead = healthSystem(world); expect(dead).not.toContain(eid); }); it('returns empty array when all entities are healthy', () => { const e1 = addEntity(world); const e2 = addEntity(world); addComponent(world, e1, Health); addComponent(world, e2, Health); Health.current[e1] = 100; Health.max[e1] = 100; Health.current[e2] = 1; Health.max[e2] = 100; const dead = healthSystem(world); expect(dead).toHaveLength(0); }); it('returns multiple dead entities', () => { const e1 = addEntity(world); const e2 = addEntity(world); const e3 = addEntity(world); addComponent(world, e1, Health); addComponent(world, e2, Health); addComponent(world, e3, Health); Health.current[e1] = 0; Health.max[e1] = 100; Health.current[e2] = -10; Health.max[e2] = 50; Health.current[e3] = 50; Health.max[e3] = 100; const dead = healthSystem(world); expect(dead).toContain(e1); expect(dead).toContain(e2); expect(dead).not.toContain(e3); expect(dead).toHaveLength(2); }); }); // ─── Damage & Healing ─────────────────────────────────────────── describe('Damage and Healing', () => { let world: World; beforeEach(() => { world = createWorld(); }); it('applies damage correctly', () => { const eid = addEntity(world); addComponent(world, eid, Health); Health.current[eid] = 100; Health.max[eid] = 100; applyDamage(eid, 30); expect(Health.current[eid]).toBe(70); }); it('allows overkill (negative health)', () => { const eid = addEntity(world); addComponent(world, eid, Health); Health.current[eid] = 10; Health.max[eid] = 100; applyDamage(eid, 50); expect(Health.current[eid]).toBe(-40); }); it('applies healing correctly', () => { const eid = addEntity(world); addComponent(world, eid, Health); Health.current[eid] = 50; Health.max[eid] = 100; applyHealing(eid, 30); expect(Health.current[eid]).toBe(80); }); it('caps healing at max health', () => { const eid = addEntity(world); addComponent(world, eid, Health); Health.current[eid] = 90; Health.max[eid] = 100; applyHealing(eid, 50); expect(Health.current[eid]).toBe(100); }); it('full damage → heal → kill lifecycle', () => { const eid = addEntity(world); addComponent(world, eid, Health); Health.current[eid] = 100; Health.max[eid] = 100; applyDamage(eid, 60); expect(Health.current[eid]).toBe(40); applyHealing(eid, 20); expect(Health.current[eid]).toBe(60); applyDamage(eid, 70); expect(Health.current[eid]).toBe(-10); const dead = healthSystem(world); expect(dead).toContain(eid); }); }); // ─── Entity Factory ───────────────────────────────────────────── describe('Entity Factory', () => { let world: World; beforeEach(() => { world = createWorld(); }); it('creates entity with position only', () => { const eid = createGameEntity(world, { position: { x: 100, y: 200 }, }); expect(typeof eid).toBe('number'); const entities = [...query(world, [Position])]; expect(entities).toContain(eid); expect(Position.x[eid]).toBe(100); expect(Position.y[eid]).toBe(200); }); it('creates entity with all components', () => { const eid = createGameEntity(world, { position: { x: 10, y: 20 }, velocity: { vx: 30, vy: 40 }, health: { current: 80, max: 100 }, sprite: { color: 0xff0000, radius: 8 }, chemicalElement: 26, // Fe }); expect(Position.x[eid]).toBe(10); expect(Position.y[eid]).toBe(20); expect(Velocity.vx[eid]).toBe(30); expect(Velocity.vy[eid]).toBe(40); expect(Health.current[eid]).toBe(80); expect(Health.max[eid]).toBe(100); expect(SpriteRef.color[eid]).toBe(0xff0000); expect(SpriteRef.radius[eid]).toBe(8); expect(ChemicalComposition.primaryElement[eid]).toBe(26); }); it('creates multiple independent entities', () => { const e1 = createGameEntity(world, { position: { x: 0, y: 0 }, }); const e2 = createGameEntity(world, { position: { x: 100, y: 100 }, }); expect(e1).not.toBe(e2); expect(Position.x[e1]).toBe(0); expect(Position.x[e2]).toBe(100); }); it('removes entity from world', () => { const eid = createGameEntity(world, { position: { x: 100, y: 200 }, health: { current: 50, max: 100 }, }); expect([...query(world, [Position])]).toContain(eid); removeGameEntity(world, eid); expect([...query(world, [Position])]).not.toContain(eid); }); it('creates entity without any components', () => { const eid = createGameEntity(world, {}); expect(typeof eid).toBe('number'); }); }); // ─── Integration ──────────────────────────────────────────────── describe('Integration', () => { it('full lifecycle: create → move → damage → die → remove', () => { const { world } = createGameWorld(); const eid = createGameEntity(world, { position: { x: 100, y: 100 }, velocity: { vx: 200, vy: 0 }, health: { current: 50, max: 100 }, }); // Move for 1 second movementSystem(world, 1000); expect(Position.x[eid]).toBeCloseTo(300); // Lethal damage applyDamage(eid, 60); expect(Health.current[eid]).toBe(-10); // Health system detects death const dead = healthSystem(world); expect(dead).toContain(eid); // Remove dead entity removeGameEntity(world, eid); expect([...query(world, [Health])]).not.toContain(eid); }); it('movement + bounce in sequence', () => { const { world } = createGameWorld(); const eid = createGameEntity(world, { position: { x: 1270, y: 360 }, velocity: { vx: 100, vy: 0 }, }); // Move past right boundary (1270 + 100*0.5 = 1320 > 1280) movementSystem(world, 500); expect(Position.x[eid]).toBeCloseTo(1320); // Bounce should clamp and reverse bounceSystem(world, 1280, 720); expect(Position.x[eid]).toBe(1280); expect(Velocity.vx[eid]).toBe(-100); // Next frame: move away from boundary movementSystem(world, 1000); expect(Position.x[eid]).toBeCloseTo(1180); }); });