import { expect } from "@esm-bundle/chai"; import { SkillTargetingSystem } from "../../src/systems/SkillTargetingSystem.js"; import { VoxelGrid } from "../../src/grid/VoxelGrid.js"; import { UnitManager } from "../../src/managers/UnitManager.js"; import { Explorer } from "../../src/units/Explorer.js"; import { Enemy } from "../../src/units/Enemy.js"; describe("Systems: SkillTargetingSystem", function () { let targetingSystem; let grid; let unitManager; let mockRegistry; let skillRegistry; beforeEach(() => { // Create a 20x10x20 grid grid = new VoxelGrid(20, 10, 20); // Create floor at y=0 for (let x = 0; x < 20; x++) { for (let z = 0; z < 20; z++) { grid.setCell(x, 0, z, 1); // Floor grid.setCell(x, 1, z, 0); // Air at y=1 (walkable) } } // Create mock unit registry mockRegistry = new Map(); mockRegistry.set("CLASS_VANGUARD", { id: "CLASS_VANGUARD", name: "Vanguard", base_stats: { health: 100, attack: 10, defense: 5, speed: 10, movement: 4, }, }); mockRegistry.set("ENEMY_DEFAULT", { id: "ENEMY_DEFAULT", name: "Enemy", type: "ENEMY", base_stats: { health: 50, attack: 5, defense: 2, speed: 8, movement: 3, }, }); unitManager = new UnitManager(mockRegistry); // Create skill registry with various skill types (using nested format like actual JSON files) skillRegistry = new Map(); skillRegistry.set("SKILL_SINGLE_TARGET", { id: "SKILL_SINGLE_TARGET", name: "Single Target", costs: { ap: 2 }, targeting: { range: 5, type: "ENEMY", line_of_sight: true, }, effects: [], }); skillRegistry.set("SKILL_CIRCLE_AOE", { id: "SKILL_CIRCLE_AOE", name: "Circle AoE", costs: { ap: 3 }, targeting: { range: 4, type: "ENEMY", line_of_sight: true, area_of_effect: { shape: "CIRCLE", size: 2 }, }, effects: [], }); skillRegistry.set("SKILL_LINE_AOE", { id: "SKILL_LINE_AOE", name: "Line AoE", costs: { ap: 2 }, targeting: { range: 6, type: "ENEMY", line_of_sight: true, area_of_effect: { shape: "LINE", size: 4 }, }, effects: [], }); skillRegistry.set("SKILL_ALLY_HEAL", { id: "SKILL_ALLY_HEAL", name: "Heal", costs: { ap: 2 }, targeting: { range: 3, type: "ALLY", line_of_sight: true, }, effects: [], }); skillRegistry.set("SKILL_EMPTY_TARGET", { id: "SKILL_EMPTY_TARGET", name: "Place Trap", costs: { ap: 1 }, targeting: { range: 3, type: "EMPTY", line_of_sight: true, }, effects: [], }); skillRegistry.set("SKILL_IGNORE_COVER", { id: "SKILL_IGNORE_COVER", name: "Piercing Shot", costs: { ap: 3 }, targeting: { range: 8, type: "ENEMY", line_of_sight: false, // false means ignore cover }, effects: [], }); targetingSystem = new SkillTargetingSystem( grid, unitManager, skillRegistry ); }); describe("Range Validation", () => { it("should validate target within range", () => { const source = unitManager.createUnit("CLASS_VANGUARD", "PLAYER"); source.position = { x: 5, y: 1, z: 5 }; grid.placeUnit(source, source.position); // Add an enemy at target position for ENEMY target type validation const enemy = unitManager.createUnit("ENEMY_DEFAULT", "ENEMY"); const targetPos = { x: 7, y: 1, z: 5 }; // 2 tiles away (within range 5) enemy.position = targetPos; grid.placeUnit(enemy, targetPos); const result = targetingSystem.validateTarget( source, targetPos, "SKILL_SINGLE_TARGET" ); expect(result.valid).to.be.true; }); it("should reject target out of range", () => { const source = unitManager.createUnit("CLASS_VANGUARD", "PLAYER"); source.position = { x: 5, y: 1, z: 5 }; grid.placeUnit(source, source.position); const targetPos = { x: 12, y: 1, z: 5 }; // 7 tiles away (out of range 5) const result = targetingSystem.validateTarget( source, targetPos, "SKILL_SINGLE_TARGET" ); expect(result.valid).to.be.false; expect(result.reason).to.include("range"); }); }); describe("Line of Sight (LOS)", () => { it("should validate target with clear line of sight", () => { const source = unitManager.createUnit("CLASS_VANGUARD", "PLAYER"); source.position = { x: 5, y: 1, z: 5 }; grid.placeUnit(source, source.position); // Add an enemy at target position for ENEMY target type validation const enemy = unitManager.createUnit("ENEMY_DEFAULT", "ENEMY"); const targetPos = { x: 8, y: 1, z: 5 }; // Clear path enemy.position = targetPos; grid.placeUnit(enemy, targetPos); const result = targetingSystem.validateTarget( source, targetPos, "SKILL_SINGLE_TARGET" ); expect(result.valid).to.be.true; }); it("should reject target blocked by solid voxel", () => { const source = unitManager.createUnit("CLASS_VANGUARD", "PLAYER"); source.position = { x: 5, y: 1, z: 5 }; grid.placeUnit(source, source.position); // Place a wall between source and target grid.setCell(6, 1, 5, 1); // Solid block grid.setCell(6, 2, 5, 1); // Solid block const targetPos = { x: 8, y: 1, z: 5 }; const result = targetingSystem.validateTarget( source, targetPos, "SKILL_SINGLE_TARGET" ); expect(result.valid).to.be.false; expect(result.reason).to.include("line of sight"); }); it("should allow target blocked by cover if skill ignores cover", () => { const source = unitManager.createUnit("CLASS_VANGUARD", "PLAYER"); source.position = { x: 5, y: 1, z: 5 }; grid.placeUnit(source, source.position); // Place a wall between source and target grid.setCell(6, 1, 5, 1); grid.setCell(6, 2, 5, 1); // Add an enemy at target position for ENEMY target type validation const enemy = unitManager.createUnit("ENEMY_DEFAULT", "ENEMY"); const targetPos = { x: 8, y: 1, z: 5 }; enemy.position = targetPos; grid.placeUnit(enemy, targetPos); const result = targetingSystem.validateTarget( source, targetPos, "SKILL_IGNORE_COVER" ); expect(result.valid).to.be.true; // Should pass despite cover }); }); describe("Target Type Validation", () => { it("should validate ENEMY target type", () => { const source = unitManager.createUnit("CLASS_VANGUARD", "PLAYER"); source.position = { x: 5, y: 1, z: 5 }; grid.placeUnit(source, source.position); const enemy = unitManager.createUnit("ENEMY_DEFAULT", "ENEMY"); enemy.position = { x: 7, y: 1, z: 5 }; grid.placeUnit(enemy, enemy.position); const result = targetingSystem.validateTarget( source, enemy.position, "SKILL_SINGLE_TARGET" ); expect(result.valid).to.be.true; }); it("should reject ENEMY target type when targeting ally", () => { const source = unitManager.createUnit("CLASS_VANGUARD", "PLAYER"); source.position = { x: 5, y: 1, z: 5 }; grid.placeUnit(source, source.position); const ally = unitManager.createUnit("CLASS_VANGUARD", "PLAYER"); ally.position = { x: 7, y: 1, z: 5 }; grid.placeUnit(ally, ally.position); const result = targetingSystem.validateTarget( source, ally.position, "SKILL_SINGLE_TARGET" ); expect(result.valid).to.be.false; expect(result.reason).to.include("target type"); }); it("should validate ALLY target type", () => { const source = unitManager.createUnit("CLASS_VANGUARD", "PLAYER"); source.position = { x: 5, y: 1, z: 5 }; grid.placeUnit(source, source.position); const ally = unitManager.createUnit("CLASS_VANGUARD", "PLAYER"); ally.position = { x: 7, y: 1, z: 5 }; grid.placeUnit(ally, ally.position); const result = targetingSystem.validateTarget( source, ally.position, "SKILL_ALLY_HEAL" ); expect(result.valid).to.be.true; }); it("should validate EMPTY target type", () => { const source = unitManager.createUnit("CLASS_VANGUARD", "PLAYER"); source.position = { x: 5, y: 1, z: 5 }; grid.placeUnit(source, source.position); const emptyPos = { x: 7, y: 1, z: 5 }; // No unit here const result = targetingSystem.validateTarget( source, emptyPos, "SKILL_EMPTY_TARGET" ); expect(result.valid).to.be.true; }); it("should reject EMPTY target type when tile is occupied", () => { const source = unitManager.createUnit("CLASS_VANGUARD", "PLAYER"); source.position = { x: 5, y: 1, z: 5 }; grid.placeUnit(source, source.position); const enemy = unitManager.createUnit("ENEMY_DEFAULT", "ENEMY"); enemy.position = { x: 7, y: 1, z: 5 }; grid.placeUnit(enemy, enemy.position); const result = targetingSystem.validateTarget( source, enemy.position, "SKILL_EMPTY_TARGET" ); expect(result.valid).to.be.false; expect(result.reason).to.include("empty"); }); }); describe("AoE Calculation: SINGLE", () => { it("should return single tile for SINGLE AoE", () => { const sourcePos = { x: 5, y: 1, z: 5 }; const cursorPos = { x: 7, y: 1, z: 5 }; const tiles = targetingSystem.getAoETiles( sourcePos, cursorPos, "SKILL_SINGLE_TARGET" ); expect(tiles).to.have.length(1); expect(tiles[0]).to.deep.equal(cursorPos); }); }); describe("AoE Calculation: CIRCLE", () => { it("should return all tiles within radius for CIRCLE AoE", () => { const sourcePos = { x: 5, y: 1, z: 5 }; const cursorPos = { x: 7, y: 1, z: 5 }; // Center of circle const tiles = targetingSystem.getAoETiles( sourcePos, cursorPos, "SKILL_CIRCLE_AOE" ); // Circle with radius 2 should include center + surrounding tiles // Should have at least the center tile expect(tiles.length).to.be.greaterThan(0); expect(tiles.some((t) => t.x === 7 && t.z === 5)).to.be.true; }); it("should include tiles at exact radius distance", () => { const sourcePos = { x: 5, y: 1, z: 5 }; const cursorPos = { x: 7, y: 1, z: 5 }; const tiles = targetingSystem.getAoETiles( sourcePos, cursorPos, "SKILL_CIRCLE_AOE" ); // Check that tiles at radius 2 are included (Manhattan distance) const hasRadius2Tile = tiles.some((t) => { const dist = Math.abs(t.x - cursorPos.x) + Math.abs(t.y - cursorPos.y) + Math.abs(t.z - cursorPos.z); return dist === 2; }); expect(hasRadius2Tile).to.be.true; }); }); describe("AoE Calculation: LINE", () => { it("should return line of tiles from source to target for LINE AoE", () => { const sourcePos = { x: 5, y: 1, z: 5 }; const cursorPos = { x: 9, y: 1, z: 5 }; // 4 tiles east const tiles = targetingSystem.getAoETiles( sourcePos, cursorPos, "SKILL_LINE_AOE" ); // Line should include tiles along the path expect(tiles.length).to.be.greaterThan(1); // Should include source position or nearby tiles in the line const hasSourceDirection = tiles.some( (t) => t.x >= sourcePos.x && t.x <= cursorPos.x && t.z === sourcePos.z ); expect(hasSourceDirection).to.be.true; }); }); describe("getUnitsInAoE", () => { it("should return all units within AoE", () => { const source = unitManager.createUnit("CLASS_VANGUARD", "PLAYER"); source.position = { x: 5, y: 1, z: 5 }; grid.placeUnit(source, source.position); // Place enemies in AoE range const enemy1 = unitManager.createUnit("ENEMY_DEFAULT", "ENEMY"); enemy1.position = { x: 7, y: 1, z: 5 }; grid.placeUnit(enemy1, enemy1.position); const enemy2 = unitManager.createUnit("ENEMY_DEFAULT", "ENEMY"); enemy2.position = { x: 8, y: 1, z: 5 }; grid.placeUnit(enemy2, enemy2.position); const sourcePos = source.position; const targetPos = { x: 7, y: 1, z: 5 }; const units = targetingSystem.getUnitsInAoE( sourcePos, targetPos, "SKILL_CIRCLE_AOE" ); expect(units.length).to.be.greaterThan(0); expect(units.some((u) => u.id === enemy1.id)).to.be.true; }); it("should only return units within AoE radius", () => { const source = unitManager.createUnit("CLASS_VANGUARD", "PLAYER"); source.position = { x: 5, y: 1, z: 5 }; grid.placeUnit(source, source.position); const enemy1 = unitManager.createUnit("ENEMY_DEFAULT", "ENEMY"); enemy1.position = { x: 7, y: 1, z: 5 }; // Within radius 2 grid.placeUnit(enemy1, enemy1.position); const enemy2 = unitManager.createUnit("ENEMY_DEFAULT", "ENEMY"); enemy2.position = { x: 12, y: 1, z: 5 }; // Outside radius 2 grid.placeUnit(enemy2, enemy2.position); const sourcePos = { x: 5, y: 1, z: 5 }; const targetPos = { x: 7, y: 1, z: 5 }; const units = targetingSystem.getUnitsInAoE( sourcePos, targetPos, "SKILL_CIRCLE_AOE" ); expect(units.some((u) => u.id === enemy1.id)).to.be.true; expect(units.some((u) => u.id === enemy2.id)).to.be.false; }); }); });