Add tests and move constants to config

This commit is contained in:
Peter Stockings
2026-01-04 10:27:27 +11:00
parent 6e3763a17b
commit f3607bc167
9 changed files with 484 additions and 35 deletions

View File

@@ -0,0 +1,136 @@
import { describe, it, expect } from 'vitest';
import { generateWorld } from '../generator';
import { isWall, inBounds } from '../world';
describe('World Generator', () => {
describe('generateWorld', () => {
it('should generate a world with correct dimensions', () => {
const runState = {
stats: { maxHp: 20, hp: 20, attack: 5, defense: 2 },
inventory: { gold: 0, items: [] }
};
const { world } = generateWorld(1, runState);
expect(world.width).toBe(60);
expect(world.height).toBe(40);
expect(world.tiles.length).toBe(60 * 40);
});
it('should place player actor', () => {
const runState = {
stats: { maxHp: 20, hp: 20, attack: 5, defense: 2 },
inventory: { gold: 0, items: [] }
};
const { world, playerId } = generateWorld(1, runState);
expect(playerId).toBe(1);
const player = world.actors.get(playerId);
expect(player).toBeDefined();
expect(player?.isPlayer).toBe(true);
expect(player?.stats).toEqual(runState.stats);
});
it('should create walkable rooms', () => {
const runState = {
stats: { maxHp: 20, hp: 20, attack: 5, defense: 2 },
inventory: { gold: 0, items: [] }
};
const { world, playerId } = generateWorld(1, runState);
const player = world.actors.get(playerId)!;
// Player should spawn in a walkable area
expect(isWall(world, player.pos.x, player.pos.y)).toBe(false);
});
it('should place exit in valid location', () => {
const runState = {
stats: { maxHp: 20, hp: 20, attack: 5, defense: 2 },
inventory: { gold: 0, items: [] }
};
const { world } = generateWorld(1, runState);
expect(inBounds(world, world.exit.x, world.exit.y)).toBe(true);
// Exit should be on a floor tile
expect(isWall(world, world.exit.x, world.exit.y)).toBe(false);
});
it('should create enemies', () => {
const runState = {
stats: { maxHp: 20, hp: 20, attack: 5, defense: 2 },
inventory: { gold: 0, items: [] }
};
const { world, playerId } = generateWorld(1, runState);
// Should have player + enemies
expect(world.actors.size).toBeGreaterThan(1);
// All non-player actors should be enemies
const enemies = Array.from(world.actors.values()).filter(a => !a.isPlayer);
expect(enemies.length).toBeGreaterThan(0);
// Enemies should have stats
enemies.forEach(enemy => {
expect(enemy.stats).toBeDefined();
expect(enemy.stats!.hp).toBeGreaterThan(0);
expect(enemy.stats!.attack).toBeGreaterThan(0);
});
});
it('should generate deterministic maps for same level', () => {
const runState = {
stats: { maxHp: 20, hp: 20, attack: 5, defense: 2 },
inventory: { gold: 0, items: [] }
};
const { world: world1, playerId: player1 } = generateWorld(1, runState);
const { world: world2, playerId: player2 } = generateWorld(1, runState);
// Same level should generate identical layouts
expect(world1.tiles).toEqual(world2.tiles);
expect(world1.exit).toEqual(world2.exit);
const player1Pos = world1.actors.get(player1)!.pos;
const player2Pos = world2.actors.get(player2)!.pos;
expect(player1Pos).toEqual(player2Pos);
});
it('should generate different maps for different levels', () => {
const runState = {
stats: { maxHp: 20, hp: 20, attack: 5, defense: 2 },
inventory: { gold: 0, items: [] }
};
const { world: world1 } = generateWorld(1, runState);
const { world: world2 } = generateWorld(2, runState);
// Different levels should have different layouts
expect(world1.tiles).not.toEqual(world2.tiles);
});
it('should scale enemy difficulty with level', () => {
const runState = {
stats: { maxHp: 20, hp: 20, attack: 5, defense: 2 },
inventory: { gold: 0, items: [] }
};
const { world: world1 } = generateWorld(1, runState);
const { world: world5 } = generateWorld(5, runState);
const enemies1 = Array.from(world1.actors.values()).filter(a => !a.isPlayer);
const enemies5 = Array.from(world5.actors.values()).filter(a => !a.isPlayer);
// Higher level should have more enemies
expect(enemies5.length).toBeGreaterThan(enemies1.length);
// Higher level enemies should have higher stats
const avgHp1 = enemies1.reduce((sum, e) => sum + (e.stats?.hp || 0), 0) / enemies1.length;
const avgHp5 = enemies5.reduce((sum, e) => sum + (e.stats?.hp || 0), 0) / enemies5.length;
expect(avgHp5).toBeGreaterThan(avgHp1);
});
});
});

