From 5eed7db4e3c9cfc06f5f15e5b55d87689b1da69b Mon Sep 17 00:00:00 2001 From: aqw42 Date: Thu, 29 May 2025 23:10:30 +0200 Subject: [PATCH] Connection status monitoring --- .../{IdleIcon.svg => DisconnectedIcon.svg} | 0 src/client/graphics/layers/NameLayer.ts | 28 +- src/core/Schemas.ts | 16 +- src/core/execution/ExecutionManager.ts | 6 +- ...cution.ts => MarkDisconnectedExecution.ts} | 10 +- src/core/game/Game.ts | 4 +- src/core/game/GameUpdates.ts | 2 +- src/core/game/GameView.ts | 4 +- src/core/game/PlayerImpl.ts | 12 +- src/server/Client.ts | 3 +- src/server/GameServer.ts | 32 +- tests/Disconnected.test.ts | 283 ++++++++++++++++++ tests/Idle.test.ts | 271 ----------------- 13 files changed, 343 insertions(+), 328 deletions(-) rename resources/images/{IdleIcon.svg => DisconnectedIcon.svg} (100%) rename src/core/execution/{MarkIdleExecution.ts => MarkDisconnectedExecution.ts} (67%) create mode 100644 tests/Disconnected.test.ts delete mode 100644 tests/Idle.test.ts diff --git a/resources/images/IdleIcon.svg b/resources/images/DisconnectedIcon.svg similarity index 100% rename from resources/images/IdleIcon.svg rename to resources/images/DisconnectedIcon.svg diff --git a/src/client/graphics/layers/NameLayer.ts b/src/client/graphics/layers/NameLayer.ts index 2ae6c818c..08b3ef2e4 100644 --- a/src/client/graphics/layers/NameLayer.ts +++ b/src/client/graphics/layers/NameLayer.ts @@ -1,8 +1,8 @@ import allianceIcon from "../../../../resources/images/AllianceIcon.svg"; import allianceRequestIcon from "../../../../resources/images/AllianceRequestIcon.svg"; import crownIcon from "../../../../resources/images/CrownIcon.svg"; +import disconnectedIcon from "../../../../resources/images/DisconnectedIcon.svg"; import embargoIcon from "../../../../resources/images/EmbargoIcon.svg"; -import idleIcon from "../../../../resources/images/IdleIcon.svg"; import nukeRedIcon from "../../../../resources/images/NukeIconRed.svg"; import nukeWhiteIcon from "../../../../resources/images/NukeIconWhite.svg"; import shieldIcon from "../../../../resources/images/ShieldIconBlack.svg"; @@ -39,7 +39,7 @@ export class NameLayer implements Layer { private renders: RenderInfo[] = []; private seenPlayers: Set = new Set(); private traitorIconImage: HTMLImageElement; - private idleIconImage: HTMLImageElement; + private disconnectedIconImage: HTMLImageElement; private allianceRequestIconImage: HTMLImageElement; private allianceIconImage: HTMLImageElement; private targetIconImage: HTMLImageElement; @@ -60,8 +60,8 @@ export class NameLayer implements Layer { ) { this.traitorIconImage = new Image(); this.traitorIconImage.src = traitorIcon; - this.idleIconImage = new Image(); - this.idleIconImage.src = idleIcon; + this.disconnectedIconImage = new Image(); + this.disconnectedIconImage.src = disconnectedIcon; this.allianceIconImage = new Image(); this.allianceIconImage.src = allianceIcon; this.allianceRequestIconImage = new Image(); @@ -353,16 +353,22 @@ export class NameLayer implements Layer { existingTraitor.remove(); } - // Idle icon - const existingIdle = iconsDiv.querySelector('[data-icon="idle"]'); - if (render.player.isIdle()) { - if (!existingIdle) { + // Disconnected icon + const existingDisconnected = iconsDiv.querySelector( + '[data-icon="disconnected"]', + ); + if (render.player.isDisconnected()) { + if (!existingDisconnected) { iconsDiv.appendChild( - this.createIconElement(this.idleIconImage.src, iconSize, "idle"), + this.createIconElement( + this.disconnectedIconImage.src, + iconSize, + "disconnected", + ), ); } - } else if (existingIdle) { - existingIdle.remove(); + } else if (existingDisconnected) { + existingDisconnected.remove(); } // Alliance icon diff --git a/src/core/Schemas.ts b/src/core/Schemas.ts index 1cf69ed36..fdb6b3f9c 100644 --- a/src/core/Schemas.ts +++ b/src/core/Schemas.ts @@ -34,7 +34,7 @@ export type Intent = | EmbargoIntent | QuickChatIntent | MoveWarshipIntent - | MarkIdleIntent; + | MarkDisconnectedIntent; export type AttackIntent = z.infer; export type CancelAttackIntent = z.infer; @@ -57,7 +57,9 @@ export type TargetTroopRatioIntent = z.infer< export type BuildUnitIntent = z.infer; export type MoveWarshipIntent = z.infer; export type QuickChatIntent = z.infer; -export type MarkIdleIntent = z.infer; +export type MarkDisconnectedIntent = z.infer< + typeof MarkDisconnectedIntentSchema +>; export type Turn = z.infer; export type GameConfig = z.infer; @@ -168,7 +170,7 @@ const BaseIntentSchema = z.object({ "attack", "cancel_attack", "spawn", - "mark_idle", + "mark_disconnected", "boat", "cancel_boat", "name", @@ -293,16 +295,16 @@ export const QuickChatIntentSchema = BaseIntentSchema.extend({ variables: z.record(SafeString).optional(), }); -export const MarkIdleIntentSchema = BaseIntentSchema.extend({ - type: z.literal("mark_idle"), - isIdle: z.boolean(), +export const MarkDisconnectedIntentSchema = BaseIntentSchema.extend({ + type: z.literal("mark_disconnected"), + isDisconnected: z.boolean(), }); const IntentSchema = z.union([ AttackIntentSchema, CancelAttackIntentSchema, SpawnIntentSchema, - MarkIdleIntentSchema, + MarkDisconnectedIntentSchema, BoatAttackIntentSchema, CancelBoatIntentSchema, AllianceRequestIntentSchema, diff --git a/src/core/execution/ExecutionManager.ts b/src/core/execution/ExecutionManager.ts index 4b85a5d7c..969d8c6ac 100644 --- a/src/core/execution/ExecutionManager.ts +++ b/src/core/execution/ExecutionManager.ts @@ -15,7 +15,7 @@ import { DonateTroopsExecution } from "./DonateTroopExecution"; import { EmbargoExecution } from "./EmbargoExecution"; import { EmojiExecution } from "./EmojiExecution"; import { FakeHumanExecution } from "./FakeHumanExecution"; -import { MarkIdleExecution } from "./MarkIdleExecution"; +import { MarkDisconnectedExecution } from "./MarkDisconnectedExecution"; import { MoveWarshipExecution } from "./MoveWarshipExecution"; import { NoOpExecution } from "./NoOpExecution"; import { QuickChatExecution } from "./QuickChatExecution"; @@ -121,8 +121,8 @@ export class Executor { intent.quickChatKey, intent.variables ?? {}, ); - case "mark_idle": - return new MarkIdleExecution(playerID, intent.isIdle); + case "mark_disconnected": + return new MarkDisconnectedExecution(playerID, intent.isDisconnected); default: throw new Error(`intent type ${intent} not found`); } diff --git a/src/core/execution/MarkIdleExecution.ts b/src/core/execution/MarkDisconnectedExecution.ts similarity index 67% rename from src/core/execution/MarkIdleExecution.ts rename to src/core/execution/MarkDisconnectedExecution.ts index ccf990f32..4fc7c3eba 100644 --- a/src/core/execution/MarkIdleExecution.ts +++ b/src/core/execution/MarkDisconnectedExecution.ts @@ -1,18 +1,18 @@ import { Execution, Game, Player, PlayerID } from "../game/Game"; -export class MarkIdleExecution implements Execution { +export class MarkDisconnectedExecution implements Execution { private player: Player; private active: boolean = true; constructor( private playerID: PlayerID, - private isIdle: boolean, + private isDisconnected: boolean, ) {} init(mg: Game, ticks: number): void { if (!mg.hasPlayer(this.playerID)) { console.warn( - `MarkIdleExecution: player ${this.playerID} not found in game`, + `MarkDisconnectedExecution: player ${this.playerID} not found in game`, ); this.active = false; return; @@ -21,7 +21,7 @@ export class MarkIdleExecution implements Execution { this.player = mg.player(this.playerID); if (!this.player) { console.warn( - `MarkIdleExecution: failed to retrieve player ${this.playerID}`, + `MarkDisconnectedExecution: failed to retrieve player ${this.playerID}`, ); this.active = false; return; @@ -29,7 +29,7 @@ export class MarkIdleExecution implements Execution { } tick(ticks: number): void { - this.player.markIdle(this.isIdle); + this.player.markDisconnected(this.isDisconnected); this.active = false; } diff --git a/src/core/game/Game.ts b/src/core/game/Game.ts index ca160d09f..4a3a88e88 100644 --- a/src/core/game/Game.ts +++ b/src/core/game/Game.ts @@ -433,8 +433,8 @@ export interface Player { largestClusterBoundingBox: { min: Cell; max: Cell } | null; lastTileChange(): Tick; - isIdle(): boolean; - markIdle(isIdle: boolean): void; + isDisconnected(): boolean; + markDisconnected(isDisconnected: boolean): void; hasSpawned(): boolean; setHasSpawned(hasSpawned: boolean): void; diff --git a/src/core/game/GameUpdates.ts b/src/core/game/GameUpdates.ts index 0e79cb395..cbd1b7e78 100644 --- a/src/core/game/GameUpdates.ts +++ b/src/core/game/GameUpdates.ts @@ -102,7 +102,7 @@ export interface PlayerUpdate { smallID: number; playerType: PlayerType; isAlive: boolean; - isIdle: boolean; + isDisconnected: boolean; tilesOwned: number; gold: number; population: number; diff --git a/src/core/game/GameView.ts b/src/core/game/GameView.ts index a0b1ffe09..e04a2776d 100644 --- a/src/core/game/GameView.ts +++ b/src/core/game/GameView.ts @@ -292,8 +292,8 @@ export class PlayerView { hasSpawned(): boolean { return this.data.hasSpawned; } - isIdle(): boolean { - return this.data.isIdle; + isDisconnected(): boolean { + return this.data.isDisconnected; } } diff --git a/src/core/game/PlayerImpl.ts b/src/core/game/PlayerImpl.ts index 70eb9b67b..d06565a86 100644 --- a/src/core/game/PlayerImpl.ts +++ b/src/core/game/PlayerImpl.ts @@ -99,7 +99,7 @@ export class PlayerImpl implements Player { public _outgoingLandAttacks: Attack[] = []; private _hasSpawned = false; - private _isIdle = false; + private _isDisconnected = false; constructor( private mg: GameImpl, @@ -137,7 +137,7 @@ export class PlayerImpl implements Player { smallID: this.smallID(), playerType: this.type(), isAlive: this.isAlive(), - isIdle: this.isIdle(), + isDisconnected: this.isDisconnected(), tilesOwned: this.numTilesOwned(), gold: Number(this._gold), population: this.population(), @@ -927,12 +927,12 @@ export class PlayerImpl implements Player { return this._lastTileChange; } - isIdle(): boolean { - return this._isIdle; + isDisconnected(): boolean { + return this._isDisconnected; } - markIdle(isIdle: boolean): void { - this._isIdle = isIdle; + markDisconnected(isDisconnected: boolean): void { + this._isDisconnected = isDisconnected; } hash(): number { diff --git a/src/server/Client.ts b/src/server/Client.ts index 375fbd890..6daa49a25 100644 --- a/src/server/Client.ts +++ b/src/server/Client.ts @@ -5,8 +5,7 @@ import { ClientID } from "../core/Schemas"; export class Client { public lastPing: number = Date.now(); - public lastAction: number = Date.now(); - public isIdle: boolean = false; + public isDisconnected: boolean = false; public hashes: Map = new Map(); diff --git a/src/server/GameServer.ts b/src/server/GameServer.ts index b652e11e3..3bcf80ba7 100644 --- a/src/server/GameServer.ts +++ b/src/server/GameServer.ts @@ -35,7 +35,7 @@ export class GameServer { private maxGameDuration = 3 * 60 * 60 * 1000; // 3 hours - private idleTimeout = 1 * 60 * 1000; // 1 minute + private disconnectedTimeout = 1 * 30 * 1000; // 30 seconds private turns: Turn[] = []; private intents: Intent[] = []; @@ -167,8 +167,7 @@ export class GameServer { return; } - client.isIdle = existing.isIdle; - client.lastAction = existing.lastAction; + client.isDisconnected = existing.isDisconnected; client.lastPing = existing.lastPing; existing.ws.removeAllListeners("message"); @@ -198,7 +197,6 @@ export class GameServer { ); return; } - client.lastAction = Date.now(); this.addIntent(clientMsg.intent); } if (clientMsg.type === "ping") { @@ -361,7 +359,7 @@ export class GameServer { this.intents = []; this.handleSynchronization(); - this.checkIdleStatus(); + this.checkDisconnectedStatus(); let msg = ""; try { @@ -542,7 +540,7 @@ export class GameServer { } } - private checkIdleStatus() { + private checkDisconnectedStatus() { if (this.turns.length % 5 !== 0) { return; } @@ -550,27 +548,25 @@ export class GameServer { const now = Date.now(); for (const [clientID, client] of this.allClients) { if ( - client.isIdle === false && - now - client.lastPing > this.idleTimeout && - now - client.lastAction > this.idleTimeout + client.isDisconnected === false && + now - client.lastPing > this.disconnectedTimeout ) { - this.markClientIdle(client, true); + this.markClientDisconnected(client, true); } else if ( - client.isIdle && - now - client.lastPing < this.idleTimeout && - now - client.lastAction < this.idleTimeout + client.isDisconnected && + now - client.lastPing < this.disconnectedTimeout ) { - this.markClientIdle(client, false); + this.markClientDisconnected(client, false); } } } - private markClientIdle(client: Client, isIdle: boolean) { - client.isIdle = isIdle; + private markClientDisconnected(client: Client, isDisconnected: boolean) { + client.isDisconnected = isDisconnected; this.addIntent({ - type: "mark_idle", + type: "mark_disconnected", clientID: client.clientID, - isIdle: isIdle, + isDisconnected: isDisconnected, }); } diff --git a/tests/Disconnected.test.ts b/tests/Disconnected.test.ts new file mode 100644 index 000000000..0df688d58 --- /dev/null +++ b/tests/Disconnected.test.ts @@ -0,0 +1,283 @@ +import { MarkDisconnectedExecution } from "../src/core/execution/MarkDisconnectedExecution"; +import { SpawnExecution } from "../src/core/execution/SpawnExecution"; +import { Game, Player, PlayerInfo, PlayerType } from "../src/core/game/Game"; +import { setup } from "./util/Setup"; +import { executeTicks } from "./util/utils"; + +let game: Game; +let player1: Player; +let player2: Player; + +describe("Disconnected", () => { + beforeEach(async () => { + game = await setup("Plains", { + infiniteGold: true, + instantBuild: true, + }); + + const player1Info = new PlayerInfo( + "us", + "Active Player", + PlayerType.Human, + null, + "player1_id", + ); + + const player2Info = new PlayerInfo( + "fr", + "Disconnected Player", + PlayerType.Human, + null, + "player2_id", + ); + + player1 = game.addPlayer(player1Info); + player2 = game.addPlayer(player2Info); + + game.addExecution( + new SpawnExecution(player1Info, game.ref(1, 1)), + new SpawnExecution(player2Info, game.ref(7, 7)), + ); + + while (game.inSpawnPhase()) { + game.executeNextTick(); + } + }); + + describe("Player disconnected state", () => { + test("should initialize players as not disconnected", () => { + expect(player1.isDisconnected()).toBe(false); + expect(player2.isDisconnected()).toBe(false); + }); + + test("should mark player as disconnected", () => { + player1.markDisconnected(true); + expect(player1.isDisconnected()).toBe(true); + }); + + test("should mark player as not disconnected", () => { + player1.markDisconnected(true); + expect(player1.isDisconnected()).toBe(true); + + player1.markDisconnected(false); + expect(player1.isDisconnected()).toBe(false); + }); + + test("should include disconnected state in player update", () => { + player1.markDisconnected(true); + const update = player1.toUpdate(); + expect(update.isDisconnected).toBe(true); + + player1.markDisconnected(false); + const update2 = player1.toUpdate(); + expect(update2.isDisconnected).toBe(false); + }); + + test("should maintain disconnected state independently for different players", () => { + player1.markDisconnected(true); + player2.markDisconnected(false); + + expect(player1.isDisconnected()).toBe(true); + expect(player2.isDisconnected()).toBe(false); + }); + }); + + describe("MarkDisconnectedExecution", () => { + test("should mark player as disconnected when executed", () => { + const execution = new MarkDisconnectedExecution(player1.id(), true); + game.addExecution(execution); + + executeTicks(game, 2); + + expect(player1.isDisconnected()).toBe(true); + expect(execution.isActive()).toBe(false); + }); + + test("should mark player as not disconnected when executed", () => { + // First mark as disconnected directly + player1.markDisconnected(true); + expect(player1.isDisconnected()).toBe(true); + + // Then mark as not disconnected via execution + const execution = new MarkDisconnectedExecution(player1.id(), false); + game.addExecution(execution); + + executeTicks(game, 2); + + expect(player1.isDisconnected()).toBe(false); + expect(execution.isActive()).toBe(false); + }); + + test("should handle multiple players with different disconnected states", () => { + const execution1 = new MarkDisconnectedExecution(player1.id(), true); + const execution2 = new MarkDisconnectedExecution(player2.id(), false); + + game.addExecution(execution1, execution2); + executeTicks(game, 2); + + expect(player1.isDisconnected()).toBe(true); + expect(player2.isDisconnected()).toBe(false); + expect(execution1.isActive()).toBe(false); + expect(execution2.isActive()).toBe(false); + }); + + test("should handle invalid player ID gracefully", () => { + const execution = new MarkDisconnectedExecution( + "invalid_player_id", + true, + ); + game.addExecution(execution); + + // Should not throw and should deactivate + expect(() => game.executeNextTick()).not.toThrow(); + expect(execution.isActive()).toBe(false); + }); + + test("should not be active during spawn phase", () => { + const execution = new MarkDisconnectedExecution(player1.id(), true); + expect(execution.activeDuringSpawnPhase()).toBe(false); + }); + + test("should handle rapid disconnected state changes", () => { + // Mark disconnected + const execution1 = new MarkDisconnectedExecution(player1.id(), true); + game.addExecution(execution1); + executeTicks(game, 2); + expect(player1.isDisconnected()).toBe(true); + + // Mark not disconnected + const execution2 = new MarkDisconnectedExecution(player1.id(), false); + game.addExecution(execution2); + executeTicks(game, 2); + expect(player1.isDisconnected()).toBe(false); + + // Mark disconnected again + const execution3 = new MarkDisconnectedExecution(player1.id(), true); + game.addExecution(execution3); + executeTicks(game, 2); + expect(player1.isDisconnected()).toBe(true); + }); + + test("should execute properly with other executions in same tick", () => { + const markDisconnectedExecution = new MarkDisconnectedExecution( + player1.id(), + true, + ); + const markDisconnectedExecution2 = new MarkDisconnectedExecution( + player2.id(), + false, + ); + + game.addExecution(markDisconnectedExecution, markDisconnectedExecution2); + + // Execute multiple ticks to ensure all executions complete + executeTicks(game, 2); + + expect(player1.isDisconnected()).toBe(true); + expect(player2.isDisconnected()).toBe(false); + expect(markDisconnectedExecution.isActive()).toBe(false); + expect(markDisconnectedExecution2.isActive()).toBe(false); + }); + }); + + describe("Disconnected state persistence", () => { + test("should maintain disconnected state across game ticks", () => { + player1.markDisconnected(true); + + // Execute several ticks + executeTicks(game, 5); + + // Disconnected state should persist + expect(player1.isDisconnected()).toBe(true); + }); + + test("should maintain disconnected state in player updates", () => { + player1.markDisconnected(true); + + // Execute some ticks and check update still shows disconnected + executeTicks(game, 3); + + const update = player1.toUpdate(); + expect(update.isDisconnected).toBe(true); + }); + + test("should handle execution during different game phases", () => { + // Test that disconnected execution works outside spawn phase + expect(game.inSpawnPhase()).toBe(false); + + const execution = new MarkDisconnectedExecution(player1.id(), true); + game.addExecution(execution); + executeTicks(game, 2); + + expect(player1.isDisconnected()).toBe(true); + expect(execution.isActive()).toBe(false); + }); + }); + + describe("Edge cases", () => { + test("should handle marking same disconnected state multiple times", () => { + // Mark disconnected multiple times + player1.markDisconnected(true); + player1.markDisconnected(true); + player1.markDisconnected(true); + + expect(player1.isDisconnected()).toBe(true); + + // Mark not disconnected multiple times + player1.markDisconnected(false); + player1.markDisconnected(false); + player1.markDisconnected(false); + + expect(player1.isDisconnected()).toBe(false); + }); + + test("should handle execution with same disconnected state", () => { + // Start with player disconnected + player1.markDisconnected(true); + expect(player1.isDisconnected()).toBe(true); + + // Execute with same disconnected state + const execution = new MarkDisconnectedExecution(player1.id(), true); + game.addExecution(execution); + executeTicks(game, 2); + + expect(player1.isDisconnected()).toBe(true); + expect(execution.isActive()).toBe(false); + }); + + test("should handle missing player during execution init", () => { + const execution = new MarkDisconnectedExecution( + "nonexistent_player", + true, + ); + + // Mock console.warn to verify it's called + const consoleSpy = jest.spyOn(console, "warn").mockImplementation(); + + game.addExecution(execution); + executeTicks(game, 2); + + expect(execution.isActive()).toBe(false); + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining( + "MarkDisconnectedExecution: player nonexistent_player not found", + ), + ); + + consoleSpy.mockRestore(); + }); + + test("should handle multiple executions for same player", () => { + const execution1 = new MarkDisconnectedExecution(player1.id(), true); + const execution2 = new MarkDisconnectedExecution(player1.id(), false); + + game.addExecution(execution1, execution2); + executeTicks(game, 2); + + // Last execution should win + expect(player1.isDisconnected()).toBe(false); + expect(execution1.isActive()).toBe(false); + expect(execution2.isActive()).toBe(false); + }); + }); +}); diff --git a/tests/Idle.test.ts b/tests/Idle.test.ts deleted file mode 100644 index 041f14a79..000000000 --- a/tests/Idle.test.ts +++ /dev/null @@ -1,271 +0,0 @@ -import { MarkIdleExecution } from "../src/core/execution/MarkIdleExecution"; -import { SpawnExecution } from "../src/core/execution/SpawnExecution"; -import { Game, Player, PlayerInfo, PlayerType } from "../src/core/game/Game"; -import { setup } from "./util/Setup"; -import { executeTicks } from "./util/utils"; - -let game: Game; -let player1: Player; -let player2: Player; - -describe("Idle", () => { - beforeEach(async () => { - game = await setup("Plains", { - infiniteGold: true, - instantBuild: true, - }); - - const player1Info = new PlayerInfo( - "us", - "Active Player", - PlayerType.Human, - null, - "player1_id", - ); - - const player2Info = new PlayerInfo( - "fr", - "Idle Player", - PlayerType.Human, - null, - "player2_id", - ); - - player1 = game.addPlayer(player1Info); - player2 = game.addPlayer(player2Info); - - game.addExecution( - new SpawnExecution(player1Info, game.ref(1, 1)), - new SpawnExecution(player2Info, game.ref(7, 7)), - ); - - while (game.inSpawnPhase()) { - game.executeNextTick(); - } - }); - - describe("Player idle state", () => { - test("should initialize players as not idle", () => { - expect(player1.isIdle()).toBe(false); - expect(player2.isIdle()).toBe(false); - }); - - test("should mark player as idle", () => { - player1.markIdle(true); - expect(player1.isIdle()).toBe(true); - }); - - test("should mark player as not idle", () => { - player1.markIdle(true); - expect(player1.isIdle()).toBe(true); - - player1.markIdle(false); - expect(player1.isIdle()).toBe(false); - }); - - test("should include idle state in player update", () => { - player1.markIdle(true); - const update = player1.toUpdate(); - expect(update.isIdle).toBe(true); - - player1.markIdle(false); - const update2 = player1.toUpdate(); - expect(update2.isIdle).toBe(false); - }); - - test("should maintain idle state independently for different players", () => { - player1.markIdle(true); - player2.markIdle(false); - - expect(player1.isIdle()).toBe(true); - expect(player2.isIdle()).toBe(false); - }); - }); - - describe("MarkIdleExecution", () => { - test("should mark player as idle when executed", () => { - const execution = new MarkIdleExecution(player1.id(), true); - game.addExecution(execution); - - executeTicks(game, 2); - - expect(player1.isIdle()).toBe(true); - expect(execution.isActive()).toBe(false); - }); - - test("should mark player as not idle when executed", () => { - // First mark as idle directly - player1.markIdle(true); - expect(player1.isIdle()).toBe(true); - - // Then mark as not idle via execution - const execution = new MarkIdleExecution(player1.id(), false); - game.addExecution(execution); - - executeTicks(game, 2); - - expect(player1.isIdle()).toBe(false); - expect(execution.isActive()).toBe(false); - }); - - test("should handle multiple players with different idle states", () => { - const execution1 = new MarkIdleExecution(player1.id(), true); - const execution2 = new MarkIdleExecution(player2.id(), false); - - game.addExecution(execution1, execution2); - executeTicks(game, 2); - - expect(player1.isIdle()).toBe(true); - expect(player2.isIdle()).toBe(false); - expect(execution1.isActive()).toBe(false); - expect(execution2.isActive()).toBe(false); - }); - - test("should handle invalid player ID gracefully", () => { - const execution = new MarkIdleExecution("invalid_player_id", true); - game.addExecution(execution); - - // Should not throw and should deactivate - expect(() => game.executeNextTick()).not.toThrow(); - expect(execution.isActive()).toBe(false); - }); - - test("should not be active during spawn phase", () => { - const execution = new MarkIdleExecution(player1.id(), true); - expect(execution.activeDuringSpawnPhase()).toBe(false); - }); - - test("should handle rapid idle state changes", () => { - // Mark idle - const execution1 = new MarkIdleExecution(player1.id(), true); - game.addExecution(execution1); - executeTicks(game, 2); - expect(player1.isIdle()).toBe(true); - - // Mark not idle - const execution2 = new MarkIdleExecution(player1.id(), false); - game.addExecution(execution2); - executeTicks(game, 2); - expect(player1.isIdle()).toBe(false); - - // Mark idle again - const execution3 = new MarkIdleExecution(player1.id(), true); - game.addExecution(execution3); - executeTicks(game, 2); - expect(player1.isIdle()).toBe(true); - }); - - test("should execute properly with other executions in same tick", () => { - const markIdleExecution = new MarkIdleExecution(player1.id(), true); - const markIdleExecution2 = new MarkIdleExecution(player2.id(), false); - - game.addExecution(markIdleExecution, markIdleExecution2); - - // Execute multiple ticks to ensure all executions complete - executeTicks(game, 2); - - expect(player1.isIdle()).toBe(true); - expect(player2.isIdle()).toBe(false); - expect(markIdleExecution.isActive()).toBe(false); - expect(markIdleExecution2.isActive()).toBe(false); - }); - }); - - describe("Idle state persistence", () => { - test("should maintain idle state across game ticks", () => { - player1.markIdle(true); - - // Execute several ticks - executeTicks(game, 5); - - // Idle state should persist - expect(player1.isIdle()).toBe(true); - }); - - test("should maintain idle state in player updates", () => { - player1.markIdle(true); - - // Execute some ticks and check update still shows idle - executeTicks(game, 3); - - const update = player1.toUpdate(); - expect(update.isIdle).toBe(true); - }); - - test("should handle execution during different game phases", () => { - // Test that idle execution works outside spawn phase - expect(game.inSpawnPhase()).toBe(false); - - const execution = new MarkIdleExecution(player1.id(), true); - game.addExecution(execution); - executeTicks(game, 2); - - expect(player1.isIdle()).toBe(true); - expect(execution.isActive()).toBe(false); - }); - }); - - describe("Edge cases", () => { - test("should handle marking same idle state multiple times", () => { - // Mark idle multiple times - player1.markIdle(true); - player1.markIdle(true); - player1.markIdle(true); - - expect(player1.isIdle()).toBe(true); - - // Mark not idle multiple times - player1.markIdle(false); - player1.markIdle(false); - player1.markIdle(false); - - expect(player1.isIdle()).toBe(false); - }); - - test("should handle execution with same idle state", () => { - // Start with player idle - player1.markIdle(true); - expect(player1.isIdle()).toBe(true); - - // Execute with same idle state - const execution = new MarkIdleExecution(player1.id(), true); - game.addExecution(execution); - executeTicks(game, 2); - - expect(player1.isIdle()).toBe(true); - expect(execution.isActive()).toBe(false); - }); - - test("should handle missing player during execution init", () => { - const execution = new MarkIdleExecution("nonexistent_player", true); - - // Mock console.warn to verify it's called - const consoleSpy = jest.spyOn(console, "warn").mockImplementation(); - - game.addExecution(execution); - executeTicks(game, 2); - - expect(execution.isActive()).toBe(false); - expect(consoleSpy).toHaveBeenCalledWith( - expect.stringContaining( - "MarkIdleExecution: player nonexistent_player not found", - ), - ); - - consoleSpy.mockRestore(); - }); - - test("should handle multiple executions for same player", () => { - const execution1 = new MarkIdleExecution(player1.id(), true); - const execution2 = new MarkIdleExecution(player1.id(), false); - - game.addExecution(execution1, execution2); - executeTicks(game, 2); - - // Last execution should win - expect(player1.isIdle()).toBe(false); - expect(execution1.isActive()).toBe(false); - expect(execution2.isActive()).toBe(false); - }); - }); -});