aether-shards/test/core/CombatStateSpec.test.js

524 lines
19 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";
/**
* Tests for CombatState.spec.js Conditions of Acceptance
* This test suite verifies that the implementation matches the specification.
*/
describe("Combat State Specification - CoA Tests", function () {
this.timeout(30000);
let gameLoop;
let container;
let mockGameStateManager;
beforeEach(async () => {
container = document.createElement("div");
document.body.appendChild(container);
gameLoop = new GameLoop();
gameLoop.init(container);
// Setup mock game state manager with state tracking
let storedCombatState = null;
mockGameStateManager = {
currentState: "STATE_COMBAT",
transitionTo: sinon.stub(),
setCombatState: sinon.stub().callsFake((state) => {
storedCombatState = state;
}),
getCombatState: sinon.stub().callsFake(() => {
return storedCombatState;
}),
};
gameLoop.gameStateManager = mockGameStateManager;
// Initialize a level
const runData = {
seed: 12345,
depth: 1,
squad: [],
};
await gameLoop.startLevel(runData, { startAnimation: false });
});
afterEach(() => {
gameLoop.stop();
if (container.parentNode) {
container.parentNode.removeChild(container);
}
if (gameLoop.renderer) {
gameLoop.renderer.dispose();
gameLoop.renderer.forceContextLoss();
}
});
describe("TurnSystem CoA Tests", () => {
let playerUnit1, playerUnit2, enemyUnit1;
beforeEach(() => {
// Create test units with different speeds
playerUnit1 = gameLoop.unitManager.createUnit("CLASS_VANGUARD", "PLAYER");
playerUnit1.baseStats.speed = 15; // Fast
playerUnit1.position = { x: 5, y: 1, z: 5 };
gameLoop.grid.placeUnit(playerUnit1, playerUnit1.position);
playerUnit2 = gameLoop.unitManager.createUnit("CLASS_VANGUARD", "PLAYER");
playerUnit2.baseStats.speed = 8; // Slow
playerUnit2.position = { x: 6, y: 1, z: 5 };
gameLoop.grid.placeUnit(playerUnit2, playerUnit2.position);
enemyUnit1 = gameLoop.unitManager.createUnit("ENEMY_DEFAULT", "ENEMY");
enemyUnit1.baseStats.speed = 12; // Medium
enemyUnit1.position = { x: 10, y: 1, z: 10 };
gameLoop.grid.placeUnit(enemyUnit1, enemyUnit1.position);
});
it("CoA 1: Initiative Roll - Units should be sorted by Speed (Highest First) on combat start", () => {
// Initialize combat
gameLoop.initializeCombatUnits();
const allUnits = gameLoop.unitManager.getAllUnits();
gameLoop.turnSystem.startCombat(allUnits);
gameLoop.updateCombatState();
const combatState = mockGameStateManager.getCombatState();
expect(combatState).to.exist;
expect(combatState.turnQueue).to.be.an("array");
// Check that turn queue is sorted by initiative (which should correlate with speed)
// Note: Current implementation uses chargeMeter, not direct speed sorting
// This test documents the current behavior vs spec
// turnQueue is string[] per spec, so we check that it exists and has entries
const queue = combatState.turnQueue;
expect(queue.length).to.be.greaterThan(0);
// Verify all entries are strings (unit IDs)
queue.forEach((unitId) => {
expect(unitId).to.be.a("string");
});
});
it("CoA 2: Turn Start Hygiene - AP should reset to baseAP when turn begins", () => {
// Set up a unit with low AP
playerUnit1.currentAP = 3;
playerUnit1.chargeMeter = 100; // Ready to act
// Initialize combat
gameLoop.initializeCombatUnits();
const allUnits = gameLoop.unitManager.getAllUnits();
gameLoop.turnSystem.startCombat(allUnits);
gameLoop.updateCombatState();
// When a unit's turn starts (they're at 100 charge), they should have full AP
// AP is calculated as: 3 + floor(speed/5)
// With speed 15: 3 + floor(15/5) = 3 + 3 = 6
const expectedAP = 3 + Math.floor(playerUnit1.baseStats.speed / 5);
expect(playerUnit1.currentAP).to.equal(expectedAP);
});
it("CoA 2: Turn Start Hygiene - Status effects should tick (placeholder test)", () => {
// TODO: Implement status effect ticking
// This is a placeholder to document the requirement
playerUnit1.statusEffects = [{ id: "poison", duration: 3, damage: 5 }];
// When turn starts, status effects should tick
// Currently not implemented - this test documents the gap
expect(playerUnit1.statusEffects.length).to.be.greaterThan(0);
});
it("CoA 2: Turn Start Hygiene - Cooldowns should decrement (placeholder test)", () => {
// TODO: Implement cooldown decrementing
// This is a placeholder to document the requirement
// Currently not implemented - this test documents the gap
expect(true).to.be.true; // Placeholder
});
it("CoA 3: Cycling - endTurn() should move to next unit in queue", () => {
gameLoop.initializeCombatUnits();
const allUnits = gameLoop.unitManager.getAllUnits();
gameLoop.turnSystem.startCombat(allUnits);
gameLoop.updateCombatState();
const initialCombatState = mockGameStateManager.getCombatState();
const initialActiveUnitId = initialCombatState.activeUnitId;
// End turn
gameLoop.endTurn();
// Verify updateCombatState was called (which recalculates queue)
expect(mockGameStateManager.setCombatState.called).to.be.true;
// Get the new combat state
const newCombatState = mockGameStateManager.getCombatState();
expect(newCombatState).to.exist;
// The active unit should have changed (unless it's the same unit's turn again)
// At minimum, the state should be updated
expect(newCombatState.activeUnitId).to.exist;
});
it("CoA 3: Cycling - Should increment round when queue is empty", () => {
// This test documents that round tracking should be implemented
// Currently roundNumber exists but doesn't increment
gameLoop.initializeCombatUnits();
const allUnits = gameLoop.unitManager.getAllUnits();
gameLoop.turnSystem.startCombat(allUnits);
gameLoop.updateCombatState();
const combatState = mockGameStateManager.getCombatState();
expect(combatState).to.exist;
// Check both round (spec) and roundNumber (UI alias)
expect(combatState.round).to.exist;
expect(combatState.roundNumber).to.exist;
// TODO: Verify round increments when queue cycles
});
});
describe("MovementSystem CoA Tests", () => {
let playerUnit;
beforeEach(() => {
playerUnit = gameLoop.unitManager.createUnit("CLASS_VANGUARD", "PLAYER");
playerUnit.baseStats.movement = 4;
playerUnit.currentAP = 10;
playerUnit.position = { x: 5, y: 1, z: 5 };
gameLoop.grid.placeUnit(playerUnit, playerUnit.position);
gameLoop.createUnitMesh(playerUnit, playerUnit.position);
});
it("CoA 1: Validation - Move should fail if tile is blocked/occupied", async () => {
// Start combat with the player unit
const allUnits = gameLoop.unitManager.getAllUnits();
gameLoop.turnSystem.startCombat(allUnits);
gameLoop.updateCombatState();
// Ensure player unit is active
const activeUnit = gameLoop.turnSystem.getActiveUnit();
if (activeUnit && activeUnit.team !== "PLAYER") {
// If enemy is active, end turn until player is active
while (gameLoop.turnSystem.getActiveUnit()?.team !== "PLAYER") {
gameLoop.endTurn();
}
}
// Place another unit on target tile
const enemyUnit = gameLoop.unitManager.createUnit(
"ENEMY_DEFAULT",
"ENEMY"
);
const occupiedPos = { x: 6, y: 1, z: 5 };
gameLoop.grid.placeUnit(enemyUnit, occupiedPos);
// Stub getCursorPosition without replacing the entire inputManager
const stub1 = sinon
.stub(gameLoop.inputManager, "getCursorPosition")
.returns(occupiedPos);
const initialPos = { ...playerUnit.position };
await gameLoop.handleCombatMovement(occupiedPos);
// Unit should not have moved
expect(playerUnit.position.x).to.equal(initialPos.x);
expect(playerUnit.position.z).to.equal(initialPos.z);
// Restore stub
stub1.restore();
});
it("CoA 1: Validation - Move should fail if no path exists", async () => {
// Start combat with the player unit
const allUnits = gameLoop.unitManager.getAllUnits();
gameLoop.turnSystem.startCombat(allUnits);
gameLoop.updateCombatState();
// Ensure player unit is active
const activeUnit = gameLoop.turnSystem.getActiveUnit();
if (activeUnit && activeUnit.team !== "PLAYER") {
// If enemy is active, end turn until player is active
while (gameLoop.turnSystem.getActiveUnit()?.team !== "PLAYER") {
gameLoop.endTurn();
}
}
// Try to move to an unreachable position (far away)
const unreachablePos = { x: 20, y: 1, z: 20 };
// Stub getCursorPosition without replacing the entire inputManager
const stub2 = sinon
.stub(gameLoop.inputManager, "getCursorPosition")
.returns(unreachablePos);
const initialPos = { ...playerUnit.position };
await gameLoop.handleCombatMovement(unreachablePos);
// Unit should not have moved
expect(playerUnit.position.x).to.equal(initialPos.x);
// Restore stub
stub2.restore();
});
it("CoA 1: Validation - Move should fail if insufficient AP", async () => {
// Start combat with the player unit
const allUnits = gameLoop.unitManager.getAllUnits();
gameLoop.turnSystem.startCombat(allUnits);
gameLoop.updateCombatState();
// Ensure player unit is active
const activeUnit = gameLoop.turnSystem.getActiveUnit();
if (activeUnit && activeUnit.team !== "PLAYER") {
// If enemy is active, end turn until player is active
while (gameLoop.turnSystem.getActiveUnit()?.team !== "PLAYER") {
gameLoop.endTurn();
}
}
playerUnit.currentAP = 0; // No AP
const targetPos = { x: 6, y: 1, z: 5 };
// Stub getCursorPosition without replacing the entire inputManager
const stub3 = sinon
.stub(gameLoop.inputManager, "getCursorPosition")
.returns(targetPos);
const initialPos = { ...playerUnit.position };
await gameLoop.handleCombatMovement(targetPos);
// Unit should not have moved
expect(playerUnit.position.x).to.equal(initialPos.x);
// Restore stub
stub3.restore();
});
it("CoA 2: Execution - Successful move should update Unit position", async () => {
// Start combat with the player unit
const allUnits = gameLoop.unitManager.getAllUnits();
gameLoop.turnSystem.startCombat(allUnits);
gameLoop.updateCombatState();
// Ensure player unit is active
const activeUnit = gameLoop.turnSystem.getActiveUnit();
if (activeUnit && activeUnit.team !== "PLAYER") {
// If enemy is active, end turn until player is active
while (gameLoop.turnSystem.getActiveUnit()?.team !== "PLAYER") {
gameLoop.endTurn();
}
}
const targetPos = { x: 6, y: 1, z: 5 };
// Stub getCursorPosition without replacing the entire inputManager
sinon.stub(gameLoop.inputManager, "getCursorPosition").returns(targetPos);
await gameLoop.handleCombatMovement(targetPos);
// Restore stub
gameLoop.inputManager.getCursorPosition.restore();
// Unit position should be updated
expect(playerUnit.position.x).to.equal(targetPos.x);
expect(playerUnit.position.z).to.equal(targetPos.z);
});
it("CoA 2: Execution - Successful move should update VoxelGrid occupancy", async () => {
// Start combat with the player unit
const allUnits = gameLoop.unitManager.getAllUnits();
gameLoop.turnSystem.startCombat(allUnits);
gameLoop.updateCombatState();
// Ensure player unit is active
const activeUnit = gameLoop.turnSystem.getActiveUnit();
if (activeUnit && activeUnit.team !== "PLAYER") {
// If enemy is active, end turn until player is active
while (gameLoop.turnSystem.getActiveUnit()?.team !== "PLAYER") {
gameLoop.endTurn();
}
}
const targetPos = { x: 6, y: 1, z: 5 };
const initialPos = { ...playerUnit.position };
// Stub getCursorPosition without replacing the entire inputManager
sinon.stub(gameLoop.inputManager, "getCursorPosition").returns(targetPos);
// Old position should have unit
expect(gameLoop.grid.getUnitAt(initialPos)).to.equal(playerUnit);
await gameLoop.handleCombatMovement(targetPos);
// Restore stub
gameLoop.inputManager.getCursorPosition.restore();
// New position should have unit
expect(gameLoop.grid.getUnitAt(targetPos)).to.equal(playerUnit);
// Old position should be empty
expect(gameLoop.grid.getUnitAt(initialPos)).to.be.undefined;
});
it("CoA 2: Execution - Successful move should deduct correct AP cost", async () => {
// Start combat with the player unit
const allUnits = gameLoop.unitManager.getAllUnits();
gameLoop.turnSystem.startCombat(allUnits);
gameLoop.updateCombatState();
// Ensure player unit is active
const activeUnit = gameLoop.turnSystem.getActiveUnit();
if (activeUnit && activeUnit.team !== "PLAYER") {
// If enemy is active, end turn until player is active
while (gameLoop.turnSystem.getActiveUnit()?.team !== "PLAYER") {
gameLoop.endTurn();
}
}
const targetPos = { x: 6, y: 1, z: 5 };
const initialAP = playerUnit.currentAP;
// Stub getCursorPosition without replacing the entire inputManager
sinon.stub(gameLoop.inputManager, "getCursorPosition").returns(targetPos);
await gameLoop.handleCombatMovement(targetPos);
// Restore stub
gameLoop.inputManager.getCursorPosition.restore();
// AP should be deducted (at least 1 for adjacent move)
expect(playerUnit.currentAP).to.be.lessThan(initialAP);
expect(playerUnit.currentAP).to.equal(initialAP - 1); // 1 tile = 1 AP
});
it("CoA 3: Path Snapping - Should move to furthest reachable tile (optional QoL)", () => {
// This is an optional feature - document that it's not implemented
// If user clicks far away but only has AP for 2 tiles, should move 2 tiles
playerUnit.currentAP = 2; // Only enough for 2 tiles
const farTargetPos = { x: 10, y: 1, z: 5 }; // 5 tiles away
// Currently not implemented - this test documents the gap
// The current implementation just fails if unreachable
expect(true).to.be.true; // Placeholder
});
});
describe("CombatState Interface Compliance", () => {
let playerUnit;
beforeEach(() => {
// Create a unit so combat state can be generated
playerUnit = gameLoop.unitManager.createUnit("CLASS_VANGUARD", "PLAYER");
playerUnit.position = { x: 5, y: 1, z: 5 };
gameLoop.grid.placeUnit(playerUnit, playerUnit.position);
});
it("Should track combat phase (now implemented per spec)", async () => {
// Spec defines: phase: CombatPhase
// Now implemented in TurnSystem
const runData = {
seed: 12345,
depth: 1,
squad: [{ id: "u1", classId: "CLASS_VANGUARD" }],
};
await gameLoop.startLevel(runData, { startAnimation: false });
// Set state to deployment so finalizeDeployment works
mockGameStateManager.currentState = "STATE_DEPLOYMENT";
const playerUnit = gameLoop.deployUnit(
runData.squad[0],
gameLoop.playerSpawnZone[0]
);
gameLoop.finalizeDeployment();
const combatState = mockGameStateManager.getCombatState();
expect(combatState).to.exist;
// Now has phase property per spec
expect(combatState.phase).to.be.oneOf([
"INIT",
"TURN_START",
"WAITING_FOR_INPUT",
"EXECUTING_ACTION",
"TURN_END",
"COMBAT_END",
]);
});
it("Should track isActive flag (now implemented per spec)", async () => {
// Spec defines isActive: boolean
// Now implemented in TurnSystem
const runData = {
seed: 12345,
depth: 1,
squad: [{ id: "u1", classId: "CLASS_VANGUARD" }],
};
await gameLoop.startLevel(runData, { startAnimation: false });
// Set state to deployment so finalizeDeployment works
mockGameStateManager.currentState = "STATE_DEPLOYMENT";
const playerUnit = gameLoop.deployUnit(
runData.squad[0],
gameLoop.playerSpawnZone[0]
);
gameLoop.finalizeDeployment();
const combatState = mockGameStateManager.getCombatState();
expect(combatState).to.exist;
// Now has isActive property per spec
expect(combatState.isActive).to.be.a("boolean");
expect(combatState.isActive).to.be.true; // Combat should be active
});
it("Should use activeUnitId string (now implemented per spec, also has activeUnit for UI)", async () => {
// Spec defines: activeUnitId: string | null
// Implementation now has both: activeUnitId (spec) and activeUnit (UI compatibility)
const runData = {
seed: 12345,
depth: 1,
squad: [{ id: "u1", classId: "CLASS_VANGUARD" }],
};
await gameLoop.startLevel(runData, { startAnimation: false });
// Set state to deployment so finalizeDeployment works
mockGameStateManager.currentState = "STATE_DEPLOYMENT";
const playerUnit = gameLoop.deployUnit(
runData.squad[0],
gameLoop.playerSpawnZone[0]
);
gameLoop.finalizeDeployment();
const combatState = mockGameStateManager.getCombatState();
expect(combatState).to.exist;
// Now has both: spec-compliant activeUnitId and UI-compatible activeUnit
expect(combatState.activeUnitId).to.be.a("string");
expect(combatState.activeUnit).to.exist; // Still available for UI
});
it("Should use turnQueue as string[] (now implemented per spec, also has enrichedQueue for UI)", async () => {
// Spec defines: turnQueue: string[]
// Implementation now has both: turnQueue (spec) and enrichedQueue (UI compatibility)
const runData = {
seed: 12345,
depth: 1,
squad: [{ id: "u1", classId: "CLASS_VANGUARD" }],
};
await gameLoop.startLevel(runData, { startAnimation: false });
// Set state to deployment so finalizeDeployment works
mockGameStateManager.currentState = "STATE_DEPLOYMENT";
const playerUnit = gameLoop.deployUnit(
runData.squad[0],
gameLoop.playerSpawnZone[0]
);
gameLoop.finalizeDeployment();
const combatState = mockGameStateManager.getCombatState();
expect(combatState).to.exist;
// Now has spec-compliant turnQueue as string[]
expect(combatState.turnQueue).to.be.an("array");
if (combatState.turnQueue.length > 0) {
// Spec requires just string IDs
expect(combatState.turnQueue[0]).to.be.a("string"); // Spec format
// Also has enrichedQueue for UI
expect(combatState.enrichedQueue).to.be.an("array");
if (combatState.enrichedQueue && combatState.enrichedQueue.length > 0) {
expect(combatState.enrichedQueue[0]).to.have.property("unitId"); // UI format
}
}
});
});
});