aether-shards/test/core/GameLoop/combat-skill-targeting.test.js

557 lines
15 KiB
JavaScript
Raw Normal View History

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