Close door after walking through again, and add more test coverage

This commit is contained in:
Peter Stockings
2026-01-05 22:14:10 +11:00
parent b35cf5a964
commit b3954a6408
10 changed files with 445 additions and 33 deletions

View File

@@ -0,0 +1,39 @@
import { describe, it, expect } from 'vitest';
import { seededRandom, manhattan, lerp } from '../math';
describe('Math Utilities', () => {
describe('seededRandom', () => {
it('should return consistent results for the same seed', () => {
const rng1 = seededRandom(12345);
const rng2 = seededRandom(12345);
expect(rng1()).toBe(rng2());
expect(rng1()).toBe(rng2());
expect(rng1()).toBe(rng2());
});
it('should return different results for different seeds', () => {
const rng1 = seededRandom(12345);
const rng2 = seededRandom(67890);
expect(rng1()).not.toBe(rng2());
});
});
describe('manhattan', () => {
it('should calculate correct distance', () => {
expect(manhattan({ x: 0, y: 0 }, { x: 3, y: 4 })).toBe(7);
expect(manhattan({ x: 1, y: 1 }, { x: 4, y: 5 })).toBe(7);
expect(manhattan({ x: -1, y: -1 }, { x: -2, y: -2 })).toBe(2);
});
});
describe('lerp', () => {
it('should interpolate correctly', () => {
expect(lerp(0, 10, 0.5)).toBe(5);
expect(lerp(0, 10, 0)).toBe(0);
expect(lerp(0, 10, 1)).toBe(10);
expect(lerp(10, 20, 0.5)).toBe(15);
});
});
});

View File

@@ -0,0 +1,54 @@
import { describe, it, expect } from 'vitest';
import {
TileType,
isBlocking,
isDestructible,
blocksSight,
getDestructionResult,
isDestructibleByWalk
} from '../terrain';
describe('Terrain', () => {
describe('Tile Definitions', () => {
it('should correctly identify blocking tiles', () => {
expect(isBlocking(TileType.WALL)).toBe(true);
expect(isBlocking(TileType.WALL_DECO)).toBe(true);
expect(isBlocking(TileType.WATER)).toBe(true);
expect(isBlocking(TileType.EMPTY)).toBe(false);
expect(isBlocking(TileType.GRASS)).toBe(false);
expect(isBlocking(TileType.EXIT)).toBe(false);
});
it('should correctly identify destructible tiles', () => {
expect(isDestructible(TileType.GRASS)).toBe(true);
expect(isDestructible(TileType.DOOR_CLOSED)).toBe(true);
expect(isDestructible(TileType.WALL)).toBe(false);
expect(isDestructible(TileType.EMPTY)).toBe(false);
});
it('should correctly identify tiles blocking sight', () => {
expect(blocksSight(TileType.WALL)).toBe(true);
expect(blocksSight(TileType.WALL_DECO)).toBe(true);
expect(blocksSight(TileType.DOOR_CLOSED)).toBe(true);
expect(blocksSight(TileType.GRASS)).toBe(true); // Grass blocks vision in this game logic
expect(blocksSight(TileType.EMPTY)).toBe(false);
expect(blocksSight(TileType.EXIT)).toBe(false);
});
it('should return correct destruction result', () => {
expect(getDestructionResult(TileType.GRASS)).toBe(TileType.GRASS_SAPLINGS);
expect(getDestructionResult(TileType.DOOR_CLOSED)).toBe(TileType.DOOR_OPEN);
expect(getDestructionResult(TileType.WALL)).toBeUndefined();
});
it('should correctly identify tiles destructible by walk', () => {
expect(isDestructibleByWalk(TileType.GRASS)).toBe(true);
expect(isDestructibleByWalk(TileType.DOOR_CLOSED)).toBe(true);
expect(isDestructibleByWalk(TileType.WALL)).toBe(false);
});
});
});

View File

@@ -0,0 +1,12 @@
import { describe, it, expect } from 'vitest';
import { key } from '../utils';
describe('Utils', () => {
describe('key', () => {
it('should generate correct key string', () => {
expect(key(1, 2)).toBe('1,2');
expect(key(0, 0)).toBe('0,0');
expect(key(-5, 10)).toBe('-5,10');
});
});
});

View File