View File

@@ -0,0 +1,125 @@
import { describe, it, expect } from 'vitest';
import { applyAction } from '../simulation';
import { type World, type Actor, type EntityId } from '../types';
describe('Combat Simulation', () => {
const createTestWorld = (actors: Map<EntityId, Actor>): World => ({
width: 10,
height: 10,
tiles: new Array(100).fill(0),
actors,
exit: { x: 9, y: 9 }
});
describe('applyAction - attack', () => {
it('should deal damage when player attacks enemy', () => {
const actors = new Map<EntityId, Actor>();
actors.set(1, {
id: 1,
isPlayer: true,
pos: { x: 3, y: 3 },
speed: 100,
energy: 0,
stats: { maxHp: 20, hp: 20, attack: 5, defense: 2 }
});
actors.set(2, {
id: 2,
isPlayer: false,
pos: { x: 4, y: 3 },
speed: 100,
energy: 0,
stats: { maxHp: 10, hp: 10, attack: 3, defense: 1 }
});
const world = createTestWorld(actors);
const events = applyAction(world, 1, { type: 'attack', targetId: 2 });
const enemy = world.actors.get(2)!;
expect(enemy.hp).toBeLessThan(10);
// Should have attack event
expect(events.some(e => e.type === 'attacked')).toBe(true);
});
it('should kill enemy when damage exceeds hp', () => {
const actors = new Map<EntityId, Actor>();
actors.set(1, {
id: 1,
isPlayer: true,
pos: { x: 3, y: 3 },
speed: 100,
energy: 0,
stats: { maxHp: 20, hp: 20, attack: 50, defense: 2 }
});
actors.set(2, {
id: 2,
isPlayer: false,
pos: { x: 4, y: 3 },
speed: 100,
energy: 0,
stats: { maxHp: 10, hp: 10, attack: 3, defense: 1 }
});
const world = createTestWorld(actors);
const events = applyAction(world, 1, { type: 'attack', targetId: 2 });
// Enemy should be removed from world
expect(world.actors.has(2)).toBe(false);
// Should have killed event
expect(events.some(e => e.type === 'killed')).toBe(true);
});
it('should apply defense to reduce damage', () => {
const actors = new Map<EntityId, Actor>();
actors.set(1, {
id: 1,
isPlayer: true,
pos: { x: 3, y: 3 },
speed: 100,
energy: 0,
stats: { maxHp: 20, hp: 20, attack: 5, defense: 2 }
});
actors.set(2, {
id: 2,
isPlayer: false,
pos: { x: 4, y: 3 },
speed: 100,
energy: 0,
stats: { maxHp: 10, hp: 10, attack: 3, defense: 3 }
});
const world = createTestWorld(actors);
const events = applyAction(world, 1, { type: 'attack', targetId: 2 });
const enemy = world.actors.get(2)!;
const damage = 10 - enemy.stats!. hp;
// Damage should be reduced by defense (5 attack - 3 defense = 2 damage)
expect(damage).toBe(2);
});
});
describe('applyAction - move', () => {
it('should move actor to new position', () => {
const actors = new Map<EntityId, Actor>();
actors.set(1, {
id: 1,
isPlayer: true,
pos: { x: 3, y: 3 },
speed: 100,
energy: 0,
stats: { maxHp: 20, hp: 20, attack: 5, defense: 2 }
});
const world = createTestWorld(actors);
const events = applyAction(world, 1, { type: 'move', dx: 1, dy: 0 });
const player = world.actors.get(1)!;
expect(player.pos).toEqual({ x: 4, y: 3 });
// Should have moved event
expect(events.some(e => e.type === 'moved')).toBe(true);
});
});
});

View File

