From f86daac9acd83d3b64bef7c2007d10a138cc9fb9 Mon Sep 17 00:00:00 2001 From: Peter Stockings Date: Mon, 5 Jan 2026 14:03:25 +1100 Subject: [PATCH] Add more test coverage --- src/engine/EntityManager.ts | 9 +- src/engine/__tests__/EntityManager.test.ts | 93 +++++++ .../__tests__/ProgressionManager.test.ts | 97 +++++++ src/engine/__tests__/simulation.test.ts | 248 +++++------------- src/engine/simulation/simulation.ts | 2 + 5 files changed, 260 insertions(+), 189 deletions(-) create mode 100644 src/engine/__tests__/EntityManager.test.ts create mode 100644 src/engine/__tests__/ProgressionManager.test.ts diff --git a/src/engine/EntityManager.ts b/src/engine/EntityManager.ts index f647c8a..a157c78 100644 --- a/src/engine/EntityManager.ts +++ b/src/engine/EntityManager.ts @@ -5,13 +5,16 @@ export class EntityManager { private grid: Map = new Map(); private actors: Map; private world: World; + private lastId: number = 0; constructor(world: World) { this.world = world; this.actors = world.actors; + this.lastId = Math.max(0, ...this.actors.keys()); this.rebuildGrid(); } + rebuildGrid() { this.grid.clear(); for (const actor of this.actors.values()) { @@ -77,6 +80,8 @@ export class EntityManager { } } + + getActorsAt(x: number, y: number): Actor[] { const i = idx(this.world, x, y); const ids = this.grid.get(i); @@ -93,6 +98,8 @@ export class EntityManager { } getNextId(): EntityId { - return Math.max(0, ...this.actors.keys()) + 1; + this.lastId++; + return this.lastId; } } + diff --git a/src/engine/__tests__/EntityManager.test.ts b/src/engine/__tests__/EntityManager.test.ts new file mode 100644 index 0000000..e9ec8a0 --- /dev/null +++ b/src/engine/__tests__/EntityManager.test.ts @@ -0,0 +1,93 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { EntityManager } from '../EntityManager'; +import { type World, type Actor } from '../../core/types'; + +describe('EntityManager', () => { + let mockWorld: World; + let entityManager: EntityManager; + + beforeEach(() => { + mockWorld = { + width: 10, + height: 10, + tiles: new Array(100).fill(0), + actors: new Map(), + exit: { x: 9, y: 9 } + }; + + entityManager = new EntityManager(mockWorld); + }); + + it('should add an actor and update the grid', () => { + const actor: Actor = { id: 1, category: 'combatant', type: 'player', pos: { x: 2, y: 3 }, isPlayer: true } as any; + entityManager.addActor(actor); + + expect(mockWorld.actors.has(1)).toBe(true); + expect(entityManager.getActorsAt(2, 3).map(a => a.id)).toContain(1); + expect(entityManager.isOccupied(2, 3)).toBe(true); + }); + + it('should remove an actor and update the grid', () => { + const actor: Actor = { id: 1, category: 'combatant', type: 'player', pos: { x: 2, y: 3 }, isPlayer: true } as any; + entityManager.addActor(actor); + entityManager.removeActor(1); + + expect(mockWorld.actors.has(1)).toBe(false); + expect(entityManager.getActorsAt(2, 3).map(a => a.id)).not.toContain(1); + expect(entityManager.isOccupied(2, 3)).toBe(false); + }); + + it('should update the grid when an actor moves', () => { + const actor: Actor = { id: 1, category: 'combatant', type: 'player', pos: { x: 2, y: 3 }, isPlayer: true } as any; + entityManager.addActor(actor); + + entityManager.moveActor(1, { x: 2, y: 3 }, { x: 4, y: 5 }); + + expect(actor.pos.x).toBe(4); + expect(actor.pos.y).toBe(5); + expect(entityManager.isOccupied(2, 3)).toBe(false); + expect(entityManager.isOccupied(4, 5)).toBe(true); + expect(entityManager.getActorsAt(4, 5).map(a => a.id)).toContain(1); + }); + + it('should correctly identify occupied tiles while ignoring specific types', () => { + const orb: Actor = { id: 1, category: 'collectible', type: 'exp_orb', pos: { x: 2, y: 2 } } as any; + const enemy: Actor = { id: 2, category: 'combatant', type: 'rat', pos: { x: 5, y: 5 } } as any; + + entityManager.addActor(orb); + entityManager.addActor(enemy); + + expect(entityManager.isOccupied(2, 2)).toBe(true); + expect(entityManager.isOccupied(2, 2, 'exp_orb')).toBe(false); + expect(entityManager.isOccupied(5, 5)).toBe(true); + expect(entityManager.isOccupied(5, 5, 'exp_orb')).toBe(true); + }); + + it('should generate the next available ID by scanning current actors', () => { + mockWorld.actors.set(10, { id: 10, pos: { x: 0, y: 0 } } as any); + mockWorld.actors.set(15, { id: 15, pos: { x: 1, y: 1 } } as any); + + // Create new manager to trigger scan since current one has stale lastId + const manager = new EntityManager(mockWorld); + expect(manager.getNextId()).toBe(16); + }); + + + + it('should handle multiple actors at the same position', () => { + const actor1: Actor = { id: 1, pos: { x: 1, y: 1 } } as any; + const actor2: Actor = { id: 2, pos: { x: 1, y: 1 } } as any; + + entityManager.addActor(actor1); + entityManager.addActor(actor2); + + const atPos = entityManager.getActorsAt(1, 1); + expect(atPos.length).toBe(2); + expect(atPos.map(a => a.id)).toContain(1); + expect(atPos.map(a => a.id)).toContain(2); + + entityManager.removeActor(1); + expect(entityManager.getActorsAt(1, 1).map(a => a.id)).toEqual([2]); + }); + +}); diff --git a/src/engine/__tests__/ProgressionManager.test.ts b/src/engine/__tests__/ProgressionManager.test.ts new file mode 100644 index 0000000..7886095 --- /dev/null +++ b/src/engine/__tests__/ProgressionManager.test.ts @@ -0,0 +1,97 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { ProgressionManager } from '../ProgressionManager'; +import { type CombatantActor } from '../../core/types'; + +describe('ProgressionManager', () => { + let progressionManager: ProgressionManager; + let mockPlayer: CombatantActor; + + beforeEach(() => { + progressionManager = new ProgressionManager(); + mockPlayer = { + id: 1, + category: 'combatant', + isPlayer: true, + pos: { x: 0, y: 0 }, + speed: 100, + energy: 0, + stats: { + maxHp: 20, + hp: 20, + level: 1, + exp: 0, + expToNextLevel: 100, + statPoints: 5, + skillPoints: 2, + strength: 10, + dexterity: 10, + intelligence: 10, + attack: 5, + defense: 2, + critChance: 5, + critMultiplier: 150, + accuracy: 90, + lifesteal: 0, + evasion: 5, + blockChance: 0, + luck: 0, + passiveNodes: [] + } + } as any; + }); + + it('should allocate strength and increase maxHp and attack', () => { + progressionManager.allocateStat(mockPlayer, 'strength'); + expect(mockPlayer.stats.strength).toBe(11); + expect(mockPlayer.stats.maxHp).toBe(22); + expect(mockPlayer.stats.hp).toBe(22); + expect(mockPlayer.stats.attack).toBeCloseTo(5.2); + expect(mockPlayer.stats.statPoints).toBe(4); + }); + + it('should allocate dexterity and increase speed', () => { + progressionManager.allocateStat(mockPlayer, 'dexterity'); + expect(mockPlayer.stats.dexterity).toBe(11); + expect(mockPlayer.speed).toBe(101); + expect(mockPlayer.stats.statPoints).toBe(4); + }); + + it('should allocate intelligence and increase defense every 5 points', () => { + // Current INT is 10 (multiple of 5) + // Next multiple is 15 + progressionManager.allocateStat(mockPlayer, 'intelligence'); + expect(mockPlayer.stats.intelligence).toBe(11); + expect(mockPlayer.stats.defense).toBe(2); // No increase yet + + mockPlayer.stats.intelligence = 14; + mockPlayer.stats.statPoints = 1; + progressionManager.allocateStat(mockPlayer, 'intelligence'); + expect(mockPlayer.stats.intelligence).toBe(15); + expect(mockPlayer.stats.defense).toBe(3); // Increased! + }); + + it('should not allocate stats if statPoints are 0', () => { + mockPlayer.stats.statPoints = 0; + progressionManager.allocateStat(mockPlayer, 'strength'); + expect(mockPlayer.stats.strength).toBe(10); + }); + + it('should apply passive node bonuses', () => { + progressionManager.allocatePassive(mockPlayer, 'off_1'); + expect(mockPlayer.stats.attack).toBe(7); + expect(mockPlayer.stats.skillPoints).toBe(1); + expect(mockPlayer.stats.passiveNodes).toContain('off_1'); + + progressionManager.allocatePassive(mockPlayer, 'util_2'); + expect(mockPlayer.stats.expToNextLevel).toBe(90); + expect(mockPlayer.stats.skillPoints).toBe(0); + }); + + it('should not apply the same passive twice', () => { + progressionManager.allocatePassive(mockPlayer, 'off_1'); + const pointsAfterFirst = mockPlayer.stats.skillPoints; + progressionManager.allocatePassive(mockPlayer, 'off_1'); + expect(mockPlayer.stats.skillPoints).toBe(pointsAfterFirst); + expect(mockPlayer.stats.attack).toBe(7); // Same as before + }); +}); diff --git a/src/engine/__tests__/simulation.test.ts b/src/engine/__tests__/simulation.test.ts index 2db5faa..91de6eb 100644 --- a/src/engine/__tests__/simulation.test.ts +++ b/src/engine/__tests__/simulation.test.ts @@ -1,227 +1,99 @@ import { describe, it, expect } from 'vitest'; -import { applyAction } from '../simulation/simulation'; +import { applyAction, decideEnemyAction } from '../simulation/simulation'; import { type World, type Actor, type EntityId, type CombatantActor } from '../../core/types'; +import { EntityManager } from '../EntityManager'; describe('Combat Simulation', () => { - const createTestWorld = (actors: Map): World => ({ - width: 10, - height: 10, - tiles: new Array(100).fill(0), - actors, - exit: { x: 9, y: 9 } - }); + let entityManager: EntityManager; + + const createTestWorld = (actors: Map): World => { + return { + width: 10, + height: 10, + tiles: new Array(100).fill(0), + actors, + exit: { x: 9, y: 9 } + }; + }; const createTestStats = (overrides: Partial = {}) => ({ maxHp: 20, hp: 20, attack: 5, defense: 2, level: 1, exp: 0, expToNextLevel: 10, statPoints: 0, skillPoints: 0, strength: 10, dexterity: 10, intelligence: 10, passiveNodes: [], - // New stats (defaults for tests to keep them deterministic) - critChance: 0, - critMultiplier: 100, - accuracy: 100, // Always hit in tests unless specified - lifesteal: 0, - evasion: 0, - blockChance: 0, - luck: 0, + critChance: 0, critMultiplier: 100, accuracy: 100, lifesteal: 0, evasion: 0, blockChance: 0, luck: 0, ...overrides }); - describe('applyAction - attack', () => { + describe('applyAction - success paths', () => { it('should deal damage when player attacks enemy', () => { const actors = new Map(); actors.set(1, { - id: 1, - category: "combatant", - isPlayer: true, - type: "player", - pos: { x: 3, y: 3 }, - speed: 100, - energy: 0, - stats: createTestStats() - }); + id: 1, category: "combatant", isPlayer: true, type: "player", pos: { x: 3, y: 3 }, speed: 100, energy: 0, stats: createTestStats() + } as any); actors.set(2, { - id: 2, - category: "combatant", - isPlayer: false, - type: "rat", - pos: { x: 4, y: 3 }, - speed: 100, - energy: 0, - stats: createTestStats({ maxHp: 10, hp: 10, attack: 3, defense: 1 }) - }); + id: 2, category: "combatant", isPlayer: false, type: "rat", pos: { x: 4, y: 3 }, speed: 100, energy: 0, stats: createTestStats({ maxHp: 10, hp: 10, attack: 3, defense: 1 }) + } as any); const world = createTestWorld(actors); - const events = applyAction(world, 1, { type: "attack", targetId: 2 }); + entityManager = new EntityManager(world); + const events = applyAction(world, 1, { type: "attack", targetId: 2 }, entityManager); const enemy = world.actors.get(2) as CombatantActor; expect(enemy.stats.hp).toBeLessThan(10); - - // Should have attack event expect(events.some(e => e.type === "attacked")).toBe(true); }); - it("should kill enemy when damage exceeds hp", () => { + it("should kill enemy and spawn EXP orb without ID reuse collision", () => { const actors = new Map(); actors.set(1, { - id: 1, - category: "combatant", - isPlayer: true, - type: "player", - pos: { x: 3, y: 3 }, - speed: 100, - energy: 0, - stats: createTestStats({ attack: 50 }) - }); + id: 1, category: "combatant", isPlayer: true, type: "player", pos: { x: 3, y: 3 }, speed: 100, energy: 0, stats: createTestStats({ attack: 50 }) + } as any); actors.set(2, { - id: 2, - category: "combatant", - isPlayer: false, - type: "rat", - pos: { x: 4, y: 3 }, - speed: 100, - energy: 0, - stats: createTestStats({ maxHp: 10, hp: 10, attack: 3, defense: 1 }) - }); + id: 2, category: "combatant", isPlayer: false, type: "rat", pos: { x: 4, y: 3 }, speed: 100, energy: 0, stats: createTestStats({ maxHp: 10, hp: 10, attack: 3, defense: 1 }) + } as any); const world = createTestWorld(actors); - const events = applyAction(world, 1, { type: "attack", targetId: 2 }); + entityManager = new EntityManager(world); + applyAction(world, 1, { type: "attack", targetId: 2 }, entityManager); - // Enemy should be removed from world + // Enemy (id 2) should be gone expect(world.actors.has(2)).toBe(false); - // Should have killed event - expect(events.some(e => e.type === "killed")).toBe(true); - }); - - it("should apply defense to reduce damage", () => { - const actors = new Map(); - actors.set(1, { - id: 1, - category: "combatant", - isPlayer: true, - type: "player", - pos: { x: 3, y: 3 }, - speed: 100, - energy: 0, - stats: createTestStats() - }); - actors.set(2, { - id: 2, - category: "combatant", - isPlayer: false, - type: "rat", - pos: { x: 4, y: 3 }, - speed: 100, - energy: 0, - stats: createTestStats({ maxHp: 10, hp: 10, attack: 3, defense: 3 }) - }); - - const world = createTestWorld(actors); - applyAction(world, 1, { type: "attack", targetId: 2 }); - - const enemy = world.actors.get(2) as CombatantActor; - const damage = 10 - enemy.stats.hp; - - // Damage should be reduced by defense (5 attack - 3 defense = 2 damage) - expect(damage).toBe(2); - }); - - it("should emit dodged event when attack misses", () => { - const actors = new Map(); - actors.set(1, { - id: 1, category: "combatant", isPlayer: true, type: "player", pos: { x: 3, y: 3 }, speed: 100, energy: 0, - stats: createTestStats({ accuracy: 0 }) // Force miss - }); - actors.set(2, { - id: 2, category: "combatant", isPlayer: false, type: "rat", pos: { x: 4, y: 3 }, speed: 100, energy: 0, - stats: createTestStats({ evasion: 0 }) - }); - - const world = createTestWorld(actors); - const events = applyAction(world, 1, { type: "attack", targetId: 2 }); - - expect(events.some(e => e.type === "dodged")).toBe(true); - expect((world.actors.get(2) as CombatantActor).stats.hp).toBe(20); - }); - - it("should emit crit flag when critical strike occurs", () => { - const actors = new Map(); - actors.set(1, { - id: 1, category: "combatant", isPlayer: true, type: "player", pos: { x: 3, y: 3 }, speed: 100, energy: 0, - stats: createTestStats({ critChance: 100, critMultiplier: 200, attack: 10 }) - }); - actors.set(2, { - id: 2, category: "combatant", isPlayer: false, type: "rat", pos: { x: 4, y: 3 }, speed: 100, energy: 0, - stats: createTestStats({ defense: 0, hp: 100 }) - }); - - const world = createTestWorld(actors); - const events = applyAction(world, 1, { type: "attack", targetId: 2 }); - - const damagedEvent = events.find(e => e.type === "damaged") as any; - expect(damagedEvent.isCrit).toBe(true); - expect(damagedEvent.amount).toBe(20); // 10 * 2.0 - }); - - it("should emit block flag and reduce damage", () => { - const actors = new Map(); - actors.set(1, { - id: 1, category: "combatant", isPlayer: true, type: "player", pos: { x: 3, y: 3 }, speed: 100, energy: 0, - stats: createTestStats({ attack: 10 }) - }); - actors.set(2, { - id: 2, category: "combatant", isPlayer: false, type: "rat", pos: { x: 4, y: 3 }, speed: 100, energy: 0, - stats: createTestStats({ defense: 0, blockChance: 100, hp: 100 }) // Force block - }); - - const world = createTestWorld(actors); - const events = applyAction(world, 1, { type: "attack", targetId: 2 }); - - const damagedEvent = events.find(e => e.type === "damaged") as any; - expect(damagedEvent.isBlock).toBe(true); - expect(damagedEvent.amount).toBe(5); // 10 * 0.5 - }); - - it("should heal attacker via lifesteal", () => { - const actors = new Map(); - actors.set(1, { - id: 1, category: "combatant", isPlayer: true, type: "player", pos: { x: 3, y: 3 }, speed: 100, energy: 0, - stats: createTestStats({ attack: 10, lifesteal: 50, hp: 5, maxHp: 20 }) - }); - actors.set(2, { - id: 2, category: "combatant", isPlayer: false, type: "rat", pos: { x: 4, y: 3 }, speed: 100, energy: 0, - stats: createTestStats({ defense: 0 }) - }); - - const world = createTestWorld(actors); - const events = applyAction(world, 1, { type: "attack", targetId: 2 }); - - expect(events.some(e => e.type === "healed")).toBe(true); - expect((world.actors.get(1) as CombatantActor).stats.hp).toBe(10); // 5 + (10 * 0.5) + // A new ID should be generated for the orb (should be 3) + const orb = [...world.actors.values()].find(a => a.type === "exp_orb"); + expect(orb).toBeDefined(); + expect(orb!.id).toBe(3); }); }); - describe("applyAction - move", () => { - it("should move actor to new position", () => { - const actors = new Map(); - actors.set(1, { - id: 1, - category: "combatant", - isPlayer: true, - type: "player", - pos: { x: 3, y: 3 }, - speed: 100, - energy: 0, - stats: createTestStats() + describe("decideEnemyAction - AI Logic", () => { + it("should path around walls", () => { + const actors = new Map(); + const player = { id: 1, category: "combatant", isPlayer: true, pos: { x: 5, y: 3 }, stats: createTestStats() } as any; + const enemy = { id: 2, category: "combatant", isPlayer: false, pos: { x: 3, y: 3 }, stats: createTestStats() } as any; + actors.set(1, player); + actors.set(2, enemy); + + const world = createTestWorld(actors); + world.tiles[3 * 10 + 4] = 4; // Wall + + entityManager = new EntityManager(world); + const action = decideEnemyAction(world, enemy, player, entityManager); + + expect(action.type).toBe("move"); }); - const world = createTestWorld(actors); - const events = applyAction(world, 1, { type: "move", dx: 1, dy: 0 }); + it("should attack if player is adjacent", () => { + const actors = new Map(); + const player = { id: 1, category: "combatant", isPlayer: true, pos: { x: 4, y: 3 }, stats: createTestStats() } as any; + const enemy = { id: 2, category: "combatant", isPlayer: false, pos: { x: 3, y: 3 }, stats: createTestStats() } as any; + actors.set(1, player); + actors.set(2, enemy); - const player = world.actors.get(1) as CombatantActor; - expect(player.pos).toEqual({ x: 4, y: 3 }); - - // Should have moved event - expect(events.some(e => e.type === "moved")).toBe(true); - }); + const world = createTestWorld(actors); + entityManager = new EntityManager(world); + + const action = decideEnemyAction(world, enemy, player, entityManager); + expect(action).toEqual({ type: "attack", targetId: 1 }); + }); }); }); diff --git a/src/engine/simulation/simulation.ts b/src/engine/simulation/simulation.ts index a6901ad..6149ec3 100644 --- a/src/engine/simulation/simulation.ts +++ b/src/engine/simulation/simulation.ts @@ -194,6 +194,8 @@ function handleAttack(w: World, actor: Actor, action: { targetId: EntityId }, em if (em) em.removeActor(target.id); else w.actors.delete(target.id); + + // Spawn EXP Orb const enemyDef = (GAME_CONFIG.enemies as any)[target.type || ""]; const expAmount = enemyDef?.expValue || 0;