Close door after walking through again, and add more test coverage
This commit is contained in:
@@ -174,5 +174,53 @@ describe('World Generator', () => {
|
||||
const avgHp5 = enemies5.reduce((sum, e) => sum + (e.stats.hp || 0), 0) / enemies5.length;
|
||||
expect(avgHp5).toBeGreaterThan(avgHp1);
|
||||
});
|
||||
it('should generate doors on dungeon floors', () => {
|
||||
const runState = {
|
||||
stats: {
|
||||
maxHp: 20, hp: 20, attack: 5, defense: 2, level: 1, exp: 0, expToNextLevel: 10, maxMana: 20, mana: 20,
|
||||
statPoints: 0, skillPoints: 0, strength: 10, dexterity: 10, intelligence: 10,
|
||||
critChance: 5, critMultiplier: 150, accuracy: 90, lifesteal: 0, evasion: 5, blockChance: 0, luck: 0,
|
||||
passiveNodes: []
|
||||
},
|
||||
inventory: { gold: 0, items: [] }
|
||||
};
|
||||
|
||||
// Generate a few worlds to ensure we hit the 50% door chance at least once
|
||||
let foundDoor = false;
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const { world } = generateWorld(1, runState); // Floor 1 is Uniform (Dungeon)
|
||||
if (world.tiles.some(t => t === 5 || t === 6)) { // 5=DOOR_CLOSED, 6=DOOR_OPEN
|
||||
foundDoor = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
expect(foundDoor).toBe(true);
|
||||
});
|
||||
|
||||
it('should ensure player spawns on safe tile (not grass)', () => {
|
||||
const runState = {
|
||||
stats: {
|
||||
maxHp: 20, hp: 20, attack: 5, defense: 2, level: 1, exp: 0, expToNextLevel: 10, maxMana: 20, mana: 20,
|
||||
statPoints: 0, skillPoints: 0, strength: 10, dexterity: 10, intelligence: 10,
|
||||
critChance: 5, critMultiplier: 150, accuracy: 90, lifesteal: 0, evasion: 5, blockChance: 0, luck: 0,
|
||||
passiveNodes: []
|
||||
},
|
||||
inventory: { gold: 0, items: [] }
|
||||
};
|
||||
|
||||
// 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)!;
|
||||
|
||||
// Check tile under player
|
||||
const tileIdx = player.pos.y * world.width + player.pos.x;
|
||||
const tile = world.tiles[tileIdx];
|
||||
|
||||
// Should be EMPTY (1), specifically NOT GRASS (15) which blocks vision
|
||||
expect(tile).toBe(1); // TileType.EMPTY
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
83
src/engine/__tests__/pathfinding.test.ts
Normal file
83
src/engine/__tests__/pathfinding.test.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { findPathAStar } from '../world/pathfinding';
|
||||
import { type World } from '../../core/types';
|
||||
import { TileType } from '../../core/terrain';
|
||||
|
||||
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 }
|
||||
});
|
||||
|
||||
it('should find a path between two reachable points', () => {
|
||||
const world = createTestWorld(10, 10);
|
||||
const seen = new Uint8Array(100).fill(1);
|
||||
|
||||
const path = findPathAStar(world, seen, { x: 0, y: 0 }, { x: 0, y: 3 });
|
||||
|
||||
expect(path.length).toBe(4); // 0,0 -> 0,1 -> 0,2 -> 0,3
|
||||
expect(path[0]).toEqual({ x: 0, y: 0 });
|
||||
expect(path[3]).toEqual({ x: 0, y: 3 });
|
||||
});
|
||||
|
||||
it('should return empty array if target is a wall', () => {
|
||||
const world = createTestWorld(10, 10);
|
||||
world.tiles[30] = TileType.WALL; // Wall at 0,3
|
||||
const seen = new Uint8Array(100).fill(1);
|
||||
|
||||
const path = findPathAStar(world, seen, { x: 0, y: 0 }, { x: 0, y: 3 });
|
||||
|
||||
expect(path).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return empty array if no path exists', () => {
|
||||
const world = createTestWorld(10, 10);
|
||||
// Create a wall blockage
|
||||
for(let x=0; x<10; x++) world.tiles[10 + x] = TileType.WALL;
|
||||
|
||||
const seen = new Uint8Array(100).fill(1);
|
||||
|
||||
const path = findPathAStar(world, seen, { x: 0, y: 0 }, { x: 0, y: 5 });
|
||||
|
||||
expect(path).toEqual([]);
|
||||
});
|
||||
|
||||
it('should respect ignoreBlockedTarget option', () => {
|
||||
const world = createTestWorld(10, 10);
|
||||
// Place an actor at target
|
||||
world.actors.set(1, { id: 1, pos: { x: 0, y: 3 }, type: 'rat' } as any);
|
||||
|
||||
const seen = new Uint8Array(100).fill(1);
|
||||
|
||||
// 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 });
|
||||
expect(pathBlocked).toEqual([]);
|
||||
|
||||
const pathIgnored = findPathAStar(world, seen, { x: 0, y: 0 }, { x: 0, y: 3 }, { ignoreBlockedTarget: true });
|
||||
expect(pathIgnored.length).toBeGreaterThan(0);
|
||||
expect(pathIgnored[pathIgnored.length - 1]).toEqual({ x: 0, y: 3 });
|
||||
});
|
||||
|
||||
it('should respect ignoreSeen option', () => {
|
||||
const world = createTestWorld(10, 10);
|
||||
const seen = new Uint8Array(100).fill(0); // Nothing seen
|
||||
|
||||
// Without ignoreSeen, should fail because target/path is unseen
|
||||
const pathUnseen = findPathAStar(world, seen, { x: 0, y: 0 }, { x: 0, y: 3 });
|
||||
expect(pathUnseen).toEqual([]);
|
||||
|
||||
// With ignoreSeen, should succeed
|
||||
const pathSeenIgnored = findPathAStar(world, seen, { x: 0, y: 0 }, { x: 0, y: 3 }, { ignoreSeen: true });
|
||||
expect(pathSeenIgnored.length).toBe(4);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { applyAction, decideEnemyAction } from '../simulation/simulation';
|
||||
import { applyAction, decideEnemyAction, stepUntilPlayerTurn } from '../simulation/simulation';
|
||||
import { type World, type Actor, type EntityId, type CombatantActor } from '../../core/types';
|
||||
import { EntityManager } from '../EntityManager';
|
||||
|
||||
@@ -126,5 +126,115 @@ describe('Combat Simulation', () => {
|
||||
const decision = decideEnemyAction(world, enemy, player, entityManager);
|
||||
expect(decision.action).toEqual({ type: "attack", targetId: 1 });
|
||||
});
|
||||
|
||||
it("should transition to alerted when spotting player", () => {
|
||||
const actors = new Map<EntityId, Actor>();
|
||||
const player = { id: 1, category: "combatant", isPlayer: true, pos: { x: 5, y: 0 }, stats: createTestStats() } as any;
|
||||
const enemy = {
|
||||
id: 2,
|
||||
category: "combatant",
|
||||
isPlayer: false,
|
||||
pos: { x: 0, y: 0 },
|
||||
stats: createTestStats(),
|
||||
aiState: "wandering"
|
||||
} as any;
|
||||
actors.set(1, player);
|
||||
actors.set(2, enemy);
|
||||
const world = createTestWorld(actors);
|
||||
|
||||
const decision = decideEnemyAction(world, enemy, player, new EntityManager(world));
|
||||
|
||||
expect(enemy.aiState).toBe("alerted");
|
||||
expect(decision.justAlerted).toBe(true);
|
||||
});
|
||||
|
||||
it("should transition from pursuing to searching when sight is lost", () => {
|
||||
const actors = new Map<EntityId, Actor>();
|
||||
// Player far away (unseen)
|
||||
const player = { id: 1, category: "combatant", isPlayer: true, pos: { x: 9, y: 9 }, stats: createTestStats() } as any;
|
||||
const enemy = {
|
||||
id: 2,
|
||||
category: "combatant",
|
||||
isPlayer: false,
|
||||
pos: { x: 0, y: 0 },
|
||||
stats: createTestStats(),
|
||||
aiState: "pursuing", // Currently pursuing
|
||||
lastKnownPlayerPos: { x: 5, y: 5 }
|
||||
} as any;
|
||||
actors.set(1, player);
|
||||
actors.set(2, enemy);
|
||||
const world = createTestWorld(actors);
|
||||
|
||||
// Should switch to searching because can't see player
|
||||
decideEnemyAction(world, enemy, player, new EntityManager(world));
|
||||
|
||||
expect(enemy.aiState).toBe("searching");
|
||||
});
|
||||
|
||||
it("should transition from searching to alerted when sight regained", () => {
|
||||
const actors = new Map<EntityId, Actor>();
|
||||
// Player adjacent (visible)
|
||||
const player = { id: 1, category: "combatant", isPlayer: true, pos: { x: 1, y: 0 }, stats: createTestStats() } as any;
|
||||
const enemy = {
|
||||
id: 2,
|
||||
category: "combatant",
|
||||
isPlayer: false,
|
||||
pos: { x: 0, y: 0 },
|
||||
stats: createTestStats(),
|
||||
aiState: "searching",
|
||||
lastKnownPlayerPos: { x: 5, y: 5 }
|
||||
} as any;
|
||||
actors.set(1, player);
|
||||
actors.set(2, enemy);
|
||||
const world = createTestWorld(actors);
|
||||
|
||||
const decision = decideEnemyAction(world, enemy, player, new EntityManager(world));
|
||||
|
||||
expect(enemy.aiState).toBe("alerted");
|
||||
expect(decision.justAlerted).toBe(true);
|
||||
});
|
||||
|
||||
it("should transition from searching to wandering when reached target", () => {
|
||||
const actors = new Map<EntityId, Actor>();
|
||||
// Player far away (unseen) - Manhattan dist > 8
|
||||
// Enemy at 9,9. Player at 0,0. Dist = 18.
|
||||
const player = { id: 1, category: "combatant", isPlayer: true, pos: { x: 0, y: 0 }, stats: createTestStats() } as any;
|
||||
const enemy = {
|
||||
id: 2,
|
||||
category: "combatant",
|
||||
isPlayer: false,
|
||||
pos: { x: 9, y: 9 }, // At target
|
||||
stats: createTestStats(),
|
||||
aiState: "searching",
|
||||
lastKnownPlayerPos: { x: 9, y: 9 }
|
||||
} as any;
|
||||
actors.set(1, player);
|
||||
actors.set(2, enemy);
|
||||
const world = createTestWorld(actors);
|
||||
|
||||
decideEnemyAction(world, enemy, player, new EntityManager(world));
|
||||
|
||||
expect(enemy.aiState).toBe("wandering");
|
||||
});
|
||||
});
|
||||
|
||||
describe("stepUntilPlayerTurn", () => {
|
||||
it("should process enemy turns", () => {
|
||||
const actors = new Map<EntityId, Actor>();
|
||||
// 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;
|
||||
|
||||
actors.set(1, player);
|
||||
actors.set(2, enemy);
|
||||
const world = createTestWorld(actors);
|
||||
const em = new EntityManager(world);
|
||||
|
||||
const result = stepUntilPlayerTurn(world, 1, em);
|
||||
|
||||
// Enemy should have taken at least one action
|
||||
expect(result.events.length).toBeGreaterThan(0);
|
||||
expect(result.awaitingPlayerId).toBe(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { idx, inBounds, isWall, isBlocked } from '../world/world-logic';
|
||||
import { idx, inBounds, isWall, isBlocked, tryDestructTile, isPlayerOnExit } from '../world/world-logic';
|
||||
import { type World, type Tile } from '../../core/types';
|
||||
import { TileType } from '../../core/terrain';
|
||||
|
||||
@@ -114,4 +114,65 @@ describe('World Utilities', () => {
|
||||
expect(isBlocked(world, 10, 10)).toBe(true);
|
||||
});
|
||||
});
|
||||
describe('tryDestructTile', () => {
|
||||
it('should destruct a destructible tile', () => {
|
||||
const tiles = new Array(100).fill(TileType.EMPTY);
|
||||
tiles[0] = TileType.GRASS;
|
||||
const world = createTestWorld(10, 10, tiles);
|
||||
|
||||
const result = tryDestructTile(world, 0, 0);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(world.tiles[0]).toBe(TileType.GRASS_SAPLINGS);
|
||||
});
|
||||
|
||||
it('should not destruct a non-destructible tile', () => {
|
||||
const tiles = new Array(100).fill(TileType.EMPTY);
|
||||
tiles[0] = TileType.WALL;
|
||||
const world = createTestWorld(10, 10, tiles);
|
||||
|
||||
const result = tryDestructTile(world, 0, 0);
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(world.tiles[0]).toBe(TileType.WALL);
|
||||
});
|
||||
|
||||
it('should return false for out of bounds', () => {
|
||||
const world = createTestWorld(10, 10, new Array(100).fill(TileType.EMPTY));
|
||||
expect(tryDestructTile(world, -1, 0)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isPlayerOnExit', () => {
|
||||
it('should return true when player is on exit', () => {
|
||||
const world = createTestWorld(10, 10, new Array(100).fill(TileType.EMPTY));
|
||||
world.exit = { x: 5, y: 5 };
|
||||
world.actors.set(1, {
|
||||
id: 1,
|
||||
pos: { x: 5, y: 5 },
|
||||
isPlayer: true
|
||||
} as any);
|
||||
|
||||
expect(isPlayerOnExit(world, 1)).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);
|
||||
|
||||
expect(isPlayerOnExit(world, 1)).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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -305,36 +305,41 @@ export function decideEnemyAction(w: World, enemy: CombatantActor, player: Comba
|
||||
|
||||
// State transitions
|
||||
let justAlerted = false;
|
||||
if (canSee && enemy.aiState === "wandering") {
|
||||
// Spotted player! Transition to alerted state
|
||||
enemy.aiState = "alerted";
|
||||
enemy.alertedAt = Date.now();
|
||||
enemy.lastKnownPlayerPos = { ...player.pos };
|
||||
justAlerted = true;
|
||||
} else 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";
|
||||
if (canSee) {
|
||||
if (enemy.aiState === "wandering" || enemy.aiState === "searching") {
|
||||
// Spotted player (or re-spotted)! Transition to alerted state
|
||||
enemy.aiState = "alerted";
|
||||
enemy.alertedAt = Date.now();
|
||||
enemy.lastKnownPlayerPos = { ...player.pos };
|
||||
justAlerted = true;
|
||||
} else if (enemy.aiState === "pursuing") {
|
||||
// Keep pursuing, update last known
|
||||
enemy.lastKnownPlayerPos = { ...player.pos };
|
||||
}
|
||||
} else if (enemy.aiState === "pursuing") {
|
||||
if (canSee) {
|
||||
// Update last known position
|
||||
enemy.lastKnownPlayerPos = { ...player.pos };
|
||||
} else {
|
||||
// Lost sight - check if we've reached last known position
|
||||
if (enemy.lastKnownPlayerPos) {
|
||||
const distToLastKnown = Math.abs(enemy.pos.x - enemy.lastKnownPlayerPos.x) +
|
||||
Math.abs(enemy.pos.y - enemy.lastKnownPlayerPos.y);
|
||||
if (distToLastKnown <= 1) {
|
||||
// Reached last known position, return to wandering
|
||||
enemy.aiState = "wandering";
|
||||
enemy.lastKnownPlayerPos = undefined;
|
||||
} 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") {
|
||||
// Lost sight while pursuing -> switch to searching
|
||||
enemy.aiState = "searching";
|
||||
} else if (enemy.aiState === "searching") {
|
||||
// Check if reached last known position
|
||||
if (enemy.lastKnownPlayerPos) {
|
||||
const distToLastKnown = Math.abs(enemy.pos.x - enemy.lastKnownPlayerPos.x) +
|
||||
Math.abs(enemy.pos.y - enemy.lastKnownPlayerPos.y);
|
||||
if (distToLastKnown <= 1) {
|
||||
// Reached last known position, return to wandering
|
||||
enemy.aiState = "wandering";
|
||||
enemy.lastKnownPlayerPos = undefined;
|
||||
}
|
||||
} else {
|
||||
enemy.aiState = "wandering";
|
||||
}
|
||||
} else {
|
||||
// No last known position, return to wandering
|
||||
enemy.aiState = "wandering";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -344,7 +349,7 @@ export function decideEnemyAction(w: World, enemy: CombatantActor, player: Comba
|
||||
}
|
||||
|
||||
if (enemy.aiState === "alerted") {
|
||||
// During alert, stay still (or could do small movement)
|
||||
// During alert, stay still
|
||||
return { action: { type: "wait" }, justAlerted };
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user