Connection status monitoring

This commit is contained in:
aqw42
2025-05-29 23:10:30 +02:00
parent 62cff7a2c4
commit 5eed7db4e3
13 changed files with 343 additions and 328 deletions

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 28 KiB

+17 -11
View File
@@ -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<PlayerView> = 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
+9 -7
View File
@@ -34,7 +34,7 @@ export type Intent =
| EmbargoIntent
| QuickChatIntent
| MoveWarshipIntent
| MarkIdleIntent;
| MarkDisconnectedIntent;
export type AttackIntent = z.infer<typeof AttackIntentSchema>;
export type CancelAttackIntent = z.infer<typeof CancelAttackIntentSchema>;
@@ -57,7 +57,9 @@ export type TargetTroopRatioIntent = z.infer<
export type BuildUnitIntent = z.infer<typeof BuildUnitIntentSchema>;
export type MoveWarshipIntent = z.infer<typeof MoveWarshipIntentSchema>;
export type QuickChatIntent = z.infer<typeof QuickChatIntentSchema>;
export type MarkIdleIntent = z.infer<typeof MarkIdleIntentSchema>;
export type MarkDisconnectedIntent = z.infer<
typeof MarkDisconnectedIntentSchema
>;
export type Turn = z.infer<typeof TurnSchema>;
export type GameConfig = z.infer<typeof GameConfigSchema>;
@@ -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,
+3 -3
View File
@@ -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`);
}
@@ -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;
}
+2 -2
View File
@@ -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;
+1 -1
View File
@@ -102,7 +102,7 @@ export interface PlayerUpdate {
smallID: number;
playerType: PlayerType;
isAlive: boolean;
isIdle: boolean;
isDisconnected: boolean;
tilesOwned: number;
gold: number;
population: number;
+2 -2
View File
@@ -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;
}
}
+6 -6
View File
@@ -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 {
+1 -2
View File
@@ -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<Tick, number> = new Map();
+14 -18
View File
@@ -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,
});
}
+283
View File
@@ -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);
});
});
});
-271
View File
@@ -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);
});
});
});