From 0263495d0b8f59d0c2d8b0b41a1023a9b0844517 Mon Sep 17 00:00:00 2001 From: Peter Stockings Date: Tue, 6 Jan 2026 10:34:23 +1100 Subject: [PATCH] Fix bug where slower enemies (ie rat) would never get scheduled a turn --- src/core/types.ts | 3 + src/engine/__tests__/ai_behavior.test.ts | 154 ++++++++++++++++++ src/engine/__tests__/simulation.test.ts | 32 ++-- src/engine/__tests__/world.test.ts | 3 +- src/engine/simulation/simulation.ts | 148 +++++++++++------ src/engine/world/generator.ts | 6 +- .../__tests__/DungeonRenderer.test.ts | 3 +- 7 files changed, 282 insertions(+), 67 deletions(-) create mode 100644 src/engine/__tests__/ai_behavior.test.ts diff --git a/src/core/types.ts b/src/core/types.ts index 5a52c6b..6b29fdd 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -124,6 +124,9 @@ export interface CombatantActor extends BaseActor { aiState?: EnemyAIState; alertedAt?: number; lastKnownPlayerPos?: Vec2; + + // Turn scheduling + energy: number; } export interface CollectibleActor extends BaseActor { diff --git a/src/engine/__tests__/ai_behavior.test.ts b/src/engine/__tests__/ai_behavior.test.ts new file mode 100644 index 0000000..99786b7 --- /dev/null +++ b/src/engine/__tests__/ai_behavior.test.ts @@ -0,0 +1,154 @@ + +import { describe, it, expect } 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 { TileType } from '../../core/terrain'; + +const createTestWorld = (actors: Map): World => { + return { + width: 10, + height: 10, + tiles: new Array(100).fill(TileType.EMPTY), + actors, + exit: { x: 9, y: 9 } + }; + }; + +const createTestStats = (overrides: Partial = {}) => ({ + maxHp: 20, hp: 20, attack: 5, defense: 2, level: 1, exp: 0, expToNextLevel: 10, + statPoints: 0, skillPoints: 0, strength: 10, dexterity: 10, intelligence: 10, passiveNodes: [], + critChance: 0, critMultiplier: 100, accuracy: 100, lifesteal: 0, evasion: 0, blockChance: 0, luck: 0, + ...overrides +}); + +describe('AI Behavior & Scheduling', () => { + let entityManager: EntityManager; + + // ------------------------------------------------------------------------- + // Scheduling Fairness + // ------------------------------------------------------------------------- + describe('Scheduler Fairness', () => { + it("should allow slower actors to act eventually", () => { + const actors = new Map(); + // Player Speed 100 + const player = { + id: 1, 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 }, + 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); + + 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 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); + }); + }); + + // ------------------------------------------------------------------------- + // Vision & Perception + // ------------------------------------------------------------------------- + describe('AI Vision', () => { + const terrainTypes = [ + { type: TileType.EMPTY, name: 'Empty' }, + { type: TileType.GRASS, name: 'Grass' }, // Blocks Vision normally + { type: TileType.GRASS_SAPLINGS, name: 'Saplings' }, + ]; + + 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 }, + stats: createTestStats(), aiState: "wandering", energy: 0 + } as any); + + const world = createTestWorld(actors); + world.tiles[0] = type; + + // Rat at 0,0. Player at 5,0. + decideEnemyAction(world, actors.get(2) as any, actors.get(1) as any, new EntityManager(world)); + + expect((actors.get(2) as CombatantActor).aiState).toBe("alerted"); + }); + }); + }); + + // ------------------------------------------------------------------------- + // Aggression & State Machine + // ------------------------------------------------------------------------- + describe('AI Aggression State Machine', () => { + it('should become pursuing when damaged by player, even if not sighting player', () => { + const actors = new Map(); + // Player far away/invisible (simulated logic) + const player = { id: 1, 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 }, + 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); + + const em = new EntityManager(world); + applyAction(world, 1, { type: "attack", targetId: 2 }, em); + + const updatedEnemy = actors.get(2) as CombatantActor; + 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 enemy = { + id: 2, + category: "combatant", + isPlayer: false, + pos: { x: 0, y: 0 }, + stats: createTestStats(), + aiState: "alerted", + alertedAt: Date.now() - 2000, // Alerted 2 seconds ago + lastKnownPlayerPos: { x: 9, y: 9 }, // Known position + energy: 0 + } as any; + + actors.set(1, player); + actors.set(2, enemy); + const world = createTestWorld(actors); + + // Player is far away and potentially blocked + world.tiles[1] = TileType.WALL; // x=1, y=0 blocked + + decideEnemyAction(world, enemy, player, new EntityManager(world)); + + // alerted -> pursuing (due to time) -> searching (due to no sight) + expect(enemy.aiState).toBe("searching"); + }); + }); +}); diff --git a/src/engine/__tests__/simulation.test.ts b/src/engine/__tests__/simulation.test.ts index f24a139..8fa5b3e 100644 --- a/src/engine/__tests__/simulation.test.ts +++ b/src/engine/__tests__/simulation.test.ts @@ -29,10 +29,10 @@ describe('Combat Simulation', () => { it('should deal damage when player attacks enemy', () => { const actors = new Map(); actors.set(1, { - id: 1, category: "combatant", isPlayer: true, type: "player", pos: { x: 3, y: 3 }, speed: 100, stats: createTestStats() + id: 1, category: "combatant", isPlayer: true, type: "player", pos: { x: 3, y: 3 }, speed: 100, stats: createTestStats(), energy: 0 } as any); actors.set(2, { - 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 }) + 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); @@ -47,10 +47,10 @@ describe('Combat Simulation', () => { it("should kill enemy and spawn EXP orb without ID reuse collision", () => { const actors = new Map(); actors.set(1, { - id: 1, category: "combatant", isPlayer: true, type: "player", pos: { x: 3, y: 3 }, speed: 100, stats: createTestStats({ attack: 50 }) + 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, { - 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 }) + 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); @@ -69,7 +69,7 @@ describe('Combat Simulation', () => { it("should destruction tile when walking on destructible-by-walk tile", () => { const actors = new Map(); actors.set(1, { - id: 1, category: "combatant", isPlayer: true, type: "player", pos: { x: 3, y: 3 }, speed: 100, stats: createTestStats() + 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); @@ -92,8 +92,8 @@ describe('Combat Simulation', () => { describe("decideEnemyAction - AI Logic", () => { it("should path around walls", () => { const actors = new Map(); - const player = { id: 1, category: "combatant", isPlayer: true, pos: { x: 5, y: 3 }, stats: createTestStats() } as any; - const enemy = { id: 2, category: "combatant", isPlayer: false, pos: { x: 3, y: 3 }, stats: createTestStats() } as any; + 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); @@ -138,7 +138,8 @@ describe('Combat Simulation', () => { isPlayer: false, pos: { x: 0, y: 0 }, stats: createTestStats(), - aiState: "wandering" + aiState: "wandering", + energy: 0 } as any; actors.set(1, player); actors.set(2, enemy); @@ -224,8 +225,8 @@ describe('Combat Simulation', () => { it("should process enemy turns", () => { const actors = new Map(); // Player is slow, enemy is fast. Enemy should move before player returns. - const player = { id: 1, category: "combatant", isPlayer: true, pos: { x: 0, y: 0 }, speed: 10, stats: createTestStats() } as any; - const enemy = { id: 2, category: "combatant", isPlayer: false, pos: { x: 5, y: 5 }, speed: 100, stats: createTestStats() } as any; + 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); @@ -254,8 +255,8 @@ describe('Combat Simulation', () => { it("should dodge attack when roll > hit chance", () => { const actors = new Map(); // Acc 100, Eva 50. Hit Chance = 50. - const player = { id: 1, category: "combatant", isPlayer: true, pos: { x: 3, y: 3 }, stats: createTestStats({ accuracy: 100 }) } as any; - const enemy = { id: 2, category: "combatant", isPlayer: false, pos: { x: 4, y: 3 }, stats: createTestStats({ evasion: 50, hp: 10 }) } as any; + 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); @@ -276,9 +277,10 @@ describe('Combat Simulation', () => { // Crit Chance 50%. const player = { id: 1, category: "combatant", isPlayer: true, pos: { x: 3, y: 3 }, - stats: createTestStats({ accuracy: 100, critChance: 50, critMultiplier: 200, attack: 10 }) + stats: createTestStats({ accuracy: 100, critChance: 50, critMultiplier: 200, attack: 10 }), + energy: 0 } as any; - const enemy = { id: 2, category: "combatant", isPlayer: false, pos: { x: 4, y: 3 }, stats: createTestStats({ evasion: 0, defense: 0, hp: 50 }) } 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); @@ -307,7 +309,7 @@ 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, blockChance: 50 }) + stats: createTestStats({ evasion: 0, defense: 0, hp: 50, blockChance: 50 }), energy: 0 } as any; actors.set(1, player); diff --git a/src/engine/__tests__/world.test.ts b/src/engine/__tests__/world.test.ts index 629916c..4862290 100644 --- a/src/engine/__tests__/world.test.ts +++ b/src/engine/__tests__/world.test.ts @@ -94,7 +94,8 @@ describe('World Utilities', () => { type: "player", pos: { x: 3, y: 3 }, speed: 100, - stats: { hp: 10, maxHp: 10, attack: 1, defense: 0 } as any + stats: { hp: 10, maxHp: 10, attack: 1, defense: 0 } as any, + energy: 100 }); expect(isBlocked(world, 3, 3)).toBe(true); diff --git a/src/engine/simulation/simulation.ts b/src/engine/simulation/simulation.ts index b7619e9..3437e1f 100644 --- a/src/engine/simulation/simulation.ts +++ b/src/engine/simulation/simulation.ts @@ -6,8 +6,6 @@ import { findPathAStar } from "../world/pathfinding"; import { GAME_CONFIG } from "../../core/config/GameConfig"; import { type EntityManager } from "../EntityManager"; import { FOV } from "rot-js"; -import * as ROT from "rot-js"; - export function applyAction(w: World, actorId: EntityId, action: Action, em?: EntityManager): SimEvent[] { const actor = w.actors.get(actorId); @@ -168,6 +166,16 @@ function handleAttack(w: World, actor: Actor, action: { targetId: EntityId }, em 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 + if (actor.pos) { + target.lastKnownPlayerPos = { ...actor.pos }; + } + } + // 5. Lifesteal Logic if (actor.stats.lifesteal > 0 && dmg > 0) { const healAmount = Math.floor(dmg * (actor.stats.lifesteal / 100)); @@ -210,7 +218,6 @@ function handleAttack(w: World, actor: Actor, action: { targetId: EntityId }, em // Spawn EXP Orb const enemyDef = (GAME_CONFIG.enemies as any)[target.type || ""]; - const expAmount = enemyDef?.expValue || 0; const orbId = em ? em.getNextId() : Math.max(0, ...w.actors.keys(), target.id) + 1; const orb: CollectibleActor = { @@ -218,7 +225,7 @@ function handleAttack(w: World, actor: Actor, action: { targetId: EntityId }, em category: "collectible", type: "exp_orb", pos: { ...target.pos }, - expAmount + expAmount: enemyDef?.expValue || 0 }; if (em) em.addActor(orb); @@ -243,6 +250,7 @@ function canEnemySeePlayer(w: World, enemy: CombatantActor, player: CombatantAct const fov = new FOV.PreciseShadowcasting((x: number, y: number) => { if (!inBounds(w, x, y)) return false; + if (x === enemy.pos.x && y === enemy.pos.y) return true; // Can always see out of own tile const idx = y * w.width + x; return !blocksSight(w.tiles[idx]); }); @@ -305,6 +313,15 @@ export function decideEnemyAction(w: World, enemy: CombatantActor, player: Comba // State transitions let justAlerted = false; + + // Check if alerted state has expired + if (enemy.aiState === "alerted") { + const alertDuration = 1000; + if (Date.now() - (enemy.alertedAt || 0) > alertDuration) { + enemy.aiState = "pursuing"; + } + } + if (canSee) { if (enemy.aiState === "wandering" || enemy.aiState === "searching") { // Spotted player (or re-spotted)! Transition to alerted state @@ -318,13 +335,7 @@ export function decideEnemyAction(w: World, enemy: CombatantActor, player: Comba } } else { // Cannot see player - if (enemy.aiState === "alerted") { - // Check if alert period is over (1 second = 1000ms) - const alertDuration = 1000; - if (Date.now() - (enemy.alertedAt || 0) > alertDuration) { - enemy.aiState = "pursuing"; - } - } else if (enemy.aiState === "pursuing") { + if (enemy.aiState === "pursuing") { // Lost sight while pursuing -> switch to searching enemy.aiState = "searching"; } else if (enemy.aiState === "searching") { @@ -402,58 +413,99 @@ export function decideEnemyAction(w: World, enemy: CombatantActor, player: Comba * Returns enemy events accumulated along the way. */ export function stepUntilPlayerTurn(w: World, playerId: EntityId, em?: EntityManager): { awaitingPlayerId: EntityId; events: SimEvent[] } { + // Energy Threshold + 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 events: SimEvent[] = []; - // Create scheduler and add all combatants - const scheduler = new ROT.Scheduler.Speed(); + // 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. - for (const actor of w.actors.values()) { - if (actor.category === "combatant") { - // ROT.Scheduler.Speed expects actors to have a getSpeed() method - // Add it dynamically if it doesn't exist - const actorWithGetSpeed = actor as any; - if (!actorWithGetSpeed.getSpeed) { - actorWithGetSpeed.getSpeed = function() { return this.speed; }; - } - scheduler.add(actorWithGetSpeed, true); - } + // 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) { - // Get next actor from scheduler - const actor = scheduler.next() as CombatantActor | null; - - if (!actor || !w.actors.has(actor.id)) { - // Actor was removed (died), continue to next - continue; + // If player has enough energy to act, return control to user + if (player.energy >= THRESHOLD) { + return { awaitingPlayerId: playerId, events }; } - if (actor.isPlayer) { - // Player's turn - return control to the user - return { awaitingPlayerId: actor.id, events }; + // Give energy to everyone + for (const actor of w.actors.values()) { + if (actor.category === "combatant") { + actor.energy += actor.speed; + } } - // Enemy turn - decide action and apply it - const decision = decideEnemyAction(w, actor, player, em); + // 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. - // Emit alert event if enemy just spotted player - if (decision.justAlerted) { - events.push({ - type: "enemy-alerted", - enemyId: actor.id, - x: actor.pos.x, - y: actor.pos.y - }); - } + // 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. - events.push(...applyAction(w, actor.id, decision.action, em)); + // 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[]; + + 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); + + if (decision.justAlerted) { + events.push({ + type: "enemy-alerted", + enemyId: actor.id, + x: actor.pos.x, + y: actor.pos.y + }); + } + + events.push(...applyAction(w, actor.id, decision.action, em)); - // Check if player was killed by this action - if (!w.actors.has(playerId)) { - return { awaitingPlayerId: null as any, events }; + // Check if player died + if (!w.actors.has(playerId)) { + return { awaitingPlayerId: null as any, events }; + } + + actionsTaken++; + if (actionsTaken > 1000) break; // Emergency break } } } diff --git a/src/engine/world/generator.ts b/src/engine/world/generator.ts index 48e0114..cc8b849 100644 --- a/src/engine/world/generator.ts +++ b/src/engine/world/generator.ts @@ -46,7 +46,8 @@ export function generateWorld(floor: number, runState: RunState): { world: World pos: { x: playerX, y: playerY }, speed: GAME_CONFIG.player.speed, stats: { ...runState.stats }, - inventory: { gold: runState.inventory.gold, items: [...runState.inventory.items] } + inventory: { gold: runState.inventory.gold, items: [...runState.inventory.items] }, + energy: 0 }); // Place exit in last room @@ -391,7 +392,8 @@ function placeEnemies(floor: number, rooms: Room[], actors: Map blockChance: 0, luck: 0, passiveNodes: [] - } + }, + energy: 0 }); occupiedPositions.add(k); diff --git a/src/rendering/__tests__/DungeonRenderer.test.ts b/src/rendering/__tests__/DungeonRenderer.test.ts index 60df105..7b40a1e 100644 --- a/src/rendering/__tests__/DungeonRenderer.test.ts +++ b/src/rendering/__tests__/DungeonRenderer.test.ts @@ -196,7 +196,8 @@ describe('DungeonRenderer', () => { type: "rat", pos: { x: 3, y: 1 }, speed: 10, - stats: { hp: 10, maxHp: 10, attack: 2, defense: 0 } as any + stats: { hp: 10, maxHp: 10, attack: 2, defense: 0 } as any, + energy: 10 }); (renderer as any).fovManager.visibleArray[1 * mockWorld.width + 3] = 1;