From b3954a640860d699e11aa91f1a0c8d3f6165bb7d Mon Sep 17 00:00:00 2001 From: Peter Stockings Date: Mon, 5 Jan 2026 22:14:10 +1100 Subject: [PATCH] Close door after walking through again, and add more test coverage --- src/core/__tests__/math.test.ts | 39 ++++++++ src/core/__tests__/terrain.test.ts | 54 +++++++++++ src/core/__tests__/utils.test.ts | 12 +++ src/core/terrain.ts | 2 +- src/core/types.ts | 2 +- src/engine/__tests__/generator.test.ts | 48 ++++++++++ src/engine/__tests__/pathfinding.test.ts | 83 +++++++++++++++++ src/engine/__tests__/simulation.test.ts | 112 ++++++++++++++++++++++- src/engine/__tests__/world.test.ts | 63 ++++++++++++- src/engine/simulation/simulation.ts | 63 +++++++------ 10 files changed, 445 insertions(+), 33 deletions(-) create mode 100644 src/core/__tests__/math.test.ts create mode 100644 src/core/__tests__/terrain.test.ts create mode 100644 src/core/__tests__/utils.test.ts create mode 100644 src/engine/__tests__/pathfinding.test.ts diff --git a/src/core/__tests__/math.test.ts b/src/core/__tests__/math.test.ts new file mode 100644 index 0000000..490ed10 --- /dev/null +++ b/src/core/__tests__/math.test.ts @@ -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); + }); + }); +}); diff --git a/src/core/__tests__/terrain.test.ts b/src/core/__tests__/terrain.test.ts new file mode 100644 index 0000000..2a37c2c --- /dev/null +++ b/src/core/__tests__/terrain.test.ts @@ -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); + }); + }); +}); diff --git a/src/core/__tests__/utils.test.ts b/src/core/__tests__/utils.test.ts new file mode 100644 index 0000000..75f09cd --- /dev/null +++ b/src/core/__tests__/utils.test.ts @@ -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'); + }); + }); +}); diff --git a/src/core/terrain.ts b/src/core/terrain.ts index 152b53b..383f3c2 100644 --- a/src/core/terrain.ts +++ b/src/core/terrain.ts @@ -32,7 +32,7 @@ export const TILE_DEFINITIONS: Record = { [TileType.EXIT]: { id: TileType.EXIT, isBlocking: false, 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_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 { diff --git a/src/core/types.ts b/src/core/types.ts index 5c3a94b..5a52c6b 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -6,7 +6,7 @@ export type Tile = number; export type EnemyType = "rat" | "bat"; export type ActorType = "player" | EnemyType; -export type EnemyAIState = "wandering" | "alerted" | "pursuing"; +export type EnemyAIState = "wandering" | "alerted" | "pursuing" | "searching"; export type Action = | { type: "move"; dx: number; dy: number } diff --git a/src/engine/__tests__/generator.test.ts b/src/engine/__tests__/generator.test.ts index e190f46..d9f0a7a 100644 --- a/src/engine/__tests__/generator.test.ts +++ b/src/engine/__tests__/generator.test.ts @@ -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 + } + }); }); }); diff --git a/src/engine/__tests__/pathfinding.test.ts b/src/engine/__tests__/pathfinding.test.ts new file mode 100644 index 0000000..01aa7e2 --- /dev/null +++ b/src/engine/__tests__/pathfinding.test.ts @@ -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); + }); +}); diff --git a/src/engine/__tests__/simulation.test.ts b/src/engine/__tests__/simulation.test.ts index 76fac28..5fc061a 100644 --- a/src/engine/__tests__/simulation.test.ts +++ b/src/engine/__tests__/simulation.test.ts @@ -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(); + 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(); + // 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(); + // 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(); + // 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(); + // 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); + }); }); }); diff --git a/src/engine/__tests__/world.test.ts b/src/engine/__tests__/world.test.ts index 0d2e02e..629916c 100644 --- a/src/engine/__tests__/world.test.ts +++ b/src/engine/__tests__/world.test.ts @@ -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); + }); + }); }); diff --git a/src/engine/simulation/simulation.ts b/src/engine/simulation/simulation.ts index 61a3285..b7619e9 100644 --- a/src/engine/simulation/simulation.ts +++ b/src/engine/simulation/simulation.ts @@ -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 }; }