@@ -32,7 +32,7 @@ export const TILE_DEFINITIONS: Record<number, TileBehavior> = {
[TileType.EXIT]: { id: TileType.EXIT, isBlocking: false, isDestructible: false }, [TileType.EXIT]: { id: TileType.EXIT, isBlocking: false, isDestructible: false },
[TileType.WATER]: { id: TileType.WATER, isBlocking: true, isDestructible: false }, [TileType.WATER]: { id: TileType.WATER, isBlocking: true, isDestructible: false },
[TileType.DOOR_CLOSED]: { id: TileType.DOOR_CLOSED, isBlocking: false, isDestructible: true, isDestructibleByWalk: true, blocksVision: true, destructsTo: TileType.DOOR_OPEN }, [TileType.DOOR_CLOSED]: { id: TileType.DOOR_CLOSED, isBlocking: false, isDestructible: true, isDestructibleByWalk: true, blocksVision: true, destructsTo: TileType.DOOR_OPEN },
[TileType.DOOR_OPEN]: { id: TileType.DOOR_OPEN, isBlocking: false, isDestructible: false } [TileType.DOOR_OPEN]: { id: TileType.DOOR_OPEN, isBlocking: false, isDestructible: true, isDestructibleByWalk: true, blocksVision: false, destructsTo: TileType.DOOR_CLOSED }
}; };
export function isBlocking(tile: number): boolean { export function isBlocking(tile: number): boolean {

View File

@@ -6,7 +6,7 @@ export type Tile = number;
export type EnemyType = "rat" | "bat"; export type EnemyType = "rat" | "bat";
export type ActorType = "player" | EnemyType; export type ActorType = "player" | EnemyType;
export type EnemyAIState = "wandering" | "alerted" | "pursuing"; export type EnemyAIState = "wandering" | "alerted" | "pursuing" | "searching";
export type Action = export type Action =
| { type: "move"; dx: number; dy: number } | { type: "move"; dx: number; dy: number }

View File

@@ -174,5 +174,53 @@ describe('World Generator', () => {
const avgHp5 = enemies5.reduce((sum, e) => sum + (e.stats.hp || 0), 0) / enemies5.length; const avgHp5 = enemies5.reduce((sum, e) => sum + (e.stats.hp || 0), 0) / enemies5.length;
expect(avgHp5).toBeGreaterThan(avgHp1); 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
}
});
}); });
}); });

View 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);
});
});

View File

@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest'; 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 { type World, type Actor, type EntityId, type CombatantActor } from '../../core/types';
import { EntityManager } from '../EntityManager'; import { EntityManager } from '../EntityManager';
@@ -126,5 +126,115 @@ describe('Combat Simulation', () => {
const decision = decideEnemyAction(world, enemy, player, entityManager); const decision = decideEnemyAction(world, enemy, player, entityManager);
expect(decision.action).toEqual({ type: "attack", targetId: 1 }); 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);
});
}); });
}); });

View File

@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest'; 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 { type World, type Tile } from '../../core/types';
import { TileType } from '../../core/terrain'; import { TileType } from '../../core/terrain';
@@ -114,4 +114,65 @@ describe('World Utilities', () => {
expect(isBlocked(world, 10, 10)).toBe(true); 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);
});
});
}); });

View File

@@ -305,36 +305,41 @@ export function decideEnemyAction(w: World, enemy: CombatantActor, player: Comba
// State transitions // State transitions
let justAlerted = false; let justAlerted = false;
if (canSee && enemy.aiState === "wandering") { if (canSee) {
// Spotted player! Transition to alerted state if (enemy.aiState === "wandering" || enemy.aiState === "searching") {
enemy.aiState = "alerted"; // Spotted player (or re-spotted)! Transition to alerted state
enemy.alertedAt = Date.now(); enemy.aiState = "alerted";
enemy.lastKnownPlayerPos = { ...player.pos }; enemy.alertedAt = Date.now();
justAlerted = true; enemy.lastKnownPlayerPos = { ...player.pos };
} else if (enemy.aiState === "alerted") { justAlerted = true;
// Check if alert period is over (1 second = 1000ms) } else if (enemy.aiState === "pursuing") {
const alertDuration = 1000; // Keep pursuing, update last known
if (Date.now() - (enemy.alertedAt || 0) > alertDuration) { enemy.lastKnownPlayerPos = { ...player.pos };
enemy.aiState = "pursuing";
} }
} else if (enemy.aiState === "pursuing") { } else {
if (canSee) { // Cannot see player
// Update last known position if (enemy.aiState === "alerted") {
enemy.lastKnownPlayerPos = { ...player.pos }; // Check if alert period is over (1 second = 1000ms)
} else { const alertDuration = 1000;
// Lost sight - check if we've reached last known position if (Date.now() - (enemy.alertedAt || 0) > alertDuration) {
if (enemy.lastKnownPlayerPos) { enemy.aiState = "pursuing";
const distToLastKnown = Math.abs(enemy.pos.x - enemy.lastKnownPlayerPos.x) + }
Math.abs(enemy.pos.y - enemy.lastKnownPlayerPos.y); } else if (enemy.aiState === "pursuing") {
if (distToLastKnown <= 1) { // Lost sight while pursuing -> switch to searching
// Reached last known position, return to wandering enemy.aiState = "searching";
enemy.aiState = "wandering"; } else if (enemy.aiState === "searching") {
enemy.lastKnownPlayerPos = undefined; // 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") { if (enemy.aiState === "alerted") {
// During alert, stay still (or could do small movement) // During alert, stay still
return { action: { type: "wait" }, justAlerted }; return { action: { type: "wait" }, justAlerted };
} }