aether-shards/test/systems/SkillTargetingSystem.test.js
Matthew Mone 56aa6d79df Add Combat Skill Usage and Targeting System Specifications
Introduce detailed specifications for combat skill usage, including interaction flow, state machine updates, and the skill targeting system. Implement the SkillTargetingSystem to handle targeting validation and area of effect calculations. Enhance the CombatHUD specification to define the UI overlay for combat phases. Integrate these systems into the GameLoop for improved combat mechanics and user experience.
2025-12-23 21:01:54 -08:00

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