aether-shards/test/core/GameLoop/combat-skill-targeting.test.js
Matthew Mone f04905044d Implement EffectProcessor and related systems for enhanced game mechanics
Introduce the EffectProcessor class to manage game state changes through various effects, including damage, healing, and status application. Define type specifications for effects, conditions, and passive abilities in Effects.d.ts. Add a comprehensive JSON registry for passive skills and item effects, enhancing gameplay dynamics. Update the GameLoop and TurnSystem to integrate the EffectProcessor, ensuring proper handling of environmental hazards and passive effects during combat. Enhance testing coverage for the EffectProcessor and environmental interactions to validate functionality and performance.
2025-12-30 20:50:11 -08:00

556 lines
15 KiB
JavaScript

import { expect } from "@esm-bundle/chai";
import sinon from "sinon";
import * as THREE from "three";
import { GameLoop } from "../../../src/core/GameLoop.js";
import { skillRegistry } from "../../../src/managers/SkillRegistry.js";
import {
createGameLoopSetup,
cleanupGameLoop,
createRunData,
createMockGameStateManagerForCombat,
setupCombatUnits,
cleanupTurnSystem,
} from "./helpers.js";
describe("Core: GameLoop - Combat Skill Targeting and Execution", function () {
this.timeout(30000);
let gameLoop;
let container;
let mockGameStateManager;
let playerUnit;
let enemyUnit;
beforeEach(async () => {
const setup = createGameLoopSetup();
gameLoop = setup.gameLoop;
container = setup.container;
gameLoop.stop();
if (
gameLoop.turnSystem &&
typeof gameLoop.turnSystem.reset === "function"
) {
gameLoop.turnSystem.reset();
}
gameLoop.init(container);
mockGameStateManager = createMockGameStateManagerForCombat();
gameLoop.gameStateManager = mockGameStateManager;
const runData = createRunData({
squad: [{ id: "u1", classId: "CLASS_VANGUARD" }],
});
await gameLoop.startLevel(runData, { startAnimation: false });
const units = setupCombatUnits(gameLoop);
playerUnit = units.playerUnit;
enemyUnit = units.enemyUnit;
// Start combat and set player unit as active
if (gameLoop.turnSystem) {
gameLoop.turnSystem.startCombat([playerUnit, enemyUnit]);
// Manually set player unit as active for testing
gameLoop.turnSystem.activeUnitId = playerUnit.id;
}
});
afterEach(() => {
gameLoop.clearMovementHighlights();
gameLoop.clearSpawnZoneHighlights();
cleanupTurnSystem(gameLoop);
cleanupGameLoop(gameLoop, container);
});
describe("Skill Targeting Validation", () => {
it("should only highlight valid targets when entering targeting mode", async () => {
// Add a skill to the player unit
const skillId = "SKILL_TELEPORT";
const skillDef = {
id: skillId,
name: "Phase Shift",
costs: { ap: 2 },
targeting: {
range: 5,
type: "EMPTY",
line_of_sight: false,
},
effects: [{ type: "TELEPORT" }],
};
// Register the skill in the skill registry
skillRegistry.skills.set(skillId, skillDef);
// Add skill to unit actions
if (!playerUnit.actions) {
playerUnit.actions = [];
}
playerUnit.actions.push({
id: skillId,
name: "Phase Shift",
costAP: 2,
cooldown: 0,
});
// Ensure unit has enough AP
playerUnit.currentAP = 10;
// Enter targeting mode
gameLoop.onSkillClicked(skillId);
// Verify we're in targeting mode
expect(gameLoop.combatState).to.equal("TARGETING_SKILL");
expect(gameLoop.activeSkillId).to.equal(skillId);
// Check that highlights were created (valid targets only)
if (gameLoop.voxelManager && gameLoop.voxelManager.rangeHighlights) {
const highlightCount = gameLoop.voxelManager.rangeHighlights.size;
// Should have highlights for valid empty tiles within range
expect(highlightCount).to.be.greaterThan(0);
}
});
it("should not highlight invalid targets (out of range)", async () => {
const skillId = "SKILL_SHORT_RANGE";
const skillDef = {
id: skillId,
name: "Short Range",
costs: { ap: 1 },
targeting: {
range: 2, // Very short range
type: "ENEMY",
line_of_sight: true,
},
effects: [],
};
// Register the skill in the skill registry
skillRegistry.skills.set(skillId, skillDef);
// Add skill to unit actions
if (!playerUnit.actions) {
playerUnit.actions = [];
}
playerUnit.actions.push({
id: skillId,
name: "Short Range",
costAP: 1,
cooldown: 0,
});
// Place enemy far away
const farAwayPos = {
x: playerUnit.position.x + 10,
y: playerUnit.position.y,
z: playerUnit.position.z + 10,
};
gameLoop.grid.moveUnit(enemyUnit, farAwayPos, { force: true });
// Enter targeting mode
gameLoop.onSkillClicked(skillId);
// Verify we're in targeting mode
expect(gameLoop.combatState).to.equal("TARGETING_SKILL");
// The far away enemy should not be highlighted
// (validation happens when checking individual tiles)
expect(gameLoop.activeSkillId).to.equal(skillId);
});
});
describe("Movement Button Toggle", () => {
it("should return to movement mode when movement button is clicked", () => {
// First enter skill targeting mode
const skillId = "SKILL_TELEPORT";
const skillDef = {
id: skillId,
name: "Phase Shift",
costs: { ap: 2 },
targeting: {
range: 5,
type: "EMPTY",
line_of_sight: false,
},
effects: [{ type: "TELEPORT" }],
};
if (gameLoop.skillTargetingSystem) {
const registry = gameLoop.skillTargetingSystem.skillRegistry;
if (registry instanceof Map) {
registry.set(skillId, skillDef);
} else if (registry.set) {
registry.set(skillId, skillDef);
} else {
registry[skillId] = skillDef;
}
}
if (!playerUnit.actions) {
playerUnit.actions = [];
}
playerUnit.actions.push({
id: skillId,
name: "Phase Shift",
costAP: 2,
cooldown: 0,
});
// Ensure unit has enough AP
playerUnit.currentAP = 10;
gameLoop.onSkillClicked(skillId);
expect(gameLoop.combatState).to.equal("TARGETING_SKILL");
// Click movement button
gameLoop.onMovementClicked();
// Should return to IDLE/movement mode
expect(gameLoop.combatState).to.equal("IDLE");
expect(gameLoop.activeSkillId).to.be.null;
});
it("should toggle skill off when clicking the same skill again", () => {
const skillId = "SKILL_TELEPORT";
const skillDef = {
id: skillId,
name: "Phase Shift",
costs: { ap: 2 },
targeting: {
range: 5,
type: "EMPTY",
line_of_sight: false,
},
effects: [{ type: "TELEPORT" }],
};
if (gameLoop.skillTargetingSystem) {
const registry = gameLoop.skillTargetingSystem.skillRegistry;
if (registry instanceof Map) {
registry.set(skillId, skillDef);
} else if (registry.set) {
registry.set(skillId, skillDef);
} else {
registry[skillId] = skillDef;
}
}
if (!playerUnit.actions) {
playerUnit.actions = [];
}
playerUnit.actions.push({
id: skillId,
name: "Phase Shift",
costAP: 2,
cooldown: 0,
});
// Ensure unit has enough AP
playerUnit.currentAP = 10;
// First click - enter targeting mode
gameLoop.onSkillClicked(skillId);
expect(gameLoop.combatState).to.equal("TARGETING_SKILL");
// Second click - should cancel targeting
gameLoop.onSkillClicked(skillId);
expect(gameLoop.combatState).to.equal("IDLE");
expect(gameLoop.activeSkillId).to.be.null;
});
});
describe("Hotkey Support", () => {
it("should trigger skill when number key is pressed", () => {
const skillId = "SKILL_TEST";
const skillDef = {
id: skillId,
name: "Test Skill",
costs: { ap: 2 },
targeting: {
range: 5,
type: "ENEMY",
line_of_sight: true,
},
effects: [],
};
// Register the skill in the skill registry
skillRegistry.skills.set(skillId, skillDef);
if (!playerUnit.actions) {
playerUnit.actions = [];
}
playerUnit.actions.push({
id: skillId,
name: "Test Skill",
costAP: 2,
cooldown: 0,
});
// Ensure unit has enough AP
playerUnit.currentAP = 10;
// Press number key 1 (first skill)
gameLoop.handleKeyInput("Digit1");
// Should enter targeting mode
expect(gameLoop.combatState).to.equal("TARGETING_SKILL");
expect(gameLoop.activeSkillId).to.equal(skillId);
});
it("should trigger movement mode when M key is pressed", () => {
// First enter skill targeting mode
const skillId = "SKILL_TEST";
const skillDef = {
id: skillId,
name: "Test Skill",
costs: { ap: 2 },
targeting: {
range: 5,
type: "ENEMY",
line_of_sight: true,
},
effects: [],
};
// Register the skill in the skill registry
skillRegistry.skills.set(skillId, skillDef);
if (!playerUnit.actions) {
playerUnit.actions = [];
}
playerUnit.actions.push({
id: skillId,
name: "Test Skill",
costAP: 2,
cooldown: 0,
});
// Ensure unit has enough AP
playerUnit.currentAP = 10;
gameLoop.onSkillClicked(skillId);
expect(gameLoop.combatState).to.equal("TARGETING_SKILL");
// Press M key
gameLoop.handleKeyInput("KeyM");
// Should return to movement mode
expect(gameLoop.combatState).to.equal("IDLE");
});
});
describe("TELEPORT Effect Execution", () => {
it("should teleport unit to target position when TELEPORT effect is executed", async () => {
const skillId = "SKILL_TELEPORT";
const skillDef = {
id: skillId,
name: "Phase Shift",
costs: { ap: 2 },
targeting: {
range: 5,
type: "EMPTY",
line_of_sight: false,
},
effects: [{ type: "TELEPORT" }],
};
// Register the skill in the skill registry
skillRegistry.skills.set(skillId, skillDef);
// Add skill to unit actions
if (!playerUnit.actions) {
playerUnit.actions = [];
}
playerUnit.actions.push({
id: skillId,
name: "Phase Shift",
costAP: 2,
cooldown: 0,
});
const originalPos = { ...playerUnit.position };
const targetPos = {
x: originalPos.x + 3,
y: originalPos.y,
z: originalPos.z + 3,
};
// Ensure target position is valid and empty
if (gameLoop.grid.isOccupied(targetPos)) {
// Clear it if occupied
const unitAtPos = gameLoop.grid.getUnitAt(targetPos);
if (unitAtPos) {
gameLoop.grid.removeUnit(unitAtPos);
}
}
// Ensure target position is walkable (floor at y=0, air at y=1 and y=2)
gameLoop.grid.setCell(targetPos.x, 0, targetPos.z, 1); // Floor
gameLoop.grid.setCell(targetPos.x, targetPos.y, targetPos.z, 0); // Air at y=1
gameLoop.grid.setCell(targetPos.x, targetPos.y + 1, targetPos.z, 0); // Air at y=2 (headroom)
// Execute the skill
await gameLoop.executeSkill(skillId, targetPos);
// Unit should be at target position
expect(playerUnit.position.x).to.equal(targetPos.x);
expect(playerUnit.position.z).to.equal(targetPos.z);
// Y might be adjusted to walkable level
expect(playerUnit.position.y).to.be.a("number");
// Unit mesh should be updated
const mesh = gameLoop.unitMeshes.get(playerUnit.id);
if (mesh) {
expect(mesh.position.x).to.equal(playerUnit.position.x);
expect(mesh.position.z).to.equal(playerUnit.position.z);
}
});
it("should deduct AP when executing TELEPORT skill", async () => {
const skillId = "SKILL_TELEPORT";
const skillDef = {
id: skillId,
name: "Phase Shift",
costs: { ap: 2 },
targeting: {
range: 5,
type: "EMPTY",
line_of_sight: false,
},
effects: [{ type: "TELEPORT" }],
};
// Register the skill in the skill registry
skillRegistry.skills.set(skillId, skillDef);
if (!playerUnit.actions) {
playerUnit.actions = [];
}
playerUnit.actions.push({
id: skillId,
name: "Phase Shift",
costAP: 2,
cooldown: 0,
});
// Ensure unit has enough AP
playerUnit.currentAP = 10;
const initialAP = playerUnit.currentAP;
const targetPos = {
x: playerUnit.position.x + 2,
y: playerUnit.position.y,
z: playerUnit.position.z + 2,
};
// Ensure target is empty
if (gameLoop.grid.isOccupied(targetPos)) {
const unitAtPos = gameLoop.grid.getUnitAt(targetPos);
if (unitAtPos) {
gameLoop.grid.removeUnit(unitAtPos);
}
}
await gameLoop.executeSkill(skillId, targetPos);
// AP should be deducted
expect(playerUnit.currentAP).to.equal(initialAP - 2);
});
it("should set cooldown when executing skill", async () => {
const skillId = "SKILL_TELEPORT";
const skillDef = {
id: skillId,
name: "Phase Shift",
costs: { ap: 2 },
cooldown_turns: 4,
targeting: {
range: 5,
type: "EMPTY",
line_of_sight: false,
},
effects: [{ type: "TELEPORT" }],
};
// Register the skill in the skill registry
skillRegistry.skills.set(skillId, skillDef);
if (!playerUnit.actions) {
playerUnit.actions = [];
}
const skillAction = {
id: skillId,
name: "Phase Shift",
costAP: 2,
cooldown: 0,
};
playerUnit.actions.push(skillAction);
// Ensure unit has enough AP
playerUnit.currentAP = 10;
const targetPos = {
x: playerUnit.position.x + 2,
y: playerUnit.position.y,
z: playerUnit.position.z + 2,
};
// Ensure target is empty
if (gameLoop.grid.isOccupied(targetPos)) {
const unitAtPos = gameLoop.grid.getUnitAt(targetPos);
if (unitAtPos) {
gameLoop.grid.removeUnit(unitAtPos);
}
}
await gameLoop.executeSkill(skillId, targetPos);
// Cooldown should be set
expect(skillAction.cooldown).to.equal(1);
});
});
describe("Skill Targeting State Management", () => {
it("should clear highlights when ending turn with skill active", () => {
const skillId = "SKILL_TELEPORT";
const skillDef = {
id: skillId,
name: "Phase Shift",
costs: { ap: 2 },
targeting: {
range: 5,
type: "EMPTY",
line_of_sight: false,
},
effects: [{ type: "TELEPORT" }],
};
// Register the skill in the skill registry
skillRegistry.skills.set(skillId, skillDef);
if (!playerUnit.actions) {
playerUnit.actions = [];
}
playerUnit.actions.push({
id: skillId,
name: "Phase Shift",
costAP: 2,
cooldown: 0,
});
// Ensure unit has enough AP
playerUnit.currentAP = 10;
// Enter targeting mode
gameLoop.onSkillClicked(skillId);
expect(gameLoop.combatState).to.equal("TARGETING_SKILL");
// End turn
gameLoop.endTurn();
// Should clear targeting state
expect(gameLoop.combatState).to.equal("IDLE");
expect(gameLoop.activeSkillId).to.be.null;
});
});
});