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.
523 lines
19 KiB
JavaScript
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
|
|
}
|
|
}
|
|
});
|
|
});
|
|
});
|