Fix bug where slower enemies (ie rat) would never get scheduled a turn
This commit is contained in:
@@ -124,6 +124,9 @@ export interface CombatantActor extends BaseActor {
|
|||||||
aiState?: EnemyAIState;
|
aiState?: EnemyAIState;
|
||||||
alertedAt?: number;
|
alertedAt?: number;
|
||||||
lastKnownPlayerPos?: Vec2;
|
lastKnownPlayerPos?: Vec2;
|
||||||
|
|
||||||
|
// Turn scheduling
|
||||||
|
energy: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CollectibleActor extends BaseActor {
|
export interface CollectibleActor extends BaseActor {
|
||||||
|
|||||||
154
src/engine/__tests__/ai_behavior.test.ts
Normal file
154
src/engine/__tests__/ai_behavior.test.ts
Normal file
@@ -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<EntityId, Actor>): World => {
|
||||||
|
return {
|
||||||
|
width: 10,
|
||||||
|
height: 10,
|
||||||
|
tiles: new Array(100).fill(TileType.EMPTY),
|
||||||
|
actors,
|
||||||
|
exit: { x: 9, y: 9 }
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const createTestStats = (overrides: Partial<any> = {}) => ({
|
||||||
|
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<EntityId, Actor>();
|
||||||
|
// 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<EntityId, Actor>();
|
||||||
|
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<EntityId, Actor>();
|
||||||
|
// 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<EntityId, Actor>();
|
||||||
|
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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -29,10 +29,10 @@ describe('Combat Simulation', () => {
|
|||||||
it('should deal damage when player attacks enemy', () => {
|
it('should deal damage when player attacks enemy', () => {
|
||||||
const actors = new Map<EntityId, Actor>();
|
const actors = new Map<EntityId, Actor>();
|
||||||
actors.set(1, {
|
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);
|
} as any);
|
||||||
actors.set(2, {
|
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);
|
} as any);
|
||||||
|
|
||||||
const world = createTestWorld(actors);
|
const world = createTestWorld(actors);
|
||||||
@@ -47,10 +47,10 @@ describe('Combat Simulation', () => {
|
|||||||
it("should kill enemy and spawn EXP orb without ID reuse collision", () => {
|
it("should kill enemy and spawn EXP orb without ID reuse collision", () => {
|
||||||
const actors = new Map<EntityId, Actor>();
|
const actors = new Map<EntityId, Actor>();
|
||||||
actors.set(1, {
|
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);
|
} as any);
|
||||||
actors.set(2, {
|
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);
|
} as any);
|
||||||
|
|
||||||
const world = createTestWorld(actors);
|
const world = createTestWorld(actors);
|
||||||
@@ -69,7 +69,7 @@ describe('Combat Simulation', () => {
|
|||||||
it("should destruction tile when walking on destructible-by-walk tile", () => {
|
it("should destruction tile when walking on destructible-by-walk tile", () => {
|
||||||
const actors = new Map<EntityId, Actor>();
|
const actors = new Map<EntityId, Actor>();
|
||||||
actors.set(1, {
|
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);
|
} as any);
|
||||||
|
|
||||||
const world = createTestWorld(actors);
|
const world = createTestWorld(actors);
|
||||||
@@ -92,8 +92,8 @@ describe('Combat Simulation', () => {
|
|||||||
describe("decideEnemyAction - AI Logic", () => {
|
describe("decideEnemyAction - AI Logic", () => {
|
||||||
it("should path around walls", () => {
|
it("should path around walls", () => {
|
||||||
const actors = new Map<EntityId, Actor>();
|
const actors = new Map<EntityId, Actor>();
|
||||||
const player = { id: 1, category: "combatant", isPlayer: true, pos: { x: 5, 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() } 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(1, player);
|
||||||
actors.set(2, enemy);
|
actors.set(2, enemy);
|
||||||
|
|
||||||
@@ -138,7 +138,8 @@ describe('Combat Simulation', () => {
|
|||||||
isPlayer: false,
|
isPlayer: false,
|
||||||
pos: { x: 0, y: 0 },
|
pos: { x: 0, y: 0 },
|
||||||
stats: createTestStats(),
|
stats: createTestStats(),
|
||||||
aiState: "wandering"
|
aiState: "wandering",
|
||||||
|
energy: 0
|
||||||
} as any;
|
} as any;
|
||||||
actors.set(1, player);
|
actors.set(1, player);
|
||||||
actors.set(2, enemy);
|
actors.set(2, enemy);
|
||||||
@@ -224,8 +225,8 @@ describe('Combat Simulation', () => {
|
|||||||
it("should process enemy turns", () => {
|
it("should process enemy turns", () => {
|
||||||
const actors = new Map<EntityId, Actor>();
|
const actors = new Map<EntityId, Actor>();
|
||||||
// Player is slow, enemy is fast. Enemy should move before player returns.
|
// 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 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() } 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(1, player);
|
||||||
actors.set(2, enemy);
|
actors.set(2, enemy);
|
||||||
@@ -254,8 +255,8 @@ describe('Combat Simulation', () => {
|
|||||||
it("should dodge attack when roll > hit chance", () => {
|
it("should dodge attack when roll > hit chance", () => {
|
||||||
const actors = new Map<EntityId, Actor>();
|
const actors = new Map<EntityId, Actor>();
|
||||||
// Acc 100, Eva 50. Hit Chance = 50.
|
// 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 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 }) } 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(1, player);
|
||||||
actors.set(2, enemy);
|
actors.set(2, enemy);
|
||||||
@@ -276,9 +277,10 @@ describe('Combat Simulation', () => {
|
|||||||
// Crit Chance 50%.
|
// Crit Chance 50%.
|
||||||
const player = {
|
const player = {
|
||||||
id: 1, category: "combatant", isPlayer: true, pos: { x: 3, y: 3 },
|
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;
|
} 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(1, player);
|
||||||
actors.set(2, enemy);
|
actors.set(2, enemy);
|
||||||
@@ -307,7 +309,7 @@ describe('Combat Simulation', () => {
|
|||||||
} as any;
|
} as any;
|
||||||
const enemy = {
|
const enemy = {
|
||||||
id: 2, category: "combatant", isPlayer: false, pos: { x: 4, y: 3 },
|
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;
|
} as any;
|
||||||
|
|
||||||
actors.set(1, player);
|
actors.set(1, player);
|
||||||
|
|||||||
@@ -94,7 +94,8 @@ describe('World Utilities', () => {
|
|||||||
type: "player",
|
type: "player",
|
||||||
pos: { x: 3, y: 3 },
|
pos: { x: 3, y: 3 },
|
||||||
speed: 100,
|
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);
|
expect(isBlocked(world, 3, 3)).toBe(true);
|
||||||
|
|||||||
@@ -6,8 +6,6 @@ import { findPathAStar } from "../world/pathfinding";
|
|||||||
import { GAME_CONFIG } from "../../core/config/GameConfig";
|
import { GAME_CONFIG } from "../../core/config/GameConfig";
|
||||||
import { type EntityManager } from "../EntityManager";
|
import { type EntityManager } from "../EntityManager";
|
||||||
import { FOV } from "rot-js";
|
import { FOV } from "rot-js";
|
||||||
import * as ROT from "rot-js";
|
|
||||||
|
|
||||||
|
|
||||||
export function applyAction(w: World, actorId: EntityId, action: Action, em?: EntityManager): SimEvent[] {
|
export function applyAction(w: World, actorId: EntityId, action: Action, em?: EntityManager): SimEvent[] {
|
||||||
const actor = w.actors.get(actorId);
|
const actor = w.actors.get(actorId);
|
||||||
@@ -168,6 +166,16 @@ function handleAttack(w: World, actor: Actor, action: { targetId: EntityId }, em
|
|||||||
|
|
||||||
target.stats.hp -= dmg;
|
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
|
// 5. Lifesteal Logic
|
||||||
if (actor.stats.lifesteal > 0 && dmg > 0) {
|
if (actor.stats.lifesteal > 0 && dmg > 0) {
|
||||||
const healAmount = Math.floor(dmg * (actor.stats.lifesteal / 100));
|
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
|
// Spawn EXP Orb
|
||||||
const enemyDef = (GAME_CONFIG.enemies as any)[target.type || ""];
|
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 orbId = em ? em.getNextId() : Math.max(0, ...w.actors.keys(), target.id) + 1;
|
||||||
|
|
||||||
const orb: CollectibleActor = {
|
const orb: CollectibleActor = {
|
||||||
@@ -218,7 +225,7 @@ function handleAttack(w: World, actor: Actor, action: { targetId: EntityId }, em
|
|||||||
category: "collectible",
|
category: "collectible",
|
||||||
type: "exp_orb",
|
type: "exp_orb",
|
||||||
pos: { ...target.pos },
|
pos: { ...target.pos },
|
||||||
expAmount
|
expAmount: enemyDef?.expValue || 0
|
||||||
};
|
};
|
||||||
|
|
||||||
if (em) em.addActor(orb);
|
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) => {
|
const fov = new FOV.PreciseShadowcasting((x: number, y: number) => {
|
||||||
if (!inBounds(w, x, y)) return false;
|
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;
|
const idx = y * w.width + x;
|
||||||
return !blocksSight(w.tiles[idx]);
|
return !blocksSight(w.tiles[idx]);
|
||||||
});
|
});
|
||||||
@@ -305,6 +313,15 @@ export function decideEnemyAction(w: World, enemy: CombatantActor, player: Comba
|
|||||||
|
|
||||||
// State transitions
|
// State transitions
|
||||||
let justAlerted = false;
|
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 (canSee) {
|
||||||
if (enemy.aiState === "wandering" || enemy.aiState === "searching") {
|
if (enemy.aiState === "wandering" || enemy.aiState === "searching") {
|
||||||
// Spotted player (or re-spotted)! Transition to alerted state
|
// Spotted player (or re-spotted)! Transition to alerted state
|
||||||
@@ -318,13 +335,7 @@ export function decideEnemyAction(w: World, enemy: CombatantActor, player: Comba
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Cannot see player
|
// Cannot see player
|
||||||
if (enemy.aiState === "alerted") {
|
if (enemy.aiState === "pursuing") {
|
||||||
// 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") {
|
|
||||||
// Lost sight while pursuing -> switch to searching
|
// Lost sight while pursuing -> switch to searching
|
||||||
enemy.aiState = "searching";
|
enemy.aiState = "searching";
|
||||||
} else if (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.
|
* Returns enemy events accumulated along the way.
|
||||||
*/
|
*/
|
||||||
export function stepUntilPlayerTurn(w: World, playerId: EntityId, em?: EntityManager): { awaitingPlayerId: EntityId; events: SimEvent[] } {
|
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;
|
const player = w.actors.get(playerId) as CombatantActor;
|
||||||
if (!player || player.category !== "combatant") throw new Error("Player missing or invalid");
|
if (!player || player.category !== "combatant") throw new Error("Player missing or invalid");
|
||||||
|
|
||||||
const events: SimEvent[] = [];
|
const events: SimEvent[] = [];
|
||||||
|
|
||||||
// Create scheduler and add all combatants
|
// If player already has enough energy (from previous accumulation), return immediately to let them act
|
||||||
const scheduler = new ROT.Scheduler.Speed();
|
// 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()) {
|
// Since this function is called AFTER user input (Player just acted), we assume Player needs to recover energy.
|
||||||
if (actor.category === "combatant") {
|
// BUT, we should check if we need to deduct energy first?
|
||||||
// ROT.Scheduler.Speed expects actors to have a getSpeed() method
|
// The caller just applied an action. We should probably deduct energy for that action BEFORE entering the loop?
|
||||||
// Add it dynamically if it doesn't exist
|
// For now, let's assume the player is at < 100 energy and needs to wait.
|
||||||
const actorWithGetSpeed = actor as any;
|
// Wait, if we don't deduct energy, the player stays at high energy?
|
||||||
if (!actorWithGetSpeed.getSpeed) {
|
// The caller doesn't manage energy. WE manage energy.
|
||||||
actorWithGetSpeed.getSpeed = function() { return this.speed; };
|
|
||||||
}
|
// Implicitly, the player just spent 100 energy to trigger this call.
|
||||||
scheduler.add(actorWithGetSpeed, true);
|
// So we should deduct it from the player NOW.
|
||||||
}
|
if (player.energy >= THRESHOLD) {
|
||||||
|
player.energy -= THRESHOLD;
|
||||||
}
|
}
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
// Get next actor from scheduler
|
// If player has enough energy to act, return control to user
|
||||||
const actor = scheduler.next() as CombatantActor | null;
|
if (player.energy >= THRESHOLD) {
|
||||||
|
return { awaitingPlayerId: playerId, events };
|
||||||
if (!actor || !w.actors.has(actor.id)) {
|
|
||||||
// Actor was removed (died), continue to next
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (actor.isPlayer) {
|
// Give energy to everyone
|
||||||
// Player's turn - return control to the user
|
for (const actor of w.actors.values()) {
|
||||||
return { awaitingPlayerId: actor.id, events };
|
if (actor.category === "combatant") {
|
||||||
|
actor.energy += actor.speed;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enemy turn - decide action and apply it
|
// Process turns for everyone who has enough energy (except player, who breaks the loop)
|
||||||
const decision = decideEnemyAction(w, actor, player, em);
|
// 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
|
// We need to loop multiple times if someone has A LOT of energy (e.g. speed 200 vs speed 50)
|
||||||
if (decision.justAlerted) {
|
// But typically we step 1 tick.
|
||||||
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));
|
// Simpler approach:
|
||||||
|
// Process all actors with energy >= THRESHOLD.
|
||||||
|
// If multiple have >= THRESHOLD, who goes first?
|
||||||
|
// Usually the one with highest energy.
|
||||||
|
|
||||||
// Check if player was killed by this action
|
// Let's protect against infinite loops if someone has infinite speed.
|
||||||
if (!w.actors.has(playerId)) {
|
let actionsTaken = 0;
|
||||||
return { awaitingPlayerId: null as any, events };
|
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 died
|
||||||
|
if (!w.actors.has(playerId)) {
|
||||||
|
return { awaitingPlayerId: null as any, events };
|
||||||
|
}
|
||||||
|
|
||||||
|
actionsTaken++;
|
||||||
|
if (actionsTaken > 1000) break; // Emergency break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,7 +46,8 @@ export function generateWorld(floor: number, runState: RunState): { world: World
|
|||||||
pos: { x: playerX, y: playerY },
|
pos: { x: playerX, y: playerY },
|
||||||
speed: GAME_CONFIG.player.speed,
|
speed: GAME_CONFIG.player.speed,
|
||||||
stats: { ...runState.stats },
|
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
|
// Place exit in last room
|
||||||
@@ -391,7 +392,8 @@ function placeEnemies(floor: number, rooms: Room[], actors: Map<EntityId, Actor>
|
|||||||
blockChance: 0,
|
blockChance: 0,
|
||||||
luck: 0,
|
luck: 0,
|
||||||
passiveNodes: []
|
passiveNodes: []
|
||||||
}
|
},
|
||||||
|
energy: 0
|
||||||
});
|
});
|
||||||
|
|
||||||
occupiedPositions.add(k);
|
occupiedPositions.add(k);
|
||||||
|
|||||||
@@ -196,7 +196,8 @@ describe('DungeonRenderer', () => {
|
|||||||
type: "rat",
|
type: "rat",
|
||||||
pos: { x: 3, y: 1 },
|
pos: { x: 3, y: 1 },
|
||||||
speed: 10,
|
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;
|
(renderer as any).fovManager.visibleArray[1 * mockWorld.width + 3] = 1;
|
||||||
|
|||||||
Reference in New Issue
Block a user