Add more tests

This commit is contained in:
Peter Stockings
2026-01-06 10:01:59 +11:00
parent cb1dfea33b
commit a2a1d0cc58
5 changed files with 331 additions and 10 deletions

View File

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

View File

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

View File

@@ -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<EntityId, Actor>): World => {
const createTestWorld = (actors: Map<EntityId, Actor>): World => {
return {
width: 10,
height: 10,
@@ -16,12 +13,17 @@ describe('Combat Simulation', () => {
};
};
const createTestStats = (overrides: Partial<any> = {}) => ({
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('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<typeof vi.spyOn>;
beforeEach(() => {
mockRandom = vi.spyOn(Math, 'random');
});
afterEach(() => {
mockRandom.mockRestore();
});
it("should dodge attack when roll > hit chance", () => {
const actors = new Map<EntityId, Actor>();
// 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<EntityId, Actor>();
// 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<EntityId, Actor>();
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<EntityId, Actor>();
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<EntityId, Actor>();
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);
});
});
});

View File

@@ -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"
*/