diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c4a721a..f34a778 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,6 +30,9 @@ jobs: - name: Build run: npx vite build + - name: Run unit tests + run: npm run test:unit + - name: Install Playwright browsers run: npx playwright install --with-deps chromium diff --git a/tests/unit/autotile.test.ts b/tests/unit/autotile.test.ts new file mode 100644 index 0000000..e88e195 --- /dev/null +++ b/tests/unit/autotile.test.ts @@ -0,0 +1,147 @@ +import { describe, it, expect } from 'vitest'; +import { LOC_TILE } from '../../js/data/constants'; +import { isWallTile, computeWallChar } from '../../js/world/autotile'; + +// ─── isWallTile ─────────────────────────────────────────────────────────────── + +describe('isWallTile(tile)', () => { + const wallTiles = [ + LOC_TILE.WALL, + LOC_TILE.WALL_INN, + LOC_TILE.WALL_SHOP, + LOC_TILE.WALL_BLACKSMITH, + LOC_TILE.WALL_HEALER, + LOC_TILE.WALL_TAVERN, + LOC_TILE.WALL_GUILD, + LOC_TILE.WALL_TEMPLE, + ]; + + for (const tile of wallTiles) { + it(`returns true for tile ${tile}`, () => { + expect(isWallTile(tile)).toBe(true); + }); + } + + const nonWallTiles = [ + LOC_TILE.FLOOR, + LOC_TILE.VOID, + LOC_TILE.DOOR, + LOC_TILE.DOOR_OPEN, + LOC_TILE.PATH, + LOC_TILE.ROAD, + ]; + + for (const tile of nonWallTiles) { + it(`returns false for non-wall tile ${tile}`, () => { + expect(isWallTile(tile)).toBe(false); + }); + } +}); + +// ─── computeWallChar ───────────────────────────────────────────────────────── + +/** + * Helper: creates a 3×3 grid where the center tile is a WALL and + * the surrounding tiles are controlled by the N/S/E/W booleans. + * + * Bitmask encoding used in computeWallChar: N=8, S=4, E=2, W=1 + */ +function makeGrid(n: boolean, s: boolean, e: boolean, w: boolean): Uint8Array { + // 3×3 layout (indices 0..8), center at index 4 + // [0][1][2] + // [3][4][5] + // [6][7][8] + const tiles = new Uint8Array(9).fill(LOC_TILE.FLOOR); + tiles[4] = LOC_TILE.WALL; // center tile + if (n) tiles[1] = LOC_TILE.WALL; // north + if (s) tiles[7] = LOC_TILE.WALL; // south + if (e) tiles[5] = LOC_TILE.WALL; // east + if (w) tiles[3] = LOC_TILE.WALL; // west + return tiles; +} + +function wallChar(n: boolean, s: boolean, e: boolean, w: boolean): string { + return computeWallChar(makeGrid(n, s, e, w), 1, 1, 3, 3); +} + +describe('computeWallChar(tiles, x, y, w, h)', () => { + it('mask 0 — isolated wall returns "·"', () => { + expect(wallChar(false, false, false, false)).toBe('·'); + }); + + it('mask 3 — E+W (horizontal run) returns "─"', () => { + expect(wallChar(false, false, true, true)).toBe('─'); + }); + + it('mask 12 — N+S (vertical run) returns "│"', () => { + expect(wallChar(true, true, false, false)).toBe('│'); + }); + + it('mask 6 — S+E (top-left rounded corner) returns "╭"', () => { + expect(wallChar(false, true, true, false)).toBe('╭'); + }); + + it('mask 5 — S+W (top-right rounded corner) returns "╮"', () => { + expect(wallChar(false, true, false, true)).toBe('╮'); + }); + + it('mask 10 — N+E (bottom-left rounded corner) returns "╰"', () => { + expect(wallChar(true, false, true, false)).toBe('╰'); + }); + + it('mask 9 — N+W (bottom-right rounded corner) returns "╯"', () => { + expect(wallChar(true, false, false, true)).toBe('╯'); + }); + + it('mask 7 — S+E+W (T pointing down) returns "┬"', () => { + expect(wallChar(false, true, true, true)).toBe('┬'); + }); + + it('mask 11 — N+E+W (T pointing up) returns "┴"', () => { + expect(wallChar(true, false, true, true)).toBe('┴'); + }); + + it('mask 13 — N+S+W (T pointing left) returns "┤"', () => { + expect(wallChar(true, true, false, true)).toBe('┤'); + }); + + it('mask 14 — N+S+E (T pointing right) returns "├"', () => { + expect(wallChar(true, true, true, false)).toBe('├'); + }); + + it('mask 15 — all neighbors (cross) returns "┼"', () => { + expect(wallChar(true, true, true, true)).toBe('┼'); + }); + + it('mask 1 — W only returns "─"', () => { + expect(wallChar(false, false, false, true)).toBe('─'); + }); + + it('mask 2 — E only returns "─"', () => { + expect(wallChar(false, false, true, false)).toBe('─'); + }); + + it('mask 4 — S only returns "│"', () => { + expect(wallChar(false, true, false, false)).toBe('│'); + }); + + it('mask 8 — N only returns "│"', () => { + expect(wallChar(true, false, false, false)).toBe('│'); + }); + + it('treats out-of-bounds neighbors as walls', () => { + // Place a single wall at corner (0,0) of a 1×1 grid + // All 4 neighbors are out-of-bounds → treated as walls → mask 15 → '┼' + const tiles = new Uint8Array([LOC_TILE.WALL]); + expect(computeWallChar(tiles, 0, 0, 1, 1)).toBe('┼'); + }); + + it('accepts a custom isWallFn', () => { + // Create a grid where all tiles are FLOOR, but the custom fn treats FLOOR as a wall + const tiles = new Uint8Array(9).fill(LOC_TILE.FLOOR); + tiles[4] = LOC_TILE.FLOOR; // center is "wall" according to custom fn + const customIsWall = (_tile: number) => true; // everything is a wall + // All neighbors are walls → mask 15 → '┼' + expect(computeWallChar(tiles, 1, 1, 3, 3, customIsWall)).toBe('┼'); + }); +}); diff --git a/tests/unit/combat.test.ts b/tests/unit/combat.test.ts new file mode 100644 index 0000000..ba0350f --- /dev/null +++ b/tests/unit/combat.test.ts @@ -0,0 +1,496 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { RNG } from '../../js/engine/rng'; + +// Mock monster/item data to avoid loading all game data +vi.mock('../../js/data/monsters', () => ({ + getMonster: (id: string) => { + const monsters: Record = { + giant_rat: { + id: 'giant_rat', name: 'Giant Rat', symbol: 'r', fg: 6, + hp: [4, 8], atk: [2, 4], def: 0, xp: 14, gold: [0, 3], + loot: [{ id: 'rat_tail', chance: 30 }], abilities: [], isBoss: false, + flavorText: ['It hisses.'], + }, + boss_rat: { + id: 'boss_rat', name: 'Boss Rat', symbol: 'R', fg: 12, + hp: [20, 30], atk: [5, 10], def: 2, xp: 100, gold: [10, 20], + loot: [], abilities: [], isBoss: true, flavorText: [], + }, + }; + return monsters[id] ?? null; + }, +})); + +vi.mock('../../js/data/items', () => ({ + getItem: (id: string) => { + const items: Record = { + short_sword: { id: 'short_sword', name: 'Short Sword', dmg: [3, 7], type: 'weapon' }, + leather_armor: { id: 'leather_armor', name: 'Leather Armor', def: 2, type: 'armor' }, + }; + return items[id] ?? null; + }, +})); + +import { + spawnMonster, + spawnEncounter, + initCombat, + playerAttack, + playerFlee, + applyRewards, + xpToLevel, + startPlayerTurn, + getActiveMonster, +} from '../../js/systems/combat'; + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +function makePlayer(overrides = {}) { + return { + name: 'Hero', + race: 'human', + background: 'warrior', + level: 1, + hp: 20, + maxHp: 20, + mp: 10, + maxMp: 10, + xp: 0, + gold: 0, + stats: { str: 10, dex: 10, con: 10, int: 10, wis: 10 }, + skills: [] as string[], + equipment: { weapon: null, armor: null, offhand: null, helmet: null, accessory: null }, + inventory: [] as object[], + statusEffects: [] as object[], + fortifyTurns: 0, + ...overrides, + }; +} + +function makeMonster(overrides = {}) { + return { + id: 'giant_rat', + name: 'Giant Rat', + symbol: 'r', + fg: 6, + hp: 6, + maxHp: 6, + atk: [2, 4], + def: 0, + xp: 14, + gold: 3, + loot: [] as object[], + abilities: [] as object[], + isBoss: false, + statusEffects: [] as object[], + usedLucky: false, + currentPhase: 0, + ...overrides, + }; +} + +// ─── spawnMonster ───────────────────────────────────────────────────────────── + +describe('spawnMonster(id, rng)', () => { + it('returns null for an unknown monster id', () => { + const rng = new RNG(1); + expect(spawnMonster('unknown_beast', rng)).toBeNull(); + }); + + it('creates a monster with HP in template range', () => { + const rng = new RNG(42); + const m = spawnMonster('giant_rat', rng); + expect(m).not.toBeNull(); + expect(m.hp).toBeGreaterThanOrEqual(4); + expect(m.hp).toBeLessThanOrEqual(8); + expect(m.maxHp).toBe(m.hp); + }); + + it('creates a monster with gold in template range', () => { + const rng = new RNG(42); + const m = spawnMonster('giant_rat', rng); + expect(m.gold).toBeGreaterThanOrEqual(0); + expect(m.gold).toBeLessThanOrEqual(3); + }); + + it('initializes statusEffects to empty array', () => { + const rng = new RNG(1); + const m = spawnMonster('giant_rat', rng); + expect(m.statusEffects).toEqual([]); + }); + + it('copies atk and def from template', () => { + const rng = new RNG(1); + const m = spawnMonster('giant_rat', rng); + expect(m.atk).toEqual([2, 4]); + expect(m.def).toBe(0); + }); + + it('sets isBoss correctly from template', () => { + const rng = new RNG(1); + expect(spawnMonster('giant_rat', rng).isBoss).toBe(false); + expect(spawnMonster('boss_rat', new RNG(1)).isBoss).toBe(true); + }); +}); + +// ─── spawnEncounter ─────────────────────────────────────────────────────────── + +describe('spawnEncounter(biome, dangerLevel, rng)', () => { + it('returns null for a biome with empty monster pool', () => { + const rng = new RNG(1); + expect(spawnEncounter({ monsters: [] }, 1, rng)).toBeNull(); + }); + + it('returns null when biome is null', () => { + const rng = new RNG(1); + expect(spawnEncounter(null, 1, rng)).toBeNull(); + }); + + it('returns an array of monsters', () => { + const rng = new RNG(1); + const result = spawnEncounter({ monsters: ['giant_rat'] }, 1, rng); + expect(Array.isArray(result)).toBe(true); + expect(result.length).toBeGreaterThanOrEqual(1); + }); + + it('does not scale HP at danger level 1', () => { + // Use a fixed seed where chance(30) is false to guarantee single spawn + // We'll test several monsters and verify all have HP in template range [4,8] + const biome = { monsters: ['giant_rat'] }; + for (let seed = 1; seed <= 20; seed++) { + const result = spawnEncounter(biome, 1, new RNG(seed)); + if (result) { + for (const m of result) { + expect(m.hp).toBeGreaterThanOrEqual(4); + expect(m.hp).toBeLessThanOrEqual(8); + } + } + } + }); + + it('scales HP upward at danger level 3', () => { + // At danger 3: hp * (1 + 2*0.3) = hp * 1.6 + // Giant rat base hp max is 8, so scaled max would be 12+ + const biome = { monsters: ['giant_rat'] }; + let foundScaled = false; + for (let seed = 1; seed <= 30; seed++) { + const result = spawnEncounter(biome, 3, new RNG(seed)); + if (result && result.length > 0) { + const m = result[0]; + if (m.hp > 8) { + foundScaled = true; + break; + } + } + } + expect(foundScaled).toBe(true); + }); +}); + +// ─── initCombat ─────────────────────────────────────────────────────────────── + +describe('initCombat(player, monsters, biome, rng)', () => { + it('starts with PLAYER_TURN state and turn 0', () => { + const rng = new RNG(1); + const player = makePlayer(); + const monsters = [makeMonster()]; + const combat = initCombat(player, monsters, null, rng); + expect(combat.state).toBe('player_turn'); + expect(combat.turn).toBe(0); + }); + + it('sets bossMode to false when no boss present', () => { + const rng = new RNG(1); + const combat = initCombat(makePlayer(), [makeMonster()], null, rng); + expect(combat.bossMode).toBe(false); + }); + + it('sets bossMode to true when a boss monster is present', () => { + const rng = new RNG(1); + const boss = makeMonster({ isBoss: true }); + const combat = initCombat(makePlayer(), [boss], null, rng); + expect(combat.bossMode).toBe(true); + }); + + it('initializes empty log', () => { + const rng = new RNG(1); + const combat = initCombat(makePlayer(), [makeMonster()], null, rng); + // log may have a message if tracking surprises; just check it's an array + expect(Array.isArray(combat.log)).toBe(true); + }); + + it('sets canFlee to true', () => { + const rng = new RNG(1); + const combat = initCombat(makePlayer(), [makeMonster()], null, rng); + expect(combat.canFlee).toBe(true); + }); +}); + +// ─── playerAttack ───────────────────────────────────────────────────────────── + +describe('playerAttack(combat)', () => { + function makeCombat(playerOverrides = {}, monsterOverrides = {}, seed = 42) { + // Use a deterministic RNG — seed 42 results in a hit (not a miss) + const rng = new RNG(seed); + const player = makePlayer(playerOverrides); + const monster = makeMonster(monsterOverrides); + const combat = initCombat(player, [monster], null, rng); + // Reset log and turn to avoid tracking surprises confusing tests + combat.log = []; + combat.turn = 1; // past turn 0 so no backstab bonus + combat._enemySurprised = false; + return { combat, player, monster }; + } + + it('deals at least 1 damage on a hit', () => { + // Use a seed where the player hits; iterate until we find one + for (let seed = 1; seed <= 50; seed++) { + const rng = new RNG(seed); + const player = makePlayer(); + const monster = makeMonster({ hp: 100, maxHp: 100, def: 0 }); + const combat = initCombat(player, [monster], null, rng); + combat.log = []; + const prevHp = monster.hp; + playerAttack(combat); + const didHit = !combat.log.some((e: { msg: string }) => e.msg.includes('miss')); + if (didHit) { + expect(monster.hp).toBeLessThan(prevHp); + expect(prevHp - monster.hp).toBeGreaterThanOrEqual(1); + break; + } + } + }); + + it('does not reduce monster HP on a miss', () => { + // Force a miss by setting a seed where chance(10) is true for miss + // Use a high dex penalty so miss chance is high + let missFound = false; + for (let seed = 1; seed <= 200; seed++) { + const rng = new RNG(seed); + const player = makePlayer({ stats: { str: 10, dex: 1, con: 10, int: 10, wis: 10 } }); // low dex = high miss + const monster = makeMonster({ hp: 100, maxHp: 100 }); + const combat = initCombat(player, [monster], null, rng); + combat.log = []; + const prevHp = monster.hp; + playerAttack(combat); + const wasMiss = combat.log.some((e: { msg: string }) => e.msg.includes('miss')); + if (wasMiss) { + expect(monster.hp).toBe(prevHp); + missFound = true; + break; + } + } + expect(missFound).toBe(true); + }); + + it('transitions to VICTORY state when last monster dies', () => { + // Give monster 1 HP so any hit kills it + for (let seed = 1; seed <= 100; seed++) { + const rng = new RNG(seed); + const player = makePlayer({ stats: { str: 20, dex: 10, con: 10, int: 10, wis: 10 } }); + const monster = makeMonster({ hp: 1, maxHp: 1, def: 0 }); + const combat = initCombat(player, [monster], null, rng); + combat.log = []; + playerAttack(combat); + const wasMiss = combat.log.some((e: { msg: string }) => e.msg.includes('miss')); + if (!wasMiss) { + expect(combat.state).toBe('victory'); + break; + } + } + }); + + it('keeps PLAYER_TURN when other monsters are still alive', () => { + for (let seed = 1; seed <= 100; seed++) { + const rng = new RNG(seed); + const player = makePlayer({ stats: { str: 20, dex: 10, con: 10, int: 10, wis: 10 } }); + const m1 = makeMonster({ hp: 1, maxHp: 1, def: 0 }); + const m2 = makeMonster({ hp: 50, maxHp: 50 }); + const combat = initCombat(player, [m1, m2], null, rng); + combat.log = []; + playerAttack(combat); + const wasMiss = combat.log.some((e: { msg: string }) => e.msg.includes('miss')); + if (!wasMiss && m1.hp <= 0) { + // Still has living monsters, should stay in player turn + expect(combat.state).toBe('player_turn'); + break; + } + } + }); + + it('stealth backstab on turn 0 is logged', () => { + for (let seed = 1; seed <= 100; seed++) { + const rng = new RNG(seed); + const player = makePlayer({ skills: ['stealth'] }); + const monster = makeMonster({ hp: 50, maxHp: 50, def: 0 }); + const combat = initCombat(player, [monster], null, rng); + combat.log = []; + combat.turn = 0; + playerAttack(combat); + const backstabLogged = combat.log.some((e: { msg: string }) => e.msg.includes('shadows')); + if (backstabLogged) { + expect(backstabLogged).toBe(true); + break; + } + } + }); + + it('power_strike costs 2 MP and is logged', () => { + for (let seed = 1; seed <= 100; seed++) { + const rng = new RNG(seed); + const player = makePlayer({ mp: 10 }); + const monster = makeMonster({ hp: 50, maxHp: 50, def: 0 }); + const combat = initCombat(player, [monster], null, rng); + combat.log = []; + combat.turn = 1; + const prevMp = player.mp; + playerAttack(combat, 'power_strike'); + const wasMiss = combat.log.some((e: { msg: string }) => e.msg.includes('miss')); + if (!wasMiss) { + expect(player.mp).toBe(prevMp - 2); + expect(combat.log.some((e: { msg: string }) => e.msg.includes('powerful'))).toBe(true); + break; + } + } + }); +}); + +// ─── playerFlee ─────────────────────────────────────────────────────────────── + +describe('playerFlee(combat)', () => { + it('sets state to DEFEAT and fled=true on a successful flee', () => { + // With chance(40), roughly 40% will flee. Try enough seeds to find one. + let found = false; + for (let seed = 1; seed <= 200; seed++) { + const rng = new RNG(seed); + const combat = initCombat(makePlayer(), [makeMonster()], null, rng); + combat.log = []; + playerFlee(combat); + if (combat.fled === true) { + expect(combat.state).toBe('defeat'); + found = true; + break; + } + } + expect(found).toBe(true); + }); + + it('logs a failure message when flee fails', () => { + let found = false; + for (let seed = 1; seed <= 200; seed++) { + const rng = new RNG(seed); + const combat = initCombat(makePlayer(), [makeMonster()], null, rng); + combat.log = []; + playerFlee(combat); + if (!combat.fled) { + expect(combat.log.some((e: { msg: string }) => e.msg.toLowerCase().includes('fail'))).toBe(true); + found = true; + break; + } + } + expect(found).toBe(true); + }); +}); + +// ─── applyRewards ───────────────────────────────────────────────────────────── + +describe('applyRewards(player, combat)', () => { + it('adds gold and xp to player', () => { + const player = makePlayer({ gold: 10, xp: 0 }); + const rng = new RNG(1); + const monster = makeMonster({ xp: 14, gold: 3, loot: [] }); + const combat = initCombat(player, [monster], null, rng); + combat.totalGold = 5; + combat.totalXp = 20; + combat.lootItems = []; + applyRewards(player, combat); + expect(player.gold).toBe(15); + // human race gets 1.10× XP multiplier → Math.floor(20 * 1.10) = 22 + expect(player.xp).toBe(22); + }); + + it('triggers level up when xp threshold is reached', () => { + const xpNeeded = xpToLevel(2); + const player = makePlayer({ level: 1, xp: xpNeeded - 10, hp: 20, maxHp: 20, mp: 5, maxMp: 5 }); + const rng = new RNG(1); + const combat = initCombat(player, [makeMonster()], null, rng); + combat.totalGold = 0; + combat.totalXp = 20; + combat.lootItems = []; + const result = applyRewards(player, combat); + expect(result.leveled).toBe(true); + expect(player.level).toBe(2); + }); + + it('does not level up when xp threshold is not reached', () => { + const player = makePlayer({ level: 1, xp: 0 }); + const rng = new RNG(1); + const combat = initCombat(player, [makeMonster()], null, rng); + combat.totalGold = 0; + combat.totalXp = 5; + combat.lootItems = []; + const result = applyRewards(player, combat); + expect(result.leveled).toBe(false); + expect(player.level).toBe(1); + }); +}); + +// ─── xpToLevel ──────────────────────────────────────────────────────────────── + +describe('xpToLevel(level)', () => { + it('returns a positive number for level 2', () => { + expect(xpToLevel(2)).toBeGreaterThan(0); + }); + + it('requires more XP for each successive level', () => { + expect(xpToLevel(3)).toBeGreaterThan(xpToLevel(2)); + expect(xpToLevel(5)).toBeGreaterThan(xpToLevel(4)); + expect(xpToLevel(10)).toBeGreaterThan(xpToLevel(9)); + }); +}); + +// ─── startPlayerTurn ───────────────────────────────────────────────────────── + +describe('startPlayerTurn(combat)', () => { + it('returns false normally (no skip)', () => { + const rng = new RNG(1); + const combat = initCombat(makePlayer(), [makeMonster()], null, rng); + expect(startPlayerTurn(combat)).toBe(false); + }); + + it('returns true and clears playerSkipTurn when set', () => { + const rng = new RNG(1); + const combat = initCombat(makePlayer(), [makeMonster()], null, rng); + combat.playerSkipTurn = true; + expect(startPlayerTurn(combat)).toBe(true); + expect(combat.playerSkipTurn).toBe(false); + }); + + it('processes poison damage on player', () => { + const player = makePlayer({ hp: 20 }); + player.statusEffects = [{ type: 'poison', turns: 2, dmg: 3 }]; + const rng = new RNG(1); + const combat = initCombat(player, [makeMonster()], null, rng); + startPlayerTurn(combat); + expect(player.hp).toBe(17); // 20 - 3 poison damage + }); +}); + +// ─── getActiveMonster ───────────────────────────────────────────────────────── + +describe('getActiveMonster(combat)', () => { + it('returns the first living monster', () => { + const rng = new RNG(1); + const m1 = makeMonster({ hp: 0 }); + const m2 = makeMonster({ hp: 5 }); + const combat = initCombat(makePlayer(), [m1, m2], null, rng); + expect(getActiveMonster(combat)).toBe(m2); + }); + + it('returns null when all monsters are dead', () => { + const rng = new RNG(1); + const m = makeMonster({ hp: 0 }); + const combat = initCombat(makePlayer(), [m], null, rng); + expect(getActiveMonster(combat)).toBeNull(); + }); +}); diff --git a/tests/unit/quest.test.ts b/tests/unit/quest.test.ts new file mode 100644 index 0000000..be4d7e8 --- /dev/null +++ b/tests/unit/quest.test.ts @@ -0,0 +1,348 @@ +import { describe, it, expect, vi } from 'vitest'; + +// Mock items module to avoid loading full game data +vi.mock('../../js/data/items', () => ({ + getItem: (id: string) => { + const items: Record = { + healing_potion: { id: 'healing_potion', name: 'Healing Potion', type: 'consumable' }, + letter: { id: 'letter', name: 'Letter', type: 'quest' }, + }; + return items[id] ?? null; + }, + addToInventory: (player: { inventory: { id: string; qty: number }[] }, itemId: string, qty = 1) => { + const existing = player.inventory.find((i) => i.id === itemId); + if (existing) existing.qty += qty; + else player.inventory.push({ id: itemId, qty }); + }, +})); + +import { + onMonsterKilled, + onItemPickedUp, + onLocationVisited, + acceptQuest, + turnInQuest, + getActiveQuests, + getAvailableQuestsAt, + getCompletedQuestsAt, + checkGoalProgress, +} from '../../js/systems/quest'; + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +function makeQuest(overrides = {}) { + return { + id: 'q_0', + templateId: 'slay_rats', + type: 'slay', + title: 'Rat Problem', + status: 'available', + dangerLevel: 1, + giverLocId: 'town_1', + giverLocName: 'Maplewood', + reward: { gold: 50, xp: 30, items: [] }, + progress: 0, + progressMax: 3, + targetMonster: 'giant_rat', + targetLocId: null, + ...overrides, + }; +} + +function makePlayer(overrides = {}) { + return { + gold: 0, + xp: 0, + level: 1, + inventory: [] as { id: string; qty: number; questId?: string }[], + ...overrides, + }; +} + +// ─── onMonsterKilled ────────────────────────────────────────────────────────── + +describe('onMonsterKilled(quests, monsterId)', () => { + it('returns null when there are no active quests', () => { + const quests = [makeQuest({ status: 'available' })]; + expect(onMonsterKilled(quests, 'giant_rat')).toBeNull(); + }); + + it('returns null when monster does not match quest target', () => { + const quests = [makeQuest({ status: 'active', targetMonster: 'giant_rat' })]; + expect(onMonsterKilled(quests, 'wolf')).toBeNull(); + }); + + it('increments progress when correct monster is killed', () => { + const quest = makeQuest({ status: 'active', progress: 0, progressMax: 3 }); + onMonsterKilled([quest], 'giant_rat'); + expect(quest.progress).toBe(1); + }); + + it('returns null and keeps ACTIVE status until progressMax reached', () => { + const quest = makeQuest({ status: 'active', progress: 1, progressMax: 3 }); + const result = onMonsterKilled([quest], 'giant_rat'); + expect(result).toBeNull(); + expect(quest.status).toBe('active'); + expect(quest.progress).toBe(2); + }); + + it('returns the quest and marks it COMPLETED when progressMax reached', () => { + const quest = makeQuest({ status: 'active', progress: 2, progressMax: 3 }); + const result = onMonsterKilled([quest], 'giant_rat'); + expect(result).toBe(quest); + expect(quest.status).toBe('completed'); + }); +}); + +// ─── onItemPickedUp ─────────────────────────────────────────────────────────── + +describe('onItemPickedUp(quests, itemId)', () => { + it('returns empty array when no active fetch quests', () => { + const quests = [makeQuest({ status: 'available', type: 'fetch', targetItem: 'healing_potion' })]; + expect(onItemPickedUp(quests, 'healing_potion')).toEqual([]); + }); + + it('returns empty array when item does not match', () => { + const quests = [makeQuest({ status: 'active', type: 'fetch', targetItem: 'healing_potion' })]; + expect(onItemPickedUp(quests, 'wolf_pelt')).toEqual([]); + }); + + it('increments progress for matching active fetch quest', () => { + const quest = makeQuest({ status: 'active', type: 'fetch', targetItem: 'healing_potion', progress: 0, progressMax: 2 }); + onItemPickedUp([quest], 'healing_potion'); + expect(quest.progress).toBe(1); + }); + + it('returns completed quests when progressMax reached', () => { + const quest = makeQuest({ status: 'active', type: 'fetch', targetItem: 'healing_potion', progress: 1, progressMax: 2 }); + const result = onItemPickedUp([quest], 'healing_potion'); + expect(result).toHaveLength(1); + expect(result[0]).toBe(quest); + expect(quest.status).toBe('completed'); + }); + + it('can complete multiple quests at once', () => { + const q1 = makeQuest({ id: 'q_0', status: 'active', type: 'fetch', targetItem: 'wolf_pelt', progress: 0, progressMax: 1 }); + const q2 = makeQuest({ id: 'q_1', status: 'active', type: 'fetch', targetItem: 'wolf_pelt', progress: 0, progressMax: 1 }); + const result = onItemPickedUp([q1, q2], 'wolf_pelt'); + expect(result).toHaveLength(2); + }); +}); + +// ─── onLocationVisited ──────────────────────────────────────────────────────── + +describe('onLocationVisited(quests, locId)', () => { + it('does not update slay quests', () => { + const quest = makeQuest({ status: 'active', type: 'slay', targetLocId: 'dungeon_1' }); + onLocationVisited([quest], 'dungeon_1'); + expect(quest.progress).toBe(0); + }); + + it('updates investigate quests when location matches', () => { + const quest = makeQuest({ status: 'active', type: 'investigate', targetLocId: 'dungeon_1', progressMax: 1 }); + const result = onLocationVisited([quest], 'dungeon_1'); + expect(result).toHaveLength(1); + expect(quest.status).toBe('completed'); + }); + + it('updates clear quests when location matches', () => { + const quest = makeQuest({ status: 'active', type: 'clear', targetLocId: 'ruins_1', progressMax: 1 }); + onLocationVisited([quest], 'ruins_1'); + expect(quest.status).toBe('completed'); + }); + + it('does not update when location does not match', () => { + const quest = makeQuest({ status: 'active', type: 'investigate', targetLocId: 'dungeon_1', progressMax: 1 }); + onLocationVisited([quest], 'dungeon_99'); + expect(quest.status).toBe('active'); + }); +}); + +// ─── acceptQuest ────────────────────────────────────────────────────────────── + +describe('acceptQuest(quests, questId, player)', () => { + it('returns false for unknown quest id', () => { + const player = makePlayer(); + expect(acceptQuest([], 'nonexistent', player)).toBe(false); + }); + + it('returns false when quest is not available', () => { + const quest = makeQuest({ status: 'active' }); + const player = makePlayer(); + expect(acceptQuest([quest], 'q_0', player)).toBe(false); + }); + + it('returns false when MAX_ACTIVE_QUESTS already reached', () => { + const available = makeQuest({ id: 'q_new', status: 'available' }); + const active = Array.from({ length: 5 }, (_, i) => + makeQuest({ id: `q_${i}`, status: 'active' }) + ); + const player = makePlayer(); + expect(acceptQuest([...active, available], 'q_new', player)).toBe(false); + }); + + it('sets quest status to ACTIVE', () => { + const quest = makeQuest({ status: 'available' }); + const player = makePlayer(); + const result = acceptQuest([quest], 'q_0', player); + expect(result).toBe(true); + expect(quest.status).toBe('active'); + }); + + it('adds letter to inventory for deliver quest type', () => { + const quest = makeQuest({ status: 'available', type: 'deliver', carryItem: 'letter' }); + const player = makePlayer(); + acceptQuest([quest], 'q_0', player); + const letter = player.inventory.find((i) => i.id === 'letter'); + expect(letter).toBeDefined(); + expect(letter?.questId).toBe('q_0'); + }); +}); + +// ─── turnInQuest ────────────────────────────────────────────────────────────── + +describe('turnInQuest(quests, questId, player)', () => { + it('returns null for unknown quest id', () => { + const player = makePlayer(); + expect(turnInQuest([], 'nonexistent', player)).toBeNull(); + }); + + it('returns null when quest is not completed', () => { + const quest = makeQuest({ status: 'active' }); + const player = makePlayer(); + expect(turnInQuest([quest], 'q_0', player)).toBeNull(); + }); + + it('awards gold and xp to the player', () => { + const quest = makeQuest({ status: 'completed', reward: { gold: 50, xp: 30, items: [] } }); + const player = makePlayer({ gold: 10, xp: 5 }); + turnInQuest([quest], 'q_0', player); + expect(player.gold).toBe(60); + expect(player.xp).toBe(35); + }); + + it('sets quest status to TURNED_IN', () => { + const quest = makeQuest({ status: 'completed', reward: { gold: 0, xp: 0, items: [] } }); + const player = makePlayer(); + turnInQuest([quest], 'q_0', player); + expect(quest.status).toBe('turned_in'); + }); + + it('returns the reward object', () => { + const reward = { gold: 50, xp: 30, items: [] }; + const quest = makeQuest({ status: 'completed', reward }); + const player = makePlayer(); + const result = turnInQuest([quest], 'q_0', player); + expect(result).toEqual(reward); + }); + + it('removes carry item from inventory on turn-in', () => { + const quest = makeQuest({ status: 'completed', reward: { gold: 0, xp: 0, items: [] }, carryItem: 'letter' }); + const player = makePlayer({ + inventory: [{ id: 'letter', qty: 1, questId: 'q_0' }], + }); + turnInQuest([quest], 'q_0', player); + const letter = player.inventory.find((i) => i.id === 'letter'); + expect(letter).toBeUndefined(); + }); +}); + +// ─── getActiveQuests / getAvailableQuestsAt / getCompletedQuestsAt ──────────── + +describe('getActiveQuests(quests)', () => { + it('returns only active quests', () => { + const quests = [ + makeQuest({ id: 'q_0', status: 'available' }), + makeQuest({ id: 'q_1', status: 'active' }), + makeQuest({ id: 'q_2', status: 'completed' }), + ]; + const active = getActiveQuests(quests); + expect(active).toHaveLength(1); + expect(active[0].id).toBe('q_1'); + }); +}); + +describe('getAvailableQuestsAt(quests, locId)', () => { + it('returns only available quests at the given location', () => { + const quests = [ + makeQuest({ id: 'q_0', status: 'available', giverLocId: 'town_1' }), + makeQuest({ id: 'q_1', status: 'available', giverLocId: 'town_2' }), + makeQuest({ id: 'q_2', status: 'active', giverLocId: 'town_1' }), + ]; + const result = getAvailableQuestsAt(quests, 'town_1', 'npc'); + expect(result).toHaveLength(1); + expect(result[0].id).toBe('q_0'); + }); +}); + +describe('getCompletedQuestsAt(quests, locId)', () => { + it('returns only completed quests at the given location', () => { + const quests = [ + makeQuest({ id: 'q_0', status: 'completed', giverLocId: 'town_1' }), + makeQuest({ id: 'q_1', status: 'completed', giverLocId: 'town_2' }), + makeQuest({ id: 'q_2', status: 'active', giverLocId: 'town_1' }), + ]; + const result = getCompletedQuestsAt(quests, 'town_1'); + expect(result).toHaveLength(1); + expect(result[0].id).toBe('q_0'); + }); +}); + +// ─── checkGoalProgress ──────────────────────────────────────────────────────── + +describe('checkGoalProgress(goal, player, world)', () => { + it('returns null when goal is null', () => { + expect(checkGoalProgress(null, makePlayer(), {})).toBeNull(); + }); + + it('returns null when goal is already completed', () => { + const goal = { completed: true, steps: [] }; + expect(checkGoalProgress(goal, makePlayer(), {})).toBeNull(); + }); + + it('returns victory event when player.defeatedBoss is true', () => { + const goal = { + completed: false, + steps: [{ id: 'defeat_boss', done: false }], + }; + const player = makePlayer({ defeatedBoss: true }); + const result = checkGoalProgress(goal, player, {}); + expect(result).toEqual({ event: 'victory' }); + expect(goal.completed).toBe(true); + }); + + it('does not return victory when boss step already done', () => { + const goal = { + completed: false, + steps: [{ id: 'defeat_boss', done: true }], + }; + const player = makePlayer({ defeatedBoss: true }); + const result = checkGoalProgress(goal, player, {}); + expect(result).toBeNull(); + }); + + it('returns goal_step event when enough key items found', () => { + const goal = { + completed: false, + keyItem: { name: 'Shard', count: 2 }, + keyItemLocations: [{ found: true }, { found: true }, { found: false }], + steps: [{ count: 0, done: false }], + currentStep: 0, + }; + const result = checkGoalProgress(goal, makePlayer(), {}); + expect(result?.event).toBe('goal_step'); + expect(goal.steps[0].done).toBe(true); + }); + + it('returns null when not enough key items found', () => { + const goal = { + completed: false, + keyItem: { name: 'Shard', count: 3 }, + keyItemLocations: [{ found: true }, { found: false }, { found: false }], + steps: [{ count: 0, done: false }], + currentStep: 0, + }; + const result = checkGoalProgress(goal, makePlayer(), {}); + expect(result).toBeNull(); + }); +}); diff --git a/tests/unit/rng.test.ts b/tests/unit/rng.test.ts new file mode 100644 index 0000000..8e0ded0 --- /dev/null +++ b/tests/unit/rng.test.ts @@ -0,0 +1,252 @@ +import { describe, it, expect } from 'vitest'; +import { RNG } from '../../js/engine/rng'; + +describe('RNG', () => { + describe('constructor', () => { + it('accepts a seed', () => { + const rng = new RNG(42); + expect(rng.seed).toBe(42); + }); + + it('uses fallback seed for 0', () => { + const rng = new RNG(0); + expect(rng.seed).toBe(0xDEADBEEF); + }); + }); + + describe('next()', () => { + it('returns a value in [0, 1)', () => { + const rng = new RNG(1); + for (let i = 0; i < 100; i++) { + const v = rng.next(); + expect(v).toBeGreaterThanOrEqual(0); + expect(v).toBeLessThan(1); + } + }); + + it('is deterministic for the same seed', () => { + const r1 = new RNG(999); + const r2 = new RNG(999); + for (let i = 0; i < 20; i++) { + expect(r1.next()).toBe(r2.next()); + } + }); + + it('produces different sequences for different seeds', () => { + const r1 = new RNG(1); + const r2 = new RNG(2); + const vals1 = Array.from({ length: 10 }, () => r1.next()); + const vals2 = Array.from({ length: 10 }, () => r2.next()); + expect(vals1).not.toEqual(vals2); + }); + }); + + describe('int(min, max)', () => { + it('returns a value in [min, max] inclusive', () => { + const rng = new RNG(42); + for (let i = 0; i < 200; i++) { + const v = rng.int(3, 7); + expect(v).toBeGreaterThanOrEqual(3); + expect(v).toBeLessThanOrEqual(7); + } + }); + + it('returns min when min === max', () => { + const rng = new RNG(42); + for (let i = 0; i < 10; i++) { + expect(rng.int(5, 5)).toBe(5); + } + }); + + it('returns an integer', () => { + const rng = new RNG(7); + for (let i = 0; i < 50; i++) { + const v = rng.int(0, 100); + expect(Number.isInteger(v)).toBe(true); + } + }); + }); + + describe('float(min, max)', () => { + it('returns a value in [min, max)', () => { + const rng = new RNG(42); + for (let i = 0; i < 200; i++) { + const v = rng.float(1.5, 3.5); + expect(v).toBeGreaterThanOrEqual(1.5); + expect(v).toBeLessThan(3.5); + } + }); + }); + + describe('pick(arr)', () => { + it('returns undefined for empty array', () => { + const rng = new RNG(1); + expect(rng.pick([])).toBeUndefined(); + }); + + it('returns the only element for a single-element array', () => { + const rng = new RNG(1); + expect(rng.pick(['only'])).toBe('only'); + }); + + it('returns elements from the array', () => { + const rng = new RNG(42); + const arr = ['a', 'b', 'c', 'd']; + for (let i = 0; i < 50; i++) { + expect(arr).toContain(rng.pick(arr)); + } + }); + }); + + describe('weightedPick(items)', () => { + it('always returns the single item when only one provided', () => { + const rng = new RNG(1); + for (let i = 0; i < 10; i++) { + expect(rng.weightedPick([{ value: 'x', weight: 5 }])).toBe('x'); + } + }); + + it('never selects an item with weight 0', () => { + const rng = new RNG(42); + const items = [ + { value: 'zero', weight: 0 }, + { value: 'positive', weight: 100 }, + ]; + for (let i = 0; i < 50; i++) { + expect(rng.weightedPick(items)).toBe('positive'); + } + }); + + it('favors higher-weighted items over many samples', () => { + const rng = new RNG(123); + const items = [ + { value: 'rare', weight: 1 }, + { value: 'common', weight: 99 }, + ]; + const counts: Record = { rare: 0, common: 0 }; + for (let i = 0; i < 1000; i++) { + counts[rng.weightedPick(items)]++; + } + expect(counts.common).toBeGreaterThan(counts.rare); + }); + }); + + describe('shuffle(arr)', () => { + it('preserves array length', () => { + const rng = new RNG(1); + const arr = [1, 2, 3, 4, 5]; + rng.shuffle(arr); + expect(arr).toHaveLength(5); + }); + + it('preserves all original elements', () => { + const rng = new RNG(7); + const arr = [10, 20, 30, 40, 50]; + const original = [...arr]; + rng.shuffle(arr); + expect(arr.sort()).toEqual(original.sort()); + }); + + it('is deterministic for the same seed', () => { + const arr1 = [1, 2, 3, 4, 5, 6, 7, 8]; + const arr2 = [...arr1]; + new RNG(55).shuffle(arr1); + new RNG(55).shuffle(arr2); + expect(arr1).toEqual(arr2); + }); + + it('produces different orders for different seeds', () => { + const arr1 = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; + const arr2 = [...arr1]; + new RNG(1).shuffle(arr1); + new RNG(2).shuffle(arr2); + expect(arr1).not.toEqual(arr2); + }); + }); + + describe('roll(n, s)', () => { + it('returns a value in [n, n*s]', () => { + const rng = new RNG(42); + for (let i = 0; i < 100; i++) { + const v = rng.roll(3, 6); // 3d6 → [3, 18] + expect(v).toBeGreaterThanOrEqual(3); + expect(v).toBeLessThanOrEqual(18); + } + }); + + it('returns 0 for 0 dice', () => { + const rng = new RNG(1); + expect(rng.roll(0, 6)).toBe(0); + }); + + it('returns an integer', () => { + const rng = new RNG(42); + expect(Number.isInteger(rng.roll(2, 8))).toBe(true); + }); + }); + + describe('chance(percent)', () => { + it('always returns false for 0%', () => { + const rng = new RNG(42); + for (let i = 0; i < 50; i++) { + expect(rng.chance(0)).toBe(false); + } + }); + + it('always returns true for 100%', () => { + const rng = new RNG(42); + for (let i = 0; i < 50; i++) { + expect(rng.chance(100)).toBe(true); + } + }); + + it('returns a boolean', () => { + const rng = new RNG(42); + expect(typeof rng.chance(50)).toBe('boolean'); + }); + + it('roughly matches expected probability over many samples', () => { + const rng = new RNG(123); + let trueCount = 0; + const samples = 2000; + for (let i = 0; i < samples; i++) { + if (rng.chance(30)) trueCount++; + } + // Expect roughly 30% ± 5% + const pct = trueCount / samples; + expect(pct).toBeGreaterThan(0.25); + expect(pct).toBeLessThan(0.35); + }); + }); + + describe('word()', () => { + it('returns a non-empty string', () => { + const rng = new RNG(1); + const w = rng.word(); + expect(typeof w).toBe('string'); + expect(w.length).toBeGreaterThan(0); + }); + + it('starts with an uppercase letter', () => { + const rng = new RNG(42); + for (let i = 0; i < 20; i++) { + const w = rng.word(); + expect(w[0]).toBe(w[0].toUpperCase()); + } + }); + + it('respects minSyl=1 maxSyl=1 (short word)', () => { + const rng = new RNG(7); + for (let i = 0; i < 20; i++) { + const w = rng.word(1, 1); + // With 1 syllable, the word should be shorter than with 3 syllables + expect(w.length).toBeGreaterThan(0); + expect(w.length).toBeLessThanOrEqual(6); // onset(2) + nucleus(2) + coda(2) + } + }); + + it('is deterministic with the same seed', () => { + expect(new RNG(100).word()).toBe(new RNG(100).word()); + }); + }); +}); diff --git a/tests/unit/worldgen.test.ts b/tests/unit/worldgen.test.ts new file mode 100644 index 0000000..ea2e1b1 --- /dev/null +++ b/tests/unit/worldgen.test.ts @@ -0,0 +1,226 @@ +import { describe, it, expect } from 'vitest'; +import { + updateFog, + clearFogAroundLocation, + getBiomeAt, + getLocationAt, + generateWorld, +} from '../../js/world/worldgen'; + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +/** Create a minimal flat world with a fog array */ +function makeWorld(width = 10, height = 10) { + const tiles = new Uint8Array(width * height).fill(3); // GRASSLAND = 3 + const fog = new Uint8Array(width * height).fill(0); // all unseen + return { width, height, tiles, fog, locations: [] }; +} + +// ─── updateFog ──────────────────────────────────────────────────────────────── + +describe('updateFog(world, px, py, radius)', () => { + it('marks tiles within radius as visible (2)', () => { + const world = makeWorld(20, 20); + updateFog(world, 10, 10, 3); + // The center tile should be visible + expect(world.fog[10 * 20 + 10]).toBe(2); + }); + + it('marks previously visible (2) tiles as seen (1)', () => { + const world = makeWorld(20, 20); + // Pre-mark some tiles as visible + world.fog[5 * 20 + 5] = 2; + world.fog[6 * 20 + 6] = 2; + // Now move player far away — the old tiles should become seen (1) + updateFog(world, 18, 18, 2); + expect(world.fog[5 * 20 + 5]).toBe(1); + expect(world.fog[6 * 20 + 6]).toBe(1); + }); + + it('does not change unseen (0) tiles far from player', () => { + const world = makeWorld(20, 20); + // Position (0,0), radius 2 — tiles at (10,10) should stay at 0 + updateFog(world, 0, 0, 2); + expect(world.fog[10 * 20 + 10]).toBe(0); + }); + + it('does not crash when player is at the edge of the world', () => { + const world = makeWorld(10, 10); + expect(() => updateFog(world, 0, 0, 3)).not.toThrow(); + expect(() => updateFog(world, 9, 9, 3)).not.toThrow(); + }); + + it('reveals a circular area, not a square', () => { + const world = makeWorld(20, 20); + const radius = 4; + updateFog(world, 10, 10, radius); + + // A tile exactly at distance radius+1 should NOT be visible + // (10 + radius + 1, 10) — too far horizontally + const farTile = world.fog[10 * 20 + (10 + radius + 1)]; + expect(farTile).not.toBe(2); + + // Corner tile at (10+radius, 10+radius) should be outside circle but would be in a square + const cornerTile = world.fog[(10 + radius) * 20 + (10 + radius)]; + // distance = sqrt(r^2 + r^2) = r*sqrt(2) ≈ 5.66 > 4, so it should NOT be visible + expect(cornerTile).not.toBe(2); + }); +}); + +// ─── clearFogAroundLocation ─────────────────────────────────────────────────── + +describe('clearFogAroundLocation(world, x, y, radius)', () => { + it('sets unseen (0) tiles within radius to seen (1)', () => { + const world = makeWorld(20, 20); + clearFogAroundLocation(world, 10, 10, 3); + // Center tile should be seen + expect(world.fog[10 * 20 + 10]).toBe(1); + }); + + it('does not downgrade visible (2) tiles to seen (1)', () => { + const world = makeWorld(20, 20); + world.fog[10 * 20 + 10] = 2; // already visible + clearFogAroundLocation(world, 10, 10, 3); + expect(world.fog[10 * 20 + 10]).toBe(2); // should remain visible + }); + + it('does not downgrade already-seen (1) tiles', () => { + const world = makeWorld(20, 20); + world.fog[10 * 20 + 10] = 1; // already seen + clearFogAroundLocation(world, 10, 10, 3); + expect(world.fog[10 * 20 + 10]).toBe(1); // unchanged + }); + + it('does not affect tiles far outside the radius', () => { + const world = makeWorld(20, 20); + clearFogAroundLocation(world, 0, 0, 2); + // Tile at (10, 10) is way outside radius 2 of (0, 0) + expect(world.fog[10 * 20 + 10]).toBe(0); + }); + + it('does not crash at world edges', () => { + const world = makeWorld(10, 10); + expect(() => clearFogAroundLocation(world, 0, 0, 5)).not.toThrow(); + expect(() => clearFogAroundLocation(world, 9, 9, 5)).not.toThrow(); + }); +}); + +// ─── getBiomeAt ─────────────────────────────────────────────────────────────── + +describe('getBiomeAt(world, x, y)', () => { + it('returns null for out-of-bounds x', () => { + const world = makeWorld(10, 10); + expect(getBiomeAt(world, -1, 5)).toBeNull(); + expect(getBiomeAt(world, 10, 5)).toBeNull(); + }); + + it('returns null for out-of-bounds y', () => { + const world = makeWorld(10, 10); + expect(getBiomeAt(world, 5, -1)).toBeNull(); + expect(getBiomeAt(world, 5, 10)).toBeNull(); + }); + + it('returns a biome object for in-bounds coordinates', () => { + const world = makeWorld(10, 10); + const biome = getBiomeAt(world, 5, 5); + expect(biome).not.toBeNull(); + expect(typeof biome).toBe('object'); + }); +}); + +// ─── getLocationAt ──────────────────────────────────────────────────────────── + +describe('getLocationAt(world, x, y)', () => { + it('returns null when no locations exist', () => { + const world = makeWorld(10, 10); + expect(getLocationAt(world, 5, 5)).toBeNull(); + }); + + it('returns the location at exact coordinates', () => { + const world = makeWorld(10, 10); + const loc = { id: 'town_1', x: 3, y: 4, name: 'Testville', type: 'TOWN' }; + world.locations = [loc]; + expect(getLocationAt(world, 3, 4)).toBe(loc); + }); + + it('returns null when coordinates do not match any location', () => { + const world = makeWorld(10, 10); + world.locations = [{ id: 'town_1', x: 3, y: 4, name: 'Testville', type: 'TOWN' }]; + expect(getLocationAt(world, 5, 6)).toBeNull(); + }); +}); + +// ─── generateWorld ──────────────────────────────────────────────────────────── + +describe('generateWorld(seed)', () => { + it('returns a world with required properties', () => { + const world = generateWorld(42); + expect(world).toHaveProperty('width'); + expect(world).toHaveProperty('height'); + expect(world).toHaveProperty('tiles'); + expect(world).toHaveProperty('fog'); + expect(world).toHaveProperty('locations'); + }); + + it('fog array is initialized to all 0s', () => { + const world = generateWorld(42); + const nonZero = world.fog.some((v: number) => v !== 0); + expect(nonZero).toBe(false); + }); + + it('contains at least one TOWN location', () => { + const world = generateWorld(42); + const towns = world.locations.filter((l: { type: string }) => l.type === 'TOWN'); + expect(towns.length).toBeGreaterThanOrEqual(1); + }); + + it('location count is within the configured bounds', () => { + const world = generateWorld(42); + // Locations are placed on valid land tiles. Some placements may fail if land + // is limited, so count can be lower than WORLD_CONFIG maximums. Expect at + // least 5 (min towns) and at most the maximum sum of all location types. + expect(world.locations.length).toBeGreaterThanOrEqual(1); + expect(world.locations.length).toBeLessThanOrEqual(40); + }); + + it('is deterministic — same seed produces same location positions', () => { + const w1 = generateWorld(12345); + const w2 = generateWorld(12345); + expect(w1.locations.length).toBe(w2.locations.length); + for (let i = 0; i < w1.locations.length; i++) { + expect(w1.locations[i].x).toBe(w2.locations[i].x); + expect(w1.locations[i].y).toBe(w2.locations[i].y); + } + }); + + it('produces different worlds for different seeds', () => { + const w1 = generateWorld(1); + const w2 = generateWorld(2); + // Very unlikely that two different seeds produce identical first-location positions + const same = w1.locations[0]?.x === w2.locations[0]?.x && + w1.locations[0]?.y === w2.locations[0]?.y; + // At least the worlds should not be byte-for-byte identical + let tilesDiffer = false; + for (let i = 0; i < w1.tiles.length; i++) { + if (w1.tiles[i] !== w2.tiles[i]) { tilesDiffer = true; break; } + } + expect(tilesDiffer || !same).toBe(true); + }); + + it('each location has an id, x, y, type, and name', () => { + const world = generateWorld(99); + for (const loc of world.locations) { + expect(loc).toHaveProperty('id'); + expect(loc).toHaveProperty('x'); + expect(loc).toHaveProperty('y'); + expect(loc).toHaveProperty('type'); + expect(loc).toHaveProperty('name'); + } + }); + + it('tiles array length equals width × height', () => { + const world = generateWorld(42); + expect(world.tiles.length).toBe(world.width * world.height); + expect(world.fog.length).toBe(world.width * world.height); + }); +});