From ef7d85750f9ee87c29e32bf1a5840d37c428d955 Mon Sep 17 00:00:00 2001 From: Peter Stockings Date: Mon, 26 Jan 2026 15:30:14 +1100 Subject: [PATCH] Begin refactoring GameScene --- src/core/types.ts | 2 +- src/engine/EntityAccessor.ts | 348 ++++++++++++++++++ src/engine/EntityManager.ts | 160 -------- src/engine/__tests__/EntityAccessor.test.ts | 275 ++++++++++++++ src/engine/__tests__/EntityManager.test.ts | 132 ------- src/engine/__tests__/PrefabTrap.test.ts | 64 ++++ src/engine/__tests__/TriggerRepro.test.ts | 55 +++ src/engine/__tests__/ai_behavior.test.ts | 113 ++++-- src/engine/__tests__/combat_logic.test.ts | 72 +++- src/engine/__tests__/generator.test.ts | 75 ++-- src/engine/__tests__/inventory.test.ts | 76 ++-- src/engine/__tests__/pathfinding.test.ts | 27 +- src/engine/__tests__/simulation.test.ts | 301 +++++++++------ src/engine/__tests__/throwing.test.ts | 25 +- src/engine/__tests__/world.test.ts | 62 ++-- src/engine/ecs/AISystem.ts | 16 +- src/engine/ecs/EntityBuilder.ts | 22 +- src/engine/ecs/MovementSystem.ts | 17 +- src/engine/ecs/Prefabs.ts | 8 +- src/engine/ecs/World.ts | 9 + src/engine/ecs/__tests__/ECSRemoval.test.ts | 38 ++ src/engine/ecs/components.ts | 11 +- src/engine/gameplay/CombatLogic.ts | 20 +- .../gameplay/__tests__/CombatLogic.test.ts | 87 ++--- .../__tests__/FireableWeapons.test.ts | 84 +++-- .../__tests__/movement_block.test.ts | 23 +- src/engine/simulation/simulation.ts | 185 +++------- src/engine/systems/EquipmentService.ts | 134 +++++++ .../__tests__/EquipmentService.test.ts | 231 ++++++++++++ .../world/__tests__/DebuggingStack.test.ts | 41 +++ src/engine/world/generator.ts | 102 +++-- src/engine/world/pathfinding.ts | 8 +- src/engine/world/world-logic.ts | 23 +- src/rendering/DungeonRenderer.ts | 46 ++- src/rendering/FovManager.ts | 9 +- src/rendering/MinimapRenderer.ts | 20 +- .../__tests__/DungeonRenderer.test.ts | 110 +++--- src/scenes/GameScene.ts | 328 ++++++----------- src/scenes/__tests__/GameScene.test.ts | 115 +++--- src/scenes/systems/EventRenderer.ts | 115 ++++++ src/scenes/systems/ItemManager.ts | 43 ++- src/scenes/systems/TargetingSystem.ts | 24 +- .../systems/__tests__/ItemManager.test.ts | 62 ++++ .../systems/__tests__/TargetingSystem.test.ts | 20 +- src/ui/GameUI.ts | 8 +- src/ui/components/InventoryOverlay.ts | 4 +- 46 files changed, 2459 insertions(+), 1291 deletions(-) create mode 100644 src/engine/EntityAccessor.ts delete mode 100644 src/engine/EntityManager.ts create mode 100644 src/engine/__tests__/EntityAccessor.test.ts delete mode 100644 src/engine/__tests__/EntityManager.test.ts create mode 100644 src/engine/__tests__/PrefabTrap.test.ts create mode 100644 src/engine/__tests__/TriggerRepro.test.ts create mode 100644 src/engine/ecs/__tests__/ECSRemoval.test.ts create mode 100644 src/engine/systems/EquipmentService.ts create mode 100644 src/engine/systems/__tests__/EquipmentService.test.ts create mode 100644 src/engine/world/__tests__/DebuggingStack.test.ts create mode 100644 src/scenes/systems/EventRenderer.ts create mode 100644 src/scenes/systems/__tests__/ItemManager.test.ts diff --git a/src/core/types.ts b/src/core/types.ts index 9f67b44..43fcb2a 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -206,13 +206,13 @@ export type World = { width: number; height: number; tiles: Tile[]; - actors: Map; exit: Vec2; }; export interface UIUpdatePayload { world: World; playerId: EntityId; + player: CombatantActor | null; // Added for ECS Access floorIndex: number; uiState: { targetingItemId: string | null; diff --git a/src/engine/EntityAccessor.ts b/src/engine/EntityAccessor.ts new file mode 100644 index 0000000..8638691 --- /dev/null +++ b/src/engine/EntityAccessor.ts @@ -0,0 +1,348 @@ +import type { + World, + EntityId, + Actor, + CombatantActor, + CollectibleActor, + ItemDropActor, + Vec2, + EnemyAIState +} from "../core/types"; +import type { ECSWorld } from "./ecs/World"; + +/** + * Centralized accessor for game entities. + * Provides a unified interface for querying actors from the World. + * + * This facade: + * - Centralizes entity access patterns + * - Makes it easy to migrate to ECS later + * - Reduces scattered world.actors calls + */ +export class EntityAccessor { + private _playerId: EntityId; + private ecsWorld: ECSWorld; + private actorCache: Map = new Map(); + + constructor( + _world: World, + playerId: EntityId, + ecsWorld: ECSWorld + ) { + this._playerId = playerId; + this.ecsWorld = ecsWorld; + } + + /** + * Updates the world reference (called when loading new floors). + */ + updateWorld(_world: World, playerId: EntityId, ecsWorld: ECSWorld): void { + this._playerId = playerId; + this.ecsWorld = ecsWorld; + this.actorCache.clear(); + } + + private entityToActor(id: EntityId): Actor | null { + if (!this.ecsWorld) return null; + + // Check cache first + const cached = this.actorCache.get(id); + if (cached) { + // Double check it still exists in ECS + if (!this.ecsWorld.hasEntity(id)) { + this.actorCache.delete(id); + return null; + } + return cached; + } + + const pos = this.ecsWorld.getComponent(id, "position"); + if (!pos) return null; + + // Check for combatant + const stats = this.ecsWorld.getComponent(id, "stats"); + const actorType = this.ecsWorld.getComponent(id, "actorType"); + + if (stats && actorType) { + const energyComp = this.ecsWorld.getComponent(id, "energy"); + const playerComp = this.ecsWorld.getComponent(id, "player"); + const ai = this.ecsWorld.getComponent(id, "ai"); + const inventory = this.ecsWorld.getComponent(id, "inventory"); + const equipment = this.ecsWorld.getComponent(id, "equipment"); + + // Create a proxy-like object to ensure writes persist to ECS components + let localEnergy = 0; + const actor = { + id, + // Pass Reference to PositionComponent so moves persist + pos: pos, + category: "combatant", + isPlayer: !!playerComp, + type: actorType.type, + // Pass Reference to StatsComponent + stats: stats, + + // Speed defaults + speed: energyComp?.speed ?? 100, + + // Pass Reference (or fallback) + inventory: inventory ?? { gold: 0, items: [] }, + equipment: equipment + } as CombatantActor; + + // Manually define 'energy' property to proxy to component + Object.defineProperty(actor, 'energy', { + get: () => energyComp ? energyComp.current : localEnergy, + set: (v: number) => { + if (energyComp) { + energyComp.current = v; + } else { + localEnergy = v; + } + }, + enumerable: true, + configurable: true + }); + + // Proxy AI state properties + Object.defineProperty(actor, 'aiState', { + get: () => ai?.state, + set: (v: EnemyAIState) => { if (ai) ai.state = v; }, + enumerable: true, + configurable: true + }); + Object.defineProperty(actor, 'alertedAt', { + get: () => ai?.alertedAt, + set: (v: number) => { if (ai) ai.alertedAt = v; }, + enumerable: true, + configurable: true + }); + Object.defineProperty(actor, 'lastKnownPlayerPos', { + get: () => ai?.lastKnownPlayerPos, + set: (v: Vec2) => { if (ai) ai.lastKnownPlayerPos = v; }, + enumerable: true, + configurable: true + }); + + this.actorCache.set(id, actor); + return actor; + } + + // Check for collectible + const collectible = this.ecsWorld.getComponent(id, "collectible"); + if (collectible) { + const actor = { + id, + pos: pos, // Reference + category: "collectible", + type: "exp_orb", + expAmount: collectible.amount + } as CollectibleActor; + this.actorCache.set(id, actor); + return actor; + } + + // Check for Item Drop + const groundItem = this.ecsWorld.getComponent(id, "groundItem"); + if (groundItem) { + const actor = { + id, + pos: pos, + category: "item_drop", + item: groundItem.item + } as ItemDropActor; + this.actorCache.set(id, actor); + return actor; + } + + return null; + } + + // ========================================== + // Player Access + // ========================================== + + /** + * Gets the player's entity ID. + */ + get playerId(): EntityId { + return this._playerId; + } + + /** + * Gets the player entity. + */ + getPlayer(): CombatantActor | null { + const actor = this.entityToActor(this._playerId); + if (actor?.category === "combatant") return actor as CombatantActor; + return null; + } + + /** + * Gets the player's current position. + */ + getPlayerPos(): Vec2 | null { + const player = this.getPlayer(); + return player ? { ...player.pos } : null; + } + + /** + * Checks if the player exists (is alive). + */ + isPlayerAlive(): boolean { + return this.ecsWorld.hasEntity(this._playerId) && (this.ecsWorld.getComponent(this._playerId, "position") !== undefined); + } + + // ========================================== + // Generic Actor Access + // ========================================== + + /** + * Gets any actor by ID. + */ + getActor(id: EntityId): Actor | null { + return this.entityToActor(id); + } + + /** + * Gets a combatant actor by ID. + */ + getCombatant(id: EntityId): CombatantActor | null { + const actor = this.entityToActor(id); + if (actor?.category === "combatant") return actor as CombatantActor; + return null; + } + + /** + * Checks if an actor exists. + */ + hasActor(id: EntityId): boolean { + return this.ecsWorld.hasEntity(id) && (this.ecsWorld.getComponent(id, "position") !== undefined); + } + + // ========================================== + // Spatial Queries + // ========================================== + + /** + * Gets all actors at a specific position. + */ + getActorsAt(x: number, y: number): Actor[] { + // Query ECS + return [...this.getAllActors()].filter(a => a.pos.x === x && a.pos.y === y); + } + + /** + * Finds an enemy combatant at a specific position. + */ + findEnemyAt(x: number, y: number): CombatantActor | null { + const actors = this.getActorsAt(x, y); + for (const actor of actors) { + if (actor.category === "combatant" && !actor.isPlayer) { + return actor; + } + } + return null; + } + + /** + * Checks if there's any enemy at the given position. + */ + hasEnemyAt(x: number, y: number): boolean { + return this.findEnemyAt(x, y) !== null; + } + + /** + * Finds a collectible at a specific position. + */ + findCollectibleAt(x: number, y: number): CollectibleActor | null { + const actors = this.getActorsAt(x, y); + for (const actor of actors) { + if (actor.category === "collectible") { + return actor; + } + } + return null; + } + + /** + * Finds an item drop at a specific position. + */ + findItemDropAt(x: number, y: number): ItemDropActor | null { + const actors = this.getActorsAt(x, y); + for (const actor of actors) { + if (actor.category === "item_drop") { + return actor; + } + } + return null; + } + + // ========================================== + // Collection Queries + // ========================================== + + /** + * Gets all enemy combatants in the world. + */ + getEnemies(): CombatantActor[] { + return [...this.getAllActors()].filter( + (a): a is CombatantActor => a.category === "combatant" && !a.isPlayer + ); + } + + /** + * Gets all combatants (player + enemies). + */ + getCombatants(): CombatantActor[] { + return [...this.getAllActors()].filter( + (a): a is CombatantActor => a.category === "combatant" + ); + } + + /** + * Gets all collectibles (exp orbs, etc.). + */ + getCollectibles(): CollectibleActor[] { + return [...this.getAllActors()].filter( + (a): a is CollectibleActor => a.category === "collectible" + ); + } + + /** + * Gets all item drops. + */ + getItemDrops(): ItemDropActor[] { + return [...this.getAllActors()].filter( + (a): a is ItemDropActor => a.category === "item_drop" + ); + } + + /** + * Iterates over all actors (for rendering, etc.). + */ + getAllActors(): IterableIterator { + const actors: Actor[] = []; + // Get all entities with position (candidates) + const entities = this.ecsWorld.getEntitiesWith("position"); + for (const id of entities) { + const actor = this.entityToActor(id); + if (actor) actors.push(actor); + } + return actors.values(); + } + + /** + * Removes an actor from the world. + */ + removeActor(id: EntityId): void { + this.ecsWorld.destroyEntity(id); + } + + /** + * Access to the raw ECS world if needed for specialized systems. + */ + get context(): ECSWorld | undefined { + return this.ecsWorld; + } +} diff --git a/src/engine/EntityManager.ts b/src/engine/EntityManager.ts deleted file mode 100644 index c3be0b6..0000000 --- a/src/engine/EntityManager.ts +++ /dev/null @@ -1,160 +0,0 @@ -import { type World, type EntityId, type Actor, type Vec2, type CombatantActor } from "../core/types"; -import { idx } from "./world/world-logic"; -import { ECSWorld } from "./ecs/World"; -import { MovementSystem } from "./ecs/MovementSystem"; -import { AISystem } from "./ecs/AISystem"; - -export class EntityManager { - private grid: Map = new Map(); - private actors: Map; - private world: World; - private lastId: number = 0; - private ecs: ECSWorld; - private movementSystem: MovementSystem; - private aiSystem: AISystem; - - constructor(world: World) { - this.world = world; - this.actors = world.actors; - this.ecs = new ECSWorld(); - this.movementSystem = new MovementSystem(this.ecs, this.world, this); - this.aiSystem = new AISystem(this.ecs, this.world, this); - this.lastId = Math.max(0, ...this.actors.keys()); - this.ecs.setNextId(this.lastId + 1); - - this.rebuildGrid(); - } - - get ecsWorld(): ECSWorld { - return this.ecs; - } - - get movement(): MovementSystem { - return this.movementSystem; - } - - get ai(): AISystem { - return this.aiSystem; - } - - rebuildGrid() { - this.grid.clear(); - // Also re-sync ECS if needed, though typically we do this once at start - for (const actor of this.actors.values()) { - this.syncActorToECS(actor); - this.addToGrid(actor); - } - } - - private syncActorToECS(actor: Actor) { - const id = actor.id; - this.ecs.addComponent(id, "position", actor.pos); - this.ecs.addComponent(id, "name", { name: actor.id.toString() }); - - if (actor.category === "combatant") { - const c = actor as CombatantActor; - this.ecs.addComponent(id, "stats", c.stats); - this.ecs.addComponent(id, "energy", { current: c.energy, speed: c.speed }); - this.ecs.addComponent(id, "actorType", { type: c.type }); - if (c.isPlayer) { - this.ecs.addComponent(id, "player", {}); - } else { - this.ecs.addComponent(id, "ai", { - state: c.aiState || "wandering", - alertedAt: c.alertedAt, - lastKnownPlayerPos: c.lastKnownPlayerPos - }); - } - } else if (actor.category === "collectible") { - this.ecs.addComponent(id, "collectible", { type: "exp_orb", amount: actor.expAmount }); - } - } - - private addToGrid(actor: Actor) { - const i = idx(this.world, actor.pos.x, actor.pos.y); - if (!this.grid.has(i)) { - this.grid.set(i, []); - } - this.grid.get(i)!.push(actor.id); - } - - private removeFromGrid(actor: Actor) { - const i = idx(this.world, actor.pos.x, actor.pos.y); - const ids = this.grid.get(i); - if (ids) { - const index = ids.indexOf(actor.id); - if (index !== -1) { - ids.splice(index, 1); - } - if (ids.length === 0) { - this.grid.delete(i); - } - } - } - - moveActor(actorId: EntityId, from: Vec2, to: Vec2) { - const actor = this.actors.get(actorId); - if (!actor) return; - - // Remove from old position - const oldIdx = idx(this.world, from.x, from.y); - const ids = this.grid.get(oldIdx); - if (ids) { - const index = ids.indexOf(actorId); - if (index !== -1) ids.splice(index, 1); - if (ids.length === 0) this.grid.delete(oldIdx); - } - - // Update position - actor.pos.x = to.x; - actor.pos.y = to.y; - - // Update ECS - const posComp = this.ecs.getComponent(actorId, "position"); - if (posComp) { - posComp.x = to.x; - posComp.y = to.y; - } - - // Add to new position - const newIdx = idx(this.world, to.x, to.y); - if (!this.grid.has(newIdx)) this.grid.set(newIdx, []); - this.grid.get(newIdx)!.push(actorId); - } - - addActor(actor: Actor) { - this.actors.set(actor.id, actor); - this.syncActorToECS(actor); - this.addToGrid(actor); - } - - removeActor(actorId: EntityId) { - const actor = this.actors.get(actorId); - if (actor) { - this.removeFromGrid(actor); - this.ecs.destroyEntity(actorId); - this.actors.delete(actorId); - } - } - - getActorsAt(x: number, y: number): Actor[] { - const i = idx(this.world, x, y); - const ids = this.grid.get(i); - if (!ids) return []; - return ids.map(id => this.actors.get(id)!).filter(Boolean); - } - - isOccupied(x: number, y: number, ignoreType?: string): boolean { - const actors = this.getActorsAt(x, y); - if (ignoreType) { - return actors.some(a => a.type !== ignoreType); - } - return actors.length > 0; - } - - getNextId(): EntityId { - this.lastId++; - return this.lastId; - } -} - diff --git a/src/engine/__tests__/EntityAccessor.test.ts b/src/engine/__tests__/EntityAccessor.test.ts new file mode 100644 index 0000000..80483bd --- /dev/null +++ b/src/engine/__tests__/EntityAccessor.test.ts @@ -0,0 +1,275 @@ + +import { describe, it, expect, beforeEach } from "vitest"; +import { EntityAccessor } from "../EntityAccessor"; +import { ECSWorld } from "../ecs/World"; +import type { World, CombatantActor, CollectibleActor, ItemDropActor, Actor, EntityId } from "../../core/types"; + +function createMockWorld(): World { + return { + width: 10, + height: 10, + tiles: new Array(100).fill(0), + exit: { x: 9, y: 9 }, + }; +} + +function createPlayer(id: number, x: number, y: number): CombatantActor { + return { + id: id as EntityId, + pos: { x, y }, + category: "combatant", + isPlayer: true, + type: "player", + speed: 100, + energy: 0, + stats: { + maxHp: 20, hp: 20, maxMana: 10, mana: 10, + attack: 5, defense: 2, level: 1, exp: 0, expToNextLevel: 10, + critChance: 5, critMultiplier: 150, accuracy: 90, lifesteal: 0, + evasion: 5, blockChance: 0, luck: 0, + statPoints: 0, skillPoints: 0, strength: 10, dexterity: 10, intelligence: 10, + passiveNodes: [], + }, + }; +} + +function createEnemy(id: number, x: number, y: number, type: "rat" | "bat" = "rat"): CombatantActor { + return { + id: id as EntityId, + pos: { x, y }, + category: "combatant", + isPlayer: false, + type, + speed: 80, + energy: 0, + stats: { + maxHp: 10, hp: 10, maxMana: 0, mana: 0, + attack: 3, defense: 1, level: 1, exp: 0, expToNextLevel: 10, + critChance: 0, critMultiplier: 100, accuracy: 80, lifesteal: 0, + evasion: 0, blockChance: 0, luck: 0, + statPoints: 0, skillPoints: 0, strength: 5, dexterity: 5, intelligence: 5, + passiveNodes: [], + }, + }; +} + +function createExpOrb(id: number, x: number, y: number): CollectibleActor { + return { + id: id as EntityId, + pos: { x, y }, + category: "collectible", + type: "exp_orb", + expAmount: 5, + }; +} + +function createItemDrop(id: number, x: number, y: number): ItemDropActor { + return { + id: id as EntityId, + pos: { x, y }, + category: "item_drop", + item: { + id: "health_potion", + name: "Health Potion", + type: "Consumable", + textureKey: "items", + spriteIndex: 0, + }, + }; +} + +describe("EntityAccessor", () => { + let world: World; + let ecsWorld: ECSWorld; + let accessor: EntityAccessor; + const PLAYER_ID = 1; + + beforeEach(() => { + world = createMockWorld(); + ecsWorld = new ECSWorld(); + accessor = new EntityAccessor(world, PLAYER_ID as EntityId, ecsWorld); + }); + + 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" }); + } + } else if (actor.category === "collectible") { + 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 }); + } + } + + describe("Player Access", () => { + it("getPlayer returns player when exists", () => { + const player = createPlayer(PLAYER_ID, 5, 5); + syncActor(player); + + expect(accessor.getPlayer()?.id).toBe(player.id); + }); + + it("getPlayer returns null when player doesn't exist", () => { + expect(accessor.getPlayer()).toBeNull(); + }); + + it("getPlayerPos returns position copy", () => { + const player = createPlayer(PLAYER_ID, 3, 4); + syncActor(player); + + const pos = accessor.getPlayerPos(); + expect(pos).toEqual({ x: 3, y: 4 }); + + // Verify it's a copy + if (pos) { + pos.x = 99; + const freshPlayer = accessor.getPlayer(); + expect(freshPlayer?.pos.x).toBe(3); + } + }); + + it("isPlayerAlive returns true when player exists", () => { + syncActor(createPlayer(PLAYER_ID, 5, 5)); + expect(accessor.isPlayerAlive()).toBe(true); + }); + + it("isPlayerAlive returns false when player is dead", () => { + expect(accessor.isPlayerAlive()).toBe(false); + }); + }); + + describe("Generic Actor Access", () => { + it("getActor returns actor by ID", () => { + const enemy = createEnemy(2, 3, 3); + syncActor(enemy); + + expect(accessor.getActor(2 as EntityId)?.id).toBe(enemy.id); + }); + + it("getActor returns null for non-existent ID", () => { + expect(accessor.getActor(999 as EntityId)).toBeNull(); + }); + + it("getCombatant returns combatant by ID", () => { + const enemy = createEnemy(2, 3, 3); + syncActor(enemy); + + expect(accessor.getCombatant(2 as EntityId)?.id).toBe(enemy.id); + }); + + it("getCombatant returns null for non-combatant", () => { + const orb = createExpOrb(3, 5, 5); + syncActor(orb); + + expect(accessor.getCombatant(3 as EntityId)).toBeNull(); + }); + + it("hasActor returns true for existing actor", () => { + syncActor(createEnemy(2, 3, 3)); + expect(accessor.hasActor(2 as EntityId)).toBe(true); + }); + + it("hasActor returns false for non-existent ID", () => { + expect(accessor.hasActor(999 as EntityId)).toBe(false); + }); + }); + + describe("Spatial Queries", () => { + it("findEnemyAt returns enemy at position", () => { + const enemy = createEnemy(2, 4, 4); + syncActor(enemy); + + expect(accessor.findEnemyAt(4, 4)?.id).toBe(enemy.id); + }); + + it("findEnemyAt returns null when no enemy at position", () => { + syncActor(createPlayer(PLAYER_ID, 4, 4)); + expect(accessor.findEnemyAt(4, 4)).toBeNull(); + }); + + it("hasEnemyAt returns true when enemy exists at position", () => { + syncActor(createEnemy(2, 4, 4)); + expect(accessor.hasEnemyAt(4, 4)).toBe(true); + }); + + it("findCollectibleAt returns collectible at position", () => { + const orb = createExpOrb(3, 6, 6); + syncActor(orb); + + expect(accessor.findCollectibleAt(6, 6)?.id).toBe(orb.id); + }); + + it("findItemDropAt returns item drop at position", () => { + const drop = createItemDrop(4, 7, 7); + syncActor(drop); + + expect(accessor.findItemDropAt(7, 7)?.id).toBe(drop.id); + }); + }); + + describe("Collection Queries", () => { + beforeEach(() => { + syncActor(createPlayer(PLAYER_ID, 5, 5)); + syncActor(createEnemy(2, 3, 3)); + syncActor(createEnemy(3, 4, 4, "bat")); + syncActor(createExpOrb(4, 6, 6)); + syncActor(createItemDrop(5, 7, 7)); + }); + + it("getEnemies returns only non-player combatants", () => { + const enemies = accessor.getEnemies(); + expect(enemies.length).toBe(2); + expect(enemies.every(e => !e.isPlayer)).toBe(true); + }); + + it("getCombatants returns player and enemies", () => { + const combatants = accessor.getCombatants(); + expect(combatants.length).toBe(3); + }); + + it("getCollectibles returns only collectibles", () => { + const collectibles = accessor.getCollectibles(); + expect(collectibles.length).toBe(1); + expect(collectibles[0].id).toBe(4); + }); + + it("getItemDrops returns only item drops", () => { + const drops = accessor.getItemDrops(); + expect(drops.length).toBe(1); + expect(drops[0].id).toBe(5); + }); + }); + + 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); + newEcsWorld.addComponent(newPlayer.id, "actorType", { type: "player" }); + newEcsWorld.addComponent(newPlayer.id, "stats", newPlayer.stats); + 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__/EntityManager.test.ts b/src/engine/__tests__/EntityManager.test.ts deleted file mode 100644 index e0e1301..0000000 --- a/src/engine/__tests__/EntityManager.test.ts +++ /dev/null @@ -1,132 +0,0 @@ -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]); - }); - - - it('should handle removing non-existent actor gracefully', () => { - // Should not throw - entityManager.removeActor(999); - }); - - it('should handle moving non-existent actor gracefully', () => { - // Should not throw - entityManager.moveActor(999, { x: 0, y: 0 }, { x: 1, y: 1 }); - }); - - it('should handle moving an actor that is not in the grid at expected position (inconsistent state)', () => { - const actor: Actor = { id: 1, pos: { x: 0, y: 0 } } as any; - // Add to actors map but NOT to grid (simulating desync) - mockWorld.actors.set(1, actor); - - // Attempt move - entityManager.moveActor(1, { x: 0, y: 0 }, { x: 1, y: 1 }); - - expect(actor.pos.x).toBe(1); - expect(actor.pos.y).toBe(1); - // Should be added to new position in grid - expect(entityManager.getActorsAt(1, 1).map(a => a.id)).toContain(1); - }); - - it('should handle moving an actor that is in grid but ID not found in list (very rare edge case)', () => { - // Manually pollute grid with empty array for old pos - // This forces `ids` to exist but `indexOf` to return -1 - const idx = 0; // 0,0 - // @ts-ignore - entityManager.grid.set(idx, [999]); // occupied by someone else - - const actor: Actor = { id: 1, pos: { x: 0, y:0 } } as any; - mockWorld.actors.set(1, actor); - - entityManager.moveActor(1, { x: 0, y: 0 }, { x: 1, y: 1 }); - expect(actor.pos).toEqual({ x: 1, y: 1 }); - }); - -}); diff --git a/src/engine/__tests__/PrefabTrap.test.ts b/src/engine/__tests__/PrefabTrap.test.ts new file mode 100644 index 0000000..66b71d8 --- /dev/null +++ b/src/engine/__tests__/PrefabTrap.test.ts @@ -0,0 +1,64 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { TriggerSystem } from '../ecs/systems/TriggerSystem'; +import { ECSWorld } from '../ecs/World'; +import { EventBus } from '../ecs/EventBus'; +import { Prefabs } from '../ecs/Prefabs'; +import type { EntityId } from '../../core/types'; + +describe('Prefab Trap Integration', () => { + let world: ECSWorld; + let eventBus: EventBus; + let system: TriggerSystem; + + beforeEach(() => { + world = new ECSWorld(); + eventBus = new EventBus(); + system = new TriggerSystem(); + system.setEventBus(eventBus); + }); + + it('should trigger poison trap when player moves onto it', () => { + // Setup Player (ID 1) + const playerId = 1 as EntityId; + world.addComponent(playerId, 'position', { x: 1, y: 1 }); + world.addComponent(playerId, 'stats', { hp: 10, maxHp: 10 } as any); + world.addComponent(playerId, 'player', {}); + + // Setup Prefab Trap (ID 100) at (2, 1) + // Use a high ID to avoid collision (simulating generator fix) + world.setNextId(100); + const trapId = Prefabs.poisonTrap(world, 2, 1, 5, 2); + + // Register system (initializes entity positions) + system.onRegister(world); + + const spy = vi.spyOn(eventBus, 'emit'); + + // === MOVE PLAYER === + // Update Player Position to (2, 1) + const pos = world.getComponent(playerId, 'position'); + if (pos) pos.x = 2; // Move reference + + // Update System + system.update([trapId], world); + + // Expect trigger activated + expect(spy).toHaveBeenCalledWith(expect.objectContaining({ + type: 'trigger_activated', + triggerId: trapId, + activatorId: playerId + })); + + // Expect damage (magnitude 2) + expect(spy).toHaveBeenCalledWith(expect.objectContaining({ + type: 'damage', + amount: 2 + })); + + // Expect status applied + expect(spy).toHaveBeenCalledWith(expect.objectContaining({ + type: 'status_applied', + status: 'poison' + })); + }); +}); diff --git a/src/engine/__tests__/TriggerRepro.test.ts b/src/engine/__tests__/TriggerRepro.test.ts new file mode 100644 index 0000000..57cf45e --- /dev/null +++ b/src/engine/__tests__/TriggerRepro.test.ts @@ -0,0 +1,55 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { TriggerSystem } from '../ecs/systems/TriggerSystem'; +import { ECSWorld } from '../ecs/World'; +import { EventBus } from '../ecs/EventBus'; +import type { EntityId } from '../../core/types'; + +describe('TriggerSystem Integration', () => { + let world: ECSWorld; + let eventBus: EventBus; + let system: TriggerSystem; + + beforeEach(() => { + world = new ECSWorld(); + eventBus = new EventBus(); + system = new TriggerSystem(); + system.setEventBus(eventBus); + }); + + it('should trigger onEnter when player moves onto trap', () => { + // Setup Player (ID 1) + const playerId = 1 as EntityId; + const playerPos = { x: 1, y: 1 }; + world.addComponent(playerId, 'position', playerPos); + world.addComponent(playerId, 'player', {}); + + // Setup Trap (ID 100) at (2, 1) + const trapId = 100 as EntityId; + world.addComponent(trapId, 'position', { x: 2, y: 1 }); + world.addComponent(trapId, 'trigger', { + onEnter: true, + damage: 10 + }); + + // Register system (initializes entity positions) + system.onRegister(world); + + // Verify initial state: Player at (1,1), Trap at (2,1) + // System tracking: Player at (1,1) + const spy = vi.spyOn(eventBus, 'emit'); + + // === MOVE PLAYER === + // Simulate MovementSystem update + playerPos.x = 2; // Move to (2,1) directly (reference update) + + // System Update + system.update([trapId], world); + + // Expect trigger activation + expect(spy).toHaveBeenCalledWith(expect.objectContaining({ + type: 'trigger_activated', + triggerId: trapId, + activatorId: playerId + })); + }); +}); diff --git a/src/engine/__tests__/ai_behavior.test.ts b/src/engine/__tests__/ai_behavior.test.ts index 99786b7..2169fae 100644 --- a/src/engine/__tests__/ai_behavior.test.ts +++ b/src/engine/__tests__/ai_behavior.test.ts @@ -1,16 +1,15 @@ - -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, beforeEach } from 'vitest'; import { decideEnemyAction, applyAction, stepUntilPlayerTurn } from '../simulation/simulation'; import { type World, type Actor, type EntityId, type CombatantActor } from '../../core/types'; -import { EntityManager } from '../EntityManager'; +import { EntityAccessor } from '../EntityAccessor'; import { TileType } from '../../core/terrain'; +import { ECSWorld } from '../ecs/World'; -const createTestWorld = (actors: Map): World => { +const createTestWorld = (): World => { return { width: 10, height: 10, tiles: new Array(100).fill(TileType.EMPTY), - actors, exit: { x: 9, y: 9 } }; }; @@ -23,7 +22,37 @@ const createTestStats = (overrides: Partial = {}) => ({ }); describe('AI Behavior & Scheduling', () => { - let entityManager: EntityManager; + let accessor: EntityAccessor; + let ecsWorld: ECSWorld; + + beforeEach(() => { + ecsWorld = new ECSWorld(); + }); + + const syncToECS = (actors: Map) => { + let maxId = 0; + for (const actor of actors.values()) { + if (actor.id > maxId) maxId = actor.id; + 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 || createTestStats()); + ecsWorld.addComponent(actor.id, "energy", { current: c.energy, speed: c.speed || 100 }); + ecsWorld.addComponent(actor.id, "actorType", { type: c.type || "player" }); + if (c.isPlayer) { + ecsWorld.addComponent(actor.id, "player", {}); + } else { + ecsWorld.addComponent(actor.id, "ai", { + state: c.aiState || "wandering", + alertedAt: c.alertedAt, + lastKnownPlayerPos: c.lastKnownPlayerPos + }); + } + } + } + ecsWorld.setNextId(maxId + 1); + }; // ------------------------------------------------------------------------- // Scheduling Fairness @@ -33,37 +62,34 @@ describe('AI Behavior & Scheduling', () => { const actors = new Map(); // Player Speed 100 const player = { - id: 1, category: "combatant", isPlayer: true, pos: { x: 0, y: 0 }, + 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, category: "combatant", isPlayer: false, pos: { x: 9, y: 9 }, + 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, player); - actors.set(2, rat); - const world = createTestWorld(actors); - entityManager = new EntityManager(world); + 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 - // With fair scheduling, Rat (80 speed) should move approx 80% as often as Player (100 speed). - // So in 20 turns, approx 16 moves. Definitley > 0. for (let i = 0; i < 20; i++) { - const result = stepUntilPlayerTurn(world, 1, entityManager); + const result = stepUntilPlayerTurn(world, 1 as EntityId, accessor); 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) ); - // console.log(`Turn ${i}: Events`, result.events); if (enemyActs.length > 0) ratMoves++; } - // console.log(`Total Rat Moves: ${ratMoves}`); expect(ratMoves).toBeGreaterThan(0); }); }); @@ -81,19 +107,22 @@ describe('AI Behavior & Scheduling', () => { terrainTypes.forEach(({ type, name }) => { it(`should see player when standing on ${name}`, () => { const actors = new Map(); - actors.set(1, { id: 1, category: "combatant", isPlayer: true, pos: { x: 5, y: 0 }, stats: createTestStats(), energy: 0 } as any); - actors.set(2, { - id: 2, category: "combatant", isPlayer: false, pos: { x: 0, y: 0 }, + 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(actors); + 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, actors.get(2) as any, actors.get(1) as any, new EntityManager(world)); + decideEnemyAction(world, testAccessor.getCombatant(2 as EntityId) as any, testAccessor.getCombatant(1 as EntityId) as any, testAccessor); - expect((actors.get(2) as CombatantActor).aiState).toBe("alerted"); + const updatedRat = testAccessor.getCombatant(2 as EntityId); + expect(updatedRat?.aiState).toBe("alerted"); }); }); }); @@ -105,29 +134,30 @@ describe('AI Behavior & Scheduling', () => { 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, category: "combatant", isPlayer: true, pos: { x: 0, y: 0 }, stats: createTestStats({ attack: 1, accuracy: 100 }), energy: 0 } as any; + 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, category: "combatant", isPlayer: false, pos: { x: 0, y: 5 }, + 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, player); - actors.set(2, enemy); - const world = createTestWorld(actors); + actors.set(1 as EntityId, player); + actors.set(2 as EntityId, enemy); + const world = createTestWorld(); + syncToECS(actors); - const em = new EntityManager(world); - applyAction(world, 1, { type: "attack", targetId: 2 }, em); + const testAccessor = new EntityAccessor(world, 1 as EntityId, ecsWorld); + applyAction(world, 1 as EntityId, { type: "attack", targetId: 2 as EntityId }, testAccessor); - const updatedEnemy = actors.get(2) as CombatantActor; - expect(updatedEnemy.aiState).toBe("pursuing"); - expect(updatedEnemy.lastKnownPlayerPos).toEqual(player.pos); + 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, category: "combatant", isPlayer: true, pos: { x: 9, y: 9 }, stats: createTestStats(), energy: 0 } as any; + 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, + id: 2 as EntityId, category: "combatant", isPlayer: false, pos: { x: 0, y: 0 }, @@ -138,17 +168,20 @@ describe('AI Behavior & Scheduling', () => { energy: 0 } as any; - actors.set(1, player); - actors.set(2, enemy); - const world = createTestWorld(actors); + 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); - decideEnemyAction(world, enemy, player, new EntityManager(world)); + 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(enemy.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 534110d..5d65228 100644 --- a/src/engine/__tests__/combat_logic.test.ts +++ b/src/engine/__tests__/combat_logic.test.ts @@ -1,9 +1,16 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, beforeEach } from "vitest"; import { getClosestVisibleEnemy } from "../gameplay/CombatLogic"; -import type { World, CombatantActor } from "../../core/types"; +import type { World, CombatantActor, Actor, EntityId } from "../../core/types"; +import { EntityAccessor } from "../EntityAccessor"; +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 = () => ({ @@ -21,29 +28,40 @@ describe("CombatLogic - getClosestVisibleEnemy", () => { width: 10, height: 10, tiles: new Array(100).fill(0), - actors: new Map(), exit: { x: 9, y: 9 } }; + const actors = new Map(); const player: CombatantActor = { id: 0, category: "combatant", type: "player", pos: { x: 5, y: 5 }, isPlayer: true, stats: createMockStats(), inventory: { gold: 0, items: [] }, equipment: {}, speed: 1, energy: 0 }; - world.actors.set(0, player); + actors.set(0 as EntityId, player); const enemy: CombatantActor = { id: 1, category: "combatant", type: "rat", pos: { x: 6, y: 6 }, isPlayer: false, stats: createMockStats(), speed: 1, energy: 0 }; - world.actors.set(1, enemy); + actors.set(1 as EntityId, enemy); + + for (const a of actors.values()) { + ecsWorld.addComponent(a.id, "position", a.pos); + ecsWorld.addComponent(a.id, "actorType", { type: a.type as any }); + if (a.category === "combatant") { + ecsWorld.addComponent(a.id, "stats", a.stats); + if (a.isPlayer) ecsWorld.addComponent(a.id, "player", {}); + } + } + + const accessor = new EntityAccessor(world, 0 as EntityId, ecsWorld); // Mock seenArray where nothing is seen const seenArray = new Uint8Array(100).fill(0); - const result = getClosestVisibleEnemy(world, player.pos, seenArray, 10); + const result = getClosestVisibleEnemy(player.pos, seenArray, 10, accessor); expect(result).toBeNull(); }); @@ -52,17 +70,17 @@ describe("CombatLogic - getClosestVisibleEnemy", () => { width: 10, height: 10, tiles: new Array(100).fill(0), - actors: new Map(), exit: { x: 9, y: 9 } }; + const actors = new Map(); const player: CombatantActor = { id: 0, category: "combatant", type: "player", pos: { x: 5, y: 5 }, isPlayer: true, stats: createMockStats(), inventory: { gold: 0, items: [] }, equipment: {}, speed: 1, energy: 0 }; - world.actors.set(0, player); + actors.set(0 as EntityId, player); // Enemy 1: Close (distance sqrt(2) ~= 1.41) const enemy1: CombatantActor = { @@ -70,7 +88,7 @@ describe("CombatLogic - getClosestVisibleEnemy", () => { stats: createMockStats(), speed: 1, energy: 0 }; - world.actors.set(1, enemy1); + actors.set(1 as EntityId, enemy1); // Enemy 2: Farther (distance sqrt(8) ~= 2.82) const enemy2: CombatantActor = { @@ -78,14 +96,25 @@ describe("CombatLogic - getClosestVisibleEnemy", () => { stats: createMockStats(), speed: 1, energy: 0 }; - world.actors.set(2, enemy2); + actors.set(2 as EntityId, enemy2); + + for (const a of actors.values()) { + ecsWorld.addComponent(a.id, "position", a.pos); + ecsWorld.addComponent(a.id, "actorType", { type: a.type as any }); + if (a.category === "combatant") { + ecsWorld.addComponent(a.id, "stats", a.stats); + if (a.isPlayer) ecsWorld.addComponent(a.id, "player", {}); + } + } + + const accessor = new EntityAccessor(world, 0 as EntityId, ecsWorld); // Mock seenArray where both are seen const seenArray = new Uint8Array(100).fill(0); seenArray[6 * 10 + 6] = 1; // Enemy 1 visible seenArray[7 * 10 + 7] = 1; // Enemy 2 visible - const result = getClosestVisibleEnemy(world, player.pos, seenArray, 10); + const result = getClosestVisibleEnemy(player.pos, seenArray, 10, accessor); expect(result).toEqual({ x: 6, y: 6 }); }); @@ -94,17 +123,17 @@ describe("CombatLogic - getClosestVisibleEnemy", () => { width: 10, height: 10, tiles: new Array(100).fill(0), - actors: new Map(), exit: { x: 9, y: 9 } }; + const actors = new Map(); const player: CombatantActor = { id: 0, category: "combatant", type: "player", pos: { x: 5, y: 5 }, isPlayer: true, stats: createMockStats(), inventory: { gold: 0, items: [] }, equipment: {}, speed: 1, energy: 0 }; - world.actors.set(0, player); + actors.set(0 as EntityId, player); // Enemy 1: Close but invisible const enemy1: CombatantActor = { @@ -112,7 +141,7 @@ describe("CombatLogic - getClosestVisibleEnemy", () => { stats: createMockStats(), speed: 1, energy: 0 }; - world.actors.set(1, enemy1); + actors.set(1 as EntityId, enemy1); // Enemy 2: Farther but visible const enemy2: CombatantActor = { @@ -120,13 +149,24 @@ describe("CombatLogic - getClosestVisibleEnemy", () => { stats: createMockStats(), speed: 1, energy: 0 }; - world.actors.set(2, enemy2); + actors.set(2 as EntityId, enemy2); + + for (const a of actors.values()) { + ecsWorld.addComponent(a.id, "position", a.pos); + ecsWorld.addComponent(a.id, "actorType", { type: a.type as any }); + if (a.category === "combatant") { + ecsWorld.addComponent(a.id, "stats", a.stats); + if (a.isPlayer) ecsWorld.addComponent(a.id, "player", {}); + } + } + + const accessor = new EntityAccessor(world, 0 as EntityId, ecsWorld); // Mock seenArray where only Enemy 2 is seen const seenArray = new Uint8Array(100).fill(0); seenArray[5 * 10 + 8] = 1; // Enemy 2 visible at (8,5) - const result = getClosestVisibleEnemy(world, player.pos, seenArray, 10); + const result = getClosestVisibleEnemy(player.pos, seenArray, 10, accessor); expect(result).toEqual({ x: 8, y: 5 }); }); }); diff --git a/src/engine/__tests__/generator.test.ts b/src/engine/__tests__/generator.test.ts index f634542..c59d1a7 100644 --- a/src/engine/__tests__/generator.test.ts +++ b/src/engine/__tests__/generator.test.ts @@ -1,8 +1,10 @@ + import { describe, it, expect } from 'vitest'; import { generateWorld } from '../world/generator'; import { isWall, inBounds } from '../world/world-logic'; -import { type CombatantActor } from '../../core/types'; + import { TileType } from '../../core/terrain'; +import { EntityAccessor } from '../EntityAccessor'; import * as ROT from 'rot-js'; describe('World Generator', () => { @@ -36,14 +38,17 @@ describe('World Generator', () => { inventory: { gold: 0, items: [] } }; - const { world, playerId } = generateWorld(1, runState); + const { world, playerId, ecsWorld } = generateWorld(1, runState); + const accessor = new EntityAccessor(world, playerId, ecsWorld); - expect(playerId).toBe(1); - const player = world.actors.get(playerId) as CombatantActor; + expect(playerId).toBeGreaterThan(0); + const player = accessor.getPlayer(); expect(player).toBeDefined(); - expect(player.category).toBe("combatant"); - expect(player.isPlayer).toBe(true); - expect(player.stats).toEqual(runState.stats); + expect(player?.category).toBe("combatant"); + expect(player?.isPlayer).toBe(true); + // We expect the stats to be the same, but they are proxies now + expect(player?.stats.hp).toEqual(runState.stats.hp); + expect(player?.stats.attack).toEqual(runState.stats.attack); }); it('should create walkable rooms', () => { @@ -57,8 +62,9 @@ describe('World Generator', () => { inventory: { gold: 0, items: [] } }; - const { world, playerId } = generateWorld(1, runState); - const player = world.actors.get(playerId)!; + const { world, playerId, ecsWorld } = generateWorld(1, runState); + const accessor = new EntityAccessor(world, playerId, ecsWorld); + const player = accessor.getPlayer()!; // Player should spawn in a walkable area expect(isWall(world, player.pos.x, player.pos.y)).toBe(false); @@ -93,13 +99,10 @@ describe('World Generator', () => { inventory: { gold: 0, items: [] } }; - const { world } = generateWorld(1, runState); + const { world, playerId, ecsWorld } = generateWorld(1, runState); + const accessor = new EntityAccessor(world, playerId, ecsWorld); - // Should have player + enemies - expect(world.actors.size).toBeGreaterThan(1); - - // All non-player actors should be enemies - const enemies = Array.from(world.actors.values()).filter(a => a.category === "combatant" && !a.isPlayer) as CombatantActor[]; + const enemies = accessor.getEnemies(); expect(enemies.length).toBeGreaterThan(0); // Enemies should have stats @@ -121,15 +124,18 @@ describe('World Generator', () => { inventory: { gold: 0, items: [] } }; - const { world: world1, playerId: player1 } = generateWorld(1, runState); - const { world: world2, playerId: player2 } = generateWorld(1, runState); + const { world: world1, playerId: player1, ecsWorld: ecs1 } = generateWorld(1, runState); + const { world: world2, playerId: player2, ecsWorld: ecs2 } = generateWorld(1, runState); // Same level should generate identical layouts expect(world1.tiles).toEqual(world2.tiles); expect(world1.exit).toEqual(world2.exit); - const player1Pos = world1.actors.get(player1)!.pos; - const player2Pos = world2.actors.get(player2)!.pos; + const accessor1 = new EntityAccessor(world1, player1, ecs1); + const accessor2 = new EntityAccessor(world2, player2, ecs2); + + const player1Pos = accessor1.getPlayer()!.pos; + const player2Pos = accessor2.getPlayer()!.pos; expect(player1Pos).toEqual(player2Pos); }); @@ -162,11 +168,14 @@ describe('World Generator', () => { inventory: { gold: 0, items: [] } }; - const { world: world1 } = generateWorld(1, runState); - const { world: world5 } = generateWorld(5, runState); + const { world: world1, playerId: p1, ecsWorld: ecs1 } = generateWorld(1, runState); + const { world: world5, playerId: p5, ecsWorld: ecs5 } = generateWorld(5, runState); - const enemies1 = Array.from(world1.actors.values()).filter(a => a.category === "combatant" && !a.isPlayer) as CombatantActor[]; - const enemies5 = Array.from(world5.actors.values()).filter(a => a.category === "combatant" && !a.isPlayer) as CombatantActor[]; + const accessor1 = new EntityAccessor(world1, p1, ecs1); + const accessor5 = new EntityAccessor(world5, p5, ecs5); + + const enemies1 = accessor1.getEnemies(); + const enemies5 = accessor5.getEnemies(); // Higher level should have more enemies expect(enemies5.length).toBeGreaterThan(enemies1.length); @@ -213,8 +222,9 @@ describe('World Generator', () => { // Generate multiple worlds to stress test spawn placement for (let i = 0; i < 10; i++) { - const { world, playerId } = generateWorld(1, runState); - const player = world.actors.get(playerId)!; + const { world, playerId, ecsWorld } = generateWorld(1, runState); + const accessor = new EntityAccessor(world, playerId, ecsWorld); + const player = accessor.getPlayer()!; // Check tile under player const tileIdx = player.pos.y * world.width + player.pos.x; @@ -259,8 +269,9 @@ describe('World Generator', () => { inventory: { gold: 0, items: [] } }; - const { world } = generateWorld(11, runState); - const enemies = Array.from(world.actors.values()).filter(a => a.category === 'combatant' && !a.isPlayer); + const { world, playerId, ecsWorld } = generateWorld(11, runState); + const accessor = new EntityAccessor(world, playerId, ecsWorld); + const enemies = accessor.getEnemies(); expect(enemies.length).toBeGreaterThan(0); }); @@ -276,8 +287,9 @@ describe('World Generator', () => { }; for (let i = 0; i < 5; i++) { - const { world, playerId } = generateWorld(10 + i, runState); - const player = world.actors.get(playerId)!; + const { world, playerId, ecsWorld } = generateWorld(10 + i, runState); + const accessor = new EntityAccessor(world, playerId, ecsWorld); + const player = accessor.getPlayer()!; const exit = world.exit; const pathfinder = new ROT.Path.AStar(exit.x, exit.y, (x, y) => { @@ -304,8 +316,9 @@ describe('World Generator', () => { }, inventory: { gold: 0, items: [] } }; - const { world, playerId } = generateWorld(12, runState); - const player = world.actors.get(playerId)!; + const { world, playerId, ecsWorld } = generateWorld(12, runState); + const accessor = new EntityAccessor(world, playerId, ecsWorld); + const player = accessor.getPlayer()!; expect(world.tiles[player.pos.y * world.width + player.pos.x]).toBe(TileType.EMPTY); }); diff --git a/src/engine/__tests__/inventory.test.ts b/src/engine/__tests__/inventory.test.ts index 1e8e0ed..184bf3e 100644 --- a/src/engine/__tests__/inventory.test.ts +++ b/src/engine/__tests__/inventory.test.ts @@ -1,39 +1,49 @@ + import { describe, it, expect, beforeEach } from "vitest"; import { ItemManager } from "../../scenes/systems/ItemManager"; -import type { World, CombatantActor, Item } from "../../core/types"; -import { EntityManager } from "../../engine/EntityManager"; +import type { World, CombatantActor, Item, EntityId } from "../../core/types"; +import { EntityAccessor } from "../../engine/EntityAccessor"; +import { ECSWorld } from "../../engine/ecs/World"; describe("ItemManager - Stacking Logic", () => { let itemManager: ItemManager; - let entityManager: EntityManager; + let accessor: EntityAccessor; let world: World; let player: CombatantActor; + let ecsWorld: ECSWorld; beforeEach(() => { world = { width: 10, height: 10, - tiles: [], - actors: new Map(), + tiles: new Array(100).fill(0), exit: { x: 9, y: 9 } } as any; - entityManager = new EntityManager(world); - itemManager = new ItemManager(world, entityManager); + ecsWorld = new ECSWorld(); + accessor = new EntityAccessor(world, 0 as EntityId, ecsWorld); + itemManager = new ItemManager(world, accessor, ecsWorld); player = { - id: 0, + id: 0 as EntityId, pos: { x: 1, y: 1 }, category: "combatant", isPlayer: true, type: "player", inventory: { gold: 0, items: [] }, - stats: {} as any, + stats: { hp: 10, maxHp: 10 } as any, equipment: {} as any, - speed: 1, + speed: 100, energy: 0 }; - world.actors.set(0, player); + + // Sync player to ECS + ecsWorld.addComponent(player.id, "position", player.pos); + ecsWorld.addComponent(player.id, "player", {}); + ecsWorld.addComponent(player.id, "stats", player.stats); + ecsWorld.addComponent(player.id, "actorType", { type: "player" }); + ecsWorld.addComponent(player.id, "inventory", player.inventory!); + ecsWorld.addComponent(player.id, "energy", { current: 0, speed: 100 }); }); it("should stack stackable items when picked up", () => { @@ -47,25 +57,27 @@ describe("ItemManager - Stacking Logic", () => { quantity: 1 }; + const playerActor = accessor.getPlayer()!; + // First potion itemManager.spawnItem(potion, { x: 1, y: 1 }); - itemManager.tryPickup(player); + itemManager.tryPickup(playerActor); - expect(player.inventory!.items.length).toBe(1); - expect(player.inventory!.items[0].quantity).toBe(1); + expect(playerActor.inventory!.items.length).toBe(1); + expect(playerActor.inventory!.items[0].quantity).toBe(1); // Second potion itemManager.spawnItem(potion, { x: 1, y: 1 }); - itemManager.tryPickup(player); + itemManager.tryPickup(playerActor); - expect(player.inventory!.items.length).toBe(1); - expect(player.inventory!.items[0].quantity).toBe(2); + expect(playerActor.inventory!.items.length).toBe(1); + expect(playerActor.inventory!.items[0].quantity).toBe(2); }); it("should NOT stack non-stackable items", () => { const sword: Item = { - id: "sword", - name: "Sword", + id: "iron_sword", + name: "Iron Sword", type: "Weapon", weaponType: "melee", textureKey: "items", @@ -74,40 +86,44 @@ describe("ItemManager - Stacking Logic", () => { stats: { attack: 1 } } as any; + const playerActor = accessor.getPlayer()!; + // First sword itemManager.spawnItem(sword, { x: 1, y: 1 }); - itemManager.tryPickup(player); + itemManager.tryPickup(playerActor); - expect(player.inventory!.items.length).toBe(1); + expect(playerActor.inventory!.items.length).toBe(1); // Second sword itemManager.spawnItem(sword, { x: 1, y: 1 }); - itemManager.tryPickup(player); + itemManager.tryPickup(playerActor); - expect(player.inventory!.items.length).toBe(2); + expect(playerActor.inventory!.items.length).toBe(2); }); it("should sum quantities of stackable items correctly", () => { const ammo: Item = { - id: "ammo", - name: "Ammo", + id: "9mm_ammo", + name: "9mm Ammo", type: "Ammo", textureKey: "items", spriteIndex: 2, stackable: true, quantity: 10, ammoType: "9mm" - }; + } as any; + + const playerActor = accessor.getPlayer()!; itemManager.spawnItem(ammo, { x: 1, y: 1 }); - itemManager.tryPickup(player); + itemManager.tryPickup(playerActor); - expect(player.inventory!.items[0].quantity).toBe(10); + expect(playerActor.inventory!.items[0].quantity).toBe(10); const moreAmmo = { ...ammo, quantity: 5 }; itemManager.spawnItem(moreAmmo, { x: 1, y: 1 }); - itemManager.tryPickup(player); + itemManager.tryPickup(playerActor); - expect(player.inventory!.items[0].quantity).toBe(15); + expect(playerActor.inventory!.items[0].quantity).toBe(15); }); }); diff --git a/src/engine/__tests__/pathfinding.test.ts b/src/engine/__tests__/pathfinding.test.ts index daeab90..4f73605 100644 --- a/src/engine/__tests__/pathfinding.test.ts +++ b/src/engine/__tests__/pathfinding.test.ts @@ -1,14 +1,16 @@ + import { describe, it, expect } from 'vitest'; import { findPathAStar } from '../world/pathfinding'; -import { type World } from '../../core/types'; +import type { World, EntityId } from '../../core/types'; import { TileType } from '../../core/terrain'; +import { ECSWorld } from '../ecs/World'; +import { EntityAccessor } from '../EntityAccessor'; describe('Pathfinding', () => { const createTestWorld = (width: number, height: number, tileType: number = TileType.EMPTY): World => ({ width, height, tiles: new Array(width * height).fill(tileType), - actors: new Map(), exit: { x: 0, y: 0 } }); @@ -47,23 +49,22 @@ describe('Pathfinding', () => { it('should respect ignoreBlockedTarget option', () => { const world = createTestWorld(10, 10); + const ecsWorld = new ECSWorld(); + // Place an actor at target - world.actors.set(1, { id: 1, pos: { x: 0, y: 3 }, type: 'rat', category: 'combatant' } as any); + 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); - // Without option, it should be blocked (because actor is there) - // Wait, default pathfinding might treat actors as blocking unless specified. - // Let's check `isBlocked` usage in `pathfinding.ts`. - // It calls `isBlocked` which checks actors. - - // However, findPathAStar has logic: - // if (!options.ignoreBlockedTarget && isBlocked(w, end.x, end.y, options.em)) return []; - - const pathBlocked = findPathAStar(world, seen, { x: 0, y: 0 }, { x: 0, y: 3 }); + // With accessor, it should be blocked + const pathBlocked = findPathAStar(world, seen, { x: 0, y: 0 }, { x: 0, y: 3 }, { accessor }); expect(pathBlocked).toEqual([]); - const pathIgnored = findPathAStar(world, seen, { x: 0, y: 0 }, { x: 0, y: 3 }, { ignoreBlockedTarget: true }); + // With ignoreBlockedTarget, it should succeed + const pathIgnored = findPathAStar(world, seen, { x: 0, y: 0 }, { x: 0, y: 3 }, { ignoreBlockedTarget: true, accessor }); expect(pathIgnored.length).toBeGreaterThan(0); expect(pathIgnored[pathIgnored.length - 1]).toEqual({ x: 0, y: 3 }); }); diff --git a/src/engine/__tests__/simulation.test.ts b/src/engine/__tests__/simulation.test.ts index be141b2..13dbc5a 100644 --- a/src/engine/__tests__/simulation.test.ts +++ b/src/engine/__tests__/simulation.test.ts @@ -1,14 +1,15 @@ + import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { applyAction, decideEnemyAction, stepUntilPlayerTurn } from '../simulation/simulation'; import { type World, type Actor, type EntityId, type CombatantActor } from '../../core/types'; -import { EntityManager } from '../EntityManager'; +import { EntityAccessor } from '../EntityAccessor'; +import { ECSWorld } from '../ecs/World'; -const createTestWorld = (actors: Map): World => { +const createTestWorld = (): World => { return { width: 10, height: 10, tiles: new Array(100).fill(0), - actors, exit: { x: 9, y: 9 } }; }; @@ -21,14 +22,45 @@ const createTestStats = (overrides: Partial = {}) => ({ }); describe('Combat Simulation', () => { - let entityManager: EntityManager; + let ecsWorld: ECSWorld; + + beforeEach(() => { + ecsWorld = new ECSWorld(); + }); + + const syncToECS = (actors: Map) => { + let maxId = 0; + for (const actor of actors.values()) { + if (actor.id > maxId) maxId = actor.id; + 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 || createTestStats()); + ecsWorld.addComponent(actor.id, "energy", { current: c.energy, speed: c.speed || 100 }); + ecsWorld.addComponent(actor.id, "actorType", { type: c.type || "player" }); + if (c.isPlayer) { + ecsWorld.addComponent(actor.id, "player", {}); + } else { + ecsWorld.addComponent(actor.id, "ai", { + state: c.aiState || "wandering", + alertedAt: c.alertedAt, + lastKnownPlayerPos: c.lastKnownPlayerPos + }); + } + } else if (actor.category === "collectible") { + ecsWorld.addComponent(actor.id, "collectible", { type: "exp_orb", amount: actor.expAmount }); + } + } + ecsWorld.setNextId(maxId + 1); + }; describe('applyAction', () => { it('should return empty events if actor does not exist', () => { - const world = createTestWorld(new Map()); - const events = applyAction(world, 999, { type: "wait" }); + const world = createTestWorld(); + const events = applyAction(world, 999 as EntityId, { type: "wait" }, new EntityAccessor(world, 1 as EntityId, ecsWorld)); expect(events).toEqual([]); }); }); @@ -36,60 +68,63 @@ describe('Combat Simulation', () => { describe('applyAction - success paths', () => { it('should deal damage when player attacks enemy', () => { const actors = new Map(); - actors.set(1, { + 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, { + 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(actors); - entityManager = new EntityManager(world); - const events = applyAction(world, 1, { type: "attack", targetId: 2 }, entityManager); + 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 = world.actors.get(2) as CombatantActor; - expect(enemy.stats.hp).toBeLessThan(10); + 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, { + 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, { + 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(actors); - entityManager = new EntityManager(world); - applyAction(world, 1, { type: "attack", targetId: 2 }, entityManager); + 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(world.actors.has(2)).toBe(false); + expect(accessor.hasActor(2 as EntityId)).toBe(false); // A new ID should be generated for the orb (should be 3) - const orb = [...world.actors.values()].find(a => a.type === "exp_orb"); + 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, { + 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(actors); + 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 - entityManager = new EntityManager(world); - applyAction(world, 1, { type: "move", dx: 1, dy: 0 }, entityManager); + 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 = world.actors.get(1); + const player = accessor.getActor(1 as EntityId); expect(player!.pos).toEqual({ x: 4, y: 3 }); // Tile should effectively be destroyed (turned to saplings/2) @@ -98,28 +133,30 @@ describe('Combat Simulation', () => { it("should handle wait action", () => { const actors = new Map(); - actors.set(1, { id: 1, category: "combatant", isPlayer: true, pos: { x: 0, y: 0 } } as any); - const world = createTestWorld(actors); - - const events = applyAction(world, 1, { type: "wait" }, new EntityManager(world)); + 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, { id: 1, category: "combatant", isPlayer: true, pos: { x: 0, y: 0 } } as any); - const world = createTestWorld(actors); + 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, { type: "unknown_hack" } as any, new EntityManager(world)); + 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, { id: 1, category: "combatant", isPlayer: true, pos: { x: 0, y: 0 } } as any); - const world = createTestWorld(actors); + 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, { type: "throw" }, new EntityManager(world)); + const events = applyAction(world, 1 as EntityId, { type: "throw" }, new EntityAccessor(world, 1 as EntityId, ecsWorld)); expect(events).toEqual([]); }); }); @@ -129,14 +166,15 @@ describe('Combat Simulation', () => { 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, player); - actors.set(2, enemy); + actors.set(1 as EntityId, player); + actors.set(2 as EntityId, enemy); - const world = createTestWorld(actors); + const world = createTestWorld(); world.tiles[3 * 10 + 4] = 4; // Wall + syncToECS(actors); - entityManager = new EntityManager(world); - const decision = decideEnemyAction(world, enemy, player, entityManager); + const accessor = new EntityAccessor(world, 1 as EntityId, ecsWorld); + const decision = decideEnemyAction(world, enemy, player, accessor); expect(decision.action.type).toBe("move"); }); @@ -154,13 +192,14 @@ describe('Combat Simulation', () => { aiState: "pursuing", lastKnownPlayerPos: { x: 4, y: 3 } } as any; - actors.set(1, player); - actors.set(2, enemy); + actors.set(1 as EntityId, player); + actors.set(2 as EntityId, enemy); - const world = createTestWorld(actors); - entityManager = new EntityManager(world); + const world = createTestWorld(); + syncToECS(actors); + const accessor = new EntityAccessor(world, 1 as EntityId, ecsWorld); - const decision = decideEnemyAction(world, enemy, player, entityManager); + const decision = decideEnemyAction(world, enemy, player, accessor); expect(decision.action).toEqual({ type: "attack", targetId: 1 }); }); @@ -176,13 +215,15 @@ describe('Combat Simulation', () => { aiState: "wandering", energy: 0 } as any; - actors.set(1, player); - actors.set(2, enemy); - const world = createTestWorld(actors); + actors.set(1 as EntityId, player); + actors.set(2 as EntityId, enemy); + const world = createTestWorld(); + syncToECS(actors); - const decision = decideEnemyAction(world, enemy, player, new EntityManager(world)); + const decision = decideEnemyAction(world, enemy, player, new EntityAccessor(world, 1 as EntityId, ecsWorld)); - expect(enemy.aiState).toBe("alerted"); + const updatedEnemy = ecsWorld.getComponent(2 as EntityId, "ai"); + expect(updatedEnemy?.state).toBe("alerted"); expect(decision.justAlerted).toBe(true); }); @@ -199,14 +240,16 @@ describe('Combat Simulation', () => { aiState: "pursuing", // Currently pursuing lastKnownPlayerPos: { x: 5, y: 5 } } as any; - actors.set(1, player); - actors.set(2, enemy); - const world = createTestWorld(actors); + 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 EntityManager(world)); + decideEnemyAction(world, enemy, player, new EntityAccessor(world, 1 as EntityId, ecsWorld)); - expect(enemy.aiState).toBe("searching"); + const updatedEnemy = ecsWorld.getComponent(2 as EntityId, "ai"); + expect(updatedEnemy?.state).toBe("searching"); }); it("should transition from searching to alerted when sight regained", () => { @@ -222,13 +265,15 @@ describe('Combat Simulation', () => { aiState: "searching", lastKnownPlayerPos: { x: 5, y: 5 } } as any; - actors.set(1, player); - actors.set(2, enemy); - const world = createTestWorld(actors); + actors.set(1 as EntityId, player); + actors.set(2 as EntityId, enemy); + const world = createTestWorld(); + syncToECS(actors); - const decision = decideEnemyAction(world, enemy, player, new EntityManager(world)); + const decision = decideEnemyAction(world, enemy, player, new EntityAccessor(world, 1 as EntityId, ecsWorld)); - expect(enemy.aiState).toBe("alerted"); + const updatedEnemy = ecsWorld.getComponent(2 as EntityId, "ai"); + expect(updatedEnemy?.state).toBe("alerted"); expect(decision.justAlerted).toBe(true); }); @@ -246,13 +291,15 @@ describe('Combat Simulation', () => { aiState: "searching", lastKnownPlayerPos: { x: 9, y: 9 } } as any; - actors.set(1, player); - actors.set(2, enemy); - const world = createTestWorld(actors); + actors.set(1 as EntityId, player); + actors.set(2 as EntityId, enemy); + const world = createTestWorld(); + syncToECS(actors); - decideEnemyAction(world, enemy, player, new EntityManager(world)); + decideEnemyAction(world, enemy, player, new EntityAccessor(world, 1 as EntityId, ecsWorld)); - expect(enemy.aiState).toBe("wandering"); + const updatedEnemy = ecsWorld.getComponent(2 as EntityId, "ai"); + expect(updatedEnemy?.state).toBe("wandering"); }); }); @@ -263,12 +310,13 @@ describe('Combat Simulation', () => { 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, player); - actors.set(2, enemy); - const world = createTestWorld(actors); - const em = new EntityManager(world); + 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, em); + const result = stepUntilPlayerTurn(world, 1 as EntityId, accessor); // Enemy should have taken at least one action expect(result.events.length).toBeGreaterThan(0); @@ -290,15 +338,16 @@ describe('Combat Simulation', () => { energy: 100 } as any; - actors.set(1, player); - actors.set(2, enemy); - const world = createTestWorld(actors); - const em = new EntityManager(world); + 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, em); + const result = stepUntilPlayerTurn(world, 1 as EntityId, accessor); - expect(world.actors.has(1)).toBe(false); // Player dead - expect(result.events.some(e => e.type === "killed" && e.targetId === 1)).toBe(true); + expect(accessor.hasActor(1 as EntityId)).toBe(false); // Player dead + expect(result.events.some((e: any) => e.type === "killed" && e.targetId === 1)).toBe(true); }); }); @@ -319,17 +368,22 @@ describe('Combat Simulation', () => { 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, player); - actors.set(2, enemy); - const world = createTestWorld(actors); + 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, { type: "attack", targetId: 2 }, new EntityManager(world)); + 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); - expect(enemy.stats.hp).toBe(10); // No damage + const updatedEnemy = ecsWorld.getComponent(2 as EntityId, "stats"); + expect(updatedEnemy?.hp).toBe(10); // No damage }); it("should crit when roll < crit chance", () => { @@ -343,9 +397,10 @@ describe('Combat Simulation', () => { } 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, player); - actors.set(2, enemy); - const world = createTestWorld(actors); + 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) @@ -353,7 +408,7 @@ describe('Combat Simulation', () => { // 3. Block roll: 0.9 (No block) mockRandom.mockReturnValueOnce(0.1).mockReturnValueOnce(0.4).mockReturnValueOnce(0.9); - const events = applyAction(world, 1, { type: "attack", targetId: 2 }, new EntityManager(world)); + 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; @@ -373,9 +428,10 @@ describe('Combat Simulation', () => { stats: createTestStats({ evasion: 0, defense: 0, hp: 50, blockChance: 50 }), energy: 0 } as any; - actors.set(1, player); - actors.set(2, enemy); - const world = createTestWorld(actors); + actors.set(1 as EntityId, player); + actors.set(2 as EntityId, enemy); + const world = createTestWorld(); + syncToECS(actors); // Mock random: // 1. Hit roll: 0.1 @@ -383,7 +439,7 @@ describe('Combat Simulation', () => { // 3. Block roll: 0.4 (Block, since < 0.5) mockRandom.mockReturnValueOnce(0.1).mockReturnValueOnce(0.9).mockReturnValueOnce(0.4); - const events = applyAction(world, 1, { type: "attack", targetId: 2 }, new EntityManager(world)); + 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; @@ -399,17 +455,19 @@ describe('Combat Simulation', () => { } 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, player); - actors.set(2, enemy); - const world = createTestWorld(actors); + 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, { type: "attack", targetId: 2 }, new EntityManager(world)); + 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. - expect(player.stats.hp).toBe(15); + const updatedPlayer = ecsWorld.getComponent(1 as EntityId, "stats"); + expect(updatedPlayer?.hp).toBe(15); expect(events.some(e => e.type === "healed")).toBe(true); }); @@ -421,16 +479,18 @@ describe('Combat Simulation', () => { } 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, player); - actors.set(2, enemy); - const world = createTestWorld(actors); + actors.set(1 as EntityId, player); + actors.set(2 as EntityId, enemy); + const world = createTestWorld(); + syncToECS(actors); mockRandom.mockReturnValue(0.1); - applyAction(world, 1, { type: "attack", targetId: 2 }, new EntityManager(world)); + 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. - expect(player.stats.hp).toBe(20); + const updatedPlayer = ecsWorld.getComponent(1 as EntityId, "stats"); + expect(updatedPlayer?.hp).toBe(20); }); }); @@ -446,15 +506,17 @@ describe('Combat Simulation', () => { id: 2, category: "collectible", type: "exp_orb", pos: { x: 4, y: 3 }, expAmount: 150 } as any; - actors.set(1, player); - actors.set(2, orb); - const world = createTestWorld(actors); + 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, { type: "move", dx: 1, dy: 0 }, new EntityManager(world)); + const events = applyAction(world, 1 as EntityId, { type: "move", dx: 1, dy: 0 }, new EntityAccessor(world, 1 as EntityId, ecsWorld)); - expect(player.stats.level).toBe(2); - expect(player.stats.exp).toBe(50); // 150 - 100 = 50 + 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); }); }); @@ -480,12 +542,13 @@ describe('Combat Simulation', () => { energy: 0 } as any; - actors.set(1, enemy); - actors.set(2, player); - const world = createTestWorld(actors); + 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 EntityManager(world)); + const decision = decideEnemyAction(world, enemy, player, new EntityAccessor(world, 1 as EntityId, ecsWorld)); expect(decision.action.type).toBe("attack"); if (decision.action.type === "attack") { @@ -498,12 +561,13 @@ describe('Combat Simulation', () => { 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, player); - actors.set(2, enemy); - const world = createTestWorld(actors); + 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, action, new EntityManager(world)); + 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(); @@ -524,11 +588,12 @@ describe('Combat Simulation', () => { } as any; const player = { id: 2, category: "combatant", isPlayer: true, pos: { x: 4, y: 6 }, stats: createTestStats(), energy: 0 } as any; - actors.set(1, enemy); - actors.set(2, player); - const world = createTestWorld(actors); + actors.set(1 as EntityId, enemy); + actors.set(2 as EntityId, player); + const world = createTestWorld(); + syncToECS(actors); - const decision = decideEnemyAction(world, enemy, player, new EntityManager(world)); + 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 diff --git a/src/engine/__tests__/throwing.test.ts b/src/engine/__tests__/throwing.test.ts index 7991503..30cd886 100644 --- a/src/engine/__tests__/throwing.test.ts +++ b/src/engine/__tests__/throwing.test.ts @@ -1,23 +1,24 @@ + import { describe, it, expect } from 'vitest'; import { traceProjectile } from '../gameplay/CombatLogic'; -import { EntityManager } from '../EntityManager'; -import { type World, type Actor, type EntityId } from '../../core/types'; +import { EntityAccessor } from '../EntityAccessor'; +import { ECSWorld } from '../ecs/World'; +import type { World, EntityId } from '../../core/types'; -const createTestWorld = (actors: Map): World => { +const createTestWorld = (): World => { return { width: 10, height: 10, tiles: new Array(100).fill(0), // 0 = Floor - actors, exit: { x: 9, y: 9 } }; }; describe('Throwing Mechanics', () => { it('should land ON the wall currently (demonstrating the bug)', () => { - const actors = new Map(); - const world = createTestWorld(actors); - const entityManager = new EntityManager(world); + const world = createTestWorld(); + const ecsWorld = new ECSWorld(); + const accessor = new EntityAccessor(world, 1 as EntityId, ecsWorld); // Wall at (5, 0) world.tiles[5] = 4; // Wall @@ -25,16 +26,16 @@ describe('Throwing Mechanics', () => { const start = { x: 0, y: 0 }; const target = { x: 5, y: 0 }; // Target the wall directly - const result = traceProjectile(world, start, target, entityManager); + const result = traceProjectile(world, start, target, accessor); // NEW BEHAVIOR: blockedPos is the tile BEFORE the wall (4, 0) expect(result.blockedPos).toEqual({ x: 4, y: 0 }); }); it('should land ON the wall when throwing PAST a wall (demonstrating the bug)', () => { - const actors = new Map(); - const world = createTestWorld(actors); - const entityManager = new EntityManager(world); + const world = createTestWorld(); + const ecsWorld = new ECSWorld(); + const accessor = new EntityAccessor(world, 1 as EntityId, ecsWorld); // Wall at (3, 0) world.tiles[3] = 4; // Wall @@ -42,7 +43,7 @@ describe('Throwing Mechanics', () => { const start = { x: 0, y: 0 }; const target = { x: 5, y: 0 }; // Target past the wall - const result = traceProjectile(world, start, target, entityManager); + const result = traceProjectile(world, start, target, accessor); // NEW BEHAVIOR: Hits the wall at 3,0, stops at 2,0 expect(result.blockedPos).toEqual({ x: 2, y: 0 }); diff --git a/src/engine/__tests__/world.test.ts b/src/engine/__tests__/world.test.ts index 4862290..fd93033 100644 --- a/src/engine/__tests__/world.test.ts +++ b/src/engine/__tests__/world.test.ts @@ -9,7 +9,6 @@ describe('World Utilities', () => { width, height, tiles, - actors: new Map(), exit: { x: 0, y: 0 } }); @@ -81,38 +80,37 @@ describe('World Utilities', () => { const world = createTestWorld(10, 10, tiles); - - expect(isBlocked(world, 5, 5)).toBe(true); + const mockAccessor = { getActorsAt: () => [] } as any; + + expect(isBlocked(world, 5, 5, mockAccessor)).toBe(true); }); it('should return true for actor positions', () => { const world = createTestWorld(10, 10, new Array(100).fill(0)); - world.actors.set(1, { - id: 1, - category: "combatant", - isPlayer: true, - type: "player", - pos: { x: 3, y: 3 }, - speed: 100, - stats: { hp: 10, maxHp: 10, attack: 1, defense: 0 } as any, - energy: 100 - }); + const mockAccessor = { + getActorsAt: (x: number, y: number) => { + if (x === 3 && y === 3) return [{ category: "combatant" }]; + return []; + } + } as any; - expect(isBlocked(world, 3, 3)).toBe(true); + 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)).toBe(false); - expect(isBlocked(world, 7, 7)).toBe(false); + expect(isBlocked(world, 3, 3, mockAccessor)).toBe(false); + expect(isBlocked(world, 7, 7, mockAccessor)).toBe(false); }); 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)).toBe(true); - expect(isBlocked(world, 10, 10)).toBe(true); + expect(isBlocked(world, -1, 0, mockAccessor)).toBe(true); + expect(isBlocked(world, 10, 10, mockAccessor)).toBe(true); }); }); describe('tryDestructTile', () => { @@ -148,32 +146,34 @@ describe('World Utilities', () => { 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 }; - world.actors.set(1, { - id: 1, - pos: { x: 5, y: 5 }, - isPlayer: true - } as any); + + const mockAccessor = { + getPlayer: () => ({ pos: { x: 5, y: 5 } }) + } as any; - expect(isPlayerOnExit(world, 1)).toBe(true); + 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 }; - world.actors.set(1, { - id: 1, - pos: { x: 4, y: 4 }, - isPlayer: true - } as any); + + const mockAccessor = { + getPlayer: () => ({ pos: { x: 4, y: 4 } }) + } as any; - expect(isPlayerOnExit(world, 1)).toBe(false); + 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 }; - expect(isPlayerOnExit(world, 999)).toBe(false); + const mockAccessor = { + getPlayer: () => null + } as any; + + expect(isPlayerOnExit(world, mockAccessor)).toBe(false); }); }); }); diff --git a/src/engine/ecs/AISystem.ts b/src/engine/ecs/AISystem.ts index ae8074b..b384e90 100644 --- a/src/engine/ecs/AISystem.ts +++ b/src/engine/ecs/AISystem.ts @@ -1,6 +1,6 @@ import { type ECSWorld } from "./World"; import { type World as GameWorld, type EntityId, type Vec2, type Action } from "../../core/types"; -import { type EntityManager } from "../EntityManager"; +import { type EntityAccessor } from "../EntityAccessor"; import { findPathAStar } from "../world/pathfinding"; import { isBlocked, inBounds } from "../world/world-logic"; import { blocksSight } from "../../core/terrain"; @@ -9,12 +9,12 @@ import { FOV } from "rot-js"; export class AISystem { private ecsWorld: ECSWorld; private gameWorld: GameWorld; - private em?: EntityManager; + private accessor: EntityAccessor; - constructor(ecsWorld: ECSWorld, gameWorld: GameWorld, em?: EntityManager) { + constructor(ecsWorld: ECSWorld, gameWorld: GameWorld, accessor: EntityAccessor) { this.ecsWorld = ecsWorld; this.gameWorld = gameWorld; - this.em = em; + this.accessor = accessor; } update(enemyId: EntityId, playerId: EntityId): { action: Action; justAlerted: boolean } { @@ -82,7 +82,11 @@ export class AISystem { // A* Pathfinding const dummySeen = new Uint8Array(this.gameWorld.width * this.gameWorld.height).fill(1); - const path = findPathAStar(this.gameWorld, dummySeen, pos, targetPos, { ignoreBlockedTarget: true, ignoreSeen: true, em: this.em }); + const path = findPathAStar(this.gameWorld, dummySeen, pos, targetPos, { + ignoreBlockedTarget: true, + ignoreSeen: true, + accessor: this.accessor + }); if (path.length >= 2) { const next = path[1]; @@ -111,7 +115,7 @@ export class AISystem { const directions = [{ dx: 0, dy: -1 }, { dx: 0, dy: 1 }, { dx: -1, dy: 0 }, { dx: 1, dy: 0 }]; // Simple shuffle and try for (const dir of directions.sort(() => Math.random() - 0.5)) { - if (!isBlocked(this.gameWorld, pos.x + dir.dx, pos.y + dir.dy, this.em)) { + if (!isBlocked(this.gameWorld, pos.x + dir.dx, pos.y + dir.dy, this.accessor)) { return { type: "move", ...dir }; } } diff --git a/src/engine/ecs/EntityBuilder.ts b/src/engine/ecs/EntityBuilder.ts index f9a661e..4b461dd 100644 --- a/src/engine/ecs/EntityBuilder.ts +++ b/src/engine/ecs/EntityBuilder.ts @@ -1,6 +1,6 @@ import { type ECSWorld } from "./World"; import { type ComponentMap } from "./components"; -import { type EntityId, type Stats, type EnemyAIState, type ActorType } from "../../core/types"; +import { type EntityId, type Stats, type EnemyAIState, type ActorType, type Inventory, type Equipment, type Item } from "../../core/types"; import { GAME_CONFIG } from "../../core/config/GameConfig"; /** @@ -89,6 +89,22 @@ export class EntityBuilder { return this; } + /** + * Add inventory component. + */ + withInventory(inventory: Inventory): this { + this.components.inventory = inventory; + return this; + } + + /** + * Add equipment component. + */ + withEquipment(equipment: Equipment): this { + this.components.equipment = equipment; + return this; + } + /** * Add AI component for enemy behavior. */ @@ -192,8 +208,8 @@ export class EntityBuilder { /** * Configure as an item on the ground. */ - asGroundItem(itemId: string, quantity: number = 1): this { - this.components.groundItem = { itemId, quantity }; + asGroundItem(item: Item): this { + this.components.groundItem = { item }; return this; } diff --git a/src/engine/ecs/MovementSystem.ts b/src/engine/ecs/MovementSystem.ts index 3469424..b31dbe6 100644 --- a/src/engine/ecs/MovementSystem.ts +++ b/src/engine/ecs/MovementSystem.ts @@ -1,17 +1,17 @@ import { type ECSWorld } from "./World"; import { type World as GameWorld, type EntityId } from "../../core/types"; import { isBlocked } from "../world/world-logic"; -import { type EntityManager } from "../EntityManager"; +import { type EntityAccessor } from "../EntityAccessor"; export class MovementSystem { private ecsWorld: ECSWorld; private gameWorld: GameWorld; - private em?: EntityManager; + private accessor: EntityAccessor; - constructor(ecsWorld: ECSWorld, gameWorld: GameWorld, em?: EntityManager) { + constructor(ecsWorld: ECSWorld, gameWorld: GameWorld, accessor: EntityAccessor) { this.ecsWorld = ecsWorld; this.gameWorld = gameWorld; - this.em = em; + this.accessor = accessor; } move(entityId: EntityId, dx: number, dy: number): boolean { @@ -21,18 +21,11 @@ export class MovementSystem { const nx = pos.x + dx; const ny = pos.y + dy; - if (!isBlocked(this.gameWorld, nx, ny, this.em)) { - const oldPos = { ...pos }; - + if (!isBlocked(this.gameWorld, nx, ny, this.accessor)) { // Update ECS Position pos.x = nx; pos.y = ny; - // Update grid-based EntityManager if present - if (this.em) { - this.em.moveActor(entityId, oldPos, { x: nx, y: ny }); - } - return true; } diff --git a/src/engine/ecs/Prefabs.ts b/src/engine/ecs/Prefabs.ts index ceb40ce..a0e38b2 100644 --- a/src/engine/ecs/Prefabs.ts +++ b/src/engine/ecs/Prefabs.ts @@ -1,6 +1,6 @@ import { type ECSWorld } from "./World"; import { EntityBuilder } from "./EntityBuilder"; -import { type EntityId } from "../../core/types"; +import { type EntityId, type Item } from "../../core/types"; import { GAME_CONFIG } from "../../core/config/GameConfig"; /** @@ -176,12 +176,12 @@ export const Prefabs = { /** * Create an item drop on the ground. */ - itemDrop(world: ECSWorld, x: number, y: number, itemId: string, quantity: number = 1, spriteIndex: number = 0): EntityId { + itemDrop(world: ECSWorld, x: number, y: number, item: Item, spriteIndex: number = 0): EntityId { return EntityBuilder.create(world) .withPosition(x, y) - .withName("Item") + .withName(item.name) .withSprite("items", spriteIndex) - .asGroundItem(itemId, quantity) + .asGroundItem(item) .build(); }, diff --git a/src/engine/ecs/World.ts b/src/engine/ecs/World.ts index 11c384b..84915f6 100644 --- a/src/engine/ecs/World.ts +++ b/src/engine/ecs/World.ts @@ -12,6 +12,10 @@ export class ECSWorld { return id; } + hasEntity(id: EntityId): boolean { + return this.entities.has(id); + } + destroyEntity(id: EntityId) { this.entities.delete(id); for (const type in this.components) { @@ -20,6 +24,7 @@ export class ECSWorld { } addComponent(id: EntityId, type: K, data: ComponentMap[K]) { + this.entities.add(id); // Ensure entity is registered if (!this.components[type]) { this.components[type] = new Map(); } @@ -71,4 +76,8 @@ export class ECSWorld { setNextId(id: number) { this.nextId = id; } + + get currentNextId(): number { + return this.nextId; + } } diff --git a/src/engine/ecs/__tests__/ECSRemoval.test.ts b/src/engine/ecs/__tests__/ECSRemoval.test.ts new file mode 100644 index 0000000..c85ff4c --- /dev/null +++ b/src/engine/ecs/__tests__/ECSRemoval.test.ts @@ -0,0 +1,38 @@ + +import { describe, it, expect } from 'vitest'; +import { ECSWorld } from '../World'; +import { EntityAccessor } from '../../EntityAccessor'; +import { EntityBuilder } from '../EntityBuilder'; +import type { World as GameWorld, EntityId } from '../../../core/types'; + +describe('ECS Removal and Accessor', () => { + it('should not report destroyed entities in getAllActors', () => { + const ecsWorld = new ECSWorld(); + const gameWorld: GameWorld = { + width: 10, + height: 10, + tiles: new Array(100).fill(0), + exit: { x: 0, y: 0 } + }; + const accessor = new EntityAccessor(gameWorld, 999 as EntityId, ecsWorld); + + // Create Entity + const id = EntityBuilder.create(ecsWorld) + .asEnemy("rat") + .withPosition(5, 5) + .withStats({ hp: 10, maxHp: 10 } as any) + .build(); + + // Verify it exists + let actors = [...accessor.getAllActors()]; + expect(actors.length).toBe(1); + expect(actors[0].id).toBe(id); + + // Destroy it + ecsWorld.destroyEntity(id); + + // Verify it is gone + actors = [...accessor.getAllActors()]; + expect(actors.length).toBe(0); + }); +}); diff --git a/src/engine/ecs/components.ts b/src/engine/ecs/components.ts index 3e47f6c..3b2974a 100644 --- a/src/engine/ecs/components.ts +++ b/src/engine/ecs/components.ts @@ -1,4 +1,4 @@ -import { type Vec2, type Stats, type ActorType, type EnemyAIState, type EntityId } from "../../core/types"; +import { type Vec2, type Stats, type ActorType, type EnemyAIState, type EntityId, type Inventory, type Equipment, type Item } from "../../core/types"; export interface PositionComponent extends Vec2 {} @@ -98,10 +98,13 @@ export interface DestructibleComponent { * For items laying on the ground that can be picked up. */ export interface GroundItemComponent { - itemId: string; // Reference to item definition - quantity: number; // Stack size + item: Item; } +export interface InventoryComponent extends Inventory {} + +export interface EquipmentComponent extends Equipment {} + export type ComponentMap = { // Core components position: PositionComponent; @@ -120,6 +123,8 @@ export type ComponentMap = { combat: CombatComponent; destructible: DestructibleComponent; groundItem: GroundItemComponent; + inventory: InventoryComponent; + equipment: EquipmentComponent; }; export type ComponentType = keyof ComponentMap; diff --git a/src/engine/gameplay/CombatLogic.ts b/src/engine/gameplay/CombatLogic.ts index c7b7423..5e15ee9 100644 --- a/src/engine/gameplay/CombatLogic.ts +++ b/src/engine/gameplay/CombatLogic.ts @@ -1,7 +1,7 @@ import { type World, type Vec2, type EntityId } from "../../core/types"; import { isBlocked } from "../world/world-logic"; import { raycast } from "../../core/math"; -import { EntityManager } from "../EntityManager"; +import { type EntityAccessor } from "../EntityAccessor"; export interface ProjectileResult { path: Vec2[]; @@ -16,7 +16,7 @@ export function traceProjectile( world: World, start: Vec2, target: Vec2, - entityManager: EntityManager, + accessor: EntityAccessor | undefined, shooterId?: EntityId ): ProjectileResult { const points = raycast(start.x, start.y, target.x, target.y); @@ -28,9 +28,13 @@ export function traceProjectile( const p = points[i]; // Check for blocking - if (isBlocked(world, p.x, p.y, entityManager)) { + if (accessor && isBlocked(world, p.x, p.y, accessor)) { // Check if we hit a combatant - const actors = entityManager.getActorsAt(p.x, p.y); + let actors: any[] = []; + if (accessor) { + actors = accessor.getActorsAt(p.x, p.y); + } + const enemy = actors.find(a => a.category === "combatant" && a.id !== shooterId); if (enemy) { @@ -56,10 +60,10 @@ export function traceProjectile( * Finds the closest visible enemy to a given position. */ export function getClosestVisibleEnemy( - world: World, origin: Vec2, seenTiles: Set | boolean[] | Uint8Array, // Support various visibility structures - width?: number // Required if seenTiles is a flat array + width?: number, // Required if seenTiles is a flat array + accessor?: EntityAccessor ): Vec2 | null { let closestDistSq = Infinity; let closestPos: Vec2 | null = null; @@ -76,7 +80,9 @@ export function getClosestVisibleEnemy( } }; - for (const actor of world.actors.values()) { + const enemies = accessor ? accessor.getEnemies() : []; + + for (const actor of enemies) { if (actor.category !== "combatant" || actor.isPlayer) continue; // Check visibility diff --git a/src/engine/gameplay/__tests__/CombatLogic.test.ts b/src/engine/gameplay/__tests__/CombatLogic.test.ts index e20092f..af3f137 100644 --- a/src/engine/gameplay/__tests__/CombatLogic.test.ts +++ b/src/engine/gameplay/__tests__/CombatLogic.test.ts @@ -1,54 +1,54 @@ + import { describe, it, expect, beforeEach } from 'vitest'; import { traceProjectile } from '../CombatLogic'; -import type { World } from '../../../core/types'; -import { EntityManager } from '../../EntityManager'; +import type { World, EntityId } from '../../../core/types'; +import { EntityAccessor } from '../../EntityAccessor'; import { TileType } from '../../../core/terrain'; +import { ECSWorld } from '../../ecs/World'; describe('CombatLogic', () => { // Mock World - const mockWorld: World = { - width: 10, - height: 10, - tiles: new Array(100).fill(TileType.EMPTY), - actors: new Map(), - exit: { x: 9, y: 9 } - }; + let mockWorld: World; + let ecsWorld: ECSWorld; + let accessor: EntityAccessor; // Helper to set wall const setWall = (x: number, y: number) => { mockWorld.tiles[y * mockWorld.width + x] = TileType.WALL; }; - // Helper to clear world - const clearWorld = () => { - mockWorld.tiles.fill(TileType.EMPTY); - mockWorld.actors.clear(); - }; - - // Mock EntityManager - const mockEntityManager = { - getActorsAt: (x: number, y: number) => { - return [...mockWorld.actors.values()].filter(a => a.pos.x === x && a.pos.y === y); - } - } as unknown as EntityManager; - beforeEach(() => { - clearWorld(); + mockWorld = { + width: 10, + height: 10, + tiles: new Array(100).fill(TileType.EMPTY), + exit: { x: 9, y: 9 } + }; + ecsWorld = new ECSWorld(); + // Shooter ID 1 + accessor = new EntityAccessor(mockWorld, 1 as EntityId, ecsWorld); }); + function syncActor(actor: any) { + ecsWorld.addComponent(actor.id as EntityId, "position", actor.pos); + if (actor.category === 'combatant') { + ecsWorld.addComponent(actor.id as EntityId, "actorType", { type: actor.type }); + ecsWorld.addComponent(actor.id as EntityId, "stats", { hp: 10 } as any); + if (actor.isPlayer) ecsWorld.addComponent(actor.id as EntityId, "player", {}); + } else if (actor.category === 'item_drop') { + ecsWorld.addComponent(actor.id as EntityId, "groundItem", { item: actor.item || {} }); + } + } + describe('traceProjectile', () => { 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, mockEntityManager); + const result = traceProjectile(mockWorld, start, end, accessor); expect(result.blockedPos).toEqual(end); expect(result.hitActorId).toBeUndefined(); - // Path should be (0,0) -> (1,0) -> (2,0) -> (3,0) -> (4,0) -> (5,0) - // But raycast implementation includes start? - // CombatLogic logic: "skip start" -> loop i=1 - // So result.path is full array from raycast. expect(result.path).toHaveLength(6); }); @@ -57,7 +57,7 @@ describe('CombatLogic', () => { const end = { x: 5, y: 0 }; setWall(3, 0); // Wall at (3,0) - const result = traceProjectile(mockWorld, start, end, mockEntityManager); + const result = traceProjectile(mockWorld, start, end, accessor); expect(result.blockedPos).toEqual({ x: 2, y: 0 }); expect(result.hitActorId).toBeUndefined(); @@ -68,17 +68,17 @@ describe('CombatLogic', () => { const end = { x: 5, y: 0 }; // Place enemy at (3,0) - const enemyId = 2; - mockWorld.actors.set(enemyId, { + const enemyId = 2 as EntityId; + const enemy = { id: enemyId, type: 'rat', category: 'combatant', pos: { x: 3, y: 0 }, isPlayer: false - // ... other props mocked if needed - } as any); + }; + syncActor(enemy); - const result = traceProjectile(mockWorld, start, end, mockEntityManager, 1); // Shooter 1 + 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); @@ -89,15 +89,16 @@ describe('CombatLogic', () => { const end = { x: 5, y: 0 }; // Shooter at start - mockWorld.actors.set(1, { - id: 1, + const shooter = { + id: 1 as EntityId, type: 'player', category: 'combatant', pos: { x: 0, y: 0 }, isPlayer: true - } as any); + }; + syncActor(shooter); - const result = traceProjectile(mockWorld, start, end, mockEntityManager, 1); + const result = traceProjectile(mockWorld, start, end, accessor, 1 as EntityId); // Should not hit self expect(result.hitActorId).toBeUndefined(); @@ -109,13 +110,15 @@ describe('CombatLogic', () => { const end = { x: 5, y: 0 }; // Item at (3,0) - mockWorld.actors.set(99, { - id: 99, + const item = { + id: 99 as EntityId, category: 'item_drop', pos: { x: 3, y: 0 }, - } as any); + item: { name: 'Test Item' } + }; + syncActor(item); - const result = traceProjectile(mockWorld, start, end, mockEntityManager); + const result = traceProjectile(mockWorld, start, end, accessor); // Should pass through item expect(result.blockedPos).toEqual(end); diff --git a/src/engine/gameplay/__tests__/FireableWeapons.test.ts b/src/engine/gameplay/__tests__/FireableWeapons.test.ts index dc6fa67..dff46d6 100644 --- a/src/engine/gameplay/__tests__/FireableWeapons.test.ts +++ b/src/engine/gameplay/__tests__/FireableWeapons.test.ts @@ -1,35 +1,36 @@ import { describe, it, expect, beforeEach } from "vitest"; import { ItemManager } from "../../../scenes/systems/ItemManager"; -import { EntityManager } from "../../EntityManager"; -import type { World, CombatantActor, RangedWeaponItem } from "../../../core/types"; +import type { World, CombatantActor, RangedWeaponItem, EntityId } from "../../../core/types"; +import { EntityAccessor } from "../../EntityAccessor"; +import { ECSWorld } from "../../ecs/World"; import { createRangedWeapon, createAmmo } from "../../../core/config/Items"; -// Mock World and EntityManager -const mockWorld: World = { - width: 10, - height: 10, - tiles: new Array(100).fill(0), - actors: new Map(), - exit: { x: 9, y: 9 } -}; - describe("Fireable Weapons & Ammo System", () => { - let entityManager: EntityManager; + let accessor: EntityAccessor; let itemManager: ItemManager; let player: CombatantActor; + let ecsWorld: ECSWorld; + let world: World; beforeEach(() => { - entityManager = new EntityManager(mockWorld); - itemManager = new ItemManager(mockWorld, entityManager); + world = { + width: 10, + height: 10, + tiles: new Array(100).fill(0), + exit: { x: 9, y: 9 } + }; + ecsWorld = new ECSWorld(); + accessor = new EntityAccessor(world, 1 as EntityId, ecsWorld); + itemManager = new ItemManager(world, accessor, ecsWorld); player = { - id: 1, + id: 1 as EntityId, pos: { x: 0, y: 0 }, category: "combatant", type: "player", isPlayer: true, - speed: 1, + speed: 100, energy: 0, stats: { maxHp: 100, hp: 100, @@ -43,55 +44,68 @@ describe("Fireable Weapons & Ammo System", () => { }, inventory: { gold: 0, items: [] }, equipment: {} - }; - mockWorld.actors.clear(); - mockWorld.actors.set(player.id, player); + } as any; + + // Sync player to ECS + ecsWorld.addComponent(player.id, "position", player.pos); + ecsWorld.addComponent(player.id, "player", {}); + ecsWorld.addComponent(player.id, "stats", player.stats); + 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 }); // Pickup - itemManager.tryPickup(player); - expect(player.inventory!.items.length).toBe(1); - expect(player.inventory!.items[0].quantity).toBe(10); + itemManager.tryPickup(playerActor); + expect(playerActor.inventory!.items.length).toBe(1); + expect(playerActor.inventory!.items[0].quantity).toBe(10); // Spawn Ammo pack 2 const ammo2 = createAmmo("ammo_9mm", 5); itemManager.spawnItem(ammo2, { x: 0, y: 0 }); // Pickup (should merge) - itemManager.tryPickup(player); - expect(player.inventory!.items.length).toBe(1); // Still 1 stack - expect(player.inventory!.items[0].quantity).toBe(15); + itemManager.tryPickup(playerActor); + expect(playerActor.inventory!.items.length).toBe(1); // Still 1 stack + expect(playerActor.inventory!.items[0].quantity).toBe(15); }); it("should consume ammo from weapon when fired", () => { + const playerActor = accessor.getPlayer()!; // Create pistol using factory (already has currentAmmo initialized) const pistol = createRangedWeapon("pistol"); - player.inventory!.items.push(pistol); + playerActor.inventory!.items.push(pistol); // Sanity Check - currentAmmo is now top-level expect(pistol.currentAmmo).toBe(6); expect(pistol.stats.magazineSize).toBe(6); // Simulate Firing (logic mimic from GameScene) - if (pistol.currentAmmo > 0) { - pistol.currentAmmo--; + if (pistol.currentAmmo! > 0) { + pistol.currentAmmo!--; } expect(pistol.currentAmmo).toBe(5); }); it("should reload weapon using inventory ammo", () => { + const playerActor = accessor.getPlayer()!; const pistol = createRangedWeapon("pistol"); pistol.currentAmmo = 0; // Empty - player.inventory!.items.push(pistol); + playerActor.inventory!.items.push(pistol); const ammo = createAmmo("ammo_9mm", 10); - player.inventory!.items.push(ammo); + playerActor.inventory!.items.push(ammo); // Logic mimic from GameScene const needed = pistol.stats.magazineSize - pistol.currentAmmo; // 6 @@ -105,12 +119,13 @@ 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; - player.inventory!.items.push(pistol); + playerActor.inventory!.items.push(pistol); const ammo = createAmmo("ammo_9mm", 3); // Only 3 bullets - player.inventory!.items.push(ammo); + playerActor.inventory!.items.push(ammo); // Logic mimic const needed = pistol.stats.magazineSize - pistol.currentAmmo; // 6 @@ -124,16 +139,17 @@ describe("Fireable Weapons & Ammo System", () => { }); 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(player)! as RangedWeaponItem; + const picked1 = itemManager.tryPickup(playerActor)! as RangedWeaponItem; // Spawn 2 const pistol2 = createRangedWeapon("pistol"); itemManager.spawnItem(pistol2, {x:0, y:0}); - const picked2 = itemManager.tryPickup(player)! as RangedWeaponItem; + const picked2 = itemManager.tryPickup(playerActor)! as RangedWeaponItem; expect(picked1).not.toBe(picked2); expect(picked1.stats).not.toBe(picked2.stats); // Critical! diff --git a/src/engine/simulation/__tests__/movement_block.test.ts b/src/engine/simulation/__tests__/movement_block.test.ts index cb6371d..0b80d37 100644 --- a/src/engine/simulation/__tests__/movement_block.test.ts +++ b/src/engine/simulation/__tests__/movement_block.test.ts @@ -1,12 +1,17 @@ + import { describe, it, expect, beforeEach } from 'vitest'; import { applyAction } from '../simulation'; -import type { World, CombatantActor, Action } from '../../../core/types'; +import type { World, CombatantActor, Action, EntityId } from '../../../core/types'; import { TileType } from '../../../core/terrain'; import { GAME_CONFIG } from '../../../core/config/GameConfig'; +import { EntityAccessor } from '../../EntityAccessor'; +import { ECSWorld } from '../../ecs/World'; describe('Movement Blocking Behavior', () => { let world: World; let player: CombatantActor; + let accessor: EntityAccessor; + let ecsWorld: ECSWorld; beforeEach(() => { // minimalist world setup @@ -14,7 +19,6 @@ describe('Movement Blocking Behavior', () => { width: 3, height: 3, tiles: new Array(9).fill(TileType.GRASS), - actors: new Map(), exit: { x: 2, y: 2 } }; @@ -22,7 +26,7 @@ describe('Movement Blocking Behavior', () => { world.tiles[1] = TileType.WALL; player = { - id: 1, + id: 1 as EntityId, type: 'player', category: 'combatant', isPlayer: true, @@ -32,12 +36,19 @@ describe('Movement Blocking Behavior', () => { stats: { ...GAME_CONFIG.player.initialStats } }; - world.actors.set(player.id, player); + ecsWorld = new ECSWorld(); + ecsWorld.addComponent(player.id, "position", player.pos); + ecsWorld.addComponent(player.id, "stats", player.stats); + ecsWorld.addComponent(player.id, "actorType", { type: player.type }); + ecsWorld.addComponent(player.id, "player", {}); + ecsWorld.addComponent(player.id, "energy", { current: player.energy, speed: player.speed }); + + accessor = new EntityAccessor(world, player.id, ecsWorld); }); 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); + const events = applyAction(world, player.id, action, accessor); expect(events).toHaveLength(1); expect(events[0]).toMatchObject({ @@ -50,7 +61,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); + const events = applyAction(world, player.id, action, accessor); expect(events).toHaveLength(1); expect(events[0]).toMatchObject({ diff --git a/src/engine/simulation/simulation.ts b/src/engine/simulation/simulation.ts index 66809cc..79c8fd8 100644 --- a/src/engine/simulation/simulation.ts +++ b/src/engine/simulation/simulation.ts @@ -3,24 +3,24 @@ import type { World, EntityId, Action, SimEvent, Actor, CombatantActor, Collecti import { isBlocked, tryDestructTile } from "../world/world-logic"; import { isDestructibleByWalk } from "../../core/terrain"; import { GAME_CONFIG } from "../../core/config/GameConfig"; -import { type EntityManager } from "../EntityManager"; +import { type EntityAccessor } from "../EntityAccessor"; +import { AISystem } from "../ecs/AISystem"; +import { Prefabs } from "../ecs/Prefabs"; -export function applyAction(w: World, actorId: EntityId, action: Action, em?: EntityManager): SimEvent[] { - const actor = w.actors.get(actorId); +export function applyAction(w: World, actorId: EntityId, action: Action, accessor: EntityAccessor): SimEvent[] { + const actor = accessor.getActor(actorId); if (!actor) return []; const events: SimEvent[] = []; switch (action.type) { case "move": - events.push(...handleMove(w, actor, action, em)); + events.push(...handleMove(w, actor, action, accessor)); break; case "attack": - events.push(...handleAttack(w, actor, action, em)); + events.push(...handleAttack(w, actor, action, accessor)); break; case "throw": - // Throwing consumes a turn but visuals are handled by the renderer/scene directly - // so we do NOT emit a "waited" event. break; case "wait": default: @@ -28,19 +28,16 @@ export function applyAction(w: World, actorId: EntityId, action: Action, em?: En break; } - // Note: Energy is now managed by ROT.Scheduler, no need to deduct manually - return events; } -function handleExpCollection(w: World, player: Actor, events: SimEvent[], em?: EntityManager) { +function handleExpCollection(player: Actor, events: SimEvent[], accessor: EntityAccessor) { if (player.category !== "combatant") return; - const orbs = [...w.actors.values()].filter(a => + const actorsAtPos = accessor.getActorsAt(player.pos.x, player.pos.y); + const orbs = actorsAtPos.filter(a => a.category === "collectible" && - a.type === "exp_orb" && - a.pos.x === player.pos.x && - a.pos.y === player.pos.y + a.type === "exp_orb" ) as CollectibleActor[]; for (const orb of orbs) { @@ -55,8 +52,7 @@ function handleExpCollection(w: World, player: Actor, events: SimEvent[], em?: E }); checkLevelUp(player, events); - if (em) em.removeActor(orb.id); - else w.actors.delete(orb.id); + accessor.removeActor(orb.id); } } @@ -91,47 +87,26 @@ function checkLevelUp(player: CombatantActor, events: SimEvent[]) { } -function handleMove(w: World, actor: Actor, action: { dx: number; dy: number }, em?: EntityManager): SimEvent[] { +function handleMove(w: World, actor: Actor, action: { dx: number; dy: number }, accessor: EntityAccessor): SimEvent[] { const from = { ...actor.pos }; const nx = actor.pos.x + action.dx; const ny = actor.pos.y + action.dy; - if (em) { - const moved = em.movement.move(actor.id, action.dx, action.dy); - if (moved) { - const to = { ...actor.pos }; - const events: SimEvent[] = [{ type: "moved", actorId: actor.id, from, to }]; - - const tileIdx = ny * w.width + nx; - const tile = w.tiles[tileIdx]; - if (isDestructibleByWalk(tile)) { - tryDestructTile(w, nx, ny); - } - - if (actor.category === "combatant" && actor.isPlayer) { - handleExpCollection(w, actor, events, em); - } - - return events; + if (!isBlocked(w, nx, ny, accessor)) { + actor.pos.x = nx; + actor.pos.y = ny; + const to = { ...actor.pos }; + const events: SimEvent[] = [{ type: "moved", actorId: actor.id, from, to }]; + + const tileIdx = ny * w.width + nx; + if (isDestructibleByWalk(w.tiles[tileIdx])) { + tryDestructTile(w, nx, ny); } - } else { - // Fallback for cases without EntityManager (e.g. tests) - if (!isBlocked(w, nx, ny)) { - actor.pos.x = nx; - actor.pos.y = ny; - const to = { ...actor.pos }; - const events: SimEvent[] = [{ type: "moved", actorId: actor.id, from, to }]; - - const tileIdx = ny * w.width + nx; - if (isDestructibleByWalk(w.tiles[tileIdx])) { - tryDestructTile(w, nx, ny); - } - if (actor.category === "combatant" && actor.isPlayer) { - handleExpCollection(w, actor, events); - } - return events; + if (actor.category === "combatant" && actor.isPlayer) { + handleExpCollection(actor, events, accessor); } + return events; } return [{ type: "move-blocked", actorId: actor.id, x: nx, y: ny }]; @@ -139,8 +114,8 @@ function handleMove(w: World, actor: Actor, action: { dx: number; dy: number }, -function handleAttack(w: World, actor: Actor, action: { targetId: EntityId }, em?: EntityManager): SimEvent[] { - const target = w.actors.get(action.targetId); +function handleAttack(_w: World, actor: Actor, action: { targetId: EntityId }, accessor: EntityAccessor): SimEvent[] { + const target = accessor.getActor(action.targetId); if (target && target.category === "combatant" && actor.category === "combatant") { const events: SimEvent[] = [{ type: "attacked", attackerId: actor.id, targetId: action.targetId }]; @@ -149,7 +124,6 @@ function handleAttack(w: World, actor: Actor, action: { targetId: EntityId }, em const hitRoll = Math.random() * 100; if (hitRoll > hitChance) { - // Miss! events.push({ type: "dodged", targetId: action.targetId, @@ -173,17 +147,15 @@ function handleAttack(w: World, actor: Actor, action: { targetId: EntityId }, em const blockRoll = Math.random() * 100; let isBlock = false; if (blockRoll < target.stats.blockChance) { - dmg = Math.floor(dmg * 0.5); // Block reduces damage by 50% + dmg = Math.floor(dmg * 0.5); isBlock = true; } target.stats.hp -= dmg; - // Aggression on damage: if target is enemy and attacker is player (or vice versa), alert them if (target.stats.hp > 0 && target.category === "combatant" && !target.isPlayer) { - // Switch to pursuing immediately target.aiState = "pursuing"; - target.alertedAt = Date.now(); // Reset alert timer if any + target.alertedAt = Date.now(); if (actor.pos) { target.lastKnownPlayerPos = { ...actor.pos }; } @@ -224,28 +196,18 @@ function handleAttack(w: World, actor: Actor, action: { targetId: EntityId }, em y: target.pos.y, victimType: target.type as ActorType }); - if (em) em.removeActor(target.id); - else w.actors.delete(target.id); - - + + accessor.removeActor(target.id); // Spawn EXP Orb const enemyDef = (GAME_CONFIG.enemies as any)[target.type || ""]; - const orbId = em ? em.getNextId() : Math.max(0, ...w.actors.keys(), target.id) + 1; + const expAmount = enemyDef?.expValue || 0; - const orb: CollectibleActor = { - id: orbId, - category: "collectible", - type: "exp_orb", - pos: { ...target.pos }, - expAmount: enemyDef?.expValue || 0 - }; - - if (em) em.addActor(orb); - else w.actors.set(orbId, orb); - - - events.push({ type: "orb-spawned", orbId, x: target.pos.x, y: target.pos.y }); + const ecsWorld = accessor.context; + if (ecsWorld) { + const orbId = Prefabs.expOrb(ecsWorld, target.pos.x, target.pos.y, expAmount); + events.push({ type: "orb-spawned", orbId, x: target.pos.x, y: target.pos.y }); + } } return events; } @@ -260,12 +222,13 @@ function handleAttack(w: World, actor: Actor, action: { targetId: EntityId }, em * - Alerted: Brief period after spotting player (shows "!") * - Pursuing: Chase player while in FOV or toward last known position */ -export function decideEnemyAction(_w: World, enemy: CombatantActor, player: CombatantActor, em?: EntityManager): { action: Action; justAlerted: boolean } { - if (em) { - const result = em.ai.update(enemy.id, player.id); +export function decideEnemyAction(w: World, enemy: CombatantActor, player: CombatantActor, accessor: EntityAccessor): { action: Action; justAlerted: boolean } { + const ecsWorld = accessor.context; + if (ecsWorld) { + const aiSystem = new AISystem(ecsWorld, w, accessor); + const result = aiSystem.update(enemy.id, player.id); - // Sync ECS component state back to Actor object for compatibility with tests and old logic - const aiComp = em.ecsWorld.getComponent(enemy.id, "ai"); + const aiComp = ecsWorld.getComponent(enemy.id, "ai"); if (aiComp) { enemy.aiState = aiComp.state; enemy.alertedAt = aiComp.alertedAt; @@ -275,8 +238,6 @@ export function decideEnemyAction(_w: World, enemy: CombatantActor, player: Comb return result; } - // Fallback for tests or cases without EntityManager - // [Existing decideEnemyAction logic could be kept here as fallback, or just return wait] return { action: { type: "wait" }, justAlerted: false }; } @@ -284,81 +245,42 @@ export function decideEnemyAction(_w: World, enemy: CombatantActor, player: Comb * Speed-based scheduler using rot-js: runs until it's the player's turn and the game needs input. * Returns enemy events accumulated along the way. */ -export function stepUntilPlayerTurn(w: World, playerId: EntityId, em?: EntityManager): { awaitingPlayerId: EntityId; events: SimEvent[] } { - // Energy Threshold +export function stepUntilPlayerTurn(w: World, playerId: EntityId, accessor: EntityAccessor): { awaitingPlayerId: EntityId; events: SimEvent[] } { const THRESHOLD = 100; - // Ensure player exists - const player = w.actors.get(playerId) as CombatantActor; - if (!player || player.category !== "combatant") throw new Error("Player missing or invalid"); + const player = accessor.getCombatant(playerId); + if (!player) throw new Error("Player missing or invalid"); const events: SimEvent[] = []; - // If player already has enough energy (from previous accumulation), return immediately to let them act - // NOTE: We do NOT deduct player energy here. The player's action will cost energy in the next turn processing or we expect the caller to have deducted it? - // Actually, standard roguelike loop: - // 1. Player acts. Deduct cost. - // 2. Loop game until Player has energy >= Threshold. - - // Since this function is called AFTER user input (Player just acted), we assume Player needs to recover energy. - // BUT, we should check if we need to deduct energy first? - // The caller just applied an action. We should probably deduct energy for that action BEFORE entering the loop? - // For now, let's assume the player is at < 100 energy and needs to wait. - // Wait, if we don't deduct energy, the player stays at high energy? - // The caller doesn't manage energy. WE manage energy. - - // Implicitly, the player just spent 100 energy to trigger this call. - // So we should deduct it from the player NOW. if (player.energy >= THRESHOLD) { player.energy -= THRESHOLD; } while (true) { - // If player has enough energy to act, return control to user if (player.energy >= THRESHOLD) { return { awaitingPlayerId: playerId, events }; } - // Give energy to everyone - for (const actor of w.actors.values()) { + const actors = [...accessor.getAllActors()]; + for (const actor of actors) { if (actor.category === "combatant") { actor.energy += actor.speed; } } - // Process turns for everyone who has enough energy (except player, who breaks the loop) - // We sort by energy to give priority to those who have waited longest/are fastest? - // ROT.Scheduler uses a priority queue. Here we can iterate. - // Iterating map values is insertion order. - // Ideally we'd duplicate the list to sort it, but for performance let's simple iterate. - - // We need to loop multiple times if someone has A LOT of energy (e.g. speed 200 vs speed 50) - // But typically we step 1 tick. - - // Simpler approach: - // Process all actors with energy >= THRESHOLD. - // If multiple have >= THRESHOLD, who goes first? - // Usually the one with highest energy. - - // Let's protect against infinite loops if someone has infinite speed. let actionsTaken = 0; while (true) { - const eligibleActors = [...w.actors.values()].filter( - a => a.category === "combatant" && a.energy >= THRESHOLD && !a.isPlayer - ) as CombatantActor[]; + const eligibleActors = accessor.getEnemies().filter(a => a.energy >= THRESHOLD); if (eligibleActors.length === 0) break; - // Sort by energy descending eligibleActors.sort((a, b) => b.energy - a.energy); - const actor = eligibleActors[0]; - // Actor takes a turn actor.energy -= THRESHOLD; - // Decide logic - const decision = decideEnemyAction(w, actor, player, em); + const decision = decideEnemyAction(w, actor, player, accessor); if (decision.justAlerted) { events.push({ @@ -369,15 +291,14 @@ export function stepUntilPlayerTurn(w: World, playerId: EntityId, em?: EntityMan }); } - events.push(...applyAction(w, actor.id, decision.action, em)); + events.push(...applyAction(w, actor.id, decision.action, accessor)); - // Check if player died - if (!w.actors.has(playerId)) { + if (!accessor.isPlayerAlive()) { return { awaitingPlayerId: null as any, events }; } actionsTaken++; - if (actionsTaken > 1000) break; // Emergency break + if (actionsTaken > 1000) break; } } } diff --git a/src/engine/systems/EquipmentService.ts b/src/engine/systems/EquipmentService.ts new file mode 100644 index 0000000..c9681e8 --- /dev/null +++ b/src/engine/systems/EquipmentService.ts @@ -0,0 +1,134 @@ +import { type CombatantActor, type Item, type Equipment } from "../../core/types"; + +/** + * Equipment slot keys matching the Equipment interface. + */ +export type EquipmentSlotKey = keyof Equipment; + +/** + * Map of item types to valid equipment slot keys. + */ +const ITEM_TYPE_TO_SLOTS: Record = { + Weapon: ["mainHand", "offHand"], + BodyArmour: ["bodyArmour"], + Helmet: ["helmet"], + Gloves: ["gloves"], + Boots: ["boots"], + Ring: ["ringLeft", "ringRight"], + Belt: ["belt"], + Amulet: ["amulet"], + Offhand: ["offHand"], +}; + +/** + * Checks if an item can be equipped in the specified slot. + */ +export function isItemValidForSlot(item: Item | undefined, slotKey: string): boolean { + if (!item || !item.type) return false; + const validSlots = ITEM_TYPE_TO_SLOTS[item.type]; + return validSlots?.includes(slotKey as EquipmentSlotKey) ?? false; +} + +/** + * Applies or removes item stats to/from a player. + * @param player - The player actor to modify + * @param item - The item with stats to apply + * @param isAdding - True to add stats, false to remove + */ +export function applyItemStats(player: CombatantActor, item: Item, isAdding: boolean): void { + if (!("stats" in item) || !item.stats) return; + + const modifier = isAdding ? 1 : -1; + const stats = item.stats as Record; + + // Primary stats + if (stats.defense) player.stats.defense += stats.defense * modifier; + if (stats.attack) player.stats.attack += stats.attack * modifier; + + // Max HP with current HP adjustment + if (stats.maxHp) { + const diff = stats.maxHp * modifier; + player.stats.maxHp += diff; + player.stats.hp = Math.min(player.stats.maxHp, player.stats.hp + (isAdding ? diff : 0)); + } + + // Max Mana with current mana adjustment + if (stats.maxMana) { + const diff = stats.maxMana * modifier; + player.stats.maxMana += diff; + player.stats.mana = Math.min(player.stats.maxMana, player.stats.mana + (isAdding ? diff : 0)); + } + + // Secondary stats + if (stats.critChance) player.stats.critChance += stats.critChance * modifier; + if (stats.accuracy) player.stats.accuracy += stats.accuracy * modifier; + if (stats.evasion) player.stats.evasion += stats.evasion * modifier; + if (stats.blockChance) player.stats.blockChance += stats.blockChance * modifier; +} + +/** + * De-equips an item from the specified slot, removing stats and returning to inventory. + * @returns The de-equipped item, or null if slot was empty + */ +export function deEquipItem( + player: CombatantActor, + slotKey: EquipmentSlotKey +): Item | null { + if (!player.equipment) return null; + + const item = (player.equipment as Record)[slotKey]; + if (!item) return null; + + // Remove from equipment + delete (player.equipment as Record)[slotKey]; + + // Remove stats + applyItemStats(player, item, false); + + // Add back to inventory + if (!player.inventory) player.inventory = { gold: 0, items: [] }; + player.inventory.items.push(item); + + return item; +} + +/** + * Equips an item to the specified slot, handling swaps if needed. + * @returns Object with success status and optional message + */ +export function equipItem( + player: CombatantActor, + item: Item, + slotKey: EquipmentSlotKey +): { success: boolean; swappedItem?: Item; message?: string } { + // Validate slot + if (!isItemValidForSlot(item, slotKey)) { + return { success: false, message: "Cannot equip there!" }; + } + + // Remove from inventory + if (!player.inventory) return { success: false, message: "No inventory" }; + const itemIdx = player.inventory.items.findIndex(it => it.id === item.id); + if (itemIdx === -1) return { success: false, message: "Item not in inventory" }; + + // Handle swap if slot is occupied + if (!player.equipment) player.equipment = {}; + const oldItem = (player.equipment as Record)[slotKey]; + let swappedItem: Item | undefined; + + if (oldItem) { + swappedItem = deEquipItem(player, slotKey) ?? undefined; + } + + // Move to equipment (re-find index after potential swap) + const newIdx = player.inventory.items.findIndex(it => it.id === item.id); + if (newIdx !== -1) { + player.inventory.items.splice(newIdx, 1); + } + (player.equipment as Record)[slotKey] = item; + + // Apply stats + applyItemStats(player, item, true); + + return { success: true, swappedItem }; +} diff --git a/src/engine/systems/__tests__/EquipmentService.test.ts b/src/engine/systems/__tests__/EquipmentService.test.ts new file mode 100644 index 0000000..93917d6 --- /dev/null +++ b/src/engine/systems/__tests__/EquipmentService.test.ts @@ -0,0 +1,231 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { + isItemValidForSlot, + applyItemStats, + deEquipItem, + equipItem, +} from "../EquipmentService"; +import type { CombatantActor, Item, WeaponItem, ArmourItem } from "../../../core/types"; + +// Helper to create a mock player +function createMockPlayer(overrides: Partial = {}): CombatantActor { + return { + id: 1, + pos: { x: 0, y: 0 }, + category: "combatant", + isPlayer: true, + type: "player", + speed: 100, + energy: 0, + stats: { + maxHp: 20, + hp: 20, + maxMana: 10, + mana: 10, + attack: 5, + defense: 2, + level: 1, + exp: 0, + expToNextLevel: 10, + critChance: 5, + critMultiplier: 150, + accuracy: 90, + lifesteal: 0, + evasion: 5, + blockChance: 0, + luck: 0, + statPoints: 0, + skillPoints: 0, + strength: 10, + dexterity: 10, + intelligence: 10, + passiveNodes: [], + }, + inventory: { gold: 0, items: [] }, + equipment: {}, + ...overrides, + }; +} + +function createSword(): WeaponItem { + return { + id: "sword_1", + name: "Iron Sword", + type: "Weapon", + weaponType: "melee", + textureKey: "items", + spriteIndex: 0, + stats: { attack: 3 }, + }; +} + +function createArmour(): ArmourItem { + return { + id: "armour_1", + name: "Leather Armor", + type: "BodyArmour", + textureKey: "items", + spriteIndex: 1, + stats: { defense: 2 }, + }; +} + +describe("EquipmentService", () => { + describe("isItemValidForSlot", () => { + it("returns true for weapon in mainHand", () => { + expect(isItemValidForSlot(createSword(), "mainHand")).toBe(true); + }); + + it("returns true for weapon in offHand", () => { + expect(isItemValidForSlot(createSword(), "offHand")).toBe(true); + }); + + it("returns false for weapon in bodyArmour slot", () => { + expect(isItemValidForSlot(createSword(), "bodyArmour")).toBe(false); + }); + + it("returns true for BodyArmour in bodyArmour slot", () => { + expect(isItemValidForSlot(createArmour(), "bodyArmour")).toBe(true); + }); + + it("returns false for undefined item", () => { + expect(isItemValidForSlot(undefined, "mainHand")).toBe(false); + }); + + it("returns false for unknown slot", () => { + expect(isItemValidForSlot(createSword(), "unknownSlot")).toBe(false); + }); + }); + + describe("applyItemStats", () => { + let player: CombatantActor; + + beforeEach(() => { + player = createMockPlayer(); + }); + + it("adds attack stat when isAdding is true", () => { + const sword = createSword(); + applyItemStats(player, sword, true); + expect(player.stats.attack).toBe(8); // 5 + 3 + }); + + it("removes attack stat when isAdding is false", () => { + const sword = createSword(); + player.stats.attack = 8; + applyItemStats(player, sword, false); + expect(player.stats.attack).toBe(5); + }); + + it("adds defense stat when isAdding is true", () => { + const armour = createArmour(); + applyItemStats(player, armour, true); + expect(player.stats.defense).toBe(4); // 2 + 2 + }); + + it("handles items without stats", () => { + const itemWithoutStats = { id: "coin", name: "Coin", type: "Currency" } as Item; + applyItemStats(player, itemWithoutStats, true); + expect(player.stats.attack).toBe(5); // unchanged + }); + }); + + describe("deEquipItem", () => { + let player: CombatantActor; + let sword: WeaponItem; + + beforeEach(() => { + sword = createSword(); + player = createMockPlayer({ + equipment: { mainHand: sword }, + inventory: { gold: 0, items: [] }, + }); + player.stats.attack = 8; // Sword already equipped + }); + + it("removes item from equipment slot", () => { + deEquipItem(player, "mainHand"); + expect(player.equipment?.mainHand).toBeUndefined(); + }); + + it("returns the de-equipped item", () => { + const result = deEquipItem(player, "mainHand"); + expect(result?.id).toBe("sword_1"); + }); + + it("adds item back to inventory", () => { + deEquipItem(player, "mainHand"); + expect(player.inventory?.items.length).toBe(1); + expect(player.inventory?.items[0].id).toBe("sword_1"); + }); + + it("removes item stats from player", () => { + deEquipItem(player, "mainHand"); + expect(player.stats.attack).toBe(5); // Back to base + }); + + it("returns null for empty slot", () => { + const result = deEquipItem(player, "offHand"); + expect(result).toBeNull(); + }); + }); + + describe("equipItem", () => { + let player: CombatantActor; + let sword: WeaponItem; + + beforeEach(() => { + sword = createSword(); + player = createMockPlayer({ + inventory: { gold: 0, items: [sword] }, + equipment: {}, + }); + }); + + it("equips item to valid slot", () => { + const result = equipItem(player, sword, "mainHand"); + expect(result.success).toBe(true); + expect(player.equipment?.mainHand?.id).toBe("sword_1"); + }); + + it("removes item from inventory", () => { + equipItem(player, sword, "mainHand"); + expect(player.inventory?.items.length).toBe(0); + }); + + it("applies item stats", () => { + equipItem(player, sword, "mainHand"); + expect(player.stats.attack).toBe(8); // 5 + 3 + }); + + it("fails for invalid slot", () => { + const result = equipItem(player, sword, "bodyArmour"); + expect(result.success).toBe(false); + expect(result.message).toBe("Cannot equip there!"); + }); + + it("swaps existing item", () => { + const sword2: WeaponItem = { + id: "sword_2", + name: "Steel Sword", + type: "Weapon", + weaponType: "melee", + textureKey: "items", + spriteIndex: 0, + stats: { attack: 5 }, + }; + player.inventory!.items.push(sword2); + + // Equip first sword + equipItem(player, sword, "mainHand"); + expect(player.stats.attack).toBe(8); + + // Equip second sword (should swap) + const result = equipItem(player, sword2, "mainHand"); + expect(result.success).toBe(true); + expect(result.swappedItem?.id).toBe("sword_1"); + expect(player.equipment?.mainHand?.id).toBe("sword_2"); + expect(player.stats.attack).toBe(10); // 5 base + 5 new sword + }); + }); +}); diff --git a/src/engine/world/__tests__/DebuggingStack.test.ts b/src/engine/world/__tests__/DebuggingStack.test.ts new file mode 100644 index 0000000..e72c079 --- /dev/null +++ b/src/engine/world/__tests__/DebuggingStack.test.ts @@ -0,0 +1,41 @@ +import { describe, it, expect } from 'vitest'; +import { generateWorld } from '../generator'; +import { GAME_CONFIG } from '../../../core/config/GameConfig'; + +describe('World Generator Stacking Debug', () => { + it('should not spawn multiple enemies on the same tile', () => { + const runState = { + stats: { ...GAME_CONFIG.player.initialStats }, + inventory: { gold: 0, items: [] } + }; + + // Run multiple times to catch sporadic rng issues + for (let i = 0; i < 50; i++) { + const floor = 1 + (i % 10); + const { ecsWorld } = generateWorld(floor, runState); + + // Get all enemies + const aiEntities = ecsWorld.getEntitiesWith("ai"); + + const positions = new Set(); + const duplicates: string[] = []; + + for (const entityId of aiEntities) { + const pos = ecsWorld.getComponent(entityId, "position"); + if (pos) { + const key = `${pos.x},${pos.y}`; + if (positions.has(key)) { + duplicates.push(key); + } + positions.add(key); + } + } + + if (duplicates.length > 0) { + console.error(`Found duplicates on iteration ${i} (floor ${floor}):`, duplicates); + } + + expect(duplicates.length).toBe(0); + } + }); +}); diff --git a/src/engine/world/generator.ts b/src/engine/world/generator.ts index 640b9c9..c392721 100644 --- a/src/engine/world/generator.ts +++ b/src/engine/world/generator.ts @@ -1,4 +1,4 @@ -import { type World, type EntityId, type RunState, type Tile, type Actor, type Vec2 } from "../../core/types"; +import { type World, type EntityId, type RunState, type Tile, type Vec2 } from "../../core/types"; import { TileType } from "../../core/terrain"; import { idx } from "./world-logic"; import { GAME_CONFIG } from "../../core/config/GameConfig"; @@ -13,6 +13,7 @@ import { seededRandom } from "../../core/math"; import * as ROT from "rot-js"; import { ECSWorld } from "../ecs/World"; import { Prefabs } from "../ecs/Prefabs"; +import { EntityBuilder } from "../ecs/EntityBuilder"; interface Room { @@ -34,6 +35,9 @@ export function generateWorld(floor: number, runState: RunState): { world: World const tiles: Tile[] = new Array(width * height).fill(TileType.WALL); const random = seededRandom(floor * 12345); + + // Create ECS World first + const ecsWorld = new ECSWorld(); // Starts at ID 1 by default // Set ROT's RNG seed for consistent dungeon generation ROT.RNG.setSeed(floor * 12345); @@ -45,35 +49,34 @@ export function generateWorld(floor: number, runState: RunState): { world: World const playerX = firstRoom.x + Math.floor(firstRoom.width / 2); const playerY = firstRoom.y + Math.floor(firstRoom.height / 2); - const actors = new Map(); - const playerId = 1; - - actors.set(playerId, { - id: playerId, - category: "combatant", - isPlayer: true, - type: "player", - pos: { x: playerX, y: playerY }, - speed: GAME_CONFIG.player.speed, - stats: { ...runState.stats }, - inventory: { + // Create Player Entity in ECS + const runInventory = { gold: runState.inventory.gold, items: [ ...runState.inventory.items, // Add starting items for testing if empty ...(runState.inventory.items.length === 0 ? [ createConsumable("health_potion", 2), - createMeleeWeapon("iron_sword", "sharp"), // Sharp sword variant + createMeleeWeapon("iron_sword", "sharp"), createConsumable("throwing_dagger", 3), createRangedWeapon("pistol"), - createArmour("leather_armor", "heavy"), // Heavy armour variant - createUpgradeScroll(2) // 2 Upgrade scrolls + createArmour("leather_armor", "heavy"), + createUpgradeScroll(2) ] : []) ] - }, - energy: 0 - }); + }; + const playerId = EntityBuilder.create(ecsWorld) + .asPlayer() + .withPosition(playerX, playerY) + // RunState stats override default player stats + .withStats(runState.stats) + .withInventory(runInventory) + .withEnergy(GAME_CONFIG.player.speed) + .build(); + + // No more legacy Actors Map + // Place exit in last room const lastRoom = rooms[rooms.length - 1]; const exit: Vec2 = { @@ -81,10 +84,10 @@ export function generateWorld(floor: number, runState: RunState): { world: World y: lastRoom.y + Math.floor(lastRoom.height / 2) }; - placeEnemies(floor, rooms, actors, random); + placeEnemies(floor, rooms, ecsWorld, random); - // Create ECS world and place traps - const ecsWorld = new ECSWorld(); + // Place traps (using same ecsWorld) + const occupiedPositions = new Set(); occupiedPositions.add(`${playerX},${playerY}`); // Don't place traps on player start occupiedPositions.add(`${exit.x},${exit.y}`); // Don't place traps on exit @@ -103,7 +106,7 @@ export function generateWorld(floor: number, runState: RunState): { world: World tiles[playerY * width + playerX] = TileType.EMPTY; return { - world: { width, height, tiles, actors, exit }, + world: { width, height, tiles, exit }, playerId, ecsWorld }; @@ -368,8 +371,7 @@ function decorate(width: number, height: number, tiles: Tile[], random: () => nu } } -function placeEnemies(floor: number, rooms: Room[], actors: Map, random: () => number): void { - let enemyId = 2; +function placeEnemies(floor: number, rooms: Room[], ecsWorld: ECSWorld, random: () => number): void { const numEnemies = GAME_CONFIG.enemyScaling.baseCount + floor * GAME_CONFIG.enemyScaling.baseCountPerFloor; const enemyTypes = Object.keys(GAME_CONFIG.enemies); @@ -394,43 +396,23 @@ function placeEnemies(floor: number, rooms: Room[], actors: Map const scaledHp = enemyDef.baseHp + floor * GAME_CONFIG.enemyScaling.hpPerFloor; const scaledAttack = enemyDef.baseAttack + Math.floor(floor / 2) * GAME_CONFIG.enemyScaling.attackPerTwoFloors; - actors.set(enemyId, { - id: enemyId, - category: "combatant", - isPlayer: false, - type, - pos: { x: ex, y: ey }, - speed: enemyDef.minSpeed + Math.floor(random() * (enemyDef.maxSpeed - enemyDef.minSpeed)), - stats: { - maxHp: scaledHp + Math.floor(random() * 4), - hp: scaledHp + Math.floor(random() * 4), - maxMana: 0, - mana: 0, - attack: scaledAttack + Math.floor(random() * 2), - defense: enemyDef.baseDefense, - level: 0, - exp: 0, - expToNextLevel: 0, - statPoints: 0, - skillPoints: 0, - strength: 0, - dexterity: 0, - intelligence: 0, - critChance: 0, - critMultiplier: 100, - accuracy: 80, - lifesteal: 0, - evasion: 0, - blockChance: 0, - luck: 0, - passiveNodes: [] - }, - energy: 0 - }); + const speed = enemyDef.minSpeed + Math.floor(random() * (enemyDef.maxSpeed - enemyDef.minSpeed)); + + // Create Enemy in ECS + EntityBuilder.create(ecsWorld) + .asEnemy(type) + .withPosition(ex, ey) + .withStats({ + maxHp: scaledHp + Math.floor(random() * 4), + hp: scaledHp + Math.floor(random() * 4), + attack: scaledAttack + Math.floor(random() * 2), + defense: enemyDef.baseDefense, + }) + .withEnergy(speed) // Configured speed + // Note: Other stats like crit/evasion are defaults from EntityBuilder or BaseStats + .build(); occupiedPositions.add(k); - enemyId++; - break; } } diff --git a/src/engine/world/pathfinding.ts b/src/engine/world/pathfinding.ts index bf4eae2..49f14c4 100644 --- a/src/engine/world/pathfinding.ts +++ b/src/engine/world/pathfinding.ts @@ -1,6 +1,6 @@ import type { World, Vec2 } from "../../core/types"; import { inBounds, isWall, isBlocked, idx } from "./world-logic"; -import { type EntityManager } from "../EntityManager"; +import { type EntityAccessor } from "../EntityAccessor"; import * as ROT from "rot-js"; /** @@ -16,14 +16,14 @@ export function findPathAStar( seen: Uint8Array, start: Vec2, end: Vec2, - options: { ignoreBlockedTarget?: boolean; ignoreSeen?: boolean; em?: EntityManager } = {} + options: { ignoreBlockedTarget?: boolean; ignoreSeen?: boolean; accessor?: EntityAccessor } = {} ): Vec2[] { // Validate target if (!inBounds(w, end.x, end.y)) return []; if (isWall(w, end.x, end.y)) return []; // Check if target is blocked (unless ignoring) - if (!options.ignoreBlockedTarget && isBlocked(w, end.x, end.y, options.em)) return []; + if (!options.ignoreBlockedTarget && isBlocked(w, end.x, end.y, options.accessor)) return []; // Check if target is unseen (unless ignoring) if (!options.ignoreSeen && seen[idx(w, end.x, end.y)] !== 1) return []; @@ -44,7 +44,7 @@ export function findPathAStar( if (!options.ignoreSeen && seen[idx(w, x, y)] !== 1) return false; // Check actor blocking - if (isBlocked(w, x, y, options.em)) return false; + if (options.accessor && isBlocked(w, x, y, options.accessor)) return false; return true; }; diff --git a/src/engine/world/world-logic.ts b/src/engine/world/world-logic.ts index 28127a0..7e15651 100644 --- a/src/engine/world/world-logic.ts +++ b/src/engine/world/world-logic.ts @@ -1,6 +1,6 @@ -import type { World, EntityId } from "../../core/types"; +import type { World } from "../../core/types"; import { isBlocking, isDestructible, getDestructionResult } from "../../core/terrain"; -import { type EntityManager } from "../EntityManager"; +import { type EntityAccessor } from "../EntityAccessor"; export function inBounds(w: World, x: number, y: number): boolean { @@ -37,26 +37,19 @@ export function tryDestructTile(w: World, x: number, y: number): boolean { return false; } -export function isBlocked(w: World, x: number, y: number, em?: EntityManager): boolean { +export function isBlocked(w: World, x: number, y: number, accessor: EntityAccessor | undefined): boolean { if (!inBounds(w, x, y)) return true; if (isBlockingTile(w, x, y)) return true; - if (em) { - const actors = em.getActorsAt(x, y); - // Only combatants block movement - return actors.some(a => a.category === "combatant"); - } - - for (const a of w.actors.values()) { - if (a.pos.x === x && a.pos.y === y && a.category === "combatant") return true; - } - return false; + if (!accessor) return false; + const actors = accessor.getActorsAt(x, y); + return actors.some(a => a.category === "combatant"); } -export function isPlayerOnExit(w: World, playerId: EntityId): boolean { - const p = w.actors.get(playerId); +export function isPlayerOnExit(w: World, accessor: EntityAccessor): boolean { + const p = accessor.getPlayer(); if (!p) return false; return p.pos.x === w.exit.x && p.pos.y === w.exit.y; } diff --git a/src/rendering/DungeonRenderer.ts b/src/rendering/DungeonRenderer.ts index c919d98..5157367 100644 --- a/src/rendering/DungeonRenderer.ts +++ b/src/rendering/DungeonRenderer.ts @@ -9,6 +9,7 @@ import { MinimapRenderer } from "./MinimapRenderer"; import { FxRenderer } from "./FxRenderer"; import { ItemSpriteFactory } from "./ItemSpriteFactory"; import { type ECSWorld } from "../engine/ecs/World"; +import { type EntityAccessor } from "../engine/EntityAccessor"; export class DungeonRenderer { private scene: Phaser.Scene; @@ -25,7 +26,8 @@ export class DungeonRenderer { private fxRenderer: FxRenderer; private world!: World; - private ecsWorld?: ECSWorld; + private entityAccessor!: EntityAccessor; + private ecsWorld!: ECSWorld; private trapSprites: Map = new Map(); constructor(scene: Phaser.Scene) { @@ -35,17 +37,33 @@ export class DungeonRenderer { this.fxRenderer = new FxRenderer(scene); } - initializeFloor(world: World, playerId: EntityId, ecsWorld?: ECSWorld) { + initializeFloor(world: World, ecsWorld: ECSWorld, entityAccessor: EntityAccessor) { this.world = world; this.ecsWorld = ecsWorld; + this.entityAccessor = entityAccessor; this.fovManager.initialize(world); - // Clear old trap sprites + // Clear old sprites from maps for (const [, sprite] of this.trapSprites) { sprite.destroy(); } this.trapSprites.clear(); + for (const [, sprite] of this.enemySprites) { + sprite.destroy(); + } + this.enemySprites.clear(); + + for (const [, sprite] of this.orbSprites) { + sprite.destroy(); + } + this.orbSprites.clear(); + + for (const [, sprite] of this.itemSprites) { + sprite.destroy(); + } + this.itemSprites.clear(); + // Setup Tilemap if (this.map) this.map.destroy(); this.map = this.scene.make.tilemap({ @@ -81,8 +99,8 @@ export class DungeonRenderer { // Kill any active tweens on the player sprite this.scene.tweens.killTweensOf(this.playerSprite); - // Get player position in new world using provided playerId - const player = world.actors.get(playerId); + + const player = this.entityAccessor.getPlayer(); if (player && player.category === "combatant") { this.playerSprite.setPosition( player.pos.x * TILE_SIZE + TILE_SIZE / 2, @@ -122,8 +140,11 @@ export class DungeonRenderer { return this.minimapRenderer.isVisible(); } - computeFov(playerId: EntityId) { - this.fovManager.compute(this.world, playerId); + computeFov() { + const player = this.entityAccessor.getPlayer(); + if (player && player.category === "combatant") { + this.fovManager.compute(this.world, player.pos); + } } isSeen(x: number, y: number): boolean { @@ -210,7 +231,8 @@ export class DungeonRenderer { const activeOrbIds = new Set(); const activeItemIds = new Set(); - for (const a of this.world.actors.values()) { + const actors = this.entityAccessor.getAllActors(); + for (const a of actors) { const i = idx(this.world, a.pos.x, a.pos.y); const isVis = visible[i] === 1; @@ -310,7 +332,7 @@ export class DungeonRenderer { for (const [id, sprite] of this.enemySprites.entries()) { if (!activeEnemyIds.has(id)) { sprite.setVisible(false); - if (!this.world.actors.has(id)) { + if (!this.entityAccessor.hasActor(id)) { sprite.destroy(); this.enemySprites.delete(id); } @@ -320,7 +342,7 @@ export class DungeonRenderer { for (const [id, orb] of this.orbSprites.entries()) { if (!activeOrbIds.has(id)) { orb.setVisible(false); - if (!this.world.actors.has(id)) { + if (!this.entityAccessor.hasActor(id)) { orb.destroy(); this.orbSprites.delete(id); } @@ -330,14 +352,14 @@ export class DungeonRenderer { for (const [id, item] of this.itemSprites.entries()) { if (!activeItemIds.has(id)) { item.setVisible(false); - if (!this.world.actors.has(id)) { + if (!this.entityAccessor.hasActor(id)) { item.destroy(); this.itemSprites.delete(id); } } } - this.minimapRenderer.render(this.world, seen, visible); + this.minimapRenderer.render(this.world, seen, visible, this.entityAccessor); } // FX Delegations diff --git a/src/rendering/FovManager.ts b/src/rendering/FovManager.ts index acad408..555f865 100644 --- a/src/rendering/FovManager.ts +++ b/src/rendering/FovManager.ts @@ -1,6 +1,6 @@ import { FOV } from "rot-js"; import type ROT from "rot-js"; -import { type World, type EntityId } from "../core/types"; +import { type World } from "../core/types"; import { idx, inBounds } from "../engine/world/world-logic"; import { blocksSight } from "../core/terrain"; import { GAME_CONFIG } from "../core/config/GameConfig"; @@ -28,13 +28,12 @@ export class FovManager { }); } - compute(world: World, playerId: EntityId) { + compute(world: World, origin: { x: number; y: number }) { this.visible.fill(0); this.visibleStrength.fill(0); - const player = world.actors.get(playerId)!; - const ox = player.pos.x; - const oy = player.pos.y; + const ox = origin.x; + const oy = origin.y; this.fov.compute(ox, oy, GAME_CONFIG.player.viewRadius, (x: number, y: number, r: number, v: number) => { if (!inBounds(world, x, y)) return; diff --git a/src/rendering/MinimapRenderer.ts b/src/rendering/MinimapRenderer.ts index 21bf475..fe11c6f 100644 --- a/src/rendering/MinimapRenderer.ts +++ b/src/rendering/MinimapRenderer.ts @@ -1,5 +1,6 @@ import Phaser from "phaser"; -import { type World, type CombatantActor } from "../core/types"; +import { type World } from "../core/types"; +import { type EntityAccessor } from "../engine/EntityAccessor"; import { idx, isWall } from "../engine/world/world-logic"; import { GAME_CONFIG } from "../core/config/GameConfig"; @@ -47,7 +48,7 @@ export class MinimapRenderer { return this.minimapVisible; } - render(world: World, seen: Uint8Array, visible: Uint8Array) { + render(world: World, seen: Uint8Array, visible: Uint8Array, accessor: EntityAccessor) { this.minimapGfx.clear(); if (!world) return; @@ -84,20 +85,17 @@ export class MinimapRenderer { this.minimapGfx.fillRect(offsetX + ex * tileSize, offsetY + ey * tileSize, tileSize, tileSize); } - const player = [...world.actors.values()].find(a => a.category === "combatant" && a.isPlayer) as CombatantActor; + const player = accessor.getPlayer(); if (player) { this.minimapGfx.fillStyle(0x66ff66, 1); this.minimapGfx.fillRect(offsetX + player.pos.x * tileSize, offsetY + player.pos.y * tileSize, tileSize, tileSize); } - for (const a of world.actors.values()) { - if (a.category === "combatant") { - if (a.isPlayer) continue; - const i = idx(world, a.pos.x, a.pos.y); - if (visible[i] === 1) { - this.minimapGfx.fillStyle(0xff6666, 1); - this.minimapGfx.fillRect(offsetX + a.pos.x * tileSize, offsetY + a.pos.y * tileSize, tileSize, tileSize); - } + for (const a of accessor.getEnemies()) { + const i = idx(world, a.pos.x, a.pos.y); + if (visible[i] === 1) { + this.minimapGfx.fillStyle(0xff6666, 1); + this.minimapGfx.fillRect(offsetX + a.pos.x * tileSize, offsetY + a.pos.y * tileSize, tileSize, tileSize); } } } diff --git a/src/rendering/__tests__/DungeonRenderer.test.ts b/src/rendering/__tests__/DungeonRenderer.test.ts index 87f3774..44ea8dd 100644 --- a/src/rendering/__tests__/DungeonRenderer.test.ts +++ b/src/rendering/__tests__/DungeonRenderer.test.ts @@ -1,6 +1,9 @@ + import { describe, it, expect, vi, beforeEach } from 'vitest'; import { DungeonRenderer } from '../DungeonRenderer'; -import { type World } from '../../core/types'; +import type { World, EntityId } from '../../core/types'; +import { ECSWorld } from '../../engine/ecs/World'; +import { EntityAccessor } from '../../engine/EntityAccessor'; // Mock Phaser vi.mock('phaser', () => { @@ -11,6 +14,10 @@ vi.mock('phaser', () => { setPosition: vi.fn().mockReturnThis(), setVisible: vi.fn().mockReturnThis(), destroy: vi.fn(), + frame: { name: '0' }, + setFrame: vi.fn(), + setAlpha: vi.fn(), + clearTint: vi.fn(), }; const mockGraphics = { @@ -27,6 +34,7 @@ vi.mock('phaser', () => { setVisible: vi.fn().mockReturnThis(), setScrollFactor: vi.fn().mockReturnThis(), setDepth: vi.fn().mockReturnThis(), + y: 0 }; const mockRectangle = { @@ -41,6 +49,13 @@ vi.mock('phaser', () => { Graphics: vi.fn(() => mockGraphics), Container: vi.fn(() => mockContainer), Rectangle: vi.fn(() => mockRectangle), + Arc: vi.fn(() => ({ + setStrokeStyle: vi.fn().mockReturnThis(), + setDepth: vi.fn().mockReturnThis(), + setPosition: vi.fn().mockReturnThis(), + setVisible: vi.fn().mockReturnThis(), + destroy: vi.fn(), + })), }, Scene: vi.fn(), Math: { @@ -54,6 +69,8 @@ describe('DungeonRenderer', () => { let mockScene: any; let renderer: DungeonRenderer; let mockWorld: World; + let ecsWorld: ECSWorld; + let accessor: EntityAccessor; beforeEach(() => { vi.clearAllMocks(); @@ -72,13 +89,25 @@ describe('DungeonRenderer', () => { setPosition: vi.fn().mockReturnThis(), setVisible: vi.fn().mockReturnThis(), destroy: vi.fn(), + frame: { name: '0' }, + setFrame: vi.fn(), + setAlpha: vi.fn(), + clearTint: vi.fn(), })), + circle: vi.fn().mockReturnValue({ + setStrokeStyle: vi.fn().mockReturnThis(), + setDepth: vi.fn().mockReturnThis(), + setPosition: vi.fn().mockReturnThis(), + setVisible: vi.fn().mockReturnThis(), + destroy: vi.fn(), + }), container: vi.fn().mockReturnValue({ add: vi.fn(), setPosition: vi.fn(), setVisible: vi.fn(), setScrollFactor: vi.fn(), setDepth: vi.fn(), + y: 0 }), rectangle: vi.fn().mockReturnValue({ setStrokeStyle: vi.fn().mockReturnThis(), @@ -89,6 +118,7 @@ describe('DungeonRenderer', () => { main: { width: 800, height: 600, + shake: vi.fn(), }, }, anims: { @@ -110,6 +140,9 @@ describe('DungeonRenderer', () => { add: vi.fn(), killTweensOf: vi.fn(), }, + time: { + now: 0 + } }; @@ -117,15 +150,16 @@ describe('DungeonRenderer', () => { width: 10, height: 10, tiles: new Array(100).fill(0), - actors: new Map(), exit: { x: 9, y: 9 }, }; + ecsWorld = new ECSWorld(); + accessor = new EntityAccessor(mockWorld, 1 as EntityId, ecsWorld); renderer = new DungeonRenderer(mockScene); }); it('should track and clear corpse sprites on floor initialization', () => { - renderer.initializeFloor(mockWorld, 1); + renderer.initializeFloor(mockWorld, ecsWorld, accessor); // Spawn a couple of corpses @@ -133,31 +167,29 @@ describe('DungeonRenderer', () => { renderer.spawnCorpse(2, 2, 'bat'); // Get the mock sprites that were returned by scene.add.sprite + // The player sprite is created first in initializeFloor if it doesn't exist + // Then the two corpses const corpse1 = mockScene.add.sprite.mock.results[1].value; const corpse2 = mockScene.add.sprite.mock.results[2].value; - expect(mockScene.add.sprite).toHaveBeenCalledTimes(3); + expect(mockScene.add.sprite).toHaveBeenCalledTimes(3); // Player + 2 corpses // Initialize floor again (changing level) - renderer.initializeFloor(mockWorld, 1); + renderer.initializeFloor(mockWorld, ecsWorld, accessor); - // Verify destroy was called on both corpse sprites + // Verify destroy was called on both corpse sprites (via fxRenderer.clearCorpses) expect(corpse1.destroy).toHaveBeenCalledTimes(1); expect(corpse2.destroy).toHaveBeenCalledTimes(1); }); - it('should render exp_orb as a circle and not as an enemy sprite', () => { - renderer.initializeFloor(mockWorld, 1); + it('should render exp_orb correctly', () => { + renderer.initializeFloor(mockWorld, ecsWorld, accessor); - // Add an exp_orb to the world - mockWorld.actors.set(2, { - id: 2, - category: "collectible", - type: "exp_orb", - pos: { x: 2, y: 1 }, - expAmount: 10 - }); + // 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 }); + ecsWorld.addComponent(2 as EntityId, "actorType", { type: "exp_orb" as any }); // Make the tile visible for it to render (renderer as any).fovManager.visibleArray[1 * mockWorld.width + 2] = 1; @@ -165,40 +197,19 @@ describe('DungeonRenderer', () => { // Reset mocks mockScene.add.sprite.mockClear(); - // Mock scene.add.circle - mockScene.add.circle = vi.fn().mockReturnValue({ - setStrokeStyle: vi.fn().mockReturnThis(), - setDepth: vi.fn().mockReturnThis(), - setPosition: vi.fn().mockReturnThis(), - setVisible: vi.fn().mockReturnThis(), - }); - renderer.render([]); - // Should NOT have added an enemy sprite for the orb - const spriteCalls = mockScene.add.sprite.mock.calls; - // Any sprite added that isn't the player (which isn't in mockWorld.actors here except if we added it) - // The current loop skips a.isPlayer and then checks if type is in GAME_CONFIG.enemies - expect(spriteCalls.length).toBe(0); - // Should HAVE added a circle for the orb expect(mockScene.add.circle).toHaveBeenCalled(); }); - it('should render any enemy type defined in config as a sprite', () => { - renderer.initializeFloor(mockWorld, 1); + it('should render any enemy type as a sprite', () => { + renderer.initializeFloor(mockWorld, ecsWorld, accessor); - // Add a rat (defined in config) - mockWorld.actors.set(3, { - id: 3, - category: "combatant", - isPlayer: false, - type: "rat", - pos: { x: 3, y: 1 }, - speed: 10, - stats: { hp: 10, maxHp: 10, attack: 2, defense: 0 } as any, - energy: 10 - }); + // Add a rat + ecsWorld.addComponent(3 as EntityId, "position", { x: 3, y: 1 }); + ecsWorld.addComponent(3 as EntityId, "actorType", { type: "rat" }); + ecsWorld.addComponent(3 as EntityId, "stats", { hp: 10, maxHp: 10 } as any); (renderer as any).fovManager.visibleArray[1 * mockWorld.width + 3] = 1; mockScene.add.sprite.mockClear(); @@ -211,21 +222,16 @@ describe('DungeonRenderer', () => { }); it('should initialize new enemy sprites at target position and not tween them', () => { - renderer.initializeFloor(mockWorld, 1); + renderer.initializeFloor(mockWorld, ecsWorld, accessor); // Position 5,5 -> 5*16 + 8 = 88 const TILE_SIZE = 16; const targetX = 5 * TILE_SIZE + TILE_SIZE / 2; const targetY = 5 * TILE_SIZE + TILE_SIZE / 2; - mockWorld.actors.set(999, { - id: 999, - category: "combatant", - isPlayer: false, - type: "rat", - pos: { x: 5, y: 5 }, - stats: { hp: 10, maxHp: 10 } as any, - } as any); + ecsWorld.addComponent(999 as EntityId, "position", { x: 5, y: 5 }); + ecsWorld.addComponent(999 as EntityId, "actorType", { type: "rat" }); + ecsWorld.addComponent(999 as EntityId, "stats", { hp: 10, maxHp: 10 } as any); (renderer as any).fovManager.visibleArray[5 * mockWorld.width + 5] = 1; mockScene.add.sprite.mockClear(); diff --git a/src/scenes/GameScene.ts b/src/scenes/GameScene.ts index 19a7923..e1c623b 100644 --- a/src/scenes/GameScene.ts +++ b/src/scenes/GameScene.ts @@ -6,7 +6,6 @@ import { type Action, type RunState, type World, - type CombatantActor, type UIUpdatePayload } from "../core/types"; import { TILE_SIZE } from "../core/constants"; @@ -16,13 +15,14 @@ import { applyAction, stepUntilPlayerTurn } from "../engine/simulation/simulatio import { generateWorld } from "../engine/world/generator"; import { DungeonRenderer } from "../rendering/DungeonRenderer"; import { GAME_CONFIG } from "../core/config/GameConfig"; -import { EntityManager } from "../engine/EntityManager"; +import { EntityAccessor } from "../engine/EntityAccessor"; import { ProgressionManager } from "../engine/ProgressionManager"; import GameUI from "../ui/GameUI"; import { CameraController } from "./systems/CameraController"; import { ItemManager } from "./systems/ItemManager"; import { TargetingSystem } from "./systems/TargetingSystem"; import { UpgradeManager } from "../engine/systems/UpgradeManager"; +import { deEquipItem, equipItem } from "../engine/systems/EquipmentService"; import { InventoryOverlay } from "../ui/components/InventoryOverlay"; import { ECSWorld } from "../engine/ecs/World"; import { SystemRegistry } from "../engine/ecs/System"; @@ -30,6 +30,7 @@ import { TriggerSystem } from "../engine/ecs/systems/TriggerSystem"; import { StatusEffectSystem } from "../engine/ecs/systems/StatusEffectSystem"; import { EventBus } from "../engine/ecs/EventBus"; import { generateLoot } from "../engine/systems/LootSystem"; +import { renderSimEvents, getEffectColor, getEffectName, type EventRenderCallbacks } from "./systems/EventRenderer"; export class GameScene extends Phaser.Scene { private world!: World; @@ -55,7 +56,7 @@ export class GameScene extends Phaser.Scene { private isInventoryOpen = false; private isCharacterOpen = false; - private entityManager!: EntityManager; + private entityAccessor!: EntityAccessor; private progressionManager: ProgressionManager = new ProgressionManager(); private targetingSystem!: TargetingSystem; @@ -80,7 +81,8 @@ export class GameScene extends Phaser.Scene { // Initialize Sub-systems this.dungeonRenderer = new DungeonRenderer(this); this.cameraController = new CameraController(this.cameras.main); - this.itemManager = new ItemManager(this.world, this.entityManager); + // Note: itemManager is temporary instantiated with undefined entityAccessor until loadFloor + this.itemManager = new ItemManager(this.world, this.entityAccessor); this.targetingSystem = new TargetingSystem(this); // Launch UI Scene @@ -147,7 +149,7 @@ export class GameScene extends Phaser.Scene { }); this.events.on("allocate-stat", (statName: string) => { - const player = this.world.actors.get(this.playerId) as CombatantActor; + const player = this.entityAccessor.getPlayer(); if (player) { this.progressionManager.allocateStat(player, statName); this.emitUIUpdate(); @@ -155,7 +157,7 @@ export class GameScene extends Phaser.Scene { }); this.events.on("allocate-passive", (nodeId: string) => { - const player = this.world.actors.get(this.playerId) as CombatantActor; + const player = this.entityAccessor.getPlayer(); if (player) { this.progressionManager.allocatePassive(player, nodeId); this.emitUIUpdate(); @@ -179,7 +181,7 @@ export class GameScene extends Phaser.Scene { this.events.on("use-item", (data: { itemId: string }) => { if (!this.awaitingPlayer) return; - const player = this.world.actors.get(this.playerId) as CombatantActor; + const player = this.entityAccessor.getPlayer(); if (!player || !player.inventory) return; const itemIdx = player.inventory.items.findIndex(it => it.id === data.itemId); @@ -232,7 +234,7 @@ export class GameScene extends Phaser.Scene { item.id, player.pos, this.world, - this.entityManager, + this.entityAccessor, this.playerId, this.dungeonRenderer.seenArray, this.world.width, @@ -302,7 +304,7 @@ export class GameScene extends Phaser.Scene { item.id, player.pos, this.world, - this.entityManager, + this.entityAccessor, this.playerId, this.dungeonRenderer.seenArray, this.world.width, @@ -315,7 +317,7 @@ export class GameScene extends Phaser.Scene { this.events.on("drop-item", (data: { itemId: string, pointerX: number, pointerY: number }) => { if (!this.awaitingPlayer) return; - const player = this.world.actors.get(this.playerId) as CombatantActor; + const player = this.entityAccessor.getPlayer(); if (!player || !player.inventory) return; const item = this.itemManager.getItem(player, data.itemId); @@ -332,7 +334,7 @@ export class GameScene extends Phaser.Scene { const targetX = player.pos.x + dx; const targetY = player.pos.y + dy; - if (inBounds(this.world, targetX, targetY) && !isBlocked(this.world, targetX, targetY, this.entityManager)) { + if (inBounds(this.world, targetX, targetY) && !isBlocked(this.world, targetX, targetY, this.entityAccessor)) { dropPos = { x: targetX, y: targetY }; } } @@ -349,43 +351,31 @@ export class GameScene extends Phaser.Scene { }); this.events.on("equip-item", (data: { itemId: string, slotKey: string }) => { - const player = this.world.actors.get(this.playerId) as CombatantActor; + const player = this.entityAccessor.getPlayer(); if (!player || !player.inventory) return; - const itemIdx = player.inventory.items.findIndex(it => it.id === data.itemId); - if (itemIdx === -1) return; - const item = player.inventory.items[itemIdx]; + const item = player.inventory.items.find(it => it.id === data.itemId); + if (!item) return; - // Type check - const isValid = this.isItemValidForSlot(item, data.slotKey); - if (!isValid) { - this.dungeonRenderer.showFloatingText(player.pos.x, player.pos.y, "Cannot equip there!", "#ff0000"); + const result = equipItem(player, item, data.slotKey as any); + if (!result.success) { + this.dungeonRenderer.showFloatingText(player.pos.x, player.pos.y, result.message ?? "Cannot equip!", "#ff0000"); return; } - // Handle swapping - if (!player.equipment) player.equipment = {}; - const oldItem = (player.equipment as any)[data.slotKey]; - if (oldItem) { - this.handleDeEquipItem(data.slotKey, player, false); // De-equip without emitting UI update yet - } - - // Move to equipment - player.inventory.items.splice(itemIdx, 1); - (player.equipment as any)[data.slotKey] = item; - - // Apply stats - this.applyItemStats(player, item, true); - this.dungeonRenderer.showFloatingText(player.pos.x, player.pos.y, `Equipped ${item.name}`, "#d4af37"); this.emitUIUpdate(); }); this.events.on("de-equip-item", (data: { slotKey: string }) => { - const player = this.world.actors.get(this.playerId) as CombatantActor; + const player = this.entityAccessor.getPlayer(); if (!player || !player.equipment) return; - this.handleDeEquipItem(data.slotKey, player, true); + const removedItem = deEquipItem(player, data.slotKey as any); + if (removedItem) { + this.dungeonRenderer.showFloatingText(player.pos.x, player.pos.y, `De-equipped ${removedItem.name}`, "#aaaaaa"); + this.emitUIUpdate(); + } }); // Right Clicks to cancel targeting @@ -411,7 +401,7 @@ export class GameScene extends Phaser.Scene { if (this.targetingSystem.isActive) { const tx = Math.floor(p.worldX / TILE_SIZE); const ty = Math.floor(p.worldY / TILE_SIZE); - const player = this.world.actors.get(this.playerId) as CombatantActor; + const player = this.entityAccessor.getPlayer(); if (player) { this.targetingSystem.updateCursor({ x: tx, y: ty }, player.pos); } @@ -438,7 +428,7 @@ export class GameScene extends Phaser.Scene { if (this.targetingSystem.isActive) { const tx = Math.floor(p.worldX / TILE_SIZE); const ty = Math.floor(p.worldY / TILE_SIZE); - const player = this.world.actors.get(this.playerId) as CombatantActor; + const player = this.entityAccessor.getPlayer(); if (player) { this.targetingSystem.updateCursor({ x: tx, y: ty }, player.pos); } @@ -473,21 +463,19 @@ export class GameScene extends Phaser.Scene { if (!this.dungeonRenderer.isSeen(tx, ty)) return; - const isEnemy = [...this.world.actors.values()].some(a => - a.category === "combatant" && a.pos.x === tx && a.pos.y === ty && !a.isPlayer - ); + const isEnemy = this.entityAccessor.hasEnemyAt(tx, ty); - const player = this.world.actors.get(this.playerId) as CombatantActor; + const player = this.entityAccessor.getPlayer(); + if (!player) return; + const dx = tx - player.pos.x; const dy = ty - player.pos.y; const isDiagonalNeighbor = Math.abs(dx) === 1 && Math.abs(dy) === 1; if (isEnemy && isDiagonalNeighbor) { - const targetId = [...this.world.actors.values()].find( - a => a.category === "combatant" && a.pos.x === tx && a.pos.y === ty && !a.isPlayer - )?.id; - if (targetId !== undefined) { - this.commitPlayerAction({ type: "attack", targetId }); + const enemy = this.entityAccessor.findEnemyAt(tx, ty); + if (enemy) { + this.commitPlayerAction({ type: "attack", targetId: enemy.id }); return; } } @@ -497,7 +485,7 @@ export class GameScene extends Phaser.Scene { this.dungeonRenderer.seenArray, { ...player.pos }, { x: tx, y: ty }, - { ignoreBlockedTarget: isEnemy } + { ignoreBlockedTarget: isEnemy, accessor: this.entityAccessor } ); if (path.length >= 2) this.playerPath = path; @@ -511,7 +499,9 @@ export class GameScene extends Phaser.Scene { // Auto-walk one step per turn if (this.playerPath.length >= 2) { - const player = this.world.actors.get(this.playerId) as CombatantActor; + const player = this.entityAccessor.getPlayer(); + if (!player) return; + const next = this.playerPath[1]; const dx = next.x - player.pos.x; const dy = next.y - player.pos.y; @@ -521,13 +511,11 @@ export class GameScene extends Phaser.Scene { return; } - if (isBlocked(this.world, next.x, next.y, this.entityManager)) { - const targetId = [...this.world.actors.values()].find( - a => a.category === "combatant" && a.pos.x === next.x && a.pos.y === next.y && !a.isPlayer - )?.id; + if (isBlocked(this.world, next.x, next.y, this.entityAccessor)) { + const enemy = this.entityAccessor.findEnemyAt(next.x, next.y); - if (targetId !== undefined) { - this.commitPlayerAction({ type: "attack", targetId }); + if (enemy) { + this.commitPlayerAction({ type: "attack", targetId: enemy.id }); this.playerPath = []; return; } else { @@ -562,16 +550,16 @@ export class GameScene extends Phaser.Scene { this.targetingSystem.cancel(); this.emitUIUpdate(); } - const player = this.world.actors.get(this.playerId) as CombatantActor; + const player = this.entityAccessor.getPlayer(); + if (!player) return; + const targetX = player.pos.x + dx; const targetY = player.pos.y + dy; - const targetId = [...this.world.actors.values()].find( - a => a.category === "combatant" && a.pos.x === targetX && a.pos.y === targetY && !a.isPlayer - )?.id; + const enemy = this.entityAccessor.findEnemyAt(targetX, targetY); - if (targetId !== undefined) { - action = { type: "attack", targetId }; + if (enemy) { + action = { type: "attack", targetId: enemy.id }; } else { if (Math.abs(dx) + Math.abs(dy) === 1) { action = { type: "move", dx, dy }; @@ -590,6 +578,7 @@ export class GameScene extends Phaser.Scene { const payload: UIUpdatePayload = { world: this.world, playerId: this.playerId, + player: this.entityAccessor.getPlayer(), floorIndex: this.floorIndex, uiState: { targetingItemId: this.targetingSystem.itemId @@ -599,7 +588,7 @@ export class GameScene extends Phaser.Scene { } private commitPlayerAction(action: Action) { - const playerEvents = applyAction(this.world, this.playerId, action, this.entityManager); + const playerEvents = applyAction(this.world, this.playerId, action, this.entityAccessor); if (playerEvents.some(ev => ev.type === "move-blocked")) { return; @@ -610,47 +599,31 @@ export class GameScene extends Phaser.Scene { // Check for pickups right after move (before enemy turn, so you get it efficiently) if (action.type === "move") { - const player = this.world.actors.get(this.playerId) as CombatantActor; + const player = this.entityAccessor.getPlayer(); + if (!player) return; + const pickedItem = this.itemManager.tryPickup(player); if (pickedItem) { this.emitUIUpdate(); } - // Sync player position to ECS for trap detection - const playerEcs = this.ecsWorld.getEntitiesWith("player"); - if (playerEcs.length > 0) { - const playerEcsId = playerEcs[0]; - const ecsPos = this.ecsWorld.getComponent(playerEcsId, "position"); - if (ecsPos) { - ecsPos.x = player.pos.x; - ecsPos.y = player.pos.y; - } - } - // Process traps and status effects + console.log(`[GameScene] Processing traps. Player Pos: ${player.pos.x},${player.pos.y}`); this.ecsRegistry.updateAll(); // Handle trap events from ECS const trapEvents = this.ecsEventBus.drain(); + if (trapEvents.length > 0) { + console.log(`[GameScene] Traps triggered: ${trapEvents.length} events`, trapEvents); + } for (const ev of trapEvents) { if (ev.type === "trigger_activated") { // Get trap trigger data for status effect display const trapTrigger = this.ecsWorld.getComponent(ev.triggerId, "trigger"); if (trapTrigger?.effect) { - // Show status effect text - const effectColors: Record = { - poison: "#00ff00", - burning: "#ff6600", - frozen: "#00ffff" - }; - const effectNames: Record = { - poison: "Poisoned!", - burning: "Burning!", - frozen: "Paralyzed!" - }; - const color = effectColors[trapTrigger.effect] ?? "#ffffff"; - const text = effectNames[trapTrigger.effect] ?? trapTrigger.effect; + const color = getEffectColor(trapTrigger.effect); + const text = getEffectName(trapTrigger.effect); this.dungeonRenderer.showFloatingText(player.pos.x, player.pos.y, text, color); } } else if (ev.type === "damage") { @@ -665,12 +638,12 @@ export class GameScene extends Phaser.Scene { } } - const enemyStep = stepUntilPlayerTurn(this.world, this.playerId, this.entityManager); + const enemyStep = stepUntilPlayerTurn(this.world, this.playerId, this.entityAccessor); this.awaitingPlayer = enemyStep.awaitingPlayerId === this.playerId; this.turnCount++; if (this.turnCount % GAME_CONFIG.mana.regenInterval === 0) { - const player = this.world.actors.get(this.playerId) as CombatantActor; + const player = this.entityAccessor.getPlayer(); if (player && player.stats.mana < player.stats.maxMana) { const regenAmount = Math.min( GAME_CONFIG.mana.regenPerTurn, @@ -680,44 +653,38 @@ export class GameScene extends Phaser.Scene { } } - const allEvents = [...playerEvents, ...enemyStep.events]; + const renderCallbacks: EventRenderCallbacks = { + showDamage: (x, y, amount, isCrit, isBlock) => this.dungeonRenderer.showDamage(x, y, amount, isCrit, isBlock), + showDodge: (x, y) => this.dungeonRenderer.showDodge(x, y), + showHeal: (x, y, amount) => this.dungeonRenderer.showHeal(x, y, amount), + spawnCorpse: (x, y, type) => this.dungeonRenderer.spawnCorpse(x, y, type), + showWait: (x, y) => this.dungeonRenderer.showWait(x, y), + spawnOrb: (orbId, x, y) => this.dungeonRenderer.spawnOrb(orbId, x, y), + collectOrb: (actorId, amount, x, y) => this.dungeonRenderer.collectOrb(actorId, amount, x, y), + showLevelUp: (x, y) => this.dungeonRenderer.showLevelUp(x, y), + showAlert: (x, y) => this.dungeonRenderer.showAlert(x, y), + showFloatingText: (x, y, message, color) => this.dungeonRenderer.showFloatingText(x, y, message, color), + }; + + renderSimEvents(allEvents, renderCallbacks, { + playerId: this.playerId, + getPlayerPos: () => this.entityAccessor.getPlayerPos() + }); + + // Handle loot drops from kills (not part of renderSimEvents since it modifies world state) for (const ev of allEvents) { - if (ev.type === "damaged") { - this.dungeonRenderer.showDamage(ev.x, ev.y, ev.amount, ev.isCrit, ev.isBlock); - } else if (ev.type === "dodged") { - this.dungeonRenderer.showDodge(ev.x, ev.y); - } else if (ev.type === "healed") { - this.dungeonRenderer.showHeal(ev.x, ev.y, ev.amount); - } else if (ev.type === "killed") { - this.dungeonRenderer.spawnCorpse(ev.x, ev.y, ev.victimType || "rat"); - - // Try to drop loot from killed enemy - if (ev.victimType && ev.victimType !== "player") { - const loot = generateLoot(Math.random, ev.victimType, this.floorIndex); - if (loot) { - this.itemManager.spawnItem(loot, { x: ev.x, y: ev.y }); - this.dungeonRenderer.showFloatingText(ev.x, ev.y, `${loot.name}!`, "#ffd700"); - } - } - } else if (ev.type === "waited" && ev.actorId === this.playerId) { - const player = this.world.actors.get(this.playerId) as CombatantActor; - if (player) { - this.dungeonRenderer.showWait(player.pos.x, player.pos.y); - } - } else if (ev.type === "orb-spawned") { - this.dungeonRenderer.spawnOrb(ev.orbId, ev.x, ev.y); - } else if (ev.type === "exp-collected" && ev.actorId === this.playerId) { - this.dungeonRenderer.collectOrb(ev.actorId, ev.amount, ev.x, ev.y); - } else if (ev.type === "leveled-up" && ev.actorId === this.playerId) { - this.dungeonRenderer.showLevelUp(ev.x, ev.y); - } else if (ev.type === "enemy-alerted") { - this.dungeonRenderer.showAlert(ev.x, ev.y); - } - } + if (ev.type === "killed" && ev.victimType && ev.victimType !== "player") { + const loot = generateLoot(Math.random, ev.victimType, this.floorIndex); + if (loot) { + this.itemManager.spawnItem(loot, { x: ev.x, y: ev.y }); + this.dungeonRenderer.showFloatingText(ev.x, ev.y, `${loot.name}!`, "#ffd700"); + } + } + } - if (!this.world.actors.has(this.playerId)) { + if (!this.entityAccessor.isPlayerAlive()) { this.syncRunStateFromPlayer(); const uiScene = this.scene.get("GameUI") as GameUI; if (uiScene && 'showDeathScreen' in uiScene) { @@ -730,17 +697,19 @@ export class GameScene extends Phaser.Scene { return; } - if (isPlayerOnExit(this.world, this.playerId)) { + if (isPlayerOnExit(this.world, this.entityAccessor)) { this.syncRunStateFromPlayer(); this.floorIndex++; this.loadFloor(this.floorIndex); return; } - this.dungeonRenderer.computeFov(this.playerId); + this.dungeonRenderer.computeFov(); if (this.cameraController.isFollowing) { - const player = this.world.actors.get(this.playerId) as CombatantActor; - this.cameraController.centerOnTile(player.pos.x, player.pos.y); + const player = this.entityAccessor.getPlayer(); + if (player) { + this.cameraController.centerOnTile(player.pos.x, player.pos.y); + } } this.dungeonRenderer.render(this.playerPath); this.emitUIUpdate(); @@ -753,45 +722,50 @@ export class GameScene extends Phaser.Scene { const { world, playerId, ecsWorld } = generateWorld(floor, this.runState); this.world = world; this.playerId = playerId; - this.entityManager = new EntityManager(this.world); - this.itemManager.updateWorld(this.world, this.entityManager); + + // Initialize or update entity accessor + if (!this.entityAccessor) { + this.entityAccessor = new EntityAccessor(this.world, this.playerId, ecsWorld); + } else { + this.entityAccessor.updateWorld(this.world, this.playerId, ecsWorld); + } + + this.itemManager.updateWorld(this.world, this.entityAccessor, ecsWorld); // Initialize ECS for traps and status effects this.ecsWorld = ecsWorld; this.ecsEventBus = new EventBus(); + // Register systems this.ecsRegistry = new SystemRegistry(this.ecsWorld, this.ecsEventBus); this.ecsRegistry.register(new TriggerSystem()); this.ecsRegistry.register(new StatusEffectSystem()); - // Add player to ECS for trap detection - const player = this.world.actors.get(this.playerId) as CombatantActor; - if (player) { - const playerEcsId = this.ecsWorld.createEntity(); - this.ecsWorld.addComponent(playerEcsId, "position", { x: player.pos.x, y: player.pos.y }); - this.ecsWorld.addComponent(playerEcsId, "stats", player.stats); - this.ecsWorld.addComponent(playerEcsId, "player", {}); - } + // NOTE: Entities are synced to ECS via EntityAccessor which bridges the World state. + // No need to manually add player here anymore. this.playerPath = []; this.awaitingPlayer = false; this.cameraController.setBounds(0, 0, this.world.width * TILE_SIZE, this.world.height * TILE_SIZE); - this.dungeonRenderer.initializeFloor(this.world, this.playerId, this.ecsWorld); + this.dungeonRenderer.initializeFloor(this.world, this.ecsWorld, this.entityAccessor); - const enemyStep = stepUntilPlayerTurn(this.world, this.playerId, this.entityManager); + const enemyStep = stepUntilPlayerTurn(this.world, this.playerId, this.entityAccessor); this.awaitingPlayer = enemyStep.awaitingPlayerId === this.playerId; - this.dungeonRenderer.computeFov(this.playerId); - this.cameraController.centerOnTile(player.pos.x, player.pos.y); + this.dungeonRenderer.computeFov(); + const p = this.entityAccessor.getPlayer(); + if (p) { + this.cameraController.centerOnTile(p.pos.x, p.pos.y); + } this.dungeonRenderer.render(this.playerPath); this.emitUIUpdate(); } private syncRunStateFromPlayer() { - const p = this.world.actors.get(this.playerId) as CombatantActor; - if (!p || p.category !== "combatant" || !p.stats || !p.inventory) return; + const p = this.entityAccessor.getPlayer(); + if (!p || !p.stats || !p.inventory) return; this.runState = { stats: { ...p.stats }, @@ -808,19 +782,15 @@ export class GameScene extends Phaser.Scene { this.loadFloor(this.floorIndex); } - - - - private executeThrow() { const success = this.targetingSystem.executeThrow( this.world, this.playerId, - this.entityManager, + this.entityAccessor, (blockedPos, hitActorId, item) => { // Damage Logic if (hitActorId !== undefined) { - const victim = this.world.actors.get(hitActorId) as CombatantActor; + const victim = this.entityAccessor.getCombatant(hitActorId); if (victim) { const stats = 'stats' in item ? item.stats : undefined; const dmg = (stats && 'attack' in stats) ? (stats.attack ?? 1) : 1; @@ -830,7 +800,8 @@ export class GameScene extends Phaser.Scene { } } - const player = this.world.actors.get(this.playerId) as CombatantActor; + const player = this.entityAccessor.getPlayer(); + if (!player) return; // Projectile Visuals let projectileId = item.id; @@ -883,63 +854,4 @@ export class GameScene extends Phaser.Scene { }; } - private isItemValidForSlot(item: any, slotKey: string): boolean { - if (!item || !item.type) return false; - if (item.type === "Weapon") return slotKey === "mainHand" || slotKey === "offHand"; - if (item.type === "BodyArmour") return slotKey === "bodyArmour"; - if (item.type === "Helmet") return slotKey === "helmet"; - if (item.type === "Boots") return slotKey === "boots"; - if (item.type === "Ring") return slotKey === "ringLeft" || slotKey === "ringRight"; - if (item.type === "Belt") return slotKey === "belt"; - if (item.type === "Offhand") return slotKey === "offHand"; - return false; - } - - private applyItemStats(player: CombatantActor, item: any, isAdding: boolean) { - if (!item.stats) return; - - const modifier = isAdding ? 1 : -1; - - // Apply stats from ArmourItem or MiscItem - if (item.stats.defense) player.stats.defense += item.stats.defense * modifier; - if (item.stats.attack) player.stats.attack += item.stats.attack * modifier; - if (item.stats.maxHp) { - const diff = item.stats.maxHp * modifier; - player.stats.maxHp += diff; - player.stats.hp = Math.min(player.stats.maxHp, player.stats.hp + (isAdding ? diff : 0)); - } - if (item.stats.maxMana) { - const diff = item.stats.maxMana * modifier; - player.stats.maxMana += diff; - player.stats.mana = Math.min(player.stats.maxMana, player.stats.mana + (isAdding ? diff : 0)); - } - - // Other secondary stats - if (item.stats.critChance) player.stats.critChance += item.stats.critChance * modifier; - if (item.stats.accuracy) player.stats.accuracy += item.stats.accuracy * modifier; - if (item.stats.evasion) player.stats.evasion += item.stats.evasion * modifier; - if (item.stats.blockChance) player.stats.blockChance += item.stats.blockChance * modifier; - } - - private handleDeEquipItem(slotKey: string, player: CombatantActor, emitUpdate: boolean) { - if (!player.equipment) return; - const item = (player.equipment as any)[slotKey]; - if (!item) return; - - // Remove from equipment - delete (player.equipment as any)[slotKey]; - - // Remove stats - this.applyItemStats(player, item, false); - - // Add back to inventory - if (!player.inventory) player.inventory = { gold: 0, items: [] }; - player.inventory.items.push(item); - - if (emitUpdate) { - this.dungeonRenderer.showFloatingText(player.pos.x, player.pos.y, `De-equipped ${item.name}`, "#aaaaaa"); - this.emitUIUpdate(); - } - } - } diff --git a/src/scenes/__tests__/GameScene.test.ts b/src/scenes/__tests__/GameScene.test.ts index a55a247..63bd8e8 100644 --- a/src/scenes/__tests__/GameScene.test.ts +++ b/src/scenes/__tests__/GameScene.test.ts @@ -40,29 +40,29 @@ vi.mock('phaser', () => { get: vi.fn(), }; add = { - graphics: vi.fn(() => ({ + graphics: vi.fn(function() { return { setDepth: vi.fn().mockReturnThis(), clear: vi.fn(), lineStyle: vi.fn(), lineBetween: vi.fn(), strokeRect: vi.fn(), - })), - sprite: vi.fn(() => ({ + }; }), + sprite: vi.fn(function() { return { setDepth: vi.fn().mockReturnThis(), setVisible: vi.fn().mockReturnThis(), setAlpha: vi.fn().mockReturnThis(), setPosition: vi.fn().mockReturnThis(), - })), - text: vi.fn(() => ({})), - rectangle: vi.fn(() => ({})), - container: vi.fn(() => ({})), + }; }), + text: vi.fn(function() { return {}; }), + rectangle: vi.fn(function() { return {}; }), + container: vi.fn(function() { return {}; }), }; load = { spritesheet: vi.fn(), }; anims = { create: vi.fn(), - exists: vi.fn(() => true), + exists: vi.fn(function() { return true; }), generateFrameNumbers: vi.fn(), }; } @@ -83,24 +83,37 @@ import { GameScene } from '../GameScene'; import * as simulation from '../../engine/simulation/simulation'; import * as generator from '../../engine/world/generator'; +vi.mock('../../engine/EntityAccessor', () => ({ + EntityAccessor: class { + getPlayer = vi.fn(() => ({ + id: 1, + pos: { x: 1, y: 1 }, + category: 'combatant', + stats: { hp: 10, maxHp: 10 } + })); + updateWorld = vi.fn(); + isPlayerAlive = vi.fn(() => true); + getActor = vi.fn(); + }, +})); + // Mock other modules vi.mock('../../rendering/DungeonRenderer', () => ({ - DungeonRenderer: vi.fn().mockImplementation(function() { - return { - initializeFloor: vi.fn(), - computeFov: vi.fn(), - render: vi.fn(), - showDamage: vi.fn(), - spawnCorpse: vi.fn(), - showWait: vi.fn(), - isMinimapVisible: vi.fn(() => false), - toggleMinimap: vi.fn(), - updateTile: vi.fn(), - showProjectile: vi.fn(), - showHeal: vi.fn(), - shakeCamera: vi.fn(), - }; - }), + DungeonRenderer: class { + initializeFloor = vi.fn(); + computeFov = vi.fn(); + render = vi.fn(); + showDamage = vi.fn(); + spawnCorpse = vi.fn(); + showWait = vi.fn(); + isMinimapVisible = vi.fn(() => false); + toggleMinimap = vi.fn(); + updateTile = vi.fn(); + showProjectile = vi.fn(); + showHeal = vi.fn(); + shakeCamera = vi.fn(); + showFloatingText = vi.fn(); + }, })); vi.mock('../../engine/simulation/simulation', () => ({ @@ -112,12 +125,33 @@ vi.mock('../../engine/world/generator', () => ({ generateWorld: vi.fn(), })); +vi.mock('../../engine/ecs/System', () => ({ + SystemRegistry: class { + register = vi.fn(); + updateAll = vi.fn(); + }, +})); + +vi.mock('../../engine/ecs/EventBus', () => ({ + EventBus: class { + drain = vi.fn(() => []); + }, +})); + +vi.mock('../../engine/ecs/systems/TriggerSystem', () => ({ + TriggerSystem: class {}, +})); + +vi.mock('../../engine/ecs/systems/StatusEffectSystem', () => ({ + StatusEffectSystem: class {}, +})); + vi.mock('../../engine/world/world-logic', () => ({ - inBounds: vi.fn(() => true), - isBlocked: vi.fn(() => false), - isPlayerOnExit: vi.fn(() => false), - idx: vi.fn((w, x, y) => y * w.width + x), - tryDestructTile: vi.fn(() => false), + inBounds: vi.fn(function() { return true; }), + isBlocked: vi.fn(function() { return false; }), + isPlayerOnExit: vi.fn(function() { return false; }), + idx: vi.fn(function(w: any, x: number, y: number) { return y * w.width + x; }), + tryDestructTile: vi.fn(function() { return false; }), })); describe('GameScene', () => { @@ -150,27 +184,18 @@ describe('GameScene', () => { width: 10, height: 10, tiles: new Array(100).fill(0), - actors: new Map(), exit: { x: 9, y: 9 }, }; - const mockPlayer = { - id: 1, - isPlayer: true, - pos: { x: 1, y: 1 }, - speed: 100, - stats: { hp: 10, maxHp: 10, attack: 5, defense: 2 }, - inventory: { gold: 0, items: [] }, - }; - mockWorld.actors.set(1, mockPlayer); + // Mock ecsWorld with required methods const mockEcsWorld = { - createEntity: vi.fn(() => 99), + createEntity: vi.fn(function() { return 99; }), addComponent: vi.fn(), getComponent: vi.fn(), - hasComponent: vi.fn(() => false), - getEntitiesWith: vi.fn(() => []), + hasComponent: vi.fn(function() { return false; }), + getEntitiesWith: vi.fn(function() { return []; }), removeEntity: vi.fn(), }; @@ -190,8 +215,8 @@ describe('GameScene', () => { }); it('should trigger death screen when player is killed', () => { - (simulation.applyAction as any).mockImplementation((world: any) => { - world.actors.delete(1); + (simulation.applyAction as any).mockImplementation(() => { + // world.actors.delete(1); return [{ type: 'killed', targetId: 1, victimType: 'player', x: 1, y: 1 }]; }); @@ -200,6 +225,8 @@ describe('GameScene', () => { events: [], }); + (scene as any).entityAccessor.isPlayerAlive = vi.fn(() => false); + (scene as any).commitPlayerAction({ type: 'wait' }); expect(mockUI.showDeathScreen).toHaveBeenCalled(); diff --git a/src/scenes/systems/EventRenderer.ts b/src/scenes/systems/EventRenderer.ts new file mode 100644 index 0000000..bf31431 --- /dev/null +++ b/src/scenes/systems/EventRenderer.ts @@ -0,0 +1,115 @@ +import type { SimEvent, ActorType, EntityId, Vec2 } from "../../core/types"; + +/** + * Callbacks for rendering game simulation events. + * These delegate to the actual rendering implementation. + */ +export interface EventRenderCallbacks { + showDamage(x: number, y: number, amount: number, isCrit?: boolean, isBlock?: boolean): void; + showDodge(x: number, y: number): void; + showHeal(x: number, y: number, amount: number): void; + spawnCorpse(x: number, y: number, type: ActorType): void; + showWait(x: number, y: number): void; + spawnOrb(orbId: EntityId, x: number, y: number): void; + collectOrb(actorId: EntityId, amount: number, x: number, y: number): void; + showLevelUp(x: number, y: number): void; + showAlert(x: number, y: number): void; + showFloatingText(x: number, y: number, message: string, color: string): void; + spawnLoot?(x: number, y: number, itemName: string): void; +} + +/** + * Context needed for event rendering decisions. + */ +export interface EventRenderContext { + playerId: EntityId; + getPlayerPos: () => Vec2 | null; +} + +/** + * Renders all simulation events using the provided callbacks. + * This is a pure function that maps events to render calls. + */ +export function renderSimEvents( + events: SimEvent[], + callbacks: EventRenderCallbacks, + context: EventRenderContext +): void { + for (const ev of events) { + switch (ev.type) { + case "damaged": + callbacks.showDamage(ev.x, ev.y, ev.amount, ev.isCrit, ev.isBlock); + break; + + case "dodged": + callbacks.showDodge(ev.x, ev.y); + break; + + case "healed": + callbacks.showHeal(ev.x, ev.y, ev.amount); + break; + + case "killed": + callbacks.spawnCorpse(ev.x, ev.y, ev.victimType || "rat"); + break; + + case "waited": + if (ev.actorId === context.playerId) { + const pos = context.getPlayerPos(); + if (pos) { + callbacks.showWait(pos.x, pos.y); + } + } + break; + + case "orb-spawned": + callbacks.spawnOrb(ev.orbId, ev.x, ev.y); + break; + + case "exp-collected": + if (ev.actorId === context.playerId) { + callbacks.collectOrb(ev.actorId, ev.amount, ev.x, ev.y); + } + break; + + case "leveled-up": + if (ev.actorId === context.playerId) { + callbacks.showLevelUp(ev.x, ev.y); + } + break; + + case "enemy-alerted": + callbacks.showAlert(ev.x, ev.y); + break; + } + } +} + +/** + * Status effect display colors and names. + */ +const EFFECT_COLORS: Record = { + poison: "#00ff00", + burning: "#ff6600", + frozen: "#00ffff" +}; + +const EFFECT_NAMES: Record = { + poison: "Poisoned!", + burning: "Burning!", + frozen: "Paralyzed!" +}; + +/** + * Gets the display color for a status effect. + */ +export function getEffectColor(effect: string): string { + return EFFECT_COLORS[effect] ?? "#ffffff"; +} + +/** + * Gets the display name for a status effect. + */ +export function getEffectName(effect: string): string { + return EFFECT_NAMES[effect] ?? effect; +} diff --git a/src/scenes/systems/ItemManager.ts b/src/scenes/systems/ItemManager.ts index 700cc0e..29769a9 100644 --- a/src/scenes/systems/ItemManager.ts +++ b/src/scenes/systems/ItemManager.ts @@ -1,5 +1,7 @@ import type { World, CombatantActor, Item, ItemDropActor, Vec2 } from "../../core/types"; -import { EntityManager } from "../../engine/EntityManager"; +import { EntityAccessor } from "../../engine/EntityAccessor"; +import { type ECSWorld } from "../../engine/ecs/World"; +import { EntityBuilder } from "../../engine/ecs/EntityBuilder"; /** * Result of attempting to use an item @@ -16,26 +18,29 @@ export interface ItemUseResult { */ export class ItemManager { private world: World; - private entityManager: EntityManager; + private entityAccessor: EntityAccessor; + private ecsWorld?: ECSWorld; - constructor(world: World, entityManager: EntityManager) { + constructor(world: World, entityAccessor: EntityAccessor, ecsWorld?: ECSWorld) { this.world = world; - this.entityManager = entityManager; + this.entityAccessor = entityAccessor; + this.ecsWorld = ecsWorld; } /** * Update references when world changes (e.g., new floor) */ - updateWorld(world: World, entityManager: EntityManager): void { + updateWorld(world: World, entityAccessor: EntityAccessor, ecsWorld?: ECSWorld): void { this.world = world; - this.entityManager = entityManager; + this.entityAccessor = entityAccessor; + if (ecsWorld) this.ecsWorld = ecsWorld; } /** * Spawn an item drop at the specified position */ spawnItem(item: Item, pos: Vec2): void { - if (!this.world || !this.entityManager) return; + if (!this.world || !this.ecsWorld) return; // Deep clone item (crucial for items with mutable stats like ammo) const clonedItem = { ...item } as Item; @@ -43,15 +48,11 @@ export class ItemManager { (clonedItem as any).stats = { ...clonedItem.stats }; } - const id = this.entityManager.getNextId(); - const drop: ItemDropActor = { - id, - pos: { x: pos.x, y: pos.y }, - category: "item_drop", - item: clonedItem - }; - - this.entityManager.addActor(drop); + // ECS Path: Spawn using EntityBuilder + EntityBuilder.create(this.ecsWorld) + .withPosition(pos.x, pos.y) + .asGroundItem(clonedItem) + .build(); } /** @@ -61,15 +62,19 @@ export class ItemManager { tryPickup(player: CombatantActor): Item | null { if (!player || !player.inventory) return null; - const actors = this.entityManager.getActorsAt(player.pos.x, player.pos.y); - const itemActor = actors.find((a): a is ItemDropActor => a.category === "item_drop"); + let itemActor: ItemDropActor | null = null; + + // Use EntityAccessor to find item on the ground + if (this.entityAccessor) { + itemActor = this.entityAccessor.findItemDropAt(player.pos.x, player.pos.y); + } if (itemActor) { const item = itemActor.item; const result = this.addItem(player, item); // Remove from world - this.entityManager.removeActor(itemActor.id); + this.entityAccessor.removeActor(itemActor.id); console.log("Picked up:", item.name); return result; diff --git a/src/scenes/systems/TargetingSystem.ts b/src/scenes/systems/TargetingSystem.ts index 87e0a42..0ed0565 100644 --- a/src/scenes/systems/TargetingSystem.ts +++ b/src/scenes/systems/TargetingSystem.ts @@ -1,10 +1,10 @@ import Phaser from "phaser"; -import type { World, CombatantActor, Item, Vec2, EntityId } from "../../core/types"; +import type { World, Item, Vec2, EntityId } from "../../core/types"; import { TILE_SIZE } from "../../core/constants"; import { GAME_CONFIG } from "../../core/config/GameConfig"; import { UI_CONFIG } from "../../core/config/ui"; import { traceProjectile, getClosestVisibleEnemy } from "../../engine/gameplay/CombatLogic"; -import type { EntityManager } from "../../engine/EntityManager"; +import { type EntityAccessor } from "../../engine/EntityAccessor"; /** * Manages targeting mode for thrown items. @@ -19,7 +19,7 @@ export class TargetingSystem { // Context for predictive visual private world: World | null = null; - private entityManager: EntityManager | null = null; + private accessor: EntityAccessor | null = null; private playerId: EntityId | null = null; constructor(scene: Phaser.Scene) { @@ -40,7 +40,7 @@ export class TargetingSystem { itemId: string, playerPos: Vec2, world: World, - entityManager: EntityManager, + accessor: EntityAccessor, playerId: EntityId, seenArray: Uint8Array, worldWidth: number, @@ -48,12 +48,12 @@ export class TargetingSystem { ): void { this.targetingItemId = itemId; this.world = world; - this.entityManager = entityManager; + this.accessor = accessor; this.playerId = playerId; this.active = true; // Auto-target closest visible enemy - const closest = getClosestVisibleEnemy(world, playerPos, seenArray, worldWidth); + const closest = getClosestVisibleEnemy(playerPos, seenArray, worldWidth, accessor); if (closest) { this.cursor = closest; @@ -84,14 +84,14 @@ export class TargetingSystem { executeThrow( world: World, playerId: EntityId, - entityManager: EntityManager, + accessor: EntityAccessor, onProjectileComplete: (blockedPos: Vec2, hitActorId: EntityId | undefined, item: Item) => void ): boolean { if (!this.active || !this.targetingItemId || !this.cursor) { return false; } - const player = world.actors.get(playerId) as CombatantActor; + const player = accessor.getCombatant(playerId); if (!player || !player.inventory) return false; const itemIdx = player.inventory.items.findIndex(it => it.id === this.targetingItemId); @@ -116,7 +116,7 @@ export class TargetingSystem { const start = player.pos; const end = { x: this.cursor.x, y: this.cursor.y }; - const result = traceProjectile(world, start, end, entityManager, playerId); + const result = traceProjectile(world, start, end, accessor, playerId); const { blockedPos, hitActorId } = result; // Call the callback with throw results @@ -133,7 +133,7 @@ export class TargetingSystem { this.targetingItemId = null; this.cursor = null; this.world = null; - this.entityManager = null; + this.accessor = null; this.playerId = null; this.graphics.clear(); this.crosshairSprite.setVisible(false); @@ -184,8 +184,8 @@ export class TargetingSystem { let finalEndX = aimEndX; let finalEndY = aimEndY; - if (this.world && this.entityManager && this.playerId !== null) { - const result = traceProjectile(this.world, playerPos, this.cursor, this.entityManager, this.playerId); + if (this.world && this.accessor && this.playerId !== null) { + const result = traceProjectile(this.world, playerPos, this.cursor, this.accessor, this.playerId); const bPos = result.blockedPos; finalEndX = bPos.x * TILE_SIZE + TILE_SIZE / 2; diff --git a/src/scenes/systems/__tests__/ItemManager.test.ts b/src/scenes/systems/__tests__/ItemManager.test.ts new file mode 100644 index 0000000..4d3c2f2 --- /dev/null +++ b/src/scenes/systems/__tests__/ItemManager.test.ts @@ -0,0 +1,62 @@ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { ItemManager } from '../ItemManager'; +import type { World, Item, ItemDropActor, EntityId } from "../../../core/types"; + +describe('ItemManager', () => { + let world: World; + let entityAccessor: any; + let itemManager: ItemManager; + + beforeEach(() => { + world = { + width: 10, + height: 10, + tiles: new Array(100).fill(1), // Floor + exit: { x: 9, y: 9 } + }; + + entityAccessor = { + findItemDropAt: vi.fn(() => null), + removeActor: vi.fn(), + context: undefined, + getEnemies: vi.fn(() => []) + }; + + itemManager = new ItemManager(world, entityAccessor); + }); + + it('should pickup an item at the player position', () => { + const player = { + id: 1 as EntityId, + pos: { x: 2, y: 2 }, + inventory: { items: [], gold: 0 } + } as any; + + const item: Item = { + id: 'health_potion', + name: 'Health Potion', + type: 'Consumable', + textureKey: 'items', + spriteIndex: 0 + }; + + const itemActor: ItemDropActor = { + id: 2 as EntityId, + category: 'item_drop', + pos: { x: 2, y: 2 }, + item + }; + + // Setup Accessor to find the item + entityAccessor.findItemDropAt.mockReturnValue(itemActor); + + const result = itemManager.tryPickup(player); + + expect(entityAccessor.findItemDropAt).toHaveBeenCalledWith(2, 2); + expect(result).not.toBeNull(); + expect(player.inventory.items.length).toBe(1); + expect(player.inventory.items[0]).toEqual({ ...item, quantity: 1 }); + expect(entityAccessor.removeActor).toHaveBeenCalledWith(2); + }); +}); diff --git a/src/scenes/systems/__tests__/TargetingSystem.test.ts b/src/scenes/systems/__tests__/TargetingSystem.test.ts index 298564a..25ab91c 100644 --- a/src/scenes/systems/__tests__/TargetingSystem.test.ts +++ b/src/scenes/systems/__tests__/TargetingSystem.test.ts @@ -1,3 +1,4 @@ + import { describe, it, expect, vi, beforeEach } from 'vitest'; // Mock Phaser @@ -18,6 +19,10 @@ vi.mock('phaser', () => { return { default: { + GameObjects: { + Sprite: vi.fn(() => mockSprite), + Graphics: vi.fn(() => mockGraphics), + }, Scene: class { add = { graphics: vi.fn(() => mockGraphics), @@ -37,11 +42,11 @@ vi.mock('../../../engine/gameplay/CombatLogic', () => ({ import { TargetingSystem } from '../TargetingSystem'; import { traceProjectile, getClosestVisibleEnemy } from '../../../engine/gameplay/CombatLogic'; import { TILE_SIZE } from '../../../core/constants'; +import type { EntityId } from '../../../core/types'; describe('TargetingSystem', () => { let targetingSystem: TargetingSystem; let mockWorld: any; - let mockEntityManager: any; let mockScene: any; let mockGraphics: any; let mockSprite: any; @@ -72,7 +77,6 @@ describe('TargetingSystem', () => { targetingSystem = new TargetingSystem(mockScene); mockWorld = { width: 10, height: 10 }; - mockEntityManager = {}; // Default return for traceProjectile (traceProjectile as any).mockReturnValue({ @@ -97,8 +101,8 @@ describe('TargetingSystem', () => { 'item-1', playerPos, mockWorld, - mockEntityManager!, - 1 as any, + {} as any, // accessor + 1 as EntityId, // playerId new Uint8Array(100), 10 ); @@ -118,8 +122,8 @@ describe('TargetingSystem', () => { 'item-1', playerPos, mockWorld, - mockEntityManager!, - 1 as any, + {} as any, // accessor + 1 as EntityId, // playerId new Uint8Array(100), 10, mousePos @@ -144,8 +148,8 @@ describe('TargetingSystem', () => { 'item-1', playerPos, mockWorld, - mockEntityManager!, - 1 as any, + {} as any, // accessor + 1 as EntityId, new Uint8Array(100), 10, targetPos diff --git a/src/ui/GameUI.ts b/src/ui/GameUI.ts index e9db2df..2343aee 100644 --- a/src/ui/GameUI.ts +++ b/src/ui/GameUI.ts @@ -1,5 +1,5 @@ import Phaser from "phaser"; -import { type CombatantActor, type Stats, type UIUpdatePayload } from "../core/types"; +import { type Stats, type UIUpdatePayload } from "../core/types"; import { HudComponent } from "./components/HudComponent"; import { MenuComponent } from "./components/MenuComponent"; import { InventoryOverlay } from "./components/InventoryOverlay"; @@ -28,7 +28,6 @@ export default class GameUI extends Phaser.Scene { this.actionButtons = new ActionButtonComponent(this); } - create() { this.hud.create(); this.menu.create(); @@ -40,7 +39,6 @@ export default class GameUI extends Phaser.Scene { const gameScene = this.scene.get("GameScene"); - // Listen for updates from GameScene gameScene.events.on("update-ui", (payload: UIUpdatePayload) => { this.updateUI(payload); @@ -91,14 +89,12 @@ export default class GameUI extends Phaser.Scene { gameScene.events.emit("character-toggled", this.character.isOpen); } - showDeathScreen(data: { floor: number; gold: number; stats: Stats }) { this.death.show(data); } private updateUI(payload: UIUpdatePayload) { - const { world, playerId, floorIndex, uiState } = payload; - const player = world.actors.get(playerId) as CombatantActor; + const { player, floorIndex, uiState } = payload; if (!player) return; this.hud.update(player.stats, floorIndex); diff --git a/src/ui/components/InventoryOverlay.ts b/src/ui/components/InventoryOverlay.ts index 2f60086..ba6b01b 100644 --- a/src/ui/components/InventoryOverlay.ts +++ b/src/ui/components/InventoryOverlay.ts @@ -615,7 +615,7 @@ export class InventoryOverlay extends OverlayComponent { } const gameScene = this.scene.scene.get("GameScene") as any; - const player = gameScene.world.actors.get(gameScene.playerId); + const player = gameScene.entityAccessor.getPlayer(); if (!player) return; let item: any = null; @@ -684,7 +684,7 @@ export class InventoryOverlay extends OverlayComponent { const gameUI = this.scene as any; const gameScene = this.scene.scene.get("GameScene") as any; - const player = gameScene.world.actors.get(gameScene.playerId); + const player = gameScene.entityAccessor.getPlayer(); const item = isFromBackpack ? player.inventory.items[startIndex!] : (player.equipment as any)[startEqKey!];