aether-shards/test/systems/SkillTargetingSystem.test.js

460 lines
14 KiB
JavaScript
Raw Normal View History

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;
});
});
});