diff --git a/src/core/__tests__/utils.test.ts b/src/core/__tests__/utils.test.ts index 75f09cd..3d63c40 100644 --- a/src/core/__tests__/utils.test.ts +++ b/src/core/__tests__/utils.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from 'vitest'; -import { key } from '../utils'; +import { key, sleep } from '../utils'; describe('Utils', () => { describe('key', () => { @@ -9,4 +9,13 @@ describe('Utils', () => { expect(key(-5, 10)).toBe('-5,10'); }); }); + + describe('sleep', () => { + it('should resolve after delay', async () => { + const start = Date.now(); + await sleep(10); + const end = Date.now(); + expect(end - start).toBeGreaterThanOrEqual(10); + }); + }); }); diff --git a/src/engine/__tests__/EntityManager.test.ts b/src/engine/__tests__/EntityManager.test.ts index e9ec8a0..e0e1301 100644 --- a/src/engine/__tests__/EntityManager.test.ts +++ b/src/engine/__tests__/EntityManager.test.ts @@ -90,4 +90,43 @@ describe('EntityManager', () => { expect(entityManager.getActorsAt(1, 1).map(a => a.id)).toEqual([2]); }); + + it('should handle removing non-existent actor gracefully', () => { + // Should not throw + entityManager.removeActor(999); + }); + + it('should handle moving non-existent actor gracefully', () => { + // Should not throw + entityManager.moveActor(999, { x: 0, y: 0 }, { x: 1, y: 1 }); + }); + + it('should handle moving an actor that is not in the grid at expected position (inconsistent state)', () => { + const actor: Actor = { id: 1, pos: { x: 0, y: 0 } } as any; + // Add to actors map but NOT to grid (simulating desync) + mockWorld.actors.set(1, actor); + + // Attempt move + entityManager.moveActor(1, { x: 0, y: 0 }, { x: 1, y: 1 }); + + expect(actor.pos.x).toBe(1); + expect(actor.pos.y).toBe(1); + // Should be added to new position in grid + expect(entityManager.getActorsAt(1, 1).map(a => a.id)).toContain(1); + }); + + it('should handle moving an actor that is in grid but ID not found in list (very rare edge case)', () => { + // Manually pollute grid with empty array for old pos + // This forces `ids` to exist but `indexOf` to return -1 + const idx = 0; // 0,0 + // @ts-ignore + entityManager.grid.set(idx, [999]); // occupied by someone else + + const actor: Actor = { id: 1, pos: { x: 0, y:0 } } as any; + mockWorld.actors.set(1, actor); + + entityManager.moveActor(1, { x: 0, y: 0 }, { x: 1, y: 1 }); + expect(actor.pos).toEqual({ x: 1, y: 1 }); + }); + }); diff --git a/src/engine/__tests__/generator.test.ts b/src/engine/__tests__/generator.test.ts index d9f0a7a..f634542 100644 --- a/src/engine/__tests__/generator.test.ts +++ b/src/engine/__tests__/generator.test.ts @@ -2,6 +2,8 @@ import { describe, it, expect } from 'vitest'; import { generateWorld } from '../world/generator'; import { isWall, inBounds } from '../world/world-logic'; import { type CombatantActor } from '../../core/types'; +import { TileType } from '../../core/terrain'; +import * as ROT from 'rot-js'; describe('World Generator', () => { describe('generateWorld', () => { @@ -223,4 +225,89 @@ describe('World Generator', () => { } }); }); + + + describe('Cave Generation (Floors 10+)', () => { + it('should generate cellular automata style maps', () => { + 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: [] } + }; + + const { world } = generateWorld(10, runState); + + // Basic validity checks + expect(world.width).toBe(60); + expect(world.height).toBe(40); + expect(world.tiles.some(t => t === TileType.EMPTY)).toBe(true); + expect(world.tiles.some(t => t === TileType.WALL)).toBe(true); + }); + + it('should place enemies in caves', () => { + 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: [] } + }; + + const { world } = generateWorld(11, runState); + const enemies = Array.from(world.actors.values()).filter(a => a.category === 'combatant' && !a.isPlayer); + expect(enemies.length).toBeGreaterThan(0); + }); + + it('should ensure the map is connected (Player can reach Exit)', () => { + 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: [] } + }; + + for (let i = 0; i < 5; i++) { + const { world, playerId } = generateWorld(10 + i, runState); + const player = world.actors.get(playerId)!; + const exit = world.exit; + + const pathfinder = new ROT.Path.AStar(exit.x, exit.y, (x, y) => { + if (!inBounds(world, x, y)) return false; + return !isWall(world, x, y); + }); + + const path: Array<[number, number]> = []; + pathfinder.compute(player.pos.x, player.pos.y, (x, y) => { + path.push([x, y]); + }); + + expect(path.length).toBeGreaterThan(0); + } + }); + + it('should verify safe spawn logic on caves', () => { + 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: [] } + }; + const { world, playerId } = generateWorld(12, runState); + const player = world.actors.get(playerId)!; + + expect(world.tiles[player.pos.y * world.width + player.pos.x]).toBe(TileType.EMPTY); + }); + }); }); diff --git a/src/engine/__tests__/simulation.test.ts b/src/engine/__tests__/simulation.test.ts index 5fc061a..f24a139 100644 --- a/src/engine/__tests__/simulation.test.ts +++ b/src/engine/__tests__/simulation.test.ts @@ -1,12 +1,9 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { applyAction, decideEnemyAction, stepUntilPlayerTurn } from '../simulation/simulation'; import { type World, type Actor, type EntityId, type CombatantActor } from '../../core/types'; import { EntityManager } from '../EntityManager'; -describe('Combat Simulation', () => { - let entityManager: EntityManager; - - const createTestWorld = (actors: Map): World => { +const createTestWorld = (actors: Map): World => { return { width: 10, height: 10, @@ -16,12 +13,17 @@ describe('Combat Simulation', () => { }; }; - const createTestStats = (overrides: Partial = {}) => ({ +const createTestStats = (overrides: Partial = {}) => ({ 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('Combat Simulation', () => { + let entityManager: EntityManager; + + describe('applyAction - success paths', () => { it('should deal damage when player attacks enemy', () => { @@ -237,4 +239,140 @@ describe('Combat Simulation', () => { expect(result.awaitingPlayerId).toBe(1); }); }); + + describe("Combat Mechanics - Detailed", () => { + let mockRandom: ReturnType; + + beforeEach(() => { + mockRandom = vi.spyOn(Math, 'random'); + }); + + afterEach(() => { + mockRandom.mockRestore(); + }); + + it("should dodge attack when roll > hit chance", () => { + const actors = new Map(); + // 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 enemy = { id: 2, category: "combatant", isPlayer: false, pos: { x: 4, y: 3 }, stats: createTestStats({ evasion: 50, hp: 10 }) } as any; + + actors.set(1, player); + actors.set(2, enemy); + const world = createTestWorld(actors); + + // Mock random to be 51 (scale 0-100 logic uses * 100) -> 0.51 + mockRandom.mockReturnValue(0.51); + + const events = applyAction(world, 1, { type: "attack", targetId: 2 }, new EntityManager(world)); + + expect(events.some(e => e.type === "dodged")).toBe(true); + expect(enemy.stats.hp).toBe(10); // No damage + }); + + it("should crit when roll < crit chance", () => { + const actors = new Map(); + // Acc 100, Eva 0. Hit Chance = 100. + // Crit Chance 50%. + const player = { + id: 1, category: "combatant", isPlayer: true, pos: { x: 3, y: 3 }, + stats: createTestStats({ accuracy: 100, critChance: 50, critMultiplier: 200, attack: 10 }) + } 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; + + actors.set(1, player); + actors.set(2, enemy); + const world = createTestWorld(actors); + + // Mock random: + // 1. Hit roll: 0.1 (Hit) + // 2. Crit roll: 0.4 (Crit, since < 0.5) + // 3. Block roll: 0.9 (No block) + mockRandom.mockReturnValueOnce(0.1).mockReturnValueOnce(0.4).mockReturnValueOnce(0.9); + + const events = applyAction(world, 1, { type: "attack", targetId: 2 }, new EntityManager(world)); + + // Damage = 10 * 2 = 20 + const dmgEvent = events.find(e => e.type === "damaged") as any; + expect(dmgEvent).toBeDefined(); + expect(dmgEvent.amount).toBe(20); + expect(dmgEvent.isCrit).toBe(true); + }); + + it("should block when roll < block chance", () => { + const actors = new Map(); + const player = { + id: 1, category: "combatant", isPlayer: true, pos: { x: 3, y: 3 }, + stats: createTestStats({ accuracy: 100, critChance: 0, attack: 10 }) + } as any; + const enemy = { + id: 2, category: "combatant", isPlayer: false, pos: { x: 4, y: 3 }, + stats: createTestStats({ evasion: 0, defense: 0, hp: 50, blockChance: 50 }) + } as any; + + actors.set(1, player); + actors.set(2, enemy); + const world = createTestWorld(actors); + + // Mock random: + // 1. Hit roll: 0.1 + // 2. Crit roll: 0.9 + // 3. Block roll: 0.4 (Block, since < 0.5) + mockRandom.mockReturnValueOnce(0.1).mockReturnValueOnce(0.9).mockReturnValueOnce(0.4); + + const events = applyAction(world, 1, { type: "attack", targetId: 2 }, new EntityManager(world)); + + // Damage = 10 * 0.5 = 5 + const dmgEvent = events.find(e => e.type === "damaged") as any; + expect(dmgEvent.amount).toBe(5); + expect(dmgEvent.isBlock).toBe(true); + }); + + it("should lifesteal on hit", () => { + const actors = new Map(); + const player = { + id: 1, category: "combatant", isPlayer: true, pos: { x: 3, y: 3 }, + stats: createTestStats({ accuracy: 100, attack: 10, lifesteal: 50, hp: 10, maxHp: 20 }) + } as any; + const enemy = { id: 2, category: "combatant", isPlayer: false, pos: { x: 4, y: 3 }, stats: createTestStats({ hp: 20, defense: 0 }) } as any; + + actors.set(1, player); + actors.set(2, enemy); + const world = createTestWorld(actors); + + // Standard hit + mockRandom.mockReturnValue(0.1); + + const events = applyAction(world, 1, { type: "attack", targetId: 2 }, new EntityManager(world)); + + // Damage 10. Heal 50% = 5. HP -> 15. + expect(player.stats.hp).toBe(15); + expect(events.some(e => e.type === "healed")).toBe(true); + }); + }); + + describe("Level Up Logic", () => { + it("should level up when collecting ample experience", () => { + const actors = new Map(); + const player = { + id: 1, category: "combatant", isPlayer: true, pos: { x: 3, y: 3 }, + stats: createTestStats({ level: 1, exp: 0, expToNextLevel: 100 }) + } as any; + // Orb with 150 exp + const orb = { + id: 2, category: "collectible", type: "exp_orb", pos: { x: 4, y: 3 }, expAmount: 150 + } as any; + + actors.set(1, player); + actors.set(2, orb); + const world = createTestWorld(actors); + + // Move player onto orb + const events = applyAction(world, 1, { type: "move", dx: 1, dy: 0 }, new EntityManager(world)); + + expect(player.stats.level).toBe(2); + expect(player.stats.exp).toBe(50); // 150 - 100 = 50 + expect(events.some(e => e.type === "leveled-up")).toBe(true); + }); + }); }); diff --git a/src/engine/world/generator.ts b/src/engine/world/generator.ts index 1d42031..48e0114 100644 --- a/src/engine/world/generator.ts +++ b/src/engine/world/generator.ts @@ -28,7 +28,7 @@ export function generateWorld(floor: number, runState: RunState): { world: World // Set ROT's RNG seed for consistent dungeon generation ROT.RNG.setSeed(floor * 12345); - const rooms = generateRooms(width, height, tiles, floor); + const rooms = generateRooms(width, height, tiles, floor, random); // Place player in first room const firstRoom = rooms[0]; @@ -77,7 +77,8 @@ export function generateWorld(floor: number, runState: RunState): { world: World } -function generateRooms(width: number, height: number, tiles: Tile[], floor: number): Room[] { +// Update generateRooms signature to accept random +function generateRooms(width: number, height: number, tiles: Tile[], floor: number, random: () => number): Room[] { const rooms: Room[] = []; // Choose dungeon algorithm based on floor depth @@ -135,6 +136,9 @@ function generateRooms(width: number, height: number, tiles: Tile[], floor: numb } else { // Cellular caves don't have explicit rooms, so find connected floor areas rooms.push(...extractRoomsFromCave(width, height, tiles)); + + // Connect the isolated cave rooms + connectRooms(width, tiles, rooms, random); } // Ensure we have at least 2 rooms for player/exit placement @@ -144,11 +148,55 @@ function generateRooms(width: number, height: number, tiles: Tile[], floor: numb { x: 5, y: 5, width: 5, height: 5 }, { x: width - 10, y: height - 10, width: 5, height: 5 } ); + // Connect the fallback rooms + connectRooms(width, tiles, rooms, random); } return rooms; } +function connectRooms(width: number, tiles: Tile[], rooms: Room[], random: () => number) { + for (let i = 0; i < rooms.length - 1; i++) { + const r1 = rooms[i]; + const r2 = rooms[i+1]; + + const c1x = r1.x + Math.floor(r1.width / 2); + const c1y = r1.y + Math.floor(r1.height / 2); + const c2x = r2.x + Math.floor(r2.width / 2); + const c2y = r2.y + Math.floor(r2.height / 2); + + if (random() < 0.5) { + digH(width, tiles, c1x, c2x, c1y); + digV(width, tiles, c1y, c2y, c2x); + } else { + digV(width, tiles, c1y, c2y, c1x); + digH(width, tiles, c1x, c2x, c2y); + } + } +} + +function digH(width: number, tiles: Tile[], x1: number, x2: number, y: number) { + const start = Math.min(x1, x2); + const end = Math.max(x1, x2); + for (let x = start; x <= end; x++) { + const idx = y * width + x; + if (tiles[idx] === TileType.WALL) { + tiles[idx] = TileType.EMPTY; + } + } +} + +function digV(width: number, tiles: Tile[], y1: number, y2: number, x: number) { + const start = Math.min(y1, y2); + const end = Math.max(y1, y2); + for (let y = start; y <= end; y++) { + const idx = y * width + x; + if (tiles[idx] === TileType.WALL) { + tiles[idx] = TileType.EMPTY; + } + } +} + /** * For cellular/cave maps, find clusters of floor tiles to use as "rooms" */