@@ -0,0 +1,110 @@
import { describe, it, expect } from 'vitest';
import { idx, inBounds, isWall, isBlocked } from '../world';
import { type World, type Tile } from '../types';
describe('World Utilities', () => {
const createTestWorld = (width: number, height: number, tiles: Tile[]): World => ({
width,
height,
tiles,
actors: new Map(),
exit: { x: 0, y: 0 }
});
describe('idx', () => {
it('should calculate correct index for 2D coordinates', () => {
const world = createTestWorld(10, 10, []);
expect(idx(world, 0, 0)).toBe(0);
expect(idx(world, 5, 0)).toBe(5);
expect(idx(world, 0, 1)).toBe(10);
expect(idx(world, 5, 3)).toBe(35);
});
});
describe('inBounds', () => {
it('should return true for coordinates within bounds', () => {
const world = createTestWorld(10, 10, []);
expect(inBounds(world, 0, 0)).toBe(true);
expect(inBounds(world, 5, 5)).toBe(true);
expect(inBounds(world, 9, 9)).toBe(true);
});
it('should return false for coordinates outside bounds', () => {
const world = createTestWorld(10, 10, []);
expect(inBounds(world, -1, 0)).toBe(false);
expect(inBounds(world, 0, -1)).toBe(false);
expect(inBounds(world, 10, 0)).toBe(false);
expect(inBounds(world, 0, 10)).toBe(false);
expect(inBounds(world, 11, 11)).toBe(false);
});
});
describe('isWall', () => {
it('should return true for wall tiles', () => {
const tiles: Tile[] = new Array(100).fill(0);
tiles[0] = 1; // wall at 0,0
tiles[55] = 1; // wall at 5,5
const world = createTestWorld(10, 10, tiles);
expect(isWall(world, 0, 0)).toBe(true);
expect(isWall(world, 5, 5)).toBe(true);
});
it('should return false for floor tiles', () => {
const tiles: Tile[] = new Array(100).fill(0);
const world = createTestWorld(10, 10, tiles);
expect(isWall(world, 3, 3)).toBe(false);
expect(isWall(world, 7, 7)).toBe(false);
});
it('should return false for out of bounds coordinates', () => {
const world = createTestWorld(10, 10, new Array(100).fill(0));
expect(isWall(world, -1, 0)).toBe(false);
expect(isWall(world, 10, 10)).toBe(false);
});
});
describe('isBlocked', () => {
it('should return true for walls', () => {
const tiles: Tile[] = new Array(100).fill(0);
tiles[55] = 1; // wall at 5,5
const world = createTestWorld(10, 10, tiles);
expect(isBlocked(world, 5, 5)).toBe(true);
});
it('should return true for actor positions', () => {
const world = createTestWorld(10, 10, new Array(100).fill(0));
world.actors.set(1, {
id: 1,
isPlayer: true,
pos: { x: 3, y: 3 },
speed: 100,
energy: 0
});
expect(isBlocked(world, 3, 3)).toBe(true);
});
it('should return false for empty floor tiles', () => {
const world = createTestWorld(10, 10, new Array(100).fill(0));
expect(isBlocked(world, 3, 3)).toBe(false);
expect(isBlocked(world, 7, 7)).toBe(false);
});
it('should return true for out of bounds', () => {
const world = createTestWorld(10, 10, new Array(100).fill(0));
expect(isBlocked(world, -1, 0)).toBe(true);
expect(isBlocked(world, 10, 10)).toBe(true);
});
});
});

View File

@@ -0,0 +1,62 @@
export const GAME_CONFIG = {
player: {
initialStats: { maxHp: 20, hp: 20, attack: 5, defense: 2 },
speed: 100,
viewRadius: 8
},
map: {
width: 60,
height: 40,
minRooms: 8,
maxRooms: 13,
roomMinWidth: 5,
roomMaxWidth: 12,
roomMinHeight: 4,
roomMaxHeight: 10
},
enemy: {
baseHpPerLevel: 2,
baseHp: 8,
baseAttack: 3,
attackPerTwoLevels: 1,
minSpeed: 80,
maxSpeed: 130,
maxDefense: 2,
baseCountPerLevel: 1,
baseCount: 3,
randomBonus: 4
},
rendering: {
tileSize: 24,
cameraZoom: 2,
wallColor: 0x2b2b2b,
floorColor: 0x161616,
exitColor: 0xffd166,
playerColor: 0x66ff66,
enemyColor: 0xff6666,
pathPreviewColor: 0x3355ff,
fogAlphaFloor: 0.15,
fogAlphaWall: 0.35,
visibleMinAlpha: 0.35,
visibleMaxAlpha: 1.0,
visibleStrengthFactor: 0.65
},
ui: {
minimapPanelWidth: 340,
minimapPanelHeight: 220,
minimapPadding: 20,
menuPanelWidth: 340,
menuPanelHeight: 220
},
gameplay: {
energyThreshold: 100,
actionCost: 100
}
} as const;
export type GameConfig = typeof GAME_CONFIG;

View File

