aether-shards/test/core/CombatStateSpec.test.js
Matthew Mone ac0f3cc396 Enhance testing and integration of inventory and character management systems
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.
2025-12-27 16:54:03 -08:00

523 lines
19 KiB
JavaScript

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