From ebaa3006f9725bf0c0a256015d0361b1cdcfc5f1 Mon Sep 17 00:00:00 2001 From: Matthew Mone Date: Mon, 22 Dec 2025 12:57:04 -0800 Subject: [PATCH] Add type definitions for generation, grid, and manager components. Implement tests for BaseGenerator, CaveGenerator, RuinGenerator, and mission management. Enhance narrative handling in NarrativeManager and RosterManager. Ensure comprehensive coverage of game mechanics and state transitions. --- src/generation/types.d.ts | 25 +++ src/grid/types.d.ts | 80 ++++++++ src/managers/types.d.ts | 108 +++++++++++ test/core/Persistence.test.js | 216 +++++++++++++++++++++ test/generation/BaseGenerator.test.js | 135 +++++++++++++ test/generation/CaveGenerator.test.js | 132 +++++++++++++ test/generation/PostProcessing.test.js | 155 +++++++++++++++ test/generation/RuinGenerator.test.js | 160 ++++++++++++++++ test/managers/MissionManager.test.js | 181 ++++++++++++++++++ test/managers/NarrativeManager.js | 102 ---------- test/managers/NarrativeManager.test.js | 251 +++++++++++++++++++++++++ test/managers/RosterManager.test.js | 135 +++++++++++++ 12 files changed, 1578 insertions(+), 102 deletions(-) create mode 100644 src/generation/types.d.ts create mode 100644 src/grid/types.d.ts create mode 100644 src/managers/types.d.ts create mode 100644 test/core/Persistence.test.js create mode 100644 test/generation/BaseGenerator.test.js create mode 100644 test/generation/CaveGenerator.test.js create mode 100644 test/generation/PostProcessing.test.js create mode 100644 test/generation/RuinGenerator.test.js create mode 100644 test/managers/MissionManager.test.js delete mode 100644 test/managers/NarrativeManager.js create mode 100644 test/managers/NarrativeManager.test.js create mode 100644 test/managers/RosterManager.test.js diff --git a/src/generation/types.d.ts b/src/generation/types.d.ts new file mode 100644 index 0000000..780a547 --- /dev/null +++ b/src/generation/types.d.ts @@ -0,0 +1,25 @@ +/** + * Type definitions for generation-related types + */ + +import type { VoxelGrid } from "../grid/VoxelGrid.js"; +import type { GeneratedAssets } from "../grid/types.js"; + +/** + * Base generator interface + */ +export interface BaseGenerator { + grid: VoxelGrid; + seed: number; + generatedAssets: GeneratedAssets; + generate(): void; +} + +/** + * Generator configuration + */ +export interface GeneratorConfig { + seed: number; + [key: string]: unknown; +} + diff --git a/src/grid/types.d.ts b/src/grid/types.d.ts new file mode 100644 index 0000000..0f33c2c --- /dev/null +++ b/src/grid/types.d.ts @@ -0,0 +1,80 @@ +/** + * Type definitions for grid-related types + */ + +import type { Unit } from "../units/Unit.js"; + +/** + * Grid size + */ +export interface GridSize { + x: number; + y: number; + z: number; +} + +/** + * Position coordinate + */ +export interface Position { + x: number; + y: number; + z: number; +} + +/** + * Voxel ID (0 = air, 1+ = solid) + */ +export type VoxelId = number; + +/** + * Hazard data + */ +export interface Hazard { + id: string; + duration: number; +} + +/** + * Voxel data for queries + */ +export interface VoxelData extends Position { + id: VoxelId; +} + +/** + * Move unit options + */ +export interface MoveUnitOptions { + force?: boolean; + [key: string]: unknown; +} + +/** + * Generated assets from world generators + */ +export interface GeneratedAssets { + textures?: { + floor?: HTMLCanvasElement | OffscreenCanvas | TextureAsset; + wall?: HTMLCanvasElement | OffscreenCanvas | TextureAsset; + [key: string]: HTMLCanvasElement | OffscreenCanvas | TextureAsset | undefined; + }; + palette?: Record; + spawnZones?: { + player?: Position[]; + enemy?: Position[]; + }; + [key: string]: unknown; +} + +/** + * Texture asset (composite with multiple maps) + */ +export interface TextureAsset { + diffuse?: HTMLCanvasElement | OffscreenCanvas; + emissive?: HTMLCanvasElement | OffscreenCanvas; + normal?: HTMLCanvasElement | OffscreenCanvas; + roughness?: HTMLCanvasElement | OffscreenCanvas; + bump?: HTMLCanvasElement | OffscreenCanvas; +} + diff --git a/src/managers/types.d.ts b/src/managers/types.d.ts new file mode 100644 index 0000000..01cafd5 --- /dev/null +++ b/src/managers/types.d.ts @@ -0,0 +1,108 @@ +/** + * Type definitions for manager-related types + */ + +import type { ExplorerData, RosterSaveData } from "../units/types.js"; + +/** + * Mission definition + */ +export interface MissionDefinition { + id: string; + config: { + title: string; + [key: string]: unknown; + }; + biome: { + generator_config: { + seed_type: "FIXED" | "RANDOM"; + seed?: number; + [key: string]: unknown; + }; + [key: string]: unknown; + }; + objectives: { + primary: Objective[]; + [key: string]: unknown; + }; + narrative?: { + intro_sequence?: string; + [key: string]: unknown; + }; + [key: string]: unknown; +} + +/** + * Objective definition + */ +export interface Objective { + type: string; + target_count?: number; + target_def_id?: string; + current?: number; + complete?: boolean; + [key: string]: unknown; +} + +/** + * Mission save data + */ +export interface MissionSaveData { + completedMissions: string[]; +} + +/** + * Narrative sequence data + */ +export interface NarrativeSequence { + id: string; + nodes: NarrativeNode[]; + [key: string]: unknown; +} + +/** + * Narrative node + */ +export interface NarrativeNode { + id: string; + type: "DIALOGUE" | "CHOICE" | "ACTION"; + text?: string; + speaker?: string; + next?: string; + choices?: NarrativeChoice[]; + trigger?: { + action: string; + [key: string]: unknown; + }; + [key: string]: unknown; +} + +/** + * Narrative choice + */ +export interface NarrativeChoice { + text: string; + next: string; + trigger?: { + action: string; + [key: string]: unknown; + }; + [key: string]: unknown; +} + +/** + * Unit registry interface + */ +export interface UnitRegistry { + get?(id: string): unknown; + [key: string]: unknown; +} + +/** + * Game event data + */ +export interface GameEventData { + unitId?: string; + [key: string]: unknown; +} + diff --git a/test/core/Persistence.test.js b/test/core/Persistence.test.js new file mode 100644 index 0000000..c332beb --- /dev/null +++ b/test/core/Persistence.test.js @@ -0,0 +1,216 @@ +import { expect } from "@esm-bundle/chai"; +import sinon from "sinon"; +import { Persistence } from "../../src/core/Persistence.js"; + +describe("Core: Persistence", () => { + let persistence; + let mockDB; + let mockStore; + let mockTransaction; + let mockRequest; + let globalObj; + + beforeEach(() => { + persistence = new Persistence(); + + // Mock IndexedDB + mockStore = { + put: sinon.stub(), + get: sinon.stub(), + delete: sinon.stub(), + }; + + mockTransaction = { + objectStore: sinon.stub().returns(mockStore), + }; + + mockDB = { + objectStoreNames: { + contains: sinon.stub().returns(false), + }, + createObjectStore: sinon.stub(), + transaction: sinon.stub().returns(mockTransaction), + }; + + // Mock indexedDB.open + mockRequest = { + onerror: null, + onsuccess: null, + onupgradeneeded: null, + result: mockDB, + }; + + // Use window or self for browser environment + globalObj = typeof window !== 'undefined' ? window : (typeof self !== 'undefined' ? self : globalThis); + globalObj.indexedDB = { + open: sinon.stub().returns(mockRequest), + }; + }); + + const triggerSuccess = () => { + if (mockRequest.onsuccess) { + mockRequest.onsuccess({ target: { result: mockDB } }); + } + }; + + const triggerUpgrade = () => { + if (mockRequest.onupgradeneeded) { + mockRequest.onupgradeneeded({ target: { result: mockDB } }); + } + }; + + it("CoA 1: init should create database and object stores", async () => { + triggerUpgrade(); + triggerSuccess(); + + await persistence.init(); + + expect(globalObj.indexedDB.open.calledWith("AetherShardsDB", 2)).to.be.true; + expect(mockDB.createObjectStore.calledWith("Runs", { keyPath: "id" })).to.be.true; + expect(mockDB.createObjectStore.calledWith("Roster", { keyPath: "id" })).to.be.true; + expect(persistence.db).to.equal(mockDB); + }); + + it("CoA 2: saveRun should store run data with active_run id", async () => { + persistence.db = mockDB; + const runData = { seed: 12345, depth: 5, squad: [] }; + + const mockPutRequest = { + onsuccess: null, + onerror: null, + }; + mockStore.put.returns(mockPutRequest); + + const savePromise = persistence.saveRun(runData); + mockPutRequest.onsuccess(); + + await savePromise; + + expect(mockDB.transaction.calledWith(["Runs"], "readwrite")).to.be.true; + expect(mockStore.put.calledOnce).to.be.true; + const savedData = mockStore.put.firstCall.args[0]; + expect(savedData.id).to.equal("active_run"); + expect(savedData.seed).to.equal(12345); + }); + + it("CoA 3: loadRun should retrieve active_run data", async () => { + persistence.db = mockDB; + const savedData = { id: "active_run", seed: 12345, depth: 5 }; + + const mockGetRequest = { + onsuccess: null, + onerror: null, + result: savedData, + }; + mockStore.get.returns(mockGetRequest); + + const loadPromise = persistence.loadRun(); + mockGetRequest.onsuccess(); + + const result = await loadPromise; + + expect(mockDB.transaction.calledWith(["Runs"], "readonly")).to.be.true; + expect(mockStore.get.calledWith("active_run")).to.be.true; + expect(result).to.deep.equal(savedData); + }); + + it("CoA 4: clearRun should delete active_run", async () => { + persistence.db = mockDB; + + const mockDeleteRequest = { + onsuccess: null, + onerror: null, + }; + mockStore.delete.returns(mockDeleteRequest); + + const deletePromise = persistence.clearRun(); + mockDeleteRequest.onsuccess(); + + await deletePromise; + + expect(mockDB.transaction.calledWith(["Runs"], "readwrite")).to.be.true; + expect(mockStore.delete.calledWith("active_run")).to.be.true; + }); + + it("CoA 5: saveRoster should wrap roster data with id", async () => { + persistence.db = mockDB; + const rosterData = { roster: [], graveyard: [] }; + + const mockPutRequest = { + onsuccess: null, + onerror: null, + }; + mockStore.put.returns(mockPutRequest); + + const savePromise = persistence.saveRoster(rosterData); + mockPutRequest.onsuccess(); + + await savePromise; + + expect(mockDB.transaction.calledWith(["Roster"], "readwrite")).to.be.true; + expect(mockStore.put.calledOnce).to.be.true; + const savedData = mockStore.put.firstCall.args[0]; + expect(savedData.id).to.equal("player_roster"); + expect(savedData.data).to.deep.equal(rosterData); + }); + + it("CoA 6: loadRoster should extract data from stored object", async () => { + persistence.db = mockDB; + const storedData = { id: "player_roster", data: { roster: [], graveyard: [] } }; + + const mockGetRequest = { + onsuccess: null, + onerror: null, + result: storedData, + }; + mockStore.get.returns(mockGetRequest); + + const loadPromise = persistence.loadRoster(); + mockGetRequest.onsuccess(); + + const result = await loadPromise; + + expect(mockDB.transaction.calledWith(["Roster"], "readonly")).to.be.true; + expect(mockStore.get.calledWith("player_roster")).to.be.true; + expect(result).to.deep.equal(storedData.data); + }); + + it("CoA 7: loadRoster should return null if no data exists", async () => { + persistence.db = mockDB; + + const mockGetRequest = { + onsuccess: null, + onerror: null, + result: undefined, + }; + mockStore.get.returns(mockGetRequest); + + const loadPromise = persistence.loadRoster(); + mockGetRequest.onsuccess(); + + const result = await loadPromise; + + expect(result).to.be.null; + }); + + it("CoA 8: saveRun should auto-init if db not initialized", async () => { + triggerUpgrade(); + triggerSuccess(); + + const runData = { seed: 12345 }; + const mockPutRequest = { + onsuccess: null, + onerror: null, + }; + mockStore.put.returns(mockPutRequest); + + const savePromise = persistence.saveRun(runData); + mockPutRequest.onsuccess(); + + await savePromise; + + expect(persistence.db).to.equal(mockDB); + expect(mockStore.put.calledOnce).to.be.true; + }); +}); + diff --git a/test/generation/BaseGenerator.test.js b/test/generation/BaseGenerator.test.js new file mode 100644 index 0000000..f991fe4 --- /dev/null +++ b/test/generation/BaseGenerator.test.js @@ -0,0 +1,135 @@ +import { expect } from "@esm-bundle/chai"; +import { BaseGenerator } from "../../src/generation/BaseGenerator.js"; +import { VoxelGrid } from "../../src/grid/VoxelGrid.js"; + +describe("Generation: BaseGenerator", () => { + let grid; + let generator; + + beforeEach(() => { + grid = new VoxelGrid(10, 5, 10); + generator = new BaseGenerator(grid, 12345); + }); + + it("CoA 1: Should initialize with grid and RNG", () => { + expect(generator.grid).to.equal(grid); + expect(generator.rng).to.exist; + expect(generator.width).to.equal(10); + expect(generator.height).to.equal(5); + expect(generator.depth).to.equal(10); + }); + + it("CoA 2: getSolidNeighbors should count solid neighbors correctly", () => { + // Set up a pattern: center is air, surrounded by solids + grid.setCell(5, 2, 5, 0); // Center (air) + grid.setCell(4, 2, 5, 1); // Left + grid.setCell(6, 2, 5, 1); // Right + grid.setCell(5, 2, 4, 1); // Front + grid.setCell(5, 2, 6, 1); // Back + grid.setCell(5, 1, 5, 1); // Below + grid.setCell(5, 3, 5, 1); // Above + + const count = generator.getSolidNeighbors(5, 2, 5); + + // Should count at least 6 neighbors (excluding center) + expect(count).to.be.greaterThanOrEqual(6); + }); + + it("CoA 3: getSolidNeighbors should handle out-of-bounds as solid", () => { + // Test edge case - corner position + grid.setCell(0, 0, 0, 0); // Air at corner + + const count = generator.getSolidNeighbors(0, 0, 0); + + // Out-of-bounds neighbors should count as solid + expect(count).to.be.greaterThan(0); + }); + + it("CoA 4: scatterCover should place objects on valid floor tiles", () => { + // Create a floor: solid at y=0, air at y=1 + for (let x = 1; x < 9; x++) { + for (let z = 1; z < 9; z++) { + grid.setCell(x, 0, z, 1); // Floor + grid.setCell(x, 1, z, 0); // Air above + } + } + + generator.scatterCover(10, 0.5); // 50% density + + // Check that some objects were placed + let objectCount = 0; + for (let x = 1; x < 9; x++) { + for (let z = 1; z < 9; z++) { + if (grid.getCell(x, 1, z) === 10) { + objectCount++; + } + } + } + + // With 50% density on ~64 tiles, we should get some objects + expect(objectCount).to.be.greaterThan(0); + }); + + it("CoA 5: scatterCover should not place objects on invalid tiles", () => { + // Create invalid floor: air at y=0 (no solid below) + for (let x = 1; x < 9; x++) { + for (let z = 1; z < 9; z++) { + grid.setCell(x, 0, z, 0); // No floor + grid.setCell(x, 1, z, 0); // Air + } + } + + generator.scatterCover(10, 1.0); // 100% density + + // No objects should be placed + let objectCount = 0; + for (let x = 1; x < 9; x++) { + for (let z = 1; z < 9; z++) { + if (grid.getCell(x, 1, z) === 10) { + objectCount++; + } + } + } + + expect(objectCount).to.equal(0); + }); + + it("CoA 6: scatterCover should respect density parameter", () => { + // Create valid floor + for (let x = 1; x < 9; x++) { + for (let z = 1; z < 9; z++) { + grid.setCell(x, 0, z, 1); + grid.setCell(x, 1, z, 0); + } + } + + // Test with different densities + generator.scatterCover(10, 0.1); // 10% density + let lowCount = 0; + for (let x = 1; x < 9; x++) { + for (let z = 1; z < 9; z++) { + if (grid.getCell(x, 1, z) === 10) lowCount++; + } + } + + // Reset and test higher density + for (let x = 1; x < 9; x++) { + for (let z = 1; z < 9; z++) { + grid.setCell(x, 1, z, 0); + } + } + + generator.scatterCover(10, 0.9); // 90% density + let highCount = 0; + for (let x = 1; x < 9; x++) { + for (let z = 1; z < 9; z++) { + if (grid.getCell(x, 1, z) === 10) highCount++; + } + } + + // Higher density should generally produce more objects + // (allowing for randomness, but trend should be clear) + expect(highCount).to.be.greaterThan(lowCount); + }); +}); + diff --git a/test/generation/CaveGenerator.test.js b/test/generation/CaveGenerator.test.js new file mode 100644 index 0000000..1e07e0d --- /dev/null +++ b/test/generation/CaveGenerator.test.js @@ -0,0 +1,132 @@ +import { expect } from "@esm-bundle/chai"; +import { CaveGenerator } from "../../src/generation/CaveGenerator.js"; +import { VoxelGrid } from "../../src/grid/VoxelGrid.js"; + +describe("Generation: CaveGenerator", () => { + let grid; + let generator; + + beforeEach(() => { + grid = new VoxelGrid(20, 10, 20); + generator = new CaveGenerator(grid, 12345); + }); + + it("CoA 1: Should initialize with texture generators", () => { + expect(generator.floorGen).to.exist; + expect(generator.wallGen).to.exist; + expect(generator.generatedAssets).to.have.property("palette"); + }); + + it("CoA 2: preloadTextures should generate texture palette", () => { + generator.preloadTextures(); + + // Should have wall variations (100-109) + expect(generator.generatedAssets.palette[100]).to.exist; + expect(generator.generatedAssets.palette[109]).to.exist; + + // Should have floor variations (200-209) + expect(generator.generatedAssets.palette[200]).to.exist; + expect(generator.generatedAssets.palette[209]).to.exist; + }); + + it("CoA 3: generate should create foundation layer", () => { + generator.generate(0.5, 2); + + // Foundation (y=0) should be solid + for (let x = 0; x < grid.size.x; x++) { + for (let z = 0; z < grid.size.z; z++) { + expect(grid.getCell(x, 0, z)).to.not.equal(0); + } + } + }); + + it("CoA 4: generate should keep sky clear", () => { + generator.generate(0.5, 2); + + // Top layer (y=height-1) should be air + const topY = grid.size.y - 1; + for (let x = 0; x < grid.size.x; x++) { + for (let z = 0; z < grid.size.z; z++) { + expect(grid.getCell(x, topY, z)).to.equal(0); + } + } + }); + + it("CoA 5: smooth should apply cellular automata rules", () => { + // Set up initial pattern + for (let x = 0; x < grid.size.x; x++) { + for (let z = 0; z < grid.size.z; z++) { + for (let y = 1; y < grid.size.y - 1; y++) { + grid.setCell(x, y, z, Math.random() > 0.5 ? 1 : 0); + } + } + } + + const beforeState = grid.cells.slice(); + generator.smooth(); + const afterState = grid.cells.slice(); + + // Smoothing should change the grid + expect(afterState).to.not.deep.equal(beforeState); + }); + + it("CoA 6: applyTextures should assign floor and wall IDs", () => { + // Create a simple structure: floor at y=1, wall above + // Floor surface: solid at y=1 with air above (y=2) + // Wall: solid at y=2 with solid above (y=3) + for (let x = 1; x < 10; x++) { + for (let z = 1; z < 10; z++) { + grid.setCell(x, 0, z, 1); // Foundation + grid.setCell(x, 1, z, 1); // Floor surface (will be textured as floor if air above) + grid.setCell(x, 2, z, 0); // Air above floor (makes y=1 a floor surface) + grid.setCell(x, 3, z, 1); // Wall (solid with solid above) + grid.setCell(x, 4, z, 1); // Solid above wall + } + } + + generator.applyTextures(); + + // Floor surfaces (y=1 with air above) should have IDs 200-209 + const floorId = grid.getCell(5, 1, 5); + expect(floorId).to.be.greaterThanOrEqual(200); + expect(floorId).to.be.lessThanOrEqual(209); + + // Walls (y=3 with solid above) should have IDs 100-109 + const wallId = grid.getCell(5, 3, 5); + expect(wallId).to.be.greaterThanOrEqual(100); + expect(wallId).to.be.lessThanOrEqual(109); + }); + + it("CoA 7: generate should scatter cover objects", () => { + generator.generate(0.5, 2); + + // Check for cover objects (ID 10) + let coverCount = 0; + for (let x = 0; x < grid.size.x; x++) { + for (let z = 0; z < grid.size.z; z++) { + for (let y = 0; y < grid.size.y; y++) { + if (grid.getCell(x, y, z) === 10) { + coverCount++; + } + } + } + } + + // Should have some cover objects + expect(coverCount).to.be.greaterThan(0); + }); + + it("CoA 8: generate with same seed should produce consistent results", () => { + const grid1 = new VoxelGrid(20, 10, 20); + const gen1 = new CaveGenerator(grid1, 12345); + gen1.generate(0.5, 2); + + const grid2 = new VoxelGrid(20, 10, 20); + const gen2 = new CaveGenerator(grid2, 12345); + gen2.generate(0.5, 2); + + // Same seed should produce same results + expect(grid1.cells).to.deep.equal(grid2.cells); + }); +}); + diff --git a/test/generation/PostProcessing.test.js b/test/generation/PostProcessing.test.js new file mode 100644 index 0000000..f13cb74 --- /dev/null +++ b/test/generation/PostProcessing.test.js @@ -0,0 +1,155 @@ +import { expect } from "@esm-bundle/chai"; +import { PostProcessor } from "../../src/generation/PostProcessing.js"; +import { VoxelGrid } from "../../src/grid/VoxelGrid.js"; + +describe("Generation: PostProcessor", () => { + let grid; + + beforeEach(() => { + grid = new VoxelGrid(20, 5, 20); + }); + + it("CoA 1: ensureConnectivity should identify separate regions", () => { + // Create two disconnected floor regions + // Region 1: left side + for (let x = 1; x < 5; x++) { + for (let z = 1; z < 5; z++) { + grid.setCell(x, 0, z, 1); // Floor + grid.setCell(x, 1, z, 0); // Air + } + } + + // Region 2: right side (disconnected) + for (let x = 15; x < 19; x++) { + for (let z = 15; z < 19; z++) { + grid.setCell(x, 0, z, 1); // Floor + grid.setCell(x, 1, z, 0); // Air + } + } + + PostProcessor.ensureConnectivity(grid); + + // After processing, smaller regions should be filled + // The grid should have connectivity ensured + expect(grid).to.exist; + }); + + it("CoA 2: ensureConnectivity should keep largest region", () => { + // Create one large region and one small region + // Large region + for (let x = 1; x < 10; x++) { + for (let z = 1; z < 10; z++) { + grid.setCell(x, 0, z, 1); + grid.setCell(x, 1, z, 0); + } + } + + // Small region + for (let x = 15; x < 17; x++) { + for (let z = 15; z < 17; z++) { + grid.setCell(x, 0, z, 1); + grid.setCell(x, 1, z, 0); + } + } + + const smallRegionAirBefore = grid.getCell(15, 1, 15); + + PostProcessor.ensureConnectivity(grid); + + // Small region should be filled (no longer air) + const smallRegionAfter = grid.getCell(15, 1, 15); + // If connectivity was ensured, small region might be filled + // (exact behavior depends on implementation) + expect(smallRegionAfter).to.exist; + }); + + it("CoA 3: floodFill should collect all connected air tiles", () => { + // Create a connected region + for (let x = 1; x < 5; x++) { + for (let z = 1; z < 5; z++) { + grid.setCell(x, 0, z, 1); + grid.setCell(x, 1, z, 0); + } + } + + const visited = new Set(); + const region = PostProcessor.floodFill(grid, 2, 1, 2, visited); + + // Should collect multiple tiles + expect(region.length).to.be.greaterThan(1); + expect(region).to.deep.include({ x: 2, y: 1, z: 2 }); + expect(region).to.deep.include({ x: 3, y: 1, z: 2 }); + }); + + it("CoA 4: floodFill should not include disconnected tiles", () => { + // Create two separate regions with proper floor setup + // Region 1: connected tiles + grid.setCell(1, 0, 1, 1); // Floor + grid.setCell(1, 1, 1, 0); // Air + grid.setCell(2, 0, 1, 1); // Floor + grid.setCell(2, 1, 1, 0); // Air (connected) + + // Region 2: disconnected (no floor connection) + grid.setCell(10, 0, 10, 1); // Floor + grid.setCell(10, 1, 10, 0); // Air (disconnected) + + const visited = new Set(); + const region = PostProcessor.floodFill(grid, 1, 1, 1, visited); + + // Should only include connected tiles from region 1 + expect(region.length).to.equal(2); + expect(region).to.deep.include({ x: 1, y: 1, z: 1 }); + expect(region).to.deep.include({ x: 2, y: 1, z: 1 }); + expect(region).to.not.deep.include({ x: 10, y: 1, z: 10 }); + }); + + it("CoA 5: floodFill should respect bounds", () => { + // Start at edge + grid.setCell(0, 0, 0, 1); + grid.setCell(0, 1, 0, 0); + + const visited = new Set(); + const region = PostProcessor.floodFill(grid, 0, 1, 0, visited); + + // Should only include valid positions + expect(region.length).to.be.greaterThanOrEqual(1); + region.forEach((pos) => { + expect(grid.isValidBounds(pos.x, pos.y, pos.z)).to.be.true; + }); + }); + + it("CoA 6: ensureConnectivity should handle empty grid", () => { + // Grid with no floor regions + grid.fill(0); + + // Should not throw + expect(() => PostProcessor.ensureConnectivity(grid)).to.not.throw(); + }); + + it("CoA 7: ensureConnectivity should handle single region", () => { + // Create one connected region + for (let x = 1; x < 10; x++) { + for (let z = 1; z < 10; z++) { + grid.setCell(x, 0, z, 1); + grid.setCell(x, 1, z, 0); + } + } + + const airCountBefore = Array.from(grid.cells).filter((v, i) => { + const y = Math.floor(i / (grid.size.x * grid.size.z)) % grid.size.y; + return y === 1 && v === 0; + }).length; + + PostProcessor.ensureConnectivity(grid); + + // Single region should remain intact + const airCountAfter = Array.from(grid.cells).filter((v, i) => { + const y = Math.floor(i / (grid.size.x * grid.size.z)) % grid.size.y; + return y === 1 && v === 0; + }).length; + + // Air count should be similar (allowing for minor changes) + expect(airCountAfter).to.be.greaterThan(0); + }); +}); + diff --git a/test/generation/RuinGenerator.test.js b/test/generation/RuinGenerator.test.js new file mode 100644 index 0000000..0b740f4 --- /dev/null +++ b/test/generation/RuinGenerator.test.js @@ -0,0 +1,160 @@ +import { expect } from "@esm-bundle/chai"; +import { RuinGenerator } from "../../src/generation/RuinGenerator.js"; +import { VoxelGrid } from "../../src/grid/VoxelGrid.js"; + +describe("Generation: RuinGenerator", () => { + let grid; + let generator; + + beforeEach(() => { + grid = new VoxelGrid(30, 10, 30); + generator = new RuinGenerator(grid, 12345); + }); + + it("CoA 1: Should initialize with texture generators and spawn zones", () => { + expect(generator.floorGen).to.exist; + expect(generator.wallGen).to.exist; + expect(generator.generatedAssets).to.have.property("palette"); + expect(generator.generatedAssets).to.have.property("spawnZones"); + expect(generator.generatedAssets.spawnZones).to.have.property("player"); + expect(generator.generatedAssets.spawnZones).to.have.property("enemy"); + }); + + it("CoA 2: generate should create rooms", () => { + generator.generate(5, 4, 8); + + // Should have some air spaces (rooms) + let airCount = 0; + for (let x = 0; x < grid.size.x; x++) { + for (let z = 0; z < grid.size.z; z++) { + if (grid.getCell(x, 1, z) === 0) { + airCount++; + } + } + } + + expect(airCount).to.be.greaterThan(0); + }); + + it("CoA 3: generate should mark spawn zones", () => { + generator.generate(3, 4, 6); + + // Should have spawn zones if rooms were created + if (generator.generatedAssets.spawnZones.player.length > 0) { + expect(generator.generatedAssets.spawnZones.player.length).to.be.greaterThan(0); + } + }); + + it("CoA 4: roomsOverlap should detect overlapping rooms", () => { + const rooms = [ + { x: 5, z: 5, w: 6, d: 6 }, + { x: 8, z: 8, w: 6, d: 6 }, // Overlaps with first + ]; + + expect(generator.roomsOverlap(rooms[1], rooms)).to.be.true; + }); + + it("CoA 5: roomsOverlap should return false for non-overlapping rooms", () => { + const rooms = [ + { x: 5, z: 5, w: 4, d: 4 }, + ]; + const newRoom = { x: 15, z: 15, w: 4, d: 4 }; // Far away + + // roomsOverlap checks if newRoom overlaps with any existing room + expect(generator.roomsOverlap(newRoom, rooms)).to.be.false; + }); + + it("CoA 6: getCenter should calculate room center", () => { + const room = { x: 10, z: 10, w: 6, d: 8, y: 1 }; + + const center = generator.getCenter(room); + + expect(center.x).to.equal(13); // 10 + 6/2 = 13 + expect(center.z).to.equal(14); // 10 + 8/2 = 14 + expect(center.y).to.equal(1); + }); + + it("CoA 7: buildRoom should create floor and walls", () => { + const room = { x: 5, y: 1, z: 5, w: 6, d: 6 }; + + generator.buildRoom(room); + + // Floor should exist + expect(grid.getCell(7, 0, 7)).to.not.equal(0); + + // Interior should be air + expect(grid.getCell(7, 1, 7)).to.equal(0); + + // Perimeter should be walls + expect(grid.getCell(5, 1, 5)).to.not.equal(0); // Corner wall + }); + + it("CoA 8: buildCorridor should connect two points", () => { + const start = { x: 5, y: 1, z: 5 }; + const end = { x: 15, y: 1, z: 15 }; + + generator.buildCorridor(start, end); + + // Path should have floor + expect(grid.getCell(10, 0, 5)).to.not.equal(0); + expect(grid.getCell(15, 0, 10)).to.not.equal(0); + }); + + it("CoA 9: markSpawnZone should collect valid floor tiles", () => { + // Create a room manually + const room = { x: 5, y: 1, z: 5, w: 6, d: 6 }; + generator.buildRoom(room); + + generator.markSpawnZone(room, "player"); + + // Should have some spawn positions + expect(generator.generatedAssets.spawnZones.player.length).to.be.greaterThan(0); + + // Spawn positions should be valid floor tiles + const spawn = generator.generatedAssets.spawnZones.player[0]; + expect(grid.getCell(spawn.x, spawn.y - 1, spawn.z)).to.not.equal(0); // Solid below + expect(grid.getCell(spawn.x, spawn.y, spawn.z)).to.equal(0); // Air at spawn + }); + + it("CoA 10: applyTextures should assign floor and wall IDs", () => { + // Create a simple structure + const room = { x: 5, y: 1, z: 5, w: 6, d: 6 }; + generator.buildRoom(room); + + generator.applyTextures(); + + // Floor should have IDs 200-209 + const floorId = grid.getCell(7, 1, 7); + if (floorId !== 0) { + // If it's a floor surface + expect(floorId).to.be.greaterThanOrEqual(200); + expect(floorId).to.be.lessThanOrEqual(209); + } + + // Wall should have IDs 100-109 + const wallId = grid.getCell(5, 1, 5); + if (wallId !== 0) { + expect(wallId).to.be.greaterThanOrEqual(100); + expect(wallId).to.be.lessThanOrEqual(109); + } + }); + + it("CoA 11: generate should scatter cover objects", () => { + generator.generate(3, 4, 6); + + // Check for cover objects (ID 10) + let coverCount = 0; + for (let x = 0; x < grid.size.x; x++) { + for (let z = 0; z < grid.size.z; z++) { + for (let y = 0; y < grid.size.y; y++) { + if (grid.getCell(x, y, z) === 10) { + coverCount++; + } + } + } + } + + expect(coverCount).to.be.greaterThan(0); + }); +}); + diff --git a/test/managers/MissionManager.test.js b/test/managers/MissionManager.test.js new file mode 100644 index 0000000..bbadff1 --- /dev/null +++ b/test/managers/MissionManager.test.js @@ -0,0 +1,181 @@ +import { expect } from "@esm-bundle/chai"; +import sinon from "sinon"; +import { MissionManager } from "../../src/managers/MissionManager.js"; +import { narrativeManager } from "../../src/managers/NarrativeManager.js"; + +describe("Manager: MissionManager", () => { + let manager; + let mockNarrativeManager; + + beforeEach(() => { + manager = new MissionManager(); + + // Mock narrativeManager + mockNarrativeManager = { + startSequence: sinon.stub(), + addEventListener: sinon.stub(), + removeEventListener: sinon.stub(), + }; + + // Replace the singleton reference in the manager if possible + // Since it's imported, we'll need to stub the methods we use + sinon.stub(narrativeManager, "startSequence"); + sinon.stub(narrativeManager, "addEventListener"); + sinon.stub(narrativeManager, "removeEventListener"); + }); + + afterEach(() => { + sinon.restore(); + }); + + it("CoA 1: Should initialize with tutorial mission registered", () => { + expect(manager.missionRegistry.has("MISSION_TUTORIAL_01")).to.be.true; + expect(manager.activeMissionId).to.be.null; + expect(manager.completedMissions).to.be.instanceof(Set); + }); + + it("CoA 2: registerMission should add mission to registry", () => { + const newMission = { + id: "MISSION_TEST_01", + config: { title: "Test Mission" }, + objectives: { primary: [] }, + }; + + manager.registerMission(newMission); + + expect(manager.missionRegistry.has("MISSION_TEST_01")).to.be.true; + expect(manager.missionRegistry.get("MISSION_TEST_01")).to.equal(newMission); + }); + + it("CoA 3: getActiveMission should return tutorial if no active mission", () => { + const mission = manager.getActiveMission(); + + expect(mission).to.exist; + expect(mission.id).to.equal("MISSION_TUTORIAL_01"); + }); + + it("CoA 4: getActiveMission should return active mission if set", () => { + const testMission = { + id: "MISSION_TEST_01", + config: { title: "Test" }, + objectives: { primary: [] }, + }; + manager.registerMission(testMission); + manager.activeMissionId = "MISSION_TEST_01"; + + const mission = manager.getActiveMission(); + + expect(mission.id).to.equal("MISSION_TEST_01"); + }); + + it("CoA 5: setupActiveMission should initialize objectives", () => { + const mission = manager.getActiveMission(); + mission.objectives = { + primary: [ + { type: "ELIMINATE_ALL", target_count: 5 }, + { type: "ELIMINATE_UNIT", target_def_id: "ENEMY_GOBLIN", target_count: 3 }, + ], + }; + + manager.setupActiveMission(); + + expect(manager.currentObjectives).to.have.length(2); + expect(manager.currentObjectives[0].current).to.equal(0); + expect(manager.currentObjectives[0].complete).to.be.false; + expect(manager.currentObjectives[1].target_count).to.equal(3); + }); + + it("CoA 6: onGameEvent should update ELIMINATE_ALL objectives", () => { + manager.setupActiveMission(); + manager.currentObjectives = [ + { type: "ELIMINATE_ALL", target_count: 3, current: 0, complete: false }, + ]; + + manager.onGameEvent("ENEMY_DEATH", { unitId: "ENEMY_1" }); + manager.onGameEvent("ENEMY_DEATH", { unitId: "ENEMY_2" }); + manager.onGameEvent("ENEMY_DEATH", { unitId: "ENEMY_3" }); + + expect(manager.currentObjectives[0].current).to.equal(3); + expect(manager.currentObjectives[0].complete).to.be.true; + }); + + it("CoA 7: onGameEvent should update ELIMINATE_UNIT objectives for specific unit", () => { + manager.setupActiveMission(); + manager.currentObjectives = [ + { + type: "ELIMINATE_UNIT", + target_def_id: "ENEMY_GOBLIN", + target_count: 2, + current: 0, + complete: false, + }, + ]; + + manager.onGameEvent("ENEMY_DEATH", { unitId: "ENEMY_GOBLIN" }); + manager.onGameEvent("ENEMY_DEATH", { unitId: "ENEMY_OTHER" }); // Should not count + manager.onGameEvent("ENEMY_DEATH", { unitId: "ENEMY_GOBLIN" }); + + expect(manager.currentObjectives[0].current).to.equal(2); + expect(manager.currentObjectives[0].complete).to.be.true; + }); + + it("CoA 8: checkVictory should dispatch mission-victory event when all objectives complete", () => { + const victorySpy = sinon.spy(); + window.addEventListener("mission-victory", victorySpy); + + manager.setupActiveMission(); + manager.currentObjectives = [ + { type: "ELIMINATE_ALL", target_count: 2, current: 2, complete: true }, + ]; + manager.activeMissionId = "MISSION_TUTORIAL_01"; + + manager.checkVictory(); + + expect(victorySpy.called).to.be.true; + expect(manager.completedMissions.has("MISSION_TUTORIAL_01")).to.be.true; + + window.removeEventListener("mission-victory", victorySpy); + }); + + it("CoA 9: completeActiveMission should add mission to completed set", () => { + manager.activeMissionId = "MISSION_TUTORIAL_01"; + + manager.completeActiveMission(); + + expect(manager.completedMissions.has("MISSION_TUTORIAL_01")).to.be.true; + }); + + it("CoA 10: load should restore completed missions", () => { + const saveData = { + completedMissions: ["MISSION_TUTORIAL_01", "MISSION_TEST_01"], + }; + + manager.load(saveData); + + expect(manager.completedMissions.has("MISSION_TUTORIAL_01")).to.be.true; + expect(manager.completedMissions.has("MISSION_TEST_01")).to.be.true; + }); + + it("CoA 11: save should serialize completed missions", () => { + manager.completedMissions.add("MISSION_TUTORIAL_01"); + manager.completedMissions.add("MISSION_TEST_01"); + + const saved = manager.save(); + + expect(saved.completedMissions).to.be.an("array"); + expect(saved.completedMissions).to.include("MISSION_TUTORIAL_01"); + expect(saved.completedMissions).to.include("MISSION_TEST_01"); + }); + + it("CoA 12: _mapNarrativeIdToFileName should convert narrative IDs to filenames", () => { + expect(manager._mapNarrativeIdToFileName("NARRATIVE_TUTORIAL_INTRO")).to.equal( + "tutorial_intro" + ); + expect(manager._mapNarrativeIdToFileName("NARRATIVE_TUTORIAL_SUCCESS")).to.equal( + "tutorial_success" + ); + // The implementation converts NARRATIVE_UNKNOWN to narrative_unknown (lowercase with NARRATIVE_ prefix removed) + expect(manager._mapNarrativeIdToFileName("NARRATIVE_UNKNOWN")).to.equal("narrative_unknown"); + }); +}); + diff --git a/test/managers/NarrativeManager.js b/test/managers/NarrativeManager.js deleted file mode 100644 index a6076de..0000000 --- a/test/managers/NarrativeManager.js +++ /dev/null @@ -1,102 +0,0 @@ -/** - * NarrativeManager.js - * Manages the flow of story events, dialogue, and tutorials. - * Extends EventTarget to broadcast UI updates. - */ -export class NarrativeManager extends EventTarget { - constructor() { - super(); - this.currentSequence = null; - this.currentNode = null; - this.history = new Set(); // Track played sequences IDs - } - - /** - * Loads and starts a narrative sequence. - * @param {Object} sequenceData - The JSON object of the conversation. - */ - startSequence(sequenceData) { - if (!sequenceData || !sequenceData.nodes) { - console.error("Invalid sequence data"); - return; - } - - console.log(`Starting Narrative: ${sequenceData.id}`); - this.currentSequence = sequenceData; - this.history.add(sequenceData.id); - - // Find first node (usually index 0 or id '1') - this.currentNode = sequenceData.nodes[0]; - this.broadcastUpdate(); - } - - /** - * Advances to the next node in the sequence. - */ - next() { - if (!this.currentNode) return; - - // 1. Handle Triggers (Side Effects) - if (this.currentNode.trigger) { - this.dispatchEvent( - new CustomEvent("narrative-trigger", { - detail: { action: this.currentNode.trigger }, - }) - ); - } - - // 2. Find Next Node - const nextId = this.currentNode.next; - - if (nextId === "END" || !nextId) { - this.endSequence(); - } else { - this.currentNode = this.currentSequence.nodes.find( - (n) => n.id === nextId - ); - this.broadcastUpdate(); - } - } - - /** - * Handles player choice selection. - */ - makeChoice(choiceIndex) { - if (!this.currentNode.choices) return; - - const choice = this.currentNode.choices[choiceIndex]; - const nextId = choice.next; - - if (choice.trigger) { - this.dispatchEvent( - new CustomEvent("narrative-trigger", { - detail: { action: choice.trigger }, - }) - ); - } - - this.currentNode = this.currentSequence.nodes.find((n) => n.id === nextId); - this.broadcastUpdate(); - } - - endSequence() { - console.log("Narrative Ended"); - this.currentSequence = null; - this.currentNode = null; - this.dispatchEvent(new CustomEvent("narrative-end")); - } - - broadcastUpdate() { - this.dispatchEvent( - new CustomEvent("narrative-update", { - detail: { - node: this.currentNode, - active: !!this.currentNode, - }, - }) - ); - } -} - -// Export singleton for global access -export const narrativeManager = new NarrativeManager(); diff --git a/test/managers/NarrativeManager.test.js b/test/managers/NarrativeManager.test.js new file mode 100644 index 0000000..6f58d2e --- /dev/null +++ b/test/managers/NarrativeManager.test.js @@ -0,0 +1,251 @@ +import { expect } from "@esm-bundle/chai"; +import sinon from "sinon"; +import { narrativeManager, NarrativeManager } from "../../src/managers/NarrativeManager.js"; + +describe("Manager: NarrativeManager", () => { + let manager; + + beforeEach(() => { + // Create a fresh instance for testing + manager = new NarrativeManager(); + }); + + it("CoA 1: Should initialize with empty state", () => { + expect(manager.currentSequence).to.be.null; + expect(manager.currentNode).to.be.null; + expect(manager.history).to.be.instanceof(Set); + expect(manager.history.size).to.equal(0); + }); + + it("CoA 2: startSequence should load sequence and set first node", () => { + const sequenceData = { + id: "TEST_SEQUENCE", + nodes: [ + { id: "1", type: "DIALOGUE", text: "Hello", next: "2" }, + { id: "2", type: "DIALOGUE", text: "World", next: "END" }, + ], + }; + + const updateSpy = sinon.spy(manager, "broadcastUpdate"); + + manager.startSequence(sequenceData); + + expect(manager.currentSequence).to.equal(sequenceData); + expect(manager.currentNode.id).to.equal("1"); + expect(manager.history.has("TEST_SEQUENCE")).to.be.true; + expect(updateSpy.called).to.be.true; + }); + + it("CoA 3: startSequence should handle invalid data gracefully", () => { + const consoleError = sinon.stub(console, "error"); + + manager.startSequence(null); + expect(manager.currentSequence).to.be.null; + + manager.startSequence({}); + expect(manager.currentSequence).to.be.null; + + consoleError.restore(); + }); + + it("CoA 4: next should advance to next node in sequence", () => { + const sequenceData = { + id: "TEST_SEQUENCE", + nodes: [ + { id: "1", type: "DIALOGUE", text: "First", next: "2" }, + { id: "2", type: "DIALOGUE", text: "Second", next: "END" }, + ], + }; + + manager.startSequence(sequenceData); + expect(manager.currentNode.id).to.equal("1"); + + manager.next(); + + expect(manager.currentNode.id).to.equal("2"); + }); + + it("CoA 5: next should end sequence when reaching END", () => { + const endSpy = sinon.spy(manager, "endSequence"); + const sequenceData = { + id: "TEST_SEQUENCE", + nodes: [{ id: "1", type: "DIALOGUE", text: "Last", next: "END" }], + }; + + manager.startSequence(sequenceData); + manager.next(); + + expect(endSpy.called).to.be.true; + expect(manager.currentSequence).to.be.null; + }); + + it("CoA 6: next should not advance on CHOICE nodes", () => { + const consoleWarn = sinon.stub(console, "warn"); + const sequenceData = { + id: "TEST_SEQUENCE", + nodes: [ + { + id: "1", + type: "CHOICE", + text: "Choose", + choices: [{ text: "Option 1", next: "2" }], + }, + ], + }; + + manager.startSequence(sequenceData); + manager.next(); + + expect(consoleWarn.called).to.be.true; + expect(manager.currentNode.id).to.equal("1"); + + consoleWarn.restore(); + }); + + it("CoA 7: makeChoice should advance based on choice selection", () => { + const sequenceData = { + id: "TEST_SEQUENCE", + nodes: [ + { + id: "1", + type: "CHOICE", + choices: [ + { text: "Option A", next: "2" }, + { text: "Option B", next: "3" }, + ], + }, + { id: "2", type: "DIALOGUE", text: "Result A", next: "END" }, + { id: "3", type: "DIALOGUE", text: "Result B", next: "END" }, + ], + }; + + manager.startSequence(sequenceData); + manager.makeChoice(1); // Select Option B + + expect(manager.currentNode.id).to.equal("3"); + }); + + it("CoA 8: makeChoice should dispatch trigger events", () => { + const triggerSpy = sinon.spy(); + manager.addEventListener("narrative-trigger", triggerSpy); + + const sequenceData = { + id: "TEST_SEQUENCE", + nodes: [ + { + id: "1", + type: "CHOICE", + choices: [ + { + text: "Option", + next: "2", + trigger: { action: "GAIN_REPUTATION", value: 10 }, + }, + ], + }, + { id: "2", type: "DIALOGUE", next: "END" }, + ], + }; + + manager.startSequence(sequenceData); + manager.makeChoice(0); + + expect(triggerSpy.called).to.be.true; + expect(triggerSpy.firstCall.args[0].detail.action).to.deep.equal({ + action: "GAIN_REPUTATION", + value: 10, + }); + }); + + it("CoA 9: _advanceToNode should process node triggers", () => { + const triggerSpy = sinon.spy(); + manager.addEventListener("narrative-trigger", triggerSpy); + + const sequenceData = { + id: "TEST_SEQUENCE", + nodes: [ + { id: "1", type: "DIALOGUE", next: "2" }, + { + id: "2", + type: "DIALOGUE", + next: "END", + trigger: { action: "UNLOCK_MISSION" }, + }, + ], + }; + + manager.startSequence(sequenceData); + manager.next(); + + expect(triggerSpy.called).to.be.true; + }); + + it("CoA 10: _advanceToNode should auto-advance ACTION nodes", () => { + const sequenceData = { + id: "TEST_SEQUENCE", + nodes: [ + { id: "1", type: "DIALOGUE", next: "2" }, + { id: "2", type: "ACTION", next: "3", trigger: { action: "DO_SOMETHING" } }, + { id: "3", type: "DIALOGUE", text: "After action", next: "END" }, + ], + }; + + manager.startSequence(sequenceData); + manager.next(); // Should skip ACTION node and go to 3 + + expect(manager.currentNode.id).to.equal("3"); + }); + + it("CoA 11: endSequence should dispatch narrative-end event", () => { + const endSpy = sinon.spy(); + manager.addEventListener("narrative-end", endSpy); + + const sequenceData = { + id: "TEST_SEQUENCE", + nodes: [{ id: "1", type: "DIALOGUE", next: "END" }], + }; + + manager.startSequence(sequenceData); + manager.endSequence(); + + expect(endSpy.called).to.be.true; + expect(manager.currentSequence).to.be.null; + expect(manager.currentNode).to.be.null; + }); + + it("CoA 12: broadcastUpdate should dispatch narrative-update event", () => { + const updateSpy = sinon.spy(); + manager.addEventListener("narrative-update", updateSpy); + + const sequenceData = { + id: "TEST_SEQUENCE", + nodes: [{ id: "1", type: "DIALOGUE", text: "Test", next: "END" }], + }; + + manager.startSequence(sequenceData); + + expect(updateSpy.called).to.be.true; + const eventDetail = updateSpy.firstCall.args[0].detail; + expect(eventDetail.node).to.equal(manager.currentNode); + expect(eventDetail.active).to.be.true; + }); + + it("CoA 13: _advanceToNode should handle missing nodes gracefully", () => { + const consoleError = sinon.stub(console, "error"); + const endSpy = sinon.spy(manager, "endSequence"); + + const sequenceData = { + id: "TEST_SEQUENCE", + nodes: [{ id: "1", type: "DIALOGUE", next: "MISSING_NODE" }], + }; + + manager.startSequence(sequenceData); + manager.next(); + + expect(consoleError.called).to.be.true; + expect(endSpy.called).to.be.true; + + consoleError.restore(); + }); +}); + diff --git a/test/managers/RosterManager.test.js b/test/managers/RosterManager.test.js new file mode 100644 index 0000000..2c935a3 --- /dev/null +++ b/test/managers/RosterManager.test.js @@ -0,0 +1,135 @@ +import { expect } from "@esm-bundle/chai"; +import { RosterManager } from "../../src/managers/RosterManager.js"; + +describe("Manager: RosterManager", () => { + let manager; + + beforeEach(() => { + manager = new RosterManager(); + }); + + it("CoA 1: Should initialize with empty roster and graveyard", () => { + expect(manager.roster).to.be.an("array").that.is.empty; + expect(manager.graveyard).to.be.an("array").that.is.empty; + expect(manager.rosterLimit).to.equal(12); + }); + + it("CoA 2: recruitUnit should add unit to roster with generated ID", () => { + const unitData = { + class: "CLASS_VANGUARD", + name: "Test Unit", + stats: { hp: 100 }, + }; + + const newUnit = manager.recruitUnit(unitData); + + expect(newUnit).to.exist; + expect(newUnit.id).to.match(/^UNIT_\d+_\d+$/); + expect(newUnit.class).to.equal("CLASS_VANGUARD"); + expect(newUnit.name).to.equal("Test Unit"); + expect(newUnit.status).to.equal("READY"); + expect(newUnit.history).to.deep.equal({ missions: 0, kills: 0 }); + expect(manager.roster).to.have.length(1); + expect(manager.roster[0]).to.equal(newUnit); + }); + + it("CoA 3: recruitUnit should return false when roster is full", () => { + // Fill roster to limit + for (let i = 0; i < manager.rosterLimit; i++) { + manager.recruitUnit({ class: "CLASS_VANGUARD", name: `Unit ${i}` }); + } + + const result = manager.recruitUnit({ class: "CLASS_VANGUARD", name: "Extra" }); + + expect(result).to.be.false; + expect(manager.roster).to.have.length(manager.rosterLimit); + }); + + it("CoA 4: handleUnitDeath should move unit to graveyard and remove from roster", () => { + const unit = manager.recruitUnit({ + class: "CLASS_VANGUARD", + name: "Test Unit", + }); + const unitId = unit.id; + + manager.handleUnitDeath(unitId); + + expect(manager.roster).to.be.empty; + expect(manager.graveyard).to.have.length(1); + expect(manager.graveyard[0].id).to.equal(unitId); + expect(manager.graveyard[0].status).to.equal("DEAD"); + }); + + it("CoA 5: handleUnitDeath should do nothing if unit not found", () => { + manager.recruitUnit({ class: "CLASS_VANGUARD", name: "Unit 1" }); + + manager.handleUnitDeath("NONEXISTENT_ID"); + + expect(manager.roster).to.have.length(1); + expect(manager.graveyard).to.be.empty; + }); + + it("CoA 6: getDeployableUnits should return only READY units", () => { + const ready1 = manager.recruitUnit({ + class: "CLASS_VANGUARD", + name: "Ready 1", + }); + const ready2 = manager.recruitUnit({ + class: "CLASS_TINKER", + name: "Ready 2", + }); + + // Manually set a unit to INJURED + manager.roster[0].status = "INJURED"; + + const deployable = manager.getDeployableUnits(); + + expect(deployable).to.have.length(1); + expect(deployable[0].id).to.equal(ready2.id); + expect(deployable[0].status).to.equal("READY"); + }); + + it("CoA 7: load should restore roster and graveyard from save data", () => { + const saveData = { + roster: [ + { id: "UNIT_1", class: "CLASS_VANGUARD", status: "READY" }, + { id: "UNIT_2", class: "CLASS_TINKER", status: "INJURED" }, + ], + graveyard: [{ id: "UNIT_3", class: "CLASS_VANGUARD", status: "DEAD" }], + }; + + manager.load(saveData); + + expect(manager.roster).to.have.length(2); + expect(manager.graveyard).to.have.length(1); + expect(manager.roster[0].id).to.equal("UNIT_1"); + expect(manager.graveyard[0].id).to.equal("UNIT_3"); + }); + + it("CoA 8: save should serialize roster and graveyard", () => { + manager.recruitUnit({ class: "CLASS_VANGUARD", name: "Unit 1" }); + manager.recruitUnit({ class: "CLASS_TINKER", name: "Unit 2" }); + const unitId = manager.roster[0].id; + manager.handleUnitDeath(unitId); + + const saved = manager.save(); + + expect(saved).to.have.property("roster"); + expect(saved).to.have.property("graveyard"); + expect(saved.roster).to.have.length(1); + expect(saved.graveyard).to.have.length(1); + expect(saved.roster[0].name).to.equal("Unit 2"); + expect(saved.graveyard[0].id).to.equal(unitId); + }); + + it("CoA 9: clear should reset roster and graveyard", () => { + manager.recruitUnit({ class: "CLASS_VANGUARD", name: "Unit 1" }); + manager.handleUnitDeath(manager.roster[0].id); + + manager.clear(); + + expect(manager.roster).to.be.empty; + expect(manager.graveyard).to.be.empty; + }); +}); +