import { expect } from "@esm-bundle/chai"; import sinon from "sinon"; import * as THREE from "three"; import { GameLoop } from "../../../src/core/GameLoop.js"; import { createGameLoopSetup, cleanupGameLoop, createRunData, createMockGameStateManagerForCombat, setupCombatUnits, cleanupTurnSystem, } from "./helpers.js"; describe.skip("Core: GameLoop - Combat Movement and Turn System", 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; // Clean up any existing state first gameLoop.stop(); // Reset turn system if it exists if ( gameLoop.turnSystem && typeof gameLoop.turnSystem.reset === "function" ) { gameLoop.turnSystem.reset(); } gameLoop.init(container); // Setup mock game state manager mockGameStateManager = createMockGameStateManagerForCombat(); gameLoop.gameStateManager = mockGameStateManager; // Initialize a level const runData = createRunData({ squad: [{ id: "u1", classId: "CLASS_VANGUARD" }], }); await gameLoop.startLevel(runData, { startAnimation: false }); // Create test units const units = setupCombatUnits(gameLoop); playerUnit = units.playerUnit; enemyUnit = units.enemyUnit; }); afterEach(() => { // Clear any highlights first gameLoop.clearMovementHighlights(); gameLoop.clearSpawnZoneHighlights(); // Clean up turn system state cleanupTurnSystem(gameLoop); // Stop the game loop (this will remove event listeners) cleanupGameLoop(gameLoop, container); }); it("CoA 5: should show movement highlights for player units in combat", () => { // Setup combat state with player as active mockGameStateManager.getCombatState.returns({ activeUnit: { id: playerUnit.id, name: playerUnit.name, }, turnQueue: [], }); // Update movement highlights gameLoop.updateMovementHighlights(playerUnit); // Should have created highlight meshes expect(gameLoop.movementHighlights.size).to.be.greaterThan(0); // Verify highlights are in the scene const highlightArray = Array.from(gameLoop.movementHighlights); expect(highlightArray.length).to.be.greaterThan(0); expect(highlightArray[0]).to.be.instanceOf(THREE.Mesh); }); it("CoA 6: should not show movement highlights for enemy units", () => { mockGameStateManager.getCombatState.returns({ activeUnit: { id: enemyUnit.id, name: enemyUnit.name, }, turnQueue: [], }); gameLoop.updateMovementHighlights(enemyUnit); // Should not have highlights for enemies expect(gameLoop.movementHighlights.size).to.equal(0); }); it("CoA 7: should clear movement highlights when not in combat", () => { // First create some highlights mockGameStateManager.getCombatState.returns({ activeUnit: { id: playerUnit.id, name: playerUnit.name, }, turnQueue: [], }); gameLoop.updateMovementHighlights(playerUnit); expect(gameLoop.movementHighlights.size).to.be.greaterThan(0); // Change state to not combat mockGameStateManager.currentState = "STATE_DEPLOYMENT"; gameLoop.updateMovementHighlights(playerUnit); // Highlights should be cleared expect(gameLoop.movementHighlights.size).to.equal(0); }); it("CoA 8: should calculate reachable positions correctly", () => { // Use MovementSystem instead of removed getReachablePositions const reachable = gameLoop.movementSystem.getReachableTiles(playerUnit, 4); // Should return an array expect(reachable).to.be.an("array"); // Should include the starting position (or nearby positions) // The exact positions depend on the grid layout, but should have some results expect(reachable.length).to.be.greaterThan(0); // All positions should be valid reachable.forEach((pos) => { expect(pos).to.have.property("x"); expect(pos).to.have.property("y"); expect(pos).to.have.property("z"); expect(gameLoop.grid.isValidBounds(pos)).to.be.true; }); }); it("CoA 9: should move player unit in combat when clicking valid position", async () => { // Start combat with TurnSystem const allUnits = [playerUnit]; gameLoop.turnSystem.startCombat(allUnits); // Ensure player is active const activeUnit = gameLoop.turnSystem.getActiveUnit(); if (activeUnit !== playerUnit) { // Advance until player is active while ( gameLoop.turnSystem.getActiveUnit() !== playerUnit && gameLoop.turnSystem.getActiveUnit() ) { const current = gameLoop.turnSystem.getActiveUnit(); gameLoop.turnSystem.endTurn(current); } } const initialPos = { ...playerUnit.position }; const targetPos = { x: initialPos.x + 1, y: initialPos.y, z: initialPos.z, }; // Adjacent position const initialAP = playerUnit.currentAP; // Handle combat movement (now async) await gameLoop.handleCombatMovement(targetPos); // Unit should have moved (or at least attempted to move) // Position might be the same if movement failed, but AP should be checked // If movement succeeded, position should change if ( playerUnit.position.x !== initialPos.x || playerUnit.position.z !== initialPos.z ) { // Movement succeeded expect(playerUnit.position.x).to.equal(targetPos.x); expect(playerUnit.position.z).to.equal(targetPos.z); expect(playerUnit.currentAP).to.be.lessThan(initialAP); } else { // Movement might have failed (e.g., not walkable), but that's okay for this test // The important thing is that the system tried to move expect(playerUnit.currentAP).to.be.at.most(initialAP); } }); it("CoA 10: should not move unit if target is not reachable", () => { mockGameStateManager.getCombatState.returns({ activeUnit: { id: playerUnit.id, name: playerUnit.name, }, turnQueue: [], }); const initialPos = { ...playerUnit.position }; const targetPos = { x: 20, y: 1, z: 20 }; // Far away, likely unreachable // Stop animation loop to prevent errors from mock inputManager gameLoop.isRunning = false; gameLoop.inputManager = { getCursorPosition: () => targetPos, update: () => {}, // Stub for animate loop isKeyPressed: () => false, // Stub for animate loop setCursor: () => {}, // Stub for animate loop }; gameLoop.handleCombatMovement(targetPos); // Unit should not have moved expect(playerUnit.position.x).to.equal(initialPos.x); expect(playerUnit.position.z).to.equal(initialPos.z); }); it("CoA 11: should not move unit if not enough AP", () => { mockGameStateManager.getCombatState.returns({ activeUnit: { id: playerUnit.id, name: playerUnit.name, }, turnQueue: [], }); playerUnit.currentAP = 0; // No AP const initialPos = { ...playerUnit.position }; const targetPos = { x: 6, y: 1, z: 5 }; // Stop animation loop to prevent errors from mock inputManager gameLoop.isRunning = false; gameLoop.inputManager = { getCursorPosition: () => targetPos, update: () => {}, // Stub for animate loop isKeyPressed: () => false, // Stub for animate loop setCursor: () => {}, // Stub for animate loop }; gameLoop.handleCombatMovement(targetPos); // Unit should not have moved expect(playerUnit.position.x).to.equal(initialPos.x); }); it("CoA 12: should end turn and advance turn queue", () => { // Start combat with TurnSystem const allUnits = [playerUnit, enemyUnit]; gameLoop.turnSystem.startCombat(allUnits); // Get the active unit (could be either player or enemy depending on speed) const activeUnit = gameLoop.turnSystem.getActiveUnit(); expect(activeUnit).to.exist; const initialCharge = activeUnit.chargeMeter; expect(initialCharge).to.be.greaterThanOrEqual(100); // Should be at least 100 to be active // End turn gameLoop.endTurn(); // Active unit's charge should be subtracted by 100 (not reset to 0) // However, after endTurn(), advanceToNextTurn() runs the tick loop which adds charge to all units // So the final charge is (initialCharge - 100) + (ticks * speed) // We verify the charge is valid and the subtraction happened (charge is at least initialCharge - 100) expect(activeUnit.chargeMeter).to.be.a("number"); expect(activeUnit.chargeMeter).to.be.at.least(0); // Charge should be at least the amount after subtracting 100 (may be higher due to tick loop) const minExpectedAfterSubtraction = Math.max(0, initialCharge - 100); expect(activeUnit.chargeMeter).to.be.at.least(minExpectedAfterSubtraction); // Turn system should have advanced to next unit const nextUnit = gameLoop.turnSystem?.getActiveUnit(); expect(nextUnit).to.exist; // Next unit should be different from the previous one (or same if it gained charge faster) expect(nextUnit.chargeMeter).to.be.greaterThanOrEqual(100); }); it("CoA 13: should restore AP for units when their turn starts (via TurnSystem)", () => { // Set enemy AP to 0 before combat starts (to verify it gets restored) enemyUnit.currentAP = 0; // Set speeds: player faster so they go first (player wins ties) playerUnit.baseStats.speed = 10; enemyUnit.baseStats.speed = 10; // Start combat with TurnSystem const allUnits = [playerUnit, enemyUnit]; gameLoop.turnSystem.startCombat(allUnits); // startCombat will initialize charges and advance to first active unit // With same speed, player should go first (tie-breaker favors player) // If not, advance until player is active let attempts = 0; while ( gameLoop.turnSystem.getActiveUnit() !== playerUnit && attempts < 10 ) { const current = gameLoop.turnSystem.getActiveUnit(); if (current) { gameLoop.turnSystem.endTurn(current); } else { break; } attempts++; } // Verify player is active expect(gameLoop.turnSystem.getActiveUnit()).to.equal(playerUnit); // End player's turn - this will trigger tick loop and enemy should become active gameLoop.endTurn(); // Enemy should have reached 100+ charge and become active // When enemy's turn starts, AP should be restored via startTurn() // Advance turns until enemy is active attempts = 0; while (gameLoop.turnSystem.getActiveUnit() !== enemyUnit && attempts < 10) { const current = gameLoop.turnSystem.getActiveUnit(); if (current && current !== enemyUnit) { gameLoop.endTurn(); } else { break; } attempts++; } // Verify enemy is now active const activeUnit = gameLoop.turnSystem.getActiveUnit(); expect(activeUnit).to.equal(enemyUnit); // AP should be restored (formula: 3 + floor(speed/5) = 3 + floor(10/5) = 5) expect(enemyUnit.currentAP).to.equal(5); }); it("CoA 14: should clear spawn zone highlights when deployment finishes", async () => { // Start in deployment mockGameStateManager.currentState = "STATE_DEPLOYMENT"; const runData = createRunData(); await gameLoop.startLevel(runData, { startAnimation: false }); // Should have spawn zone highlights expect(gameLoop.spawnZoneHighlights.size).to.be.greaterThan(0); // Finalize deployment gameLoop.finalizeDeployment(); // Spawn zone highlights should be cleared expect(gameLoop.spawnZoneHighlights.size).to.equal(0); }); it("CoA 14b: should update combat state immediately when deployment finishes", async () => { // Start in deployment mockGameStateManager.currentState = "STATE_DEPLOYMENT"; const runData = createRunData({ squad: [{ id: "u1", classId: "CLASS_VANGUARD" }], }); await gameLoop.startLevel(runData, { startAnimation: false }); // Deploy a unit so we have units in combat const unitDef = runData.squad[0]; const validTile = gameLoop.playerSpawnZone[0]; gameLoop.deployUnit(unitDef, validTile); // Spy on updateCombatState to verify it's called const updateCombatStateSpy = sinon.spy(gameLoop, "updateCombatState"); // Finalize deployment gameLoop.finalizeDeployment(); // updateCombatState should have been called immediately expect(updateCombatStateSpy.calledOnce).to.be.true; // setCombatState should have been called with a valid combat state expect(mockGameStateManager.setCombatState.called).to.be.true; const combatStateCall = mockGameStateManager.setCombatState.getCall(-1); expect(combatStateCall).to.exist; const combatState = combatStateCall.args[0]; expect(combatState).to.exist; expect(combatState.isActive).to.be.true; expect(combatState.turnQueue).to.be.an("array"); // Restore spy updateCombatStateSpy.restore(); }); it("CoA 15: should clear movement highlights when starting new level", async () => { // Create some movement highlights first mockGameStateManager.getCombatState.returns({ activeUnit: { id: playerUnit.id, name: playerUnit.name, }, turnQueue: [], }); gameLoop.updateMovementHighlights(playerUnit); expect(gameLoop.movementHighlights.size).to.be.greaterThan(0); // Start a new level const runData = createRunData({ seed: 99999 }); await gameLoop.startLevel(runData, { startAnimation: false }); // Movement highlights should be cleared expect(gameLoop.movementHighlights.size).to.equal(0); }); it("CoA 16: should initialize all units with full AP when combat starts", () => { // Create multiple units with different speeds const fastUnit = gameLoop.unitManager.createUnit( "CLASS_VANGUARD", "PLAYER" ); fastUnit.baseStats.speed = 20; // Fast unit fastUnit.position = { x: 3, y: 1, z: 3 }; gameLoop.grid.placeUnit(fastUnit, fastUnit.position); const slowUnit = gameLoop.unitManager.createUnit( "CLASS_VANGUARD", "PLAYER" ); slowUnit.baseStats.speed = 5; // Slow unit slowUnit.position = { x: 4, y: 1, z: 4 }; gameLoop.grid.placeUnit(slowUnit, slowUnit.position); const enemyUnit2 = gameLoop.unitManager.createUnit( "ENEMY_DEFAULT", "ENEMY" ); enemyUnit2.baseStats.speed = 8; enemyUnit2.position = { x: 10, y: 1, z: 10 }; gameLoop.grid.placeUnit(enemyUnit2, enemyUnit2.position); // Initialize combat units gameLoop.initializeCombatUnits(); // All units should have full AP (10) regardless of charge expect(fastUnit.currentAP).to.equal(10); expect(slowUnit.currentAP).to.equal(10); expect(enemyUnit2.currentAP).to.equal(10); // Charge should still be set based on speed expect(fastUnit.chargeMeter).to.be.greaterThan(slowUnit.chargeMeter); }); });