Add comprehensive tests for the InventoryManager and InventoryContainer to validate item management functionalities. Implement integration tests for the CharacterSheet component, ensuring proper interaction with the inventory system. Update the Explorer class to support new inventory features and maintain backward compatibility. Refactor related components for improved clarity and performance.
459 lines
14 KiB
JavaScript
459 lines
14 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 (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;
|
|
});
|
|
});
|
|
});
|