/** * Projectile System Tests — Phase 4.5 * * Tests: projectile creation, direction calculation, * lifetime expiry, tile collision removal. */ import { describe, it, expect } from 'vitest'; import { createWorld, addEntity, addComponent, query } from 'bitecs'; import { Position, Velocity, Projectile, SpriteRef } from '../src/ecs/components'; import { launchProjectile, projectileSystem, calculateDirection, type ProjectileData, PROJECTILE_SPEED, PROJECTILE_LIFETIME, } from '../src/player/projectile'; // === Direction Calculation === describe('calculateDirection', () => { it('normalizes direction vector', () => { const d = calculateDirection(0, 0, 100, 0); expect(d.dx).toBeCloseTo(1); expect(d.dy).toBeCloseTo(0); }); it('handles diagonal direction', () => { const d = calculateDirection(0, 0, 100, 100); const mag = Math.sqrt(d.dx * d.dx + d.dy * d.dy); expect(mag).toBeCloseTo(1); }); it('handles negative direction', () => { const d = calculateDirection(100, 100, 0, 0); expect(d.dx).toBeCloseTo(-1 / Math.SQRT2); expect(d.dy).toBeCloseTo(-1 / Math.SQRT2); }); it('returns zero for same position', () => { const d = calculateDirection(50, 50, 50, 50); expect(d.dx).toBe(1); // default to right expect(d.dy).toBe(0); }); }); // === Launch Projectile === describe('launchProjectile', () => { it('creates entity at source position', () => { const world = createWorld(); const projData = new Map(); const eid = launchProjectile(world, projData, 100, 200, 200, 200, 'Na'); expect(Position.x[eid]).toBe(100); expect(Position.y[eid]).toBe(200); }); it('sets velocity toward target', () => { const world = createWorld(); const projData = new Map(); const eid = launchProjectile(world, projData, 0, 0, 100, 0, 'Na'); expect(Velocity.vx[eid]).toBeCloseTo(PROJECTILE_SPEED); expect(Velocity.vy[eid]).toBeCloseTo(0); }); it('stores chemical data', () => { const world = createWorld(); const projData = new Map(); const eid = launchProjectile(world, projData, 0, 0, 100, 0, 'Na'); expect(projData.get(eid)).toBeDefined(); expect(projData.get(eid)!.itemId).toBe('Na'); }); it('sets lifetime', () => { const world = createWorld(); const projData = new Map(); const eid = launchProjectile(world, projData, 0, 0, 100, 0, 'Na'); expect(Projectile.lifetime[eid]).toBe(PROJECTILE_LIFETIME); }); it('sets sprite with element color', () => { const world = createWorld(); const projData = new Map(); const eid = launchProjectile(world, projData, 0, 0, 100, 0, 'Na'); expect(SpriteRef.radius[eid]).toBeGreaterThan(0); expect(SpriteRef.color[eid]).toBeDefined(); }); }); // === Projectile System === describe('projectileSystem', () => { it('decrements lifetime', () => { const world = createWorld(); const eid = addEntity(world); addComponent(world, eid, Position); addComponent(world, eid, Velocity); addComponent(world, eid, Projectile); Position.x[eid] = 48; Position.y[eid] = 48; Velocity.vx[eid] = 100; Velocity.vy[eid] = 0; Projectile.lifetime[eid] = 1000; const grid = [[0, 0, 0], [0, 0, 0], [0, 0, 0]]; const walkable = new Set([0]); const projData = new Map(); projData.set(eid, { itemId: 'Na' }); projectileSystem(world, 100, grid, 32, walkable, projData); expect(Projectile.lifetime[eid]).toBe(900); }); it('removes expired projectiles', () => { const world = createWorld(); const eid = addEntity(world); addComponent(world, eid, Position); addComponent(world, eid, Velocity); addComponent(world, eid, Projectile); Position.x[eid] = 48; Position.y[eid] = 48; Projectile.lifetime[eid] = 50; const grid = [[0, 0, 0], [0, 0, 0], [0, 0, 0]]; const walkable = new Set([0]); const projData = new Map(); projData.set(eid, { itemId: 'Na' }); projectileSystem(world, 100, grid, 32, walkable, projData); expect(query(world, [Projectile]).length).toBe(0); expect(projData.size).toBe(0); }); it('removes projectiles on non-walkable tile', () => { const world = createWorld(); const eid = addEntity(world); addComponent(world, eid, Position); addComponent(world, eid, Velocity); addComponent(world, eid, Projectile); // Position on non-walkable tile (1,1) = tile 3 Position.x[eid] = 48; Position.y[eid] = 48; Projectile.lifetime[eid] = 5000; const grid = [[0, 0, 0], [0, 3, 0], [0, 0, 0]]; const walkable = new Set([0]); const projData = new Map(); projData.set(eid, { itemId: 'Na' }); projectileSystem(world, 16, grid, 32, walkable, projData); expect(query(world, [Projectile]).length).toBe(0); }); it('keeps valid projectiles alive', () => { const world = createWorld(); const eid = addEntity(world); addComponent(world, eid, Position); addComponent(world, eid, Velocity); addComponent(world, eid, Projectile); Position.x[eid] = 48; Position.y[eid] = 48; Projectile.lifetime[eid] = 5000; const grid = [[0, 0, 0], [0, 0, 0], [0, 0, 0]]; const walkable = new Set([0]); const projData = new Map(); projData.set(eid, { itemId: 'Na' }); projectileSystem(world, 16, grid, 32, walkable, projData); expect(query(world, [Projectile]).length).toBe(1); expect(projData.size).toBe(1); }); });