189 lines
8.6 KiB
TypeScript
189 lines
8.6 KiB
TypeScript
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 { EntityAccessor } from '../EntityAccessor';
|
|
import { TileType } from '../../core/terrain';
|
|
import { ECSWorld } from '../ecs/World';
|
|
|
|
const createTestWorld = (): World => {
|
|
return {
|
|
width: 10,
|
|
height: 10,
|
|
tiles: new Array(100).fill(TileType.EMPTY),
|
|
exit: { x: 9, y: 9 },
|
|
trackPath: []
|
|
};
|
|
};
|
|
|
|
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 accessor: EntityAccessor;
|
|
let ecsWorld: ECSWorld;
|
|
|
|
beforeEach(() => {
|
|
ecsWorld = new ECSWorld();
|
|
});
|
|
|
|
const syncToECS = (actors: Map<EntityId, Actor>) => {
|
|
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
|
|
// -------------------------------------------------------------------------
|
|
describe('Scheduler Fairness', () => {
|
|
it("should allow slower actors to act eventually", () => {
|
|
const actors = new Map<EntityId, Actor>();
|
|
// Player Speed 100
|
|
const player = {
|
|
id: 1 as EntityId, category: "combatant", isPlayer: true, pos: { x: 0, y: 0 },
|
|
speed: 100, stats: createTestStats(), energy: 0
|
|
} as any;
|
|
|
|
// Rat Speed 80 (Slow)
|
|
const rat = {
|
|
id: 2 as EntityId, category: "combatant", isPlayer: false, pos: { x: 9, y: 9 },
|
|
speed: 80, stats: createTestStats(), aiState: "wandering", energy: 0
|
|
} as any;
|
|
|
|
actors.set(1 as EntityId, player);
|
|
actors.set(2 as EntityId, rat);
|
|
const world = createTestWorld();
|
|
syncToECS(actors);
|
|
accessor = new EntityAccessor(world, 1 as EntityId, ecsWorld);
|
|
|
|
let ratMoves = 0;
|
|
|
|
// Simulate 20 player turns
|
|
for (let i = 0; i < 20; i++) {
|
|
const result = stepUntilPlayerTurn(world, 1 as EntityId, accessor);
|
|
const enemyActs = result.events.filter(e =>
|
|
(e.type === "moved" || e.type === "waited" || e.type === "enemy-alerted") &&
|
|
((e as any).actorId === 2 || (e as any).enemyId === 2)
|
|
);
|
|
|
|
if (enemyActs.length > 0) 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 as EntityId, { id: 1 as EntityId, category: "combatant", isPlayer: true, pos: { x: 5, y: 0 }, stats: createTestStats(), energy: 0 } as any);
|
|
actors.set(2 as EntityId, {
|
|
id: 2 as EntityId, category: "combatant", isPlayer: false, pos: { x: 0, y: 0 },
|
|
stats: createTestStats(), aiState: "wandering", energy: 0
|
|
} as any);
|
|
|
|
const world = createTestWorld();
|
|
world.tiles[0] = type;
|
|
syncToECS(actors);
|
|
|
|
const testAccessor = new EntityAccessor(world, 1 as EntityId, ecsWorld);
|
|
// Rat at 0,0. Player at 5,0.
|
|
decideEnemyAction(world, testAccessor.getCombatant(2 as EntityId) as any, testAccessor.getCombatant(1 as EntityId) as any, testAccessor);
|
|
|
|
const updatedRat = testAccessor.getCombatant(2 as EntityId);
|
|
expect(updatedRat?.aiState).toBe("alerted");
|
|
});
|
|
});
|
|
});
|
|
|
|
// -------------------------------------------------------------------------
|
|
// 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 as EntityId, category: "combatant", isPlayer: true, pos: { x: 0, y: 0 }, stats: createTestStats({ attack: 1, accuracy: 100 }), energy: 0 } as any;
|
|
const enemy = {
|
|
id: 2 as EntityId, category: "combatant", isPlayer: false, pos: { x: 0, y: 5 },
|
|
stats: createTestStats({ hp: 10, defense: 0, evasion: 0 }), aiState: "wandering", energy: 0
|
|
} as any;
|
|
|
|
actors.set(1 as EntityId, player);
|
|
actors.set(2 as EntityId, enemy);
|
|
const world = createTestWorld();
|
|
syncToECS(actors);
|
|
|
|
const testAccessor = new EntityAccessor(world, 1 as EntityId, ecsWorld);
|
|
applyAction(world, 1 as EntityId, { type: "attack", targetId: 2 as EntityId }, testAccessor);
|
|
|
|
const updatedEnemy = testAccessor.getCombatant(2 as EntityId);
|
|
expect(updatedEnemy?.aiState).toBe("pursuing");
|
|
expect(updatedEnemy?.lastKnownPlayerPos).toEqual(player.pos);
|
|
});
|
|
|
|
it("should transition from alerted to pursuing after delay even if sight is blocked", () => {
|
|
const actors = new Map<EntityId, Actor>();
|
|
const player = { id: 1 as EntityId, category: "combatant", isPlayer: true, pos: { x: 9, y: 9 }, stats: createTestStats(), energy: 0 } as any;
|
|
const enemy = {
|
|
id: 2 as EntityId,
|
|
category: "combatant",
|
|
isPlayer: false,
|
|
pos: { x: 0, y: 0 },
|
|
stats: createTestStats(),
|
|
aiState: "alerted",
|
|
alertedAt: Date.now() - 2000, // Alerted 2 seconds ago
|
|
lastKnownPlayerPos: { x: 9, y: 9 }, // Known position
|
|
energy: 0
|
|
} as any;
|
|
|
|
actors.set(1 as EntityId, player);
|
|
actors.set(2 as EntityId, enemy);
|
|
const world = createTestWorld();
|
|
|
|
// Player is far away and potentially blocked
|
|
world.tiles[1] = TileType.WALL; // x=1, y=0 blocked
|
|
syncToECS(actors);
|
|
|
|
const testAccessor = new EntityAccessor(world, 1 as EntityId, ecsWorld);
|
|
const rat = testAccessor.getCombatant(2 as EntityId)!;
|
|
decideEnemyAction(world, rat, testAccessor.getPlayer()!, testAccessor);
|
|
|
|
// alerted -> pursuing (due to time) -> searching (due to no sight)
|
|
expect(rat.aiState).toBe("searching");
|
|
});
|
|
});
|
|
});
|