From 4b50e341a7cd11ac38602fd11025f1f90ea880a1 Mon Sep 17 00:00:00 2001 From: Kyle Banicevic Date: Sat, 31 Jan 2026 14:56:53 +1100 Subject: [PATCH] Half changes to switch to exit level, Ran out of credits, re added enemies --- public/assets/sprites/items/track_switch.png | Bin 0 -> 896 bytes src/core/config/GameConfig.ts | 7 +- src/engine/__tests__/EntityAccessor.test.ts | 33 +- src/engine/__tests__/ai_behavior.test.ts | 109 +- src/engine/__tests__/combat_logic.test.ts | 13 +- src/engine/__tests__/pathfinding.test.ts | 29 +- src/engine/__tests__/simulation.test.ts | 1037 +++++++++-------- src/engine/__tests__/throwing.test.ts | 3 +- src/engine/__tests__/world.test.ts | 78 +- src/engine/ecs/EntityBuilder.ts | 2 +- src/engine/ecs/Prefabs.ts | 4 +- src/engine/ecs/__tests__/ECSRemoval.test.ts | 3 +- src/engine/ecs/systems/TriggerSystem.ts | 8 +- .../gameplay/__tests__/CombatLogic.test.ts | 57 +- .../__tests__/FireableWeapons.test.ts | 55 +- .../__tests__/movement_block.test.ts | 13 +- src/engine/world/generator.ts | 27 +- src/engine/world/world-logic.ts | 15 +- src/rendering/DungeonRenderer.ts | 2 +- .../__tests__/DungeonRenderer.test.ts | 11 +- .../systems/__tests__/ItemManager.test.ts | 3 +- 21 files changed, 762 insertions(+), 747 deletions(-) create mode 100644 public/assets/sprites/items/track_switch.png diff --git a/public/assets/sprites/items/track_switch.png b/public/assets/sprites/items/track_switch.png new file mode 100644 index 0000000000000000000000000000000000000000..06067aefbd17075f2f501383fd098ae5d1e2ccd6 GIT binary patch literal 896 zcmV-`1AqL9P)Px&I!Q!9R9Hvtm&;C*K^TVLng7(2JrEH{B+(5UFF;eh02l7DG?iY18?V8&@vtQY zLO>HS61@N=E{usw7hHfXNGa`qntx`@Hw62_>wc2KLo9y-JUgMIT(C%}tWYZxCZVddTT zp+X!IfB*KWe);ATCdNk~r3B|3jfM50K@1eGR_^I;FGL}qLA%{UsdNolP86$E%bM-Y^8EQCAm2LLLSipu4(;Ec>Q!azVO7;_qAyxID}=qRE{m=^8%Axa~6 zv;iP+4Rm+A#+)>=*$kADaNQKBhPJl;U~O&fAH)HGdcCfS#e$(%GC>q4kkZ`D20{p7 zp6%_QtdP&5+Y69Md&X3=+d7j;gE5XyrwwN8HC)F6w>W|zM3N{d#jv{iVKBh#?5rXY zTC~$?8{$kRhgPeFd_HdgwAM(H1lja&#yLZ?*~X2rA{ZE?zNMomLJ$P7ERI4U4=Dwt zq&8Njc5K_j^76{j3AnBUr38Ato{5=r8yg!NXDYU-sVVJwF5);g$x)3+rLnlUI2fQ- zt0~SjgoqKvF_eMka2&@ZKsDg|zIke`;QL)8$}J0yLxsknqyU(PQvDZgfD&_E7nWt2 z1RIUUV1R11s%Vl-1fn=HXGRMMAeBnN^E`xMXoR9DL>vd;yl?I}t|>f%r;E$BZD`FP zL}LD?HT#7;KR-VhfF?{&Pb-Rxc1dSWhGa}TAR+pOq|fLX2EI90YEycTOepUD&AB%x z49z;yAl-$9g`?Y$mX*t8b)KJV{p0C(&Y-crzJ5HwdCbqU_Dt1%7Nb*N#TO}l8m+&6 Whkh_n)1?dm0000 { function syncActor(actor: Actor) { ecsWorld.addComponent(actor.id, "position", actor.pos); ecsWorld.addComponent(actor.id, "name", { name: actor.id.toString() }); - + if (actor.category === "combatant") { - const c = actor as CombatantActor; - ecsWorld.addComponent(actor.id, "stats", c.stats); - ecsWorld.addComponent(actor.id, "energy", { current: c.energy, speed: c.speed }); - ecsWorld.addComponent(actor.id, "actorType", { type: c.type }); - if (c.isPlayer) { - ecsWorld.addComponent(actor.id, "player", {}); - } else { - ecsWorld.addComponent(actor.id, "ai", { state: "wandering" }); - } + const c = actor as CombatantActor; + ecsWorld.addComponent(actor.id, "stats", c.stats); + ecsWorld.addComponent(actor.id, "energy", { current: c.energy, speed: c.speed }); + ecsWorld.addComponent(actor.id, "actorType", { type: c.type }); + if (c.isPlayer) { + ecsWorld.addComponent(actor.id, "player", {}); + } else { + ecsWorld.addComponent(actor.id, "ai", { state: "wandering" }); + } } else if (actor.category === "collectible") { - ecsWorld.addComponent(actor.id, "collectible", { type: "exp_orb", amount: (actor as CollectibleActor).expAmount }); + ecsWorld.addComponent(actor.id, "collectible", { type: "exp_orb", amount: (actor as CollectibleActor).expAmount }); } else if (actor.category === "item_drop") { - ecsWorld.addComponent(actor.id, "groundItem", { item: (actor as ItemDropActor).item }); + ecsWorld.addComponent(actor.id, "groundItem", { item: (actor as ItemDropActor).item }); } } @@ -129,7 +130,7 @@ describe("EntityAccessor", () => { const pos = accessor.getPlayerPos(); expect(pos).toEqual({ x: 3, y: 4 }); - + // Verify it's a copy if (pos) { pos.x = 99; @@ -253,11 +254,11 @@ describe("EntityAccessor", () => { describe("updateWorld", () => { it("updates references correctly", () => { syncActor(createPlayer(PLAYER_ID, 1, 1)); - + const newWorld = createMockWorld(); const newEcsWorld = new ECSWorld(); const newPlayerId = 10; - + const newPlayer = createPlayer(newPlayerId, 8, 8); // Manually add to newEcsWorld newEcsWorld.addComponent(newPlayer.id, "position", newPlayer.pos); @@ -266,7 +267,7 @@ describe("EntityAccessor", () => { newEcsWorld.addComponent(newPlayer.id, "player", {}); accessor.updateWorld(newWorld, newPlayerId as EntityId, newEcsWorld); - + const player = accessor.getPlayer(); expect(player?.id).toBe(newPlayerId); expect(player?.pos).toEqual({ x: 8, y: 8 }); diff --git a/src/engine/__tests__/ai_behavior.test.ts b/src/engine/__tests__/ai_behavior.test.ts index 2169fae..7c2dc24 100644 --- a/src/engine/__tests__/ai_behavior.test.ts +++ b/src/engine/__tests__/ai_behavior.test.ts @@ -10,10 +10,11 @@ const createTestWorld = (): World => { width: 10, height: 10, tiles: new Array(100).fill(TileType.EMPTY), - exit: { x: 9, y: 9 } + exit: { x: 9, y: 9 }, + trackPath: [] }; - }; - +}; + 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: [], @@ -43,7 +44,7 @@ describe('AI Behavior & Scheduling', () => { if (c.isPlayer) { ecsWorld.addComponent(actor.id, "player", {}); } else { - ecsWorld.addComponent(actor.id, "ai", { + ecsWorld.addComponent(actor.id, "ai", { state: c.aiState || "wandering", alertedAt: c.alertedAt, lastKnownPlayerPos: c.lastKnownPlayerPos @@ -61,33 +62,33 @@ describe('AI Behavior & Scheduling', () => { it("should allow slower actors to act eventually", () => { const actors = new Map(); // Player Speed 100 - const player = { - id: 1 as EntityId, category: "combatant", isPlayer: true, pos: { x: 0, y: 0 }, - speed: 100, stats: createTestStats(), energy: 0 + const player = { + id: 1 as EntityId, category: "combatant", isPlayer: true, pos: { x: 0, y: 0 }, + speed: 100, stats: createTestStats(), energy: 0 } as any; - + // Rat Speed 80 (Slow) - const rat = { - id: 2 as EntityId, category: "combatant", isPlayer: false, pos: { x: 9, y: 9 }, - speed: 80, stats: createTestStats(), aiState: "wandering", energy: 0 + const rat = { + id: 2 as EntityId, category: "combatant", isPlayer: false, pos: { x: 9, y: 9 }, + speed: 80, stats: createTestStats(), aiState: "wandering", energy: 0 } as any; - + actors.set(1 as EntityId, player); actors.set(2 as EntityId, rat); const world = createTestWorld(); syncToECS(actors); accessor = new EntityAccessor(world, 1 as EntityId, ecsWorld); - + let ratMoves = 0; - + // Simulate 20 player turns for (let i = 0; i < 20; i++) { const result = stepUntilPlayerTurn(world, 1 as EntityId, accessor); - const enemyActs = result.events.filter(e => - (e.type === "moved" || e.type === "waited" || e.type === "enemy-alerted") && + const enemyActs = result.events.filter(e => + (e.type === "moved" || e.type === "waited" || e.type === "enemy-alerted") && ((e as any).actorId === 2 || (e as any).enemyId === 2) ); - + if (enemyActs.length > 0) ratMoves++; } expect(ratMoves).toBeGreaterThan(0); @@ -107,20 +108,20 @@ describe('AI Behavior & Scheduling', () => { terrainTypes.forEach(({ type, name }) => { it(`should see player when standing on ${name}`, () => { const actors = new Map(); - actors.set(1 as EntityId, { id: 1 as EntityId, category: "combatant", isPlayer: true, pos: { x: 5, y: 0 }, stats: createTestStats(), energy: 0 } as any); - actors.set(2 as EntityId, { - id: 2 as EntityId, category: "combatant", isPlayer: false, pos: { x: 0, y: 0 }, - stats: createTestStats(), aiState: "wandering", energy: 0 - } as any); - + actors.set(1 as EntityId, { id: 1 as EntityId, category: "combatant", isPlayer: true, pos: { x: 5, y: 0 }, stats: createTestStats(), energy: 0 } as any); + actors.set(2 as EntityId, { + id: 2 as EntityId, category: "combatant", isPlayer: false, pos: { x: 0, y: 0 }, + stats: createTestStats(), aiState: "wandering", energy: 0 + } as any); + const world = createTestWorld(); world.tiles[0] = type; syncToECS(actors); - + const testAccessor = new EntityAccessor(world, 1 as EntityId, ecsWorld); // Rat at 0,0. Player at 5,0. decideEnemyAction(world, testAccessor.getCombatant(2 as EntityId) as any, testAccessor.getCombatant(1 as EntityId) as any, testAccessor); - + const updatedRat = testAccessor.getCombatant(2 as EntityId); expect(updatedRat?.aiState).toBe("alerted"); }); @@ -132,56 +133,56 @@ describe('AI Behavior & Scheduling', () => { // ------------------------------------------------------------------------- describe('AI Aggression State Machine', () => { it('should become pursuing when damaged by player, even if not sighting player', () => { - const actors = new Map(); - // Player far away/invisible (simulated logic) - const player = { id: 1 as EntityId, category: "combatant", isPlayer: true, pos: { x: 0, y: 0 }, stats: createTestStats({ attack: 1, accuracy: 100 }), energy: 0 } as any; - const enemy = { - id: 2 as EntityId, category: "combatant", isPlayer: false, pos: { x: 0, y: 5 }, - stats: createTestStats({ hp: 10, defense: 0, evasion: 0 }), aiState: "wandering", energy: 0 - } as any; - - actors.set(1 as EntityId, player); - actors.set(2 as EntityId, enemy); - const world = createTestWorld(); - syncToECS(actors); - - const testAccessor = new EntityAccessor(world, 1 as EntityId, ecsWorld); - applyAction(world, 1 as EntityId, { type: "attack", targetId: 2 as EntityId }, testAccessor); - - const updatedEnemy = testAccessor.getCombatant(2 as EntityId); - expect(updatedEnemy?.aiState).toBe("pursuing"); - expect(updatedEnemy?.lastKnownPlayerPos).toEqual(player.pos); + const actors = new Map(); + // Player far away/invisible (simulated logic) + const player = { id: 1 as EntityId, category: "combatant", isPlayer: true, pos: { x: 0, y: 0 }, stats: createTestStats({ attack: 1, accuracy: 100 }), energy: 0 } as any; + const enemy = { + id: 2 as EntityId, category: "combatant", isPlayer: false, pos: { x: 0, y: 5 }, + stats: createTestStats({ hp: 10, defense: 0, evasion: 0 }), aiState: "wandering", energy: 0 + } as any; + + actors.set(1 as EntityId, player); + actors.set(2 as EntityId, enemy); + const world = createTestWorld(); + syncToECS(actors); + + const testAccessor = new EntityAccessor(world, 1 as EntityId, ecsWorld); + applyAction(world, 1 as EntityId, { type: "attack", targetId: 2 as EntityId }, testAccessor); + + const updatedEnemy = testAccessor.getCombatant(2 as EntityId); + expect(updatedEnemy?.aiState).toBe("pursuing"); + expect(updatedEnemy?.lastKnownPlayerPos).toEqual(player.pos); }); it("should transition from alerted to pursuing after delay even if sight is blocked", () => { const actors = new Map(); const player = { id: 1 as EntityId, category: "combatant", isPlayer: true, pos: { x: 9, y: 9 }, stats: createTestStats(), energy: 0 } as any; - const enemy = { - id: 2 as EntityId, - category: "combatant", - isPlayer: false, - pos: { x: 0, y: 0 }, + const enemy = { + id: 2 as EntityId, + category: "combatant", + isPlayer: false, + pos: { x: 0, y: 0 }, stats: createTestStats(), aiState: "alerted", alertedAt: Date.now() - 2000, // Alerted 2 seconds ago lastKnownPlayerPos: { x: 9, y: 9 }, // Known position energy: 0 } as any; - + actors.set(1 as EntityId, player); actors.set(2 as EntityId, enemy); const world = createTestWorld(); - + // Player is far away and potentially blocked world.tiles[1] = TileType.WALL; // x=1, y=0 blocked syncToECS(actors); - + const testAccessor = new EntityAccessor(world, 1 as EntityId, ecsWorld); const rat = testAccessor.getCombatant(2 as EntityId)!; decideEnemyAction(world, rat, testAccessor.getPlayer()!, testAccessor); - + // alerted -> pursuing (due to time) -> searching (due to no sight) - expect(rat.aiState).toBe("searching"); + expect(rat.aiState).toBe("searching"); }); }); }); diff --git a/src/engine/__tests__/combat_logic.test.ts b/src/engine/__tests__/combat_logic.test.ts index 5d65228..fc6f16a 100644 --- a/src/engine/__tests__/combat_logic.test.ts +++ b/src/engine/__tests__/combat_logic.test.ts @@ -7,11 +7,11 @@ import { ECSWorld } from "../ecs/World"; describe("CombatLogic - getClosestVisibleEnemy", () => { let ecsWorld: ECSWorld; - + beforeEach(() => { ecsWorld = new ECSWorld(); }); - + // Helper to create valid default stats for testing const createMockStats = () => ({ hp: 10, maxHp: 10, attack: 1, defense: 0, @@ -28,7 +28,8 @@ describe("CombatLogic - getClosestVisibleEnemy", () => { width: 10, height: 10, tiles: new Array(100).fill(0), - exit: { x: 9, y: 9 } + exit: { x: 9, y: 9 }, + trackPath: [] }; const actors = new Map(); @@ -70,7 +71,8 @@ describe("CombatLogic - getClosestVisibleEnemy", () => { width: 10, height: 10, tiles: new Array(100).fill(0), - exit: { x: 9, y: 9 } + exit: { x: 9, y: 9 }, + trackPath: [] }; const actors = new Map(); @@ -123,7 +125,8 @@ describe("CombatLogic - getClosestVisibleEnemy", () => { width: 10, height: 10, tiles: new Array(100).fill(0), - exit: { x: 9, y: 9 } + exit: { x: 9, y: 9 }, + trackPath: [] }; const actors = new Map(); diff --git a/src/engine/__tests__/pathfinding.test.ts b/src/engine/__tests__/pathfinding.test.ts index 4f73605..91edb3a 100644 --- a/src/engine/__tests__/pathfinding.test.ts +++ b/src/engine/__tests__/pathfinding.test.ts @@ -11,15 +11,16 @@ describe('Pathfinding', () => { width, height, tiles: new Array(width * height).fill(tileType), - exit: { x: 0, y: 0 } + exit: { x: 0, y: 0 }, + trackPath: [] }); it('should find a path between two reachable points', () => { const world = createTestWorld(10, 10); const seen = new Uint8Array(100).fill(1); - + const path = findPathAStar(world, seen, { x: 0, y: 0 }, { x: 0, y: 3 }); - + expect(path.length).toBe(4); // 0,0 -> 0,1 -> 0,2 -> 0,3 expect(path[0]).toEqual({ x: 0, y: 0 }); expect(path[3]).toEqual({ x: 0, y: 3 }); @@ -29,36 +30,36 @@ describe('Pathfinding', () => { const world = createTestWorld(10, 10); world.tiles[30] = TileType.WALL; // Wall at 0,3 const seen = new Uint8Array(100).fill(1); - + const path = findPathAStar(world, seen, { x: 0, y: 0 }, { x: 0, y: 3 }); - + expect(path).toEqual([]); }); it('should return empty array if no path exists', () => { const world = createTestWorld(10, 10); // Create a wall blockage - for(let x=0; x<10; x++) world.tiles[10 + x] = TileType.WALL; - + for (let x = 0; x < 10; x++) world.tiles[10 + x] = TileType.WALL; + const seen = new Uint8Array(100).fill(1); - + const path = findPathAStar(world, seen, { x: 0, y: 0 }, { x: 0, y: 5 }); - + expect(path).toEqual([]); }); it('should respect ignoreBlockedTarget option', () => { const world = createTestWorld(10, 10); const ecsWorld = new ECSWorld(); - + // Place an actor at target ecsWorld.addComponent(1 as EntityId, "position", { x: 0, y: 3 }); ecsWorld.addComponent(1 as EntityId, "actorType", { type: "rat" }); ecsWorld.addComponent(1 as EntityId, "stats", { hp: 10 } as any); - + const seen = new Uint8Array(100).fill(1); const accessor = new EntityAccessor(world, 0 as EntityId, ecsWorld); - + // With accessor, it should be blocked const pathBlocked = findPathAStar(world, seen, { x: 0, y: 0 }, { x: 0, y: 3 }, { accessor }); expect(pathBlocked).toEqual([]); @@ -72,11 +73,11 @@ describe('Pathfinding', () => { it('should respect ignoreSeen option', () => { const world = createTestWorld(10, 10); const seen = new Uint8Array(100).fill(0); // Nothing seen - + // Without ignoreSeen, should fail because target/path is unseen const pathUnseen = findPathAStar(world, seen, { x: 0, y: 0 }, { x: 0, y: 3 }); expect(pathUnseen).toEqual([]); - + // With ignoreSeen, should succeed const pathSeenIgnored = findPathAStar(world, seen, { x: 0, y: 0 }, { x: 0, y: 3 }, { ignoreSeen: true }); expect(pathSeenIgnored.length).toBe(4); diff --git a/src/engine/__tests__/simulation.test.ts b/src/engine/__tests__/simulation.test.ts index 13dbc5a..b701394 100644 --- a/src/engine/__tests__/simulation.test.ts +++ b/src/engine/__tests__/simulation.test.ts @@ -10,10 +10,11 @@ const createTestWorld = (): World => { width: 10, height: 10, tiles: new Array(100).fill(0), - exit: { x: 9, y: 9 } + exit: { x: 9, y: 9 }, + trackPath: [] }; - }; - +}; + 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: [], @@ -42,7 +43,7 @@ describe('Combat Simulation', () => { if (c.isPlayer) { ecsWorld.addComponent(actor.id, "player", {}); } else { - ecsWorld.addComponent(actor.id, "ai", { + ecsWorld.addComponent(actor.id, "ai", { state: c.aiState || "wandering", alertedAt: c.alertedAt, lastKnownPlayerPos: c.lastKnownPlayerPos @@ -57,548 +58,548 @@ describe('Combat Simulation', () => { - describe('applyAction', () => { - it('should return empty events if actor does not exist', () => { - const world = createTestWorld(); - const events = applyAction(world, 999 as EntityId, { type: "wait" }, new EntityAccessor(world, 1 as EntityId, ecsWorld)); - expect(events).toEqual([]); - }); - }); - - describe('applyAction - success paths', () => { - it('should deal damage when player attacks enemy', () => { - const actors = new Map(); - actors.set(1 as EntityId, { - id: 1, category: "combatant", isPlayer: true, type: "player", pos: { x: 3, y: 3 }, speed: 100, stats: createTestStats(), energy: 0 - } as any); - actors.set(2 as EntityId, { - id: 2, category: "combatant", isPlayer: false, type: "rat", pos: { x: 4, y: 3 }, speed: 100, stats: createTestStats({ maxHp: 10, hp: 10, attack: 3, defense: 1 }), energy: 0 - } as any); - - const world = createTestWorld(); - syncToECS(actors); - const accessor = new EntityAccessor(world, 1 as EntityId, ecsWorld); - const events = applyAction(world, 1 as EntityId, { type: "attack", targetId: 2 as EntityId }, accessor); - - const enemy = accessor.getCombatant(2 as EntityId); - expect(enemy?.stats.hp).toBeLessThan(10); - expect(events.some(e => e.type === "attacked")).toBe(true); + describe('applyAction', () => { + it('should return empty events if actor does not exist', () => { + const world = createTestWorld(); + const events = applyAction(world, 999 as EntityId, { type: "wait" }, new EntityAccessor(world, 1 as EntityId, ecsWorld)); + expect(events).toEqual([]); + }); }); - it("should kill enemy and spawn EXP orb without ID reuse collision", () => { - const actors = new Map(); - actors.set(1 as EntityId, { - id: 1, category: "combatant", isPlayer: true, type: "player", pos: { x: 3, y: 3 }, speed: 100, stats: createTestStats({ attack: 50 }), energy: 0 - } as any); - actors.set(2 as EntityId, { - id: 2, category: "combatant", isPlayer: false, type: "rat", pos: { x: 4, y: 3 }, speed: 100, stats: createTestStats({ maxHp: 10, hp: 10, attack: 3, defense: 1 }), energy: 0 - } as any); + describe('applyAction - success paths', () => { + it('should deal damage when player attacks enemy', () => { + const actors = new Map(); + actors.set(1 as EntityId, { + id: 1, category: "combatant", isPlayer: true, type: "player", pos: { x: 3, y: 3 }, speed: 100, stats: createTestStats(), energy: 0 + } as any); + actors.set(2 as EntityId, { + id: 2, category: "combatant", isPlayer: false, type: "rat", pos: { x: 4, y: 3 }, speed: 100, stats: createTestStats({ maxHp: 10, hp: 10, attack: 3, defense: 1 }), energy: 0 + } as any); - const world = createTestWorld(); - syncToECS(actors); - const accessor = new EntityAccessor(world, 1 as EntityId, ecsWorld); - applyAction(world, 1 as EntityId, { type: "attack", targetId: 2 as EntityId }, accessor); + const world = createTestWorld(); + syncToECS(actors); + const accessor = new EntityAccessor(world, 1 as EntityId, ecsWorld); + const events = applyAction(world, 1 as EntityId, { type: "attack", targetId: 2 as EntityId }, accessor); - // Enemy (id 2) should be gone - expect(accessor.hasActor(2 as EntityId)).toBe(false); - - // A new ID should be generated for the orb (should be 3) - const orb = accessor.getCollectibles().find(a => a.type === "exp_orb"); - expect(orb).toBeDefined(); - expect(orb!.id).toBe(3); + const enemy = accessor.getCombatant(2 as EntityId); + expect(enemy?.stats.hp).toBeLessThan(10); + expect(events.some(e => e.type === "attacked")).toBe(true); + }); + + it("should kill enemy and spawn EXP orb without ID reuse collision", () => { + const actors = new Map(); + actors.set(1 as EntityId, { + id: 1, category: "combatant", isPlayer: true, type: "player", pos: { x: 3, y: 3 }, speed: 100, stats: createTestStats({ attack: 50 }), energy: 0 + } as any); + actors.set(2 as EntityId, { + id: 2, category: "combatant", isPlayer: false, type: "rat", pos: { x: 4, y: 3 }, speed: 100, stats: createTestStats({ maxHp: 10, hp: 10, attack: 3, defense: 1 }), energy: 0 + } as any); + + const world = createTestWorld(); + syncToECS(actors); + const accessor = new EntityAccessor(world, 1 as EntityId, ecsWorld); + applyAction(world, 1 as EntityId, { type: "attack", targetId: 2 as EntityId }, accessor); + + // Enemy (id 2) should be gone + expect(accessor.hasActor(2 as EntityId)).toBe(false); + + // A new ID should be generated for the orb (should be 3) + const orb = accessor.getCollectibles().find(a => a.type === "exp_orb"); + expect(orb).toBeDefined(); + expect(orb!.id).toBe(3); + }); + + it("should destruction tile when walking on destructible-by-walk tile", () => { + const actors = new Map(); + actors.set(1 as EntityId, { + id: 1, category: "combatant", isPlayer: true, type: "player", pos: { x: 3, y: 3 }, speed: 100, stats: createTestStats(), energy: 0 + } as any); + + const world = createTestWorld(); + // tile at 4,3 is grass (15) which is destructible by walk + const grassIdx = 3 * 10 + 4; + world.tiles[grassIdx] = 15; // TileType.GRASS + + syncToECS(actors); + const accessor = new EntityAccessor(world, 1 as EntityId, ecsWorld); + applyAction(world, 1 as EntityId, { type: "move", dx: 1, dy: 0 }, accessor); + + // Player moved to 4,3 + const player = accessor.getActor(1 as EntityId); + expect(player!.pos).toEqual({ x: 4, y: 3 }); + + // Tile should effectively be destroyed (turned to saplings/2) + expect(world.tiles[grassIdx]).toBe(2); // TileType.GRASS_SAPLINGS + }); + + it("should handle wait action", () => { + const actors = new Map(); + actors.set(1 as EntityId, { id: 1, category: "combatant", isPlayer: true, pos: { x: 0, y: 0 }, stats: createTestStats(), type: "player" } as any); + const world = createTestWorld(); + syncToECS(actors); + const events = applyAction(world, 1 as EntityId, { type: "wait" }, new EntityAccessor(world, 1 as EntityId, ecsWorld)); + expect(events).toEqual([{ type: "waited", actorId: 1 }]); + }); + + it("should default to wait for unknown action type", () => { + const actors = new Map(); + actors.set(1 as EntityId, { id: 1, category: "combatant", isPlayer: true, pos: { x: 0, y: 0 }, stats: createTestStats(), type: "player" } as any); + const world = createTestWorld(); + syncToECS(actors); + + const events = applyAction(world, 1 as EntityId, { type: "unknown_hack" } as any, new EntityAccessor(world, 1 as EntityId, ecsWorld)); + expect(events).toEqual([{ type: "waited", actorId: 1 }]); + }); + + it("should NOT emit wait event for throw action", () => { + const actors = new Map(); + actors.set(1 as EntityId, { id: 1, category: "combatant", isPlayer: true, pos: { x: 0, y: 0 }, stats: createTestStats(), type: "player" } as any); + const world = createTestWorld(); + syncToECS(actors); + + const events = applyAction(world, 1 as EntityId, { type: "throw" }, new EntityAccessor(world, 1 as EntityId, ecsWorld)); + expect(events).toEqual([]); + }); }); - it("should destruction tile when walking on destructible-by-walk tile", () => { - const actors = new Map(); - actors.set(1 as EntityId, { - id: 1, category: "combatant", isPlayer: true, type: "player", pos: { x: 3, y: 3 }, speed: 100, stats: createTestStats(), energy: 0 - } as any); + 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(), energy: 0 } as any; + const enemy = { id: 2, category: "combatant", isPlayer: false, pos: { x: 3, y: 3 }, stats: createTestStats(), energy: 0 } as any; + actors.set(1 as EntityId, player); + actors.set(2 as EntityId, enemy); - const world = createTestWorld(); - // tile at 4,3 is grass (15) which is destructible by walk - const grassIdx = 3 * 10 + 4; - world.tiles[grassIdx] = 15; // TileType.GRASS + const world = createTestWorld(); + world.tiles[3 * 10 + 4] = 4; // Wall + syncToECS(actors); - syncToECS(actors); - const accessor = new EntityAccessor(world, 1 as EntityId, ecsWorld); - applyAction(world, 1 as EntityId, { type: "move", dx: 1, dy: 0 }, accessor); + const accessor = new EntityAccessor(world, 1 as EntityId, ecsWorld); + const decision = decideEnemyAction(world, enemy, player, accessor); - // Player moved to 4,3 - const player = accessor.getActor(1 as EntityId); - expect(player!.pos).toEqual({ x: 4, y: 3 }); + expect(decision.action.type).toBe("move"); + }); - // Tile should effectively be destroyed (turned to saplings/2) - expect(world.tiles[grassIdx]).toBe(2); // TileType.GRASS_SAPLINGS + 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(), + // Set AI state to pursuing so the enemy will attack when adjacent + aiState: "pursuing", + lastKnownPlayerPos: { x: 4, y: 3 } + } as any; + actors.set(1 as EntityId, player); + actors.set(2 as EntityId, enemy); + + const world = createTestWorld(); + syncToECS(actors); + const accessor = new EntityAccessor(world, 1 as EntityId, ecsWorld); + + const decision = decideEnemyAction(world, enemy, player, accessor); + expect(decision.action).toEqual({ type: "attack", targetId: 1 }); + }); + + it("should transition to alerted when spotting player", () => { + const actors = new Map(); + const player = { id: 1, category: "combatant", isPlayer: true, pos: { x: 5, y: 0 }, stats: createTestStats() } as any; + const enemy = { + id: 2, + category: "combatant", + isPlayer: false, + pos: { x: 0, y: 0 }, + stats: createTestStats(), + aiState: "wandering", + energy: 0 + } as any; + actors.set(1 as EntityId, player); + actors.set(2 as EntityId, enemy); + const world = createTestWorld(); + syncToECS(actors); + + const decision = decideEnemyAction(world, enemy, player, new EntityAccessor(world, 1 as EntityId, ecsWorld)); + + const updatedEnemy = ecsWorld.getComponent(2 as EntityId, "ai"); + expect(updatedEnemy?.state).toBe("alerted"); + expect(decision.justAlerted).toBe(true); + }); + + it("should transition from pursuing to searching when sight is lost", () => { + const actors = new Map(); + // Player far away (unseen) + const player = { id: 1, category: "combatant", isPlayer: true, pos: { x: 9, y: 9 }, stats: createTestStats() } as any; + const enemy = { + id: 2, + category: "combatant", + isPlayer: false, + pos: { x: 0, y: 0 }, + stats: createTestStats(), + aiState: "pursuing", // Currently pursuing + lastKnownPlayerPos: { x: 5, y: 5 } + } as any; + actors.set(1 as EntityId, player); + actors.set(2 as EntityId, enemy); + const world = createTestWorld(); + syncToECS(actors); + + // Should switch to searching because can't see player + decideEnemyAction(world, enemy, player, new EntityAccessor(world, 1 as EntityId, ecsWorld)); + + const updatedEnemy = ecsWorld.getComponent(2 as EntityId, "ai"); + expect(updatedEnemy?.state).toBe("searching"); + }); + + it("should transition from searching to alerted when sight regained", () => { + const actors = new Map(); + // Player adjacent (visible) + const player = { id: 1, category: "combatant", isPlayer: true, pos: { x: 1, y: 0 }, stats: createTestStats() } as any; + const enemy = { + id: 2, + category: "combatant", + isPlayer: false, + pos: { x: 0, y: 0 }, + stats: createTestStats(), + aiState: "searching", + lastKnownPlayerPos: { x: 5, y: 5 } + } as any; + actors.set(1 as EntityId, player); + actors.set(2 as EntityId, enemy); + const world = createTestWorld(); + syncToECS(actors); + + const decision = decideEnemyAction(world, enemy, player, new EntityAccessor(world, 1 as EntityId, ecsWorld)); + + const updatedEnemy = ecsWorld.getComponent(2 as EntityId, "ai"); + expect(updatedEnemy?.state).toBe("alerted"); + expect(decision.justAlerted).toBe(true); + }); + + it("should transition from searching to wandering when reached target", () => { + const actors = new Map(); + // Player far away (unseen) - Manhattan dist > 8 + // Enemy at 9,9. Player at 0,0. Dist = 18. + const player = { id: 1, category: "combatant", isPlayer: true, pos: { x: 0, y: 0 }, stats: createTestStats() } as any; + const enemy = { + id: 2, + category: "combatant", + isPlayer: false, + pos: { x: 9, y: 9 }, // At target + stats: createTestStats(), + aiState: "searching", + lastKnownPlayerPos: { x: 9, y: 9 } + } as any; + actors.set(1 as EntityId, player); + actors.set(2 as EntityId, enemy); + const world = createTestWorld(); + syncToECS(actors); + + decideEnemyAction(world, enemy, player, new EntityAccessor(world, 1 as EntityId, ecsWorld)); + + const updatedEnemy = ecsWorld.getComponent(2 as EntityId, "ai"); + expect(updatedEnemy?.state).toBe("wandering"); + }); }); - it("should handle wait action", () => { - const actors = new Map(); - actors.set(1 as EntityId, { id: 1, category: "combatant", isPlayer: true, pos: { x: 0, y: 0 }, stats: createTestStats(), type: "player" } as any); - const world = createTestWorld(); - syncToECS(actors); - const events = applyAction(world, 1 as EntityId, { type: "wait" }, new EntityAccessor(world, 1 as EntityId, ecsWorld)); - expect(events).toEqual([{ type: "waited", actorId: 1 }]); + describe("stepUntilPlayerTurn", () => { + it("should process enemy turns", () => { + const actors = new Map(); + // Player is slow, enemy is fast. Enemy should move before player returns. + const player = { id: 1, category: "combatant", isPlayer: true, pos: { x: 0, y: 0 }, speed: 10, stats: createTestStats(), energy: 0 } as any; + const enemy = { id: 2, category: "combatant", isPlayer: false, pos: { x: 5, y: 5 }, speed: 100, stats: createTestStats(), energy: 0 } as any; + + actors.set(1 as EntityId, player); + actors.set(2 as EntityId, enemy); + const world = createTestWorld(); + syncToECS(actors); + const accessor = new EntityAccessor(world, 1 as EntityId, ecsWorld); + + const result = stepUntilPlayerTurn(world, 1 as EntityId, accessor); + + // Enemy should have taken at least one action + expect(result.events.length).toBeGreaterThan(0); + expect(result.awaitingPlayerId).toBe(1); + }); + + it("should handle player death during enemy turn", () => { + const actors = new Map(); + const player = { id: 1, category: "combatant", isPlayer: true, pos: { x: 0, y: 0 }, speed: 10, stats: createTestStats({ hp: 1 }), energy: 0 } as any; + // Enemy that will kill player + const enemy = { + id: 2, + category: "combatant", + isPlayer: false, + pos: { x: 1, y: 0 }, + speed: 100, + stats: createTestStats({ attack: 100 }), + aiState: "pursuing", + energy: 100 + } as any; + + actors.set(1 as EntityId, player); + actors.set(2 as EntityId, enemy); + const world = createTestWorld(); + syncToECS(actors); + const accessor = new EntityAccessor(world, 1 as EntityId, ecsWorld); + + const result = stepUntilPlayerTurn(world, 1 as EntityId, accessor); + + expect(accessor.hasActor(1 as EntityId)).toBe(false); // Player dead + expect(result.events.some((e: any) => e.type === "killed" && e.targetId === 1)).toBe(true); + }); }); - it("should default to wait for unknown action type", () => { - const actors = new Map(); - actors.set(1 as EntityId, { id: 1, category: "combatant", isPlayer: true, pos: { x: 0, y: 0 }, stats: createTestStats(), type: "player" } as any); - const world = createTestWorld(); - syncToECS(actors); + describe("Combat Mechanics - Detailed", () => { + let mockRandom: ReturnType; - const events = applyAction(world, 1 as EntityId, { type: "unknown_hack" } as any, new EntityAccessor(world, 1 as EntityId, ecsWorld)); - expect(events).toEqual([{ type: "waited", actorId: 1 }]); + beforeEach(() => { + mockRandom = vi.spyOn(Math, 'random'); + }); + + afterEach(() => { + mockRandom.mockRestore(); + }); + + it("should dodge attack when roll > hit chance", () => { + const actors = new Map(); + // Acc 100, Eva 50. Hit Chance = 50. + const player = { id: 1, category: "combatant", isPlayer: true, pos: { x: 3, y: 3 }, stats: createTestStats({ accuracy: 100 }), energy: 0 } as any; + const enemy = { id: 2, category: "combatant", isPlayer: false, pos: { x: 4, y: 3 }, stats: createTestStats({ evasion: 50, hp: 10 }), energy: 0 } as any; + + actors.set(1 as EntityId, player); + actors.set(2 as EntityId, enemy); + const world = createTestWorld(); + syncToECS(actors); + + // Mock random to be 51 (scale 0-100 logic uses * 100) -> 0.51 + mockRandom.mockReturnValue(0.1); // Hit roll + // Wait, hitChance is Acc (100) - Eva (50) = 50. + // Roll 0.51 * 100 = 51. 51 > 50 -> Dodge. + mockRandom.mockReturnValue(0.51); + + const events = applyAction(world, 1 as EntityId, { type: "attack", targetId: 2 as EntityId }, new EntityAccessor(world, 1 as EntityId, ecsWorld)); + + expect(events.some(e => e.type === "dodged")).toBe(true); + const updatedEnemy = ecsWorld.getComponent(2 as EntityId, "stats"); + expect(updatedEnemy?.hp).toBe(10); // No damage + }); + + it("should crit when roll < crit chance", () => { + const actors = new Map(); + // Acc 100, Eva 0. Hit Chance = 100. + // Crit Chance 50%. + const player = { + id: 1, category: "combatant", isPlayer: true, pos: { x: 3, y: 3 }, + stats: createTestStats({ accuracy: 100, critChance: 50, critMultiplier: 200, attack: 10 }), + energy: 0 + } as any; + const enemy = { id: 2, category: "combatant", isPlayer: false, pos: { x: 4, y: 3 }, stats: createTestStats({ evasion: 0, defense: 0, hp: 50 }), energy: 0 } as any; + + actors.set(1 as EntityId, player); + actors.set(2 as EntityId, enemy); + const world = createTestWorld(); + syncToECS(actors); + + // Mock random: + // 1. Hit roll: 0.1 (Hit) + // 2. Crit roll: 0.4 (Crit, since < 0.5) + // 3. Block roll: 0.9 (No block) + mockRandom.mockReturnValueOnce(0.1).mockReturnValueOnce(0.4).mockReturnValueOnce(0.9); + + const events = applyAction(world, 1 as EntityId, { type: "attack", targetId: 2 as EntityId }, new EntityAccessor(world, 1 as EntityId, ecsWorld)); + + // Damage = 10 * 2 = 20 + const dmgEvent = events.find(e => e.type === "damaged") as any; + expect(dmgEvent).toBeDefined(); + expect(dmgEvent.amount).toBe(20); + expect(dmgEvent.isCrit).toBe(true); + }); + + it("should block when roll < block chance", () => { + const actors = new Map(); + const player = { + id: 1, category: "combatant", isPlayer: true, pos: { x: 3, y: 3 }, + stats: createTestStats({ accuracy: 100, critChance: 0, attack: 10 }) + } as any; + const enemy = { + id: 2, category: "combatant", isPlayer: false, pos: { x: 4, y: 3 }, + stats: createTestStats({ evasion: 0, defense: 0, hp: 50, blockChance: 50 }), energy: 0 + } as any; + + actors.set(1 as EntityId, player); + actors.set(2 as EntityId, enemy); + const world = createTestWorld(); + syncToECS(actors); + + // Mock random: + // 1. Hit roll: 0.1 + // 2. Crit roll: 0.9 + // 3. Block roll: 0.4 (Block, since < 0.5) + mockRandom.mockReturnValueOnce(0.1).mockReturnValueOnce(0.9).mockReturnValueOnce(0.4); + + const events = applyAction(world, 1 as EntityId, { type: "attack", targetId: 2 as EntityId }, new EntityAccessor(world, 1 as EntityId, ecsWorld)); + + // Damage = 10 * 0.5 = 5 + const dmgEvent = events.find(e => e.type === "damaged") as any; + expect(dmgEvent.amount).toBe(5); + expect(dmgEvent.isBlock).toBe(true); + }); + + it("should lifesteal on hit", () => { + const actors = new Map(); + const player = { + id: 1, category: "combatant", isPlayer: true, pos: { x: 3, y: 3 }, + stats: createTestStats({ accuracy: 100, attack: 10, lifesteal: 50, hp: 10, maxHp: 20 }) + } as any; + const enemy = { id: 2, category: "combatant", isPlayer: false, pos: { x: 4, y: 3 }, stats: createTestStats({ hp: 20, defense: 0 }) } as any; + + actors.set(1 as EntityId, player); + actors.set(2 as EntityId, enemy); + const world = createTestWorld(); + syncToECS(actors); + + // Standard hit + mockRandom.mockReturnValue(0.1); + + const events = applyAction(world, 1 as EntityId, { type: "attack", targetId: 2 as EntityId }, new EntityAccessor(world, 1 as EntityId, ecsWorld)); + + // Damage 10. Heal 50% = 5. HP -> 15. + const updatedPlayer = ecsWorld.getComponent(1 as EntityId, "stats"); + expect(updatedPlayer?.hp).toBe(15); + expect(events.some(e => e.type === "healed")).toBe(true); + }); + + it("should not lifesteal beyond maxHp", () => { + const actors = new Map(); + const player = { + id: 1, category: "combatant", isPlayer: true, pos: { x: 3, y: 3 }, + stats: createTestStats({ accuracy: 100, attack: 10, lifesteal: 100, hp: 19, maxHp: 20 }) + } as any; + const enemy = { id: 2, category: "combatant", isPlayer: false, pos: { x: 4, y: 3 }, stats: createTestStats({ hp: 20, defense: 0 }) } as any; + + actors.set(1 as EntityId, player); + actors.set(2 as EntityId, enemy); + const world = createTestWorld(); + syncToECS(actors); + + mockRandom.mockReturnValue(0.1); + + applyAction(world, 1 as EntityId, { type: "attack", targetId: 2 as EntityId }, new EntityAccessor(world, 1 as EntityId, ecsWorld)); + + // Damage 10. Heal 10. HP 19+10 = 29 > 20. Should be 20. + const updatedPlayer = ecsWorld.getComponent(1 as EntityId, "stats"); + expect(updatedPlayer?.hp).toBe(20); + }); }); - it("should NOT emit wait event for throw action", () => { - const actors = new Map(); - actors.set(1 as EntityId, { id: 1, category: "combatant", isPlayer: true, pos: { x: 0, y: 0 }, stats: createTestStats(), type: "player" } as any); - const world = createTestWorld(); - syncToECS(actors); + describe("Level Up Logic", () => { + it("should level up when collecting ample experience", () => { + const actors = new Map(); + const player = { + id: 1, category: "combatant", isPlayer: true, pos: { x: 3, y: 3 }, + stats: createTestStats({ level: 1, exp: 0, expToNextLevel: 100 }) + } as any; + // Orb with 150 exp + const orb = { + id: 2, category: "collectible", type: "exp_orb", pos: { x: 4, y: 3 }, expAmount: 150 + } as any; - const events = applyAction(world, 1 as EntityId, { type: "throw" }, new EntityAccessor(world, 1 as EntityId, ecsWorld)); - expect(events).toEqual([]); + actors.set(1 as EntityId, player); + actors.set(2 as EntityId, orb); + const world = createTestWorld(); + syncToECS(actors); + + // Move player onto orb + const events = applyAction(world, 1 as EntityId, { type: "move", dx: 1, dy: 0 }, new EntityAccessor(world, 1 as EntityId, ecsWorld)); + + const updatedPlayer = ecsWorld.getComponent(1 as EntityId, "stats"); + expect(updatedPlayer?.level).toBe(2); + expect(updatedPlayer?.exp).toBe(50); // 150 - 100 = 50 + expect(events.some(e => e.type === "leveled-up")).toBe(true); + }); }); - }); + describe("Diagonal Mechanics", () => { + it("should allow enemy to attack player diagonally", () => { + const actors = new Map(); + // Enemy at 4,4. Player at 5,5 (diagonal) + const enemy = { + id: 1, + category: "combatant", + isPlayer: false, + pos: { x: 4, y: 4 }, + stats: createTestStats(), + aiState: "pursuing", // Skip alert phase + energy: 0 + } as any; + const player = { + id: 2, + category: "combatant", + isPlayer: true, + pos: { x: 5, y: 5 }, + stats: createTestStats(), + energy: 0 + } as any; - 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(), energy: 0 } as any; - const enemy = { id: 2, category: "combatant", isPlayer: false, pos: { x: 3, y: 3 }, stats: createTestStats(), energy: 0 } as any; - actors.set(1 as EntityId, player); - actors.set(2 as EntityId, enemy); + actors.set(1 as EntityId, enemy); + actors.set(2 as EntityId, player); + const world = createTestWorld(); + syncToECS(actors); - const world = createTestWorld(); - world.tiles[3 * 10 + 4] = 4; // Wall - syncToECS(actors); + // Enemy should decide to attack + const decision = decideEnemyAction(world, enemy, player, new EntityAccessor(world, 1 as EntityId, ecsWorld)); - const accessor = new EntityAccessor(world, 1 as EntityId, ecsWorld); - const decision = decideEnemyAction(world, enemy, player, accessor); - - expect(decision.action.type).toBe("move"); - }); + expect(decision.action.type).toBe("attack"); + if (decision.action.type === "attack") { + expect(decision.action.targetId).toBe(player.id); + } + }); - 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(), - // Set AI state to pursuing so the enemy will attack when adjacent - aiState: "pursuing", - lastKnownPlayerPos: { x: 4, y: 3 } - } as any; - actors.set(1 as EntityId, player); - actors.set(2 as EntityId, enemy); + it("should allow player to attack enemy diagonally via applyAction", () => { + const actors = new Map(); + const player = { id: 1, category: "combatant", isPlayer: true, pos: { x: 4, y: 4 }, stats: createTestStats(), energy: 0 } as any; + const enemy = { id: 2, category: "combatant", isPlayer: false, pos: { x: 5, y: 5 }, stats: createTestStats(), energy: 0 } as any; - const world = createTestWorld(); - syncToECS(actors); - const accessor = new EntityAccessor(world, 1 as EntityId, ecsWorld); - - const decision = decideEnemyAction(world, enemy, player, accessor); - expect(decision.action).toEqual({ type: "attack", targetId: 1 }); - }); + actors.set(1 as EntityId, player); + actors.set(2 as EntityId, enemy); + const world = createTestWorld(); + syncToECS(actors); - it("should transition to alerted when spotting player", () => { - const actors = new Map(); - const player = { id: 1, category: "combatant", isPlayer: true, pos: { x: 5, y: 0 }, stats: createTestStats() } as any; - const enemy = { - id: 2, - category: "combatant", - isPlayer: false, - pos: { x: 0, y: 0 }, - stats: createTestStats(), - aiState: "wandering", - energy: 0 - } as any; - actors.set(1 as EntityId, player); - actors.set(2 as EntityId, enemy); - const world = createTestWorld(); - syncToECS(actors); - - const decision = decideEnemyAction(world, enemy, player, new EntityAccessor(world, 1 as EntityId, ecsWorld)); - - const updatedEnemy = ecsWorld.getComponent(2 as EntityId, "ai"); - expect(updatedEnemy?.state).toBe("alerted"); - expect(decision.justAlerted).toBe(true); - }); + const action: any = { type: "attack", targetId: 2 }; + const events = applyAction(world, 1 as EntityId, action, new EntityAccessor(world, 1 as EntityId, ecsWorld)); - it("should transition from pursuing to searching when sight is lost", () => { - const actors = new Map(); - // Player far away (unseen) - const player = { id: 1, category: "combatant", isPlayer: true, pos: { x: 9, y: 9 }, stats: createTestStats() } as any; - const enemy = { - id: 2, - category: "combatant", - isPlayer: false, - pos: { x: 0, y: 0 }, - stats: createTestStats(), - aiState: "pursuing", // Currently pursuing - lastKnownPlayerPos: { x: 5, y: 5 } - } as any; - actors.set(1 as EntityId, player); - actors.set(2 as EntityId, enemy); - const world = createTestWorld(); - syncToECS(actors); - - // Should switch to searching because can't see player - decideEnemyAction(world, enemy, player, new EntityAccessor(world, 1 as EntityId, ecsWorld)); - - const updatedEnemy = ecsWorld.getComponent(2 as EntityId, "ai"); - expect(updatedEnemy?.state).toBe("searching"); - }); + const attackEvent = events.find(e => e.type === "attacked"); + expect(attackEvent).toBeDefined(); + expect(attackEvent?.targetId).toBe(2); + }); - it("should transition from searching to alerted when sight regained", () => { - const actors = new Map(); - // Player adjacent (visible) - const player = { id: 1, category: "combatant", isPlayer: true, pos: { x: 1, y: 0 }, stats: createTestStats() } as any; - const enemy = { - id: 2, - category: "combatant", - isPlayer: false, - pos: { x: 0, y: 0 }, - stats: createTestStats(), - aiState: "searching", - lastKnownPlayerPos: { x: 5, y: 5 } - } as any; - actors.set(1 as EntityId, player); - actors.set(2 as EntityId, enemy); - const world = createTestWorld(); - syncToECS(actors); - - const decision = decideEnemyAction(world, enemy, player, new EntityAccessor(world, 1 as EntityId, ecsWorld)); - - const updatedEnemy = ecsWorld.getComponent(2 as EntityId, "ai"); - expect(updatedEnemy?.state).toBe("alerted"); - expect(decision.justAlerted).toBe(true); - }); + it("should NOT generate diagonal move for enemy", () => { + const actors = new Map(); + // Enemy at 4,4. Player at 4,6. Dist 2. + const enemy = { + id: 1, + category: "combatant", + isPlayer: false, + pos: { x: 4, y: 4 }, + stats: createTestStats(), + aiState: "pursuing", + energy: 0 + } as any; + const player = { id: 2, category: "combatant", isPlayer: true, pos: { x: 4, y: 6 }, stats: createTestStats(), energy: 0 } as any; - it("should transition from searching to wandering when reached target", () => { - const actors = new Map(); - // Player far away (unseen) - Manhattan dist > 8 - // Enemy at 9,9. Player at 0,0. Dist = 18. - const player = { id: 1, category: "combatant", isPlayer: true, pos: { x: 0, y: 0 }, stats: createTestStats() } as any; - const enemy = { - id: 2, - category: "combatant", - isPlayer: false, - pos: { x: 9, y: 9 }, // At target - stats: createTestStats(), - aiState: "searching", - lastKnownPlayerPos: { x: 9, y: 9 } - } as any; - actors.set(1 as EntityId, player); - actors.set(2 as EntityId, enemy); - const world = createTestWorld(); - syncToECS(actors); - - decideEnemyAction(world, enemy, player, new EntityAccessor(world, 1 as EntityId, ecsWorld)); - - const updatedEnemy = ecsWorld.getComponent(2 as EntityId, "ai"); - expect(updatedEnemy?.state).toBe("wandering"); - }); - }); + actors.set(1 as EntityId, enemy); + actors.set(2 as EntityId, player); + const world = createTestWorld(); + syncToECS(actors); - describe("stepUntilPlayerTurn", () => { - it("should process enemy turns", () => { - const actors = new Map(); - // Player is slow, enemy is fast. Enemy should move before player returns. - const player = { id: 1, category: "combatant", isPlayer: true, pos: { x: 0, y: 0 }, speed: 10, stats: createTestStats(), energy: 0 } as any; - const enemy = { id: 2, category: "combatant", isPlayer: false, pos: { x: 5, y: 5 }, speed: 100, stats: createTestStats(), energy: 0 } as any; - - actors.set(1 as EntityId, player); - actors.set(2 as EntityId, enemy); - const world = createTestWorld(); - syncToECS(actors); - const accessor = new EntityAccessor(world, 1 as EntityId, ecsWorld); - - const result = stepUntilPlayerTurn(world, 1 as EntityId, accessor); - - // Enemy should have taken at least one action - expect(result.events.length).toBeGreaterThan(0); - expect(result.awaitingPlayerId).toBe(1); - }); - - it("should handle player death during enemy turn", () => { - const actors = new Map(); - const player = { id: 1, category: "combatant", isPlayer: true, pos: { x: 0, y: 0 }, speed: 10, stats: createTestStats({ hp: 1 }), energy: 0 } as any; - // Enemy that will kill player - const enemy = { - id: 2, - category: "combatant", - isPlayer: false, - pos: { x: 1, y: 0 }, - speed: 100, - stats: createTestStats({ attack: 100 }), - aiState: "pursuing", - energy: 100 - } as any; - - actors.set(1 as EntityId, player); - actors.set(2 as EntityId, enemy); - const world = createTestWorld(); - syncToECS(actors); - const accessor = new EntityAccessor(world, 1 as EntityId, ecsWorld); - - const result = stepUntilPlayerTurn(world, 1 as EntityId, accessor); - - expect(accessor.hasActor(1 as EntityId)).toBe(false); // Player dead - expect(result.events.some((e: any) => e.type === "killed" && e.targetId === 1)).toBe(true); - }); - }); - - describe("Combat Mechanics - Detailed", () => { - let mockRandom: ReturnType; - - beforeEach(() => { - mockRandom = vi.spyOn(Math, 'random'); + const decision = decideEnemyAction(world, enemy, player, new EntityAccessor(world, 1 as EntityId, ecsWorld)); + if (decision.action.type === "move") { + const { dx, dy } = decision.action; + // Should be (0, 1) or cardinal, sum of abs should be 1 + expect(Math.abs(dx) + Math.abs(dy)).toBe(1); + } + }); }); - - afterEach(() => { - mockRandom.mockRestore(); - }); - - it("should dodge attack when roll > hit chance", () => { - const actors = new Map(); - // Acc 100, Eva 50. Hit Chance = 50. - const player = { id: 1, category: "combatant", isPlayer: true, pos: { x: 3, y: 3 }, stats: createTestStats({ accuracy: 100 }), energy: 0 } as any; - const enemy = { id: 2, category: "combatant", isPlayer: false, pos: { x: 4, y: 3 }, stats: createTestStats({ evasion: 50, hp: 10 }), energy: 0 } as any; - - actors.set(1 as EntityId, player); - actors.set(2 as EntityId, enemy); - const world = createTestWorld(); - syncToECS(actors); - - // Mock random to be 51 (scale 0-100 logic uses * 100) -> 0.51 - mockRandom.mockReturnValue(0.1); // Hit roll - // Wait, hitChance is Acc (100) - Eva (50) = 50. - // Roll 0.51 * 100 = 51. 51 > 50 -> Dodge. - mockRandom.mockReturnValue(0.51); - - const events = applyAction(world, 1 as EntityId, { type: "attack", targetId: 2 as EntityId }, new EntityAccessor(world, 1 as EntityId, ecsWorld)); - - expect(events.some(e => e.type === "dodged")).toBe(true); - const updatedEnemy = ecsWorld.getComponent(2 as EntityId, "stats"); - expect(updatedEnemy?.hp).toBe(10); // No damage - }); - - it("should crit when roll < crit chance", () => { - const actors = new Map(); - // Acc 100, Eva 0. Hit Chance = 100. - // Crit Chance 50%. - const player = { - id: 1, category: "combatant", isPlayer: true, pos: { x: 3, y: 3 }, - stats: createTestStats({ accuracy: 100, critChance: 50, critMultiplier: 200, attack: 10 }), - energy: 0 - } as any; - const enemy = { id: 2, category: "combatant", isPlayer: false, pos: { x: 4, y: 3 }, stats: createTestStats({ evasion: 0, defense: 0, hp: 50 }), energy: 0 } as any; - - actors.set(1 as EntityId, player); - actors.set(2 as EntityId, enemy); - const world = createTestWorld(); - syncToECS(actors); - - // Mock random: - // 1. Hit roll: 0.1 (Hit) - // 2. Crit roll: 0.4 (Crit, since < 0.5) - // 3. Block roll: 0.9 (No block) - mockRandom.mockReturnValueOnce(0.1).mockReturnValueOnce(0.4).mockReturnValueOnce(0.9); - - const events = applyAction(world, 1 as EntityId, { type: "attack", targetId: 2 as EntityId }, new EntityAccessor(world, 1 as EntityId, ecsWorld)); - - // Damage = 10 * 2 = 20 - const dmgEvent = events.find(e => e.type === "damaged") as any; - expect(dmgEvent).toBeDefined(); - expect(dmgEvent.amount).toBe(20); - expect(dmgEvent.isCrit).toBe(true); - }); - - it("should block when roll < block chance", () => { - const actors = new Map(); - const player = { - id: 1, category: "combatant", isPlayer: true, pos: { x: 3, y: 3 }, - stats: createTestStats({ accuracy: 100, critChance: 0, attack: 10 }) - } as any; - const enemy = { - id: 2, category: "combatant", isPlayer: false, pos: { x: 4, y: 3 }, - stats: createTestStats({ evasion: 0, defense: 0, hp: 50, blockChance: 50 }), energy: 0 - } as any; - - actors.set(1 as EntityId, player); - actors.set(2 as EntityId, enemy); - const world = createTestWorld(); - syncToECS(actors); - - // Mock random: - // 1. Hit roll: 0.1 - // 2. Crit roll: 0.9 - // 3. Block roll: 0.4 (Block, since < 0.5) - mockRandom.mockReturnValueOnce(0.1).mockReturnValueOnce(0.9).mockReturnValueOnce(0.4); - - const events = applyAction(world, 1 as EntityId, { type: "attack", targetId: 2 as EntityId }, new EntityAccessor(world, 1 as EntityId, ecsWorld)); - - // Damage = 10 * 0.5 = 5 - const dmgEvent = events.find(e => e.type === "damaged") as any; - expect(dmgEvent.amount).toBe(5); - expect(dmgEvent.isBlock).toBe(true); - }); - - it("should lifesteal on hit", () => { - const actors = new Map(); - const player = { - id: 1, category: "combatant", isPlayer: true, pos: { x: 3, y: 3 }, - stats: createTestStats({ accuracy: 100, attack: 10, lifesteal: 50, hp: 10, maxHp: 20 }) - } as any; - const enemy = { id: 2, category: "combatant", isPlayer: false, pos: { x: 4, y: 3 }, stats: createTestStats({ hp: 20, defense: 0 }) } as any; - - actors.set(1 as EntityId, player); - actors.set(2 as EntityId, enemy); - const world = createTestWorld(); - syncToECS(actors); - - // Standard hit - mockRandom.mockReturnValue(0.1); - - const events = applyAction(world, 1 as EntityId, { type: "attack", targetId: 2 as EntityId }, new EntityAccessor(world, 1 as EntityId, ecsWorld)); - - // Damage 10. Heal 50% = 5. HP -> 15. - const updatedPlayer = ecsWorld.getComponent(1 as EntityId, "stats"); - expect(updatedPlayer?.hp).toBe(15); - expect(events.some(e => e.type === "healed")).toBe(true); - }); - - it("should not lifesteal beyond maxHp", () => { - const actors = new Map(); - const player = { - id: 1, category: "combatant", isPlayer: true, pos: { x: 3, y: 3 }, - stats: createTestStats({ accuracy: 100, attack: 10, lifesteal: 100, hp: 19, maxHp: 20 }) - } as any; - const enemy = { id: 2, category: "combatant", isPlayer: false, pos: { x: 4, y: 3 }, stats: createTestStats({ hp: 20, defense: 0 }) } as any; - - actors.set(1 as EntityId, player); - actors.set(2 as EntityId, enemy); - const world = createTestWorld(); - syncToECS(actors); - - mockRandom.mockReturnValue(0.1); - - applyAction(world, 1 as EntityId, { type: "attack", targetId: 2 as EntityId }, new EntityAccessor(world, 1 as EntityId, ecsWorld)); - - // Damage 10. Heal 10. HP 19+10 = 29 > 20. Should be 20. - const updatedPlayer = ecsWorld.getComponent(1 as EntityId, "stats"); - expect(updatedPlayer?.hp).toBe(20); - }); - }); - - describe("Level Up Logic", () => { - it("should level up when collecting ample experience", () => { - const actors = new Map(); - const player = { - id: 1, category: "combatant", isPlayer: true, pos: { x: 3, y: 3 }, - stats: createTestStats({ level: 1, exp: 0, expToNextLevel: 100 }) - } as any; - // Orb with 150 exp - const orb = { - id: 2, category: "collectible", type: "exp_orb", pos: { x: 4, y: 3 }, expAmount: 150 - } as any; - - actors.set(1 as EntityId, player); - actors.set(2 as EntityId, orb); - const world = createTestWorld(); - syncToECS(actors); - - // Move player onto orb - const events = applyAction(world, 1 as EntityId, { type: "move", dx: 1, dy: 0 }, new EntityAccessor(world, 1 as EntityId, ecsWorld)); - - const updatedPlayer = ecsWorld.getComponent(1 as EntityId, "stats"); - expect(updatedPlayer?.level).toBe(2); - expect(updatedPlayer?.exp).toBe(50); // 150 - 100 = 50 - expect(events.some(e => e.type === "leveled-up")).toBe(true); - }); - }); - describe("Diagonal Mechanics", () => { - it("should allow enemy to attack player diagonally", () => { - const actors = new Map(); - // Enemy at 4,4. Player at 5,5 (diagonal) - const enemy = { - id: 1, - category: "combatant", - isPlayer: false, - pos: { x: 4, y: 4 }, - stats: createTestStats(), - aiState: "pursuing", // Skip alert phase - energy: 0 - } as any; - const player = { - id: 2, - category: "combatant", - isPlayer: true, - pos: { x: 5, y: 5 }, - stats: createTestStats(), - energy: 0 - } as any; - - actors.set(1 as EntityId, enemy); - actors.set(2 as EntityId, player); - const world = createTestWorld(); - syncToECS(actors); - - // Enemy should decide to attack - const decision = decideEnemyAction(world, enemy, player, new EntityAccessor(world, 1 as EntityId, ecsWorld)); - - expect(decision.action.type).toBe("attack"); - if (decision.action.type === "attack") { - expect(decision.action.targetId).toBe(player.id); - } - }); - - it("should allow player to attack enemy diagonally via applyAction", () => { - const actors = new Map(); - const player = { id: 1, category: "combatant", isPlayer: true, pos: { x: 4, y: 4 }, stats: createTestStats(), energy: 0 } as any; - const enemy = { id: 2, category: "combatant", isPlayer: false, pos: { x: 5, y: 5 }, stats: createTestStats(), energy: 0 } as any; - - actors.set(1 as EntityId, player); - actors.set(2 as EntityId, enemy); - const world = createTestWorld(); - syncToECS(actors); - - const action: any = { type: "attack", targetId: 2 }; - const events = applyAction(world, 1 as EntityId, action, new EntityAccessor(world, 1 as EntityId, ecsWorld)); - - const attackEvent = events.find(e => e.type === "attacked"); - expect(attackEvent).toBeDefined(); - expect(attackEvent?.targetId).toBe(2); - }); - - it("should NOT generate diagonal move for enemy", () => { - const actors = new Map(); - // Enemy at 4,4. Player at 4,6. Dist 2. - const enemy = { - id: 1, - category: "combatant", - isPlayer: false, - pos: { x: 4, y: 4 }, - stats: createTestStats(), - aiState: "pursuing", - energy: 0 - } as any; - const player = { id: 2, category: "combatant", isPlayer: true, pos: { x: 4, y: 6 }, stats: createTestStats(), energy: 0 } as any; - - actors.set(1 as EntityId, enemy); - actors.set(2 as EntityId, player); - const world = createTestWorld(); - syncToECS(actors); - - const decision = decideEnemyAction(world, enemy, player, new EntityAccessor(world, 1 as EntityId, ecsWorld)); - if (decision.action.type === "move") { - const { dx, dy } = decision.action; - // Should be (0, 1) or cardinal, sum of abs should be 1 - expect(Math.abs(dx) + Math.abs(dy)).toBe(1); - } - }); - }); }); diff --git a/src/engine/__tests__/throwing.test.ts b/src/engine/__tests__/throwing.test.ts index 30cd886..64285f1 100644 --- a/src/engine/__tests__/throwing.test.ts +++ b/src/engine/__tests__/throwing.test.ts @@ -10,7 +10,8 @@ const createTestWorld = (): World => { width: 10, height: 10, tiles: new Array(100).fill(0), // 0 = Floor - exit: { x: 9, y: 9 } + exit: { x: 9, y: 9 }, + trackPath: [] }; }; diff --git a/src/engine/__tests__/world.test.ts b/src/engine/__tests__/world.test.ts index fd93033..5914cad 100644 --- a/src/engine/__tests__/world.test.ts +++ b/src/engine/__tests__/world.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { idx, inBounds, isWall, isBlocked, tryDestructTile, isPlayerOnExit } from '../world/world-logic'; +import { idx, inBounds, isWall, isBlocked, tryDestructTile } from '../world/world-logic'; import { type World, type Tile } from '../../core/types'; import { TileType } from '../../core/terrain'; @@ -9,13 +9,14 @@ describe('World Utilities', () => { width, height, tiles, - exit: { x: 0, y: 0 } + exit: { x: 0, y: 0 }, + trackPath: [] }); describe('idx', () => { it('should calculate correct index for 2D coordinates', () => { const world = createTestWorld(10, 10, []); - + expect(idx(world, 0, 0)).toBe(0); expect(idx(world, 5, 0)).toBe(5); expect(idx(world, 0, 1)).toBe(10); @@ -26,7 +27,7 @@ describe('World Utilities', () => { describe('inBounds', () => { it('should return true for coordinates within bounds', () => { const world = createTestWorld(10, 10, []); - + expect(inBounds(world, 0, 0)).toBe(true); expect(inBounds(world, 5, 5)).toBe(true); expect(inBounds(world, 9, 9)).toBe(true); @@ -34,7 +35,7 @@ describe('World Utilities', () => { it('should return false for coordinates outside bounds', () => { const world = createTestWorld(10, 10, []); - + expect(inBounds(world, -1, 0)).toBe(false); expect(inBounds(world, 0, -1)).toBe(false); expect(inBounds(world, 10, 0)).toBe(false); @@ -49,9 +50,9 @@ describe('World Utilities', () => { tiles[0] = TileType.WALL; // wall at 0,0 tiles[55] = TileType.WALL; // wall at 5,5 - + const world = createTestWorld(10, 10, tiles); - + expect(isWall(world, 0, 0)).toBe(true); expect(isWall(world, 5, 5)).toBe(true); }); @@ -59,7 +60,7 @@ describe('World Utilities', () => { it('should return false for floor tiles', () => { const tiles: Tile[] = new Array(100).fill(TileType.EMPTY); const world = createTestWorld(10, 10, tiles); - + expect(isWall(world, 3, 3)).toBe(false); expect(isWall(world, 7, 7)).toBe(false); @@ -67,7 +68,7 @@ describe('World Utilities', () => { it('should return false for out of bounds coordinates', () => { const world = createTestWorld(10, 10, new Array(100).fill(0)); - + expect(isWall(world, -1, 0)).toBe(false); expect(isWall(world, 10, 10)).toBe(false); }); @@ -78,7 +79,7 @@ describe('World Utilities', () => { const tiles: Tile[] = new Array(100).fill(TileType.EMPTY); tiles[55] = TileType.WALL; // wall at 5,5 - + const world = createTestWorld(10, 10, tiles); const mockAccessor = { getActorsAt: () => [] } as any; @@ -88,19 +89,19 @@ describe('World Utilities', () => { it('should return true for actor positions', () => { const world = createTestWorld(10, 10, new Array(100).fill(0)); const mockAccessor = { - getActorsAt: (x: number, y: number) => { - if (x === 3 && y === 3) return [{ category: "combatant" }]; - return []; - } + getActorsAt: (x: number, y: number) => { + if (x === 3 && y === 3) return [{ category: "combatant" }]; + return []; + } } as any; - + expect(isBlocked(world, 3, 3, mockAccessor)).toBe(true); }); it('should return false for empty floor tiles', () => { const world = createTestWorld(10, 10, new Array(100).fill(0)); const mockAccessor = { getActorsAt: () => [] } as any; - + expect(isBlocked(world, 3, 3, mockAccessor)).toBe(false); expect(isBlocked(world, 7, 7, mockAccessor)).toBe(false); }); @@ -108,7 +109,7 @@ describe('World Utilities', () => { it('should return true for out of bounds', () => { const world = createTestWorld(10, 10, new Array(100).fill(0)); const mockAccessor = { getActorsAt: () => [] } as any; - + expect(isBlocked(world, -1, 0, mockAccessor)).toBe(true); expect(isBlocked(world, 10, 10, mockAccessor)).toBe(true); }); @@ -120,7 +121,7 @@ describe('World Utilities', () => { const world = createTestWorld(10, 10, tiles); const result = tryDestructTile(world, 0, 0); - + expect(result).toBe(true); expect(world.tiles[0]).toBe(TileType.GRASS_SAPLINGS); }); @@ -131,49 +132,14 @@ describe('World Utilities', () => { const world = createTestWorld(10, 10, tiles); const result = tryDestructTile(world, 0, 0); - + expect(result).toBe(false); expect(world.tiles[0]).toBe(TileType.WALL); }); - + it('should return false for out of bounds', () => { - const world = createTestWorld(10, 10, new Array(100).fill(TileType.EMPTY)); - expect(tryDestructTile(world, -1, 0)).toBe(false); - }); - }); - - describe('isPlayerOnExit', () => { - it('should return true when player is on exit', () => { const world = createTestWorld(10, 10, new Array(100).fill(TileType.EMPTY)); - world.exit = { x: 5, y: 5 }; - - const mockAccessor = { - getPlayer: () => ({ pos: { x: 5, y: 5 } }) - } as any; - - expect(isPlayerOnExit(world, mockAccessor)).toBe(true); - }); - - it('should return false when player is not on exit', () => { - const world = createTestWorld(10, 10, new Array(100).fill(TileType.EMPTY)); - world.exit = { x: 5, y: 5 }; - - const mockAccessor = { - getPlayer: () => ({ pos: { x: 4, y: 4 } }) - } as any; - - expect(isPlayerOnExit(world, mockAccessor)).toBe(false); - }); - - it('should return false when player does not exist', () => { - const world = createTestWorld(10, 10, new Array(100).fill(TileType.EMPTY)); - world.exit = { x: 5, y: 5 }; - - const mockAccessor = { - getPlayer: () => null - } as any; - - expect(isPlayerOnExit(world, mockAccessor)).toBe(false); + expect(tryDestructTile(world, -1, 0)).toBe(false); }); }); }); diff --git a/src/engine/ecs/EntityBuilder.ts b/src/engine/ecs/EntityBuilder.ts index d22ef8c..ef0e716 100644 --- a/src/engine/ecs/EntityBuilder.ts +++ b/src/engine/ecs/EntityBuilder.ts @@ -174,7 +174,7 @@ export class EntityBuilder { effectDuration?: number; }): this { this.components.trigger = { - onEnter: options.onEnter ?? true, + onEnter: options.onEnter ?? false, onExit: options.onExit, onInteract: options.onInteract, oneShot: options.oneShot, diff --git a/src/engine/ecs/Prefabs.ts b/src/engine/ecs/Prefabs.ts index 35c5a2e..aac116a 100644 --- a/src/engine/ecs/Prefabs.ts +++ b/src/engine/ecs/Prefabs.ts @@ -243,9 +243,11 @@ export const Prefabs = { return EntityBuilder.create(world) .withPosition(x, y) .withName("Track Switch") - .withSprite("dungeon", 31) // TileType.SWITCH_OFF + .withSprite("track_switch", 0) .asTrigger({ + onEnter: false, onInteract: true, + oneShot: true, targetId: cartId }) .build(); diff --git a/src/engine/ecs/__tests__/ECSRemoval.test.ts b/src/engine/ecs/__tests__/ECSRemoval.test.ts index c85ff4c..7eb4914 100644 --- a/src/engine/ecs/__tests__/ECSRemoval.test.ts +++ b/src/engine/ecs/__tests__/ECSRemoval.test.ts @@ -12,7 +12,8 @@ describe('ECS Removal and Accessor', () => { width: 10, height: 10, tiles: new Array(100).fill(0), - exit: { x: 0, y: 0 } + exit: { x: 0, y: 0 }, + trackPath: [] }; const accessor = new EntityAccessor(gameWorld, 999 as EntityId, ecsWorld); diff --git a/src/engine/ecs/systems/TriggerSystem.ts b/src/engine/ecs/systems/TriggerSystem.ts index 395eb0a..edfe438 100644 --- a/src/engine/ecs/systems/TriggerSystem.ts +++ b/src/engine/ecs/systems/TriggerSystem.ts @@ -105,9 +105,9 @@ export class TriggerSystem extends System { if (mineCart) { mineCart.isMoving = true; - // Change switch sprite to "on" (using dungeon sprite 32) + // Change switch sprite if applicable (optional for now as we only have one frame) const sprite = world.getComponent(triggerId, "sprite"); - if (sprite) { + if (sprite && sprite.texture === "dungeon") { sprite.index = 32; } } @@ -149,9 +149,9 @@ export class TriggerSystem extends System { if (trigger.oneShot) { trigger.triggered = true; - // Change sprite to triggered appearance (dungeon sprite 23) + // Change sprite to triggered appearance if it's a dungeon sprite const sprite = world.getComponent(triggerId, "sprite"); - if (sprite) { + if (sprite && sprite.texture === "dungeon") { sprite.index = 23; // Triggered/spent trap appearance } } diff --git a/src/engine/gameplay/__tests__/CombatLogic.test.ts b/src/engine/gameplay/__tests__/CombatLogic.test.ts index af3f137..545c7db 100644 --- a/src/engine/gameplay/__tests__/CombatLogic.test.ts +++ b/src/engine/gameplay/__tests__/CombatLogic.test.ts @@ -16,13 +16,14 @@ describe('CombatLogic', () => { const setWall = (x: number, y: number) => { mockWorld.tiles[y * mockWorld.width + x] = TileType.WALL; }; - + beforeEach(() => { mockWorld = { width: 10, height: 10, tiles: new Array(100).fill(TileType.EMPTY), - exit: { x: 9, y: 9 } + exit: { x: 9, y: 9 }, + trackPath: [] }; ecsWorld = new ECSWorld(); // Shooter ID 1 @@ -44,12 +45,12 @@ describe('CombatLogic', () => { it('should travel full path if no obstacles', () => { const start = { x: 0, y: 0 }; const end = { x: 5, y: 0 }; - + const result = traceProjectile(mockWorld, start, end, accessor); - + expect(result.blockedPos).toEqual(end); expect(result.hitActorId).toBeUndefined(); - expect(result.path).toHaveLength(6); + expect(result.path).toHaveLength(6); }); it('should stop at wall', () => { @@ -58,7 +59,7 @@ describe('CombatLogic', () => { setWall(3, 0); // Wall at (3,0) const result = traceProjectile(mockWorld, start, end, accessor); - + expect(result.blockedPos).toEqual({ x: 2, y: 0 }); expect(result.hitActorId).toBeUndefined(); }); @@ -66,7 +67,7 @@ describe('CombatLogic', () => { it('should stop at enemy', () => { const start = { x: 0, y: 0 }; const end = { x: 5, y: 0 }; - + // Place enemy at (3,0) const enemyId = 2 as EntityId; const enemy = { @@ -79,36 +80,36 @@ describe('CombatLogic', () => { syncActor(enemy); const result = traceProjectile(mockWorld, start, end, accessor, 1 as EntityId); // Shooter 1 - + expect(result.blockedPos).toEqual({ x: 3, y: 0 }); expect(result.hitActorId).toBe(enemyId); }); it('should ignore shooter position', () => { - const start = { x: 0, y: 0 }; - const end = { x: 5, y: 0 }; - - // Shooter at start - const shooter = { - id: 1 as EntityId, - type: 'player', - category: 'combatant', - pos: { x: 0, y: 0 }, - isPlayer: true - }; - syncActor(shooter); + const start = { x: 0, y: 0 }; + const end = { x: 5, y: 0 }; - const result = traceProjectile(mockWorld, start, end, accessor, 1 as EntityId); - - // Should not hit self - expect(result.hitActorId).toBeUndefined(); - expect(result.blockedPos).toEqual(end); + // Shooter at start + const shooter = { + id: 1 as EntityId, + type: 'player', + category: 'combatant', + pos: { x: 0, y: 0 }, + isPlayer: true + }; + syncActor(shooter); + + const result = traceProjectile(mockWorld, start, end, accessor, 1 as EntityId); + + // Should not hit self + expect(result.hitActorId).toBeUndefined(); + expect(result.blockedPos).toEqual(end); }); it('should ignore non-combatant actors (e.g. items)', () => { const start = { x: 0, y: 0 }; const end = { x: 5, y: 0 }; - + // Item at (3,0) const item = { id: 99 as EntityId, @@ -119,10 +120,10 @@ describe('CombatLogic', () => { syncActor(item); const result = traceProjectile(mockWorld, start, end, accessor); - + // Should pass through item expect(result.blockedPos).toEqual(end); expect(result.hitActorId).toBeUndefined(); - }); + }); }); }); diff --git a/src/engine/gameplay/__tests__/FireableWeapons.test.ts b/src/engine/gameplay/__tests__/FireableWeapons.test.ts index dff46d6..41b8adf 100644 --- a/src/engine/gameplay/__tests__/FireableWeapons.test.ts +++ b/src/engine/gameplay/__tests__/FireableWeapons.test.ts @@ -18,12 +18,13 @@ describe("Fireable Weapons & Ammo System", () => { width: 10, height: 10, tiles: new Array(100).fill(0), - exit: { x: 9, y: 9 } + exit: { x: 9, y: 9 }, + trackPath: [] }; ecsWorld = new ECSWorld(); accessor = new EntityAccessor(world, 1 as EntityId, ecsWorld); itemManager = new ItemManager(world, accessor, ecsWorld); - + player = { id: 1 as EntityId, pos: { x: 0, y: 0 }, @@ -53,14 +54,14 @@ describe("Fireable Weapons & Ammo System", () => { ecsWorld.addComponent(player.id, "actorType", { type: "player" }); ecsWorld.addComponent(player.id, "inventory", player.inventory!); ecsWorld.addComponent(player.id, "energy", { current: 0, speed: 100 }); - + // Avoid ID collisions between manually added player (ID 1) and spawned entities ecsWorld.setNextId(10); }); it("should stack ammo correctly", () => { const playerActor = accessor.getPlayer()!; - + // Spawn Ammo pack 1 const ammo1 = createAmmo("ammo_9mm", 10); itemManager.spawnItem(ammo1, { x: 0, y: 0 }); @@ -85,7 +86,7 @@ describe("Fireable Weapons & Ammo System", () => { // Create pistol using factory (already has currentAmmo initialized) const pistol = createRangedWeapon("pistol"); playerActor.inventory!.items.push(pistol); - + // Sanity Check - currentAmmo is now top-level expect(pistol.currentAmmo).toBe(6); expect(pistol.stats.magazineSize).toBe(6); @@ -110,7 +111,7 @@ describe("Fireable Weapons & Ammo System", () => { // Logic mimic from GameScene const needed = pistol.stats.magazineSize - pistol.currentAmmo; // 6 const toTake = Math.min(needed, ammo.quantity!); // 6 - + pistol.currentAmmo += toTake; ammo.quantity! -= toTake; @@ -121,7 +122,7 @@ describe("Fireable Weapons & Ammo System", () => { it("should handle partial reload if not enough ammo", () => { const playerActor = accessor.getPlayer()!; const pistol = createRangedWeapon("pistol"); - pistol.currentAmmo = 0; + pistol.currentAmmo = 0; playerActor.inventory!.items.push(pistol); const ammo = createAmmo("ammo_9mm", 3); // Only 3 bullets @@ -130,32 +131,32 @@ describe("Fireable Weapons & Ammo System", () => { // Logic mimic const needed = pistol.stats.magazineSize - pistol.currentAmmo; // 6 const toTake = Math.min(needed, ammo.quantity!); // 3 - + pistol.currentAmmo += toTake; ammo.quantity! -= toTake; expect(pistol.currentAmmo).toBe(3); expect(ammo.quantity).toBe(0); }); - + it("should deep clone on spawn so pistols remain independent", () => { - const playerActor = accessor.getPlayer()!; - const pistol1 = createRangedWeapon("pistol"); - - // Spawn 1 - itemManager.spawnItem(pistol1, {x:0, y:0}); - const picked1 = itemManager.tryPickup(playerActor)! as RangedWeaponItem; - - // Spawn 2 - const pistol2 = createRangedWeapon("pistol"); - itemManager.spawnItem(pistol2, {x:0, y:0}); - const picked2 = itemManager.tryPickup(playerActor)! as RangedWeaponItem; - - expect(picked1).not.toBe(picked2); - expect(picked1.stats).not.toBe(picked2.stats); // Critical! - - // Modifying one should not affect other - picked1.currentAmmo = 0; - expect(picked2.currentAmmo).toBe(6); + const playerActor = accessor.getPlayer()!; + const pistol1 = createRangedWeapon("pistol"); + + // Spawn 1 + itemManager.spawnItem(pistol1, { x: 0, y: 0 }); + const picked1 = itemManager.tryPickup(playerActor)! as RangedWeaponItem; + + // Spawn 2 + const pistol2 = createRangedWeapon("pistol"); + itemManager.spawnItem(pistol2, { x: 0, y: 0 }); + const picked2 = itemManager.tryPickup(playerActor)! as RangedWeaponItem; + + expect(picked1).not.toBe(picked2); + expect(picked1.stats).not.toBe(picked2.stats); // Critical! + + // Modifying one should not affect other + picked1.currentAmmo = 0; + expect(picked2.currentAmmo).toBe(6); }); }); diff --git a/src/engine/simulation/__tests__/movement_block.test.ts b/src/engine/simulation/__tests__/movement_block.test.ts index 0b80d37..fab48c6 100644 --- a/src/engine/simulation/__tests__/movement_block.test.ts +++ b/src/engine/simulation/__tests__/movement_block.test.ts @@ -19,11 +19,12 @@ describe('Movement Blocking Behavior', () => { width: 3, height: 3, tiles: new Array(9).fill(TileType.GRASS), - exit: { x: 2, y: 2 } + exit: { x: 2, y: 2 }, + trackPath: [] }; - + // Blocking wall at (1, 0) - world.tiles[1] = TileType.WALL; + world.tiles[1] = TileType.WALL; player = { id: 1 as EntityId, @@ -35,7 +36,7 @@ describe('Movement Blocking Behavior', () => { energy: 0, stats: { ...GAME_CONFIG.player.initialStats } }; - + ecsWorld = new ECSWorld(); ecsWorld.addComponent(player.id, "position", player.pos); ecsWorld.addComponent(player.id, "stats", player.stats); @@ -49,7 +50,7 @@ describe('Movement Blocking Behavior', () => { it('should return move-blocked event when moving into a wall', () => { const action: Action = { type: 'move', dx: 1, dy: 0 }; // Try to move right into wall at (1,0) const events = applyAction(world, player.id, action, accessor); - + expect(events).toHaveLength(1); expect(events[0]).toMatchObject({ type: 'move-blocked', @@ -62,7 +63,7 @@ describe('Movement Blocking Behavior', () => { it('should return moved event when moving into empty space', () => { const action: Action = { type: 'move', dx: 0, dy: 1 }; // Move down to (0,1) - valid const events = applyAction(world, player.id, action, accessor); - + expect(events).toHaveLength(1); expect(events[0]).toMatchObject({ type: 'moved', diff --git a/src/engine/world/generator.ts b/src/engine/world/generator.ts index e2c1282..6d608fd 100644 --- a/src/engine/world/generator.ts +++ b/src/engine/world/generator.ts @@ -99,8 +99,25 @@ export function generateWorld(floor: number, runState: RunState): { world: World const exit = { ...trackPath[trackPath.length - 1] }; - // Place Switch at the end of the track - Prefabs.trackSwitch(ecsWorld, exit.x, exit.y, cartId); + // Place Switch adjacent to the end of the track + let switchPos = { x: exit.x, y: exit.y }; + const neighbors = [ + { x: exit.x + 1, y: exit.y }, + { x: exit.x - 1, y: exit.y }, + { x: exit.x, y: exit.y + 1 }, + { x: exit.x, y: exit.y - 1 }, + ]; + for (const n of neighbors) { + if (n.x >= 1 && n.x < width - 1 && n.y >= 1 && n.y < height - 1) { + const t = tiles[n.y * width + n.x]; + if (t === TileType.EMPTY || t === TileType.EMPTY_DECO || t === TileType.GRASS || t === TileType.TRACK) { + switchPos = n; + // Don't break if it's track, try to find a real empty spot first + if (t !== TileType.TRACK) break; + } + } + } + Prefabs.trackSwitch(ecsWorld, switchPos.x, switchPos.y, cartId); // Mark all track and room tiles as occupied for objects const occupiedPositions = new Set(); @@ -366,7 +383,7 @@ function placeEnemies( const room = rooms[roomIdx]; // Try to find an empty spot in the room - for (let attempts = 0; attempts < 5; attempts++) { + for (let attempts = 0; attempts < 20; attempts++) { const ex = room.x + 1 + Math.floor(random() * (room.width - 2)); const ey = room.y + 1 + Math.floor(random() * (room.height - 2)); @@ -389,6 +406,9 @@ function placeEnemies( EntityBuilder.create(ecsWorld) .asEnemy(type) .withPosition(ex, ey) + .withSprite(type, 0) + .withName(type.charAt(0).toUpperCase() + type.slice(1)) + .withCombat() .withStats({ maxHp: scaledHp + Math.floor(random() * 4), hp: scaledHp + Math.floor(random() * 4), @@ -396,7 +416,6 @@ function placeEnemies( defense: enemyDef.baseDefense, }) .withEnergy(speed) // Configured speed - // Note: Other stats like crit/evasion are defaults from EntityBuilder or BaseStats .build(); occupiedPositions.add(k); diff --git a/src/engine/world/world-logic.ts b/src/engine/world/world-logic.ts index a5dd33d..aa6a251 100644 --- a/src/engine/world/world-logic.ts +++ b/src/engine/world/world-logic.ts @@ -43,7 +43,20 @@ export function isBlocked(w: World, x: number, y: number, accessor: EntityAccess if (!accessor) return false; const actors = accessor.getActorsAt(x, y); - return actors.some(a => a.category === "combatant"); + if (actors.some(a => a.category === "combatant")) return true; + + // Check for interactable entities (switches, etc.) that should block movement + if (accessor.context) { + const ecs = accessor.context; + const isInteractable = ecs.getEntitiesWith("position", "trigger").some(id => { + const p = ecs.getComponent(id, "position"); + const t = ecs.getComponent(id, "trigger"); + return p?.x === x && p?.y === y && t?.onInteract; + }); + if (isInteractable) return true; + } + + return false; } diff --git a/src/rendering/DungeonRenderer.ts b/src/rendering/DungeonRenderer.ts index 6222f01..285a2ad 100644 --- a/src/rendering/DungeonRenderer.ts +++ b/src/rendering/DungeonRenderer.ts @@ -148,7 +148,7 @@ export class DungeonRenderer { const spriteData = this.ecsWorld.getComponent(entId, "sprite"); if (pos && spriteData) { try { - const isStandalone = spriteData.texture === "mine_cart" || spriteData.texture === "ceramic_dragon_head"; + const isStandalone = spriteData.texture === "mine_cart" || spriteData.texture === "ceramic_dragon_head" || spriteData.texture === "track_switch"; const sprite = this.scene.add.sprite( pos.x * TILE_SIZE + TILE_SIZE / 2, pos.y * TILE_SIZE + TILE_SIZE / 2, diff --git a/src/rendering/__tests__/DungeonRenderer.test.ts b/src/rendering/__tests__/DungeonRenderer.test.ts index 3d3d339..2be0493 100644 --- a/src/rendering/__tests__/DungeonRenderer.test.ts +++ b/src/rendering/__tests__/DungeonRenderer.test.ts @@ -142,7 +142,7 @@ describe('DungeonRenderer', () => { killTweensOf: vi.fn(), }, time: { - now: 0 + now: 0 } }; @@ -152,6 +152,7 @@ describe('DungeonRenderer', () => { height: 10, tiles: new Array(100).fill(0), exit: { x: 9, y: 9 }, + trackPath: [] }; ecsWorld = new ECSWorld(); accessor = new EntityAccessor(mockWorld, 1 as EntityId, ecsWorld); @@ -186,7 +187,7 @@ describe('DungeonRenderer', () => { it('should render exp_orb correctly', () => { renderer.initializeFloor(mockWorld, ecsWorld, accessor); - + // Add an exp_orb to the ECS world ecsWorld.addComponent(2 as EntityId, "position", { x: 2, y: 1 }); ecsWorld.addComponent(2 as EntityId, "collectible", { type: "exp_orb", amount: 10 }); @@ -206,7 +207,7 @@ describe('DungeonRenderer', () => { it('should render any enemy type as a sprite', () => { renderer.initializeFloor(mockWorld, ecsWorld, accessor); - + // Add a rat ecsWorld.addComponent(3 as EntityId, "position", { x: 3, y: 1 }); ecsWorld.addComponent(3 as EntityId, "actorType", { type: "rat" }); @@ -224,7 +225,7 @@ describe('DungeonRenderer', () => { it('should initialize new enemy sprites at target position and not tween them', () => { renderer.initializeFloor(mockWorld, ecsWorld, accessor); - + // Position 5,5 -> 5*16 + 8 = 88 const TILE_SIZE = 16; const targetX = 5 * TILE_SIZE + TILE_SIZE / 2; @@ -242,7 +243,7 @@ describe('DungeonRenderer', () => { // Check spawn position expect(mockScene.add.sprite).toHaveBeenCalledWith(targetX, targetY, 'rat', 0); - + // Should NOT tween because it's the first spawn expect(mockScene.tweens.add).not.toHaveBeenCalled(); }); diff --git a/src/scenes/systems/__tests__/ItemManager.test.ts b/src/scenes/systems/__tests__/ItemManager.test.ts index 4d3c2f2..c2a84b1 100644 --- a/src/scenes/systems/__tests__/ItemManager.test.ts +++ b/src/scenes/systems/__tests__/ItemManager.test.ts @@ -13,7 +13,8 @@ describe('ItemManager', () => { width: 10, height: 10, tiles: new Array(100).fill(1), // Floor - exit: { x: 9, y: 9 } + exit: { x: 9, y: 9 }, + trackPath: [] }; entityAccessor = {