437 lines
13 KiB
JavaScript
437 lines
13 KiB
JavaScript
|
|
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
|
||
|
|
skillRegistry = new Map();
|
||
|
|
skillRegistry.set("SKILL_SINGLE_TARGET", {
|
||
|
|
id: "SKILL_SINGLE_TARGET",
|
||
|
|
name: "Single Target",
|
||
|
|
range: 5,
|
||
|
|
target_type: "ENEMY",
|
||
|
|
aoe_type: "SINGLE",
|
||
|
|
costAP: 2,
|
||
|
|
effects: [],
|
||
|
|
});
|
||
|
|
skillRegistry.set("SKILL_CIRCLE_AOE", {
|
||
|
|
id: "SKILL_CIRCLE_AOE",
|
||
|
|
name: "Circle AoE",
|
||
|
|
range: 4,
|
||
|
|
target_type: "ENEMY",
|
||
|
|
aoe_type: "CIRCLE",
|
||
|
|
aoe_radius: 2,
|
||
|
|
costAP: 3,
|
||
|
|
effects: [],
|
||
|
|
});
|
||
|
|
skillRegistry.set("SKILL_LINE_AOE", {
|
||
|
|
id: "SKILL_LINE_AOE",
|
||
|
|
name: "Line AoE",
|
||
|
|
range: 6,
|
||
|
|
target_type: "ENEMY",
|
||
|
|
aoe_type: "LINE",
|
||
|
|
aoe_length: 4,
|
||
|
|
costAP: 2,
|
||
|
|
effects: [],
|
||
|
|
});
|
||
|
|
skillRegistry.set("SKILL_ALLY_HEAL", {
|
||
|
|
id: "SKILL_ALLY_HEAL",
|
||
|
|
name: "Heal",
|
||
|
|
range: 3,
|
||
|
|
target_type: "ALLY",
|
||
|
|
aoe_type: "SINGLE",
|
||
|
|
costAP: 2,
|
||
|
|
effects: [],
|
||
|
|
});
|
||
|
|
skillRegistry.set("SKILL_EMPTY_TARGET", {
|
||
|
|
id: "SKILL_EMPTY_TARGET",
|
||
|
|
name: "Place Trap",
|
||
|
|
range: 3,
|
||
|
|
target_type: "EMPTY",
|
||
|
|
aoe_type: "SINGLE",
|
||
|
|
costAP: 1,
|
||
|
|
effects: [],
|
||
|
|
});
|
||
|
|
skillRegistry.set("SKILL_IGNORE_COVER", {
|
||
|
|
id: "SKILL_IGNORE_COVER",
|
||
|
|
name: "Piercing Shot",
|
||
|
|
range: 8,
|
||
|
|
target_type: "ENEMY",
|
||
|
|
aoe_type: "SINGLE",
|
||
|
|
ignore_cover: true,
|
||
|
|
costAP: 3,
|
||
|
|
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);
|
||
|
|
|
||
|
|
const targetPos = { x: 7, y: 1, z: 5 }; // 2 tiles away (within range 5)
|
||
|
|
|
||
|
|
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);
|
||
|
|
|
||
|
|
const targetPos = { x: 8, y: 1, z: 5 }; // Clear path
|
||
|
|
|
||
|
|
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);
|
||
|
|
|
||
|
|
const targetPos = { x: 8, y: 1, z: 5 };
|
||
|
|
|
||
|
|
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;
|
||
|
|
});
|
||
|
|
});
|
||
|
|
});
|