Add more tests
This commit is contained in:
@@ -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 });
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user