@@ -1,5 +1,6 @@
import { type World, type EntityId, type RunState, type Tile, type Actor, type Vec2 } from "./types";
import { idx } from "./world";
import { GAME_CONFIG } from "./config/GameConfig";
interface Room {
x: number;
@@ -16,9 +17,15 @@ function seededRandom(seed: number): () => number {
};
}
export function makeTestWorld(level: number, runState: RunState): { world: World; playerId: EntityId } {
const width = 60;
const height = 40;
/**
* Generates a procedural dungeon world with rooms and corridors
* @param level The level number (affects difficulty and randomness seed)
* @param runState Player's persistent state across levels
* @returns Generated world and player ID
*/
export function generateWorld(level: number, runState: RunState): { world: World; playerId: EntityId } {
const width = GAME_CONFIG.map.width;
const height = GAME_CONFIG.map.height;
const tiles: Tile[] = new Array(width * height).fill(1); // Start with all walls
const fakeWorldForIdx: World = { width, height, tiles, actors: new Map(), exit: { x: 0, y: 0 } };
@@ -26,11 +33,11 @@ export function makeTestWorld(level: number, runState: RunState): { world: World
// Generate rooms
const rooms: Room[] = [];
const numRooms = 8 + Math.floor(random() * 6); // 8-13 rooms
const numRooms = GAME_CONFIG.map.minRooms + Math.floor(random() * (GAME_CONFIG.map.maxRooms - GAME_CONFIG.map.minRooms + 1));
for (let i = 0; i < numRooms; i++) {
const roomWidth = 5 + Math.floor(random() * 8); // 5-12
const roomHeight = 4 + Math.floor(random() * 7); // 4-10
const roomWidth = GAME_CONFIG.map.roomMinWidth + Math.floor(random() * (GAME_CONFIG.map.roomMaxWidth - GAME_CONFIG.map.roomMinWidth + 1));
const roomHeight = GAME_CONFIG.map.roomMinHeight + Math.floor(random() * (GAME_CONFIG.map.roomMaxHeight - GAME_CONFIG.map.roomMinHeight + 1));
const roomX = 1 + Math.floor(random() * (width - roomWidth - 2));
const roomY = 1 + Math.floor(random() * (height - roomHeight - 2));
@@ -108,7 +115,7 @@ export function makeTestWorld(level: number, runState: RunState): { world: World
id: playerId,
isPlayer: true,
pos: { x: playerX, y: playerY },
speed: 100,
speed: GAME_CONFIG.player.speed,
energy: 0,
stats: { ...runState.stats },
inventory: { gold: runState.inventory.gold, items: [...runState.inventory.items] }
@@ -116,7 +123,7 @@ export function makeTestWorld(level: number, runState: RunState): { world: World
// Place enemies in random rooms (skip first room with player)
let enemyId = 2;
const numEnemies = 3 + level + Math.floor(random() * 4); // Increases with level
const numEnemies = GAME_CONFIG.enemy.baseCount + level * GAME_CONFIG.enemy.baseCountPerLevel + Math.floor(random() * GAME_CONFIG.enemy.randomBonus);
for (let i = 0; i < numEnemies && i < rooms.length - 1; i++) {
const roomIdx = 1 + Math.floor(random() * (rooms.length - 1));
@@ -126,20 +133,20 @@ export function makeTestWorld(level: number, runState: RunState): { world: World
const enemyY = room.y + 1 + Math.floor(random() * (room.height - 2));
// Vary enemy stats by level
const baseHp = 8 + level * 2;
const baseAttack = 3 + Math.floor(level / 2);
const baseHp = GAME_CONFIG.enemy.baseHp + level * GAME_CONFIG.enemy.baseHpPerLevel;
const baseAttack = GAME_CONFIG.enemy.baseAttack + Math.floor(level / 2) * GAME_CONFIG.enemy.attackPerTwoLevels;
actors.set(enemyId, {
id: enemyId,
isPlayer: false,
pos: { x: enemyX, y: enemyY },
speed: 80 + Math.floor(random() * 50), // 80-130 speed
speed: GAME_CONFIG.enemy.minSpeed + Math.floor(random() * (GAME_CONFIG.enemy.maxSpeed - GAME_CONFIG.enemy.minSpeed)),
energy: 0,
stats: {
maxHp: baseHp + Math.floor(random() * 4),
hp: baseHp + Math.floor(random() * 4),
attack: baseAttack + Math.floor(random() * 2),
defense: Math.floor(random() * 3)
defense: Math.floor(random() * (GAME_CONFIG.enemy.maxDefense + 1))
}
});
enemyId++;
@@ -147,3 +154,8 @@ export function makeTestWorld(level: number, runState: RunState): { world: World
return { world: { width, height, tiles, actors, exit }, playerId };
}
// Backward compatibility - will be removed in Phase 2
/** @deprecated Use generateWorld instead */
export const makeTestWorld = generateWorld;

View File

@@ -53,6 +53,10 @@ export type World = {
exit: Vec2;
};
export const TILE_SIZE = 24;
export const ENERGY_THRESHOLD = 100;
export const ACTION_COST = 100;
// Import constants from config
import { GAME_CONFIG } from "./config/GameConfig";
export const TILE_SIZE = GAME_CONFIG.rendering.tileSize;
export const ENERGY_THRESHOLD = GAME_CONFIG.gameplay.energyThreshold;
export const ACTION_COST = GAME_CONFIG.gameplay.actionCost;