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 dispatch UNIT_MOVE event to MissionManager when teleporting", 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" }], }; skillRegistry.skills.set(skillId, skillDef); 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)) { const unitAtPos = gameLoop.grid.getUnitAt(targetPos); if (unitAtPos) { gameLoop.grid.removeUnit(unitAtPos); } } // Ensure target position is walkable gameLoop.grid.setCell(targetPos.x, 0, targetPos.z, 1); // Floor gameLoop.grid.setCell(targetPos.x, targetPos.y, targetPos.z, 0); // Air gameLoop.grid.setCell(targetPos.x, targetPos.y + 1, targetPos.z, 0); // Air above // Set up MissionManager spy const onGameEventSpy = sinon.spy(); if (gameLoop.missionManager) { gameLoop.missionManager.onGameEvent = onGameEventSpy; } await gameLoop.executeSkill(skillId, targetPos); // Verify UNIT_MOVE event was dispatched expect(onGameEventSpy.called).to.be.true; const unitMoveCall = onGameEventSpy.getCalls().find( (call) => call.args[0] === "UNIT_MOVE" ); expect(unitMoveCall).to.not.be.undefined; expect(unitMoveCall.args[1].unitId).to.equal(playerUnit.id); expect(unitMoveCall.args[1].position.x).to.equal(targetPos.x); expect(unitMoveCall.args[1].position.z).to.equal(targetPos.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; }); }); });