mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-08-18 00:28:14 +00:00
Monitoring client connections (#941)
## Description: Disconnected client detection : If a client haven't send a ping to the server since more than 30 seconds They will then be marked disconnected with a dedicated icon. No action are yet taken, this allows for extensive in-game test before adding the *consequences* of the player leaving the game. I also added extensive unit tests, lessening the risk of regression for the future.  ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced - [x] I understand that submitting code with bugs that could have been caught through manual testing blocks releases and new features for all contributors ## Please put your Discord username so you can be contacted if a bug or regression is found: theodoreleon.aetarax --------- Co-authored-by: evanpelle <evanpelle@gmail.com>
This commit is contained in:
committed by
evanpelle
co-authored by
evanpelle
parent
936df2b171
commit
9f9fa9ca8b
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 28 KiB |
@@ -2,6 +2,7 @@ import allianceIcon from "../../../../resources/images/AllianceIcon.svg";
|
|||||||
import allianceRequestBlackIcon from "../../../../resources/images/AllianceRequestBlackIcon.svg";
|
import allianceRequestBlackIcon from "../../../../resources/images/AllianceRequestBlackIcon.svg";
|
||||||
import allianceRequestWhiteIcon from "../../../../resources/images/AllianceRequestWhiteIcon.svg";
|
import allianceRequestWhiteIcon from "../../../../resources/images/AllianceRequestWhiteIcon.svg";
|
||||||
import crownIcon from "../../../../resources/images/CrownIcon.svg";
|
import crownIcon from "../../../../resources/images/CrownIcon.svg";
|
||||||
|
import disconnectedIcon from "../../../../resources/images/DisconnectedIcon.svg";
|
||||||
import embargoBlackIcon from "../../../../resources/images/EmbargoBlackIcon.svg";
|
import embargoBlackIcon from "../../../../resources/images/EmbargoBlackIcon.svg";
|
||||||
import embargoWhiteIcon from "../../../../resources/images/EmbargoWhiteIcon.svg";
|
import embargoWhiteIcon from "../../../../resources/images/EmbargoWhiteIcon.svg";
|
||||||
import nukeRedIcon from "../../../../resources/images/NukeIconRed.svg";
|
import nukeRedIcon from "../../../../resources/images/NukeIconRed.svg";
|
||||||
@@ -40,6 +41,7 @@ export class NameLayer implements Layer {
|
|||||||
private renders: RenderInfo[] = [];
|
private renders: RenderInfo[] = [];
|
||||||
private seenPlayers: Set<PlayerView> = new Set();
|
private seenPlayers: Set<PlayerView> = new Set();
|
||||||
private traitorIconImage: HTMLImageElement;
|
private traitorIconImage: HTMLImageElement;
|
||||||
|
private disconnectedIconImage: HTMLImageElement;
|
||||||
private allianceRequestBlackIconImage: HTMLImageElement;
|
private allianceRequestBlackIconImage: HTMLImageElement;
|
||||||
private allianceRequestWhiteIconImage: HTMLImageElement;
|
private allianceRequestWhiteIconImage: HTMLImageElement;
|
||||||
private allianceIconImage: HTMLImageElement;
|
private allianceIconImage: HTMLImageElement;
|
||||||
@@ -61,6 +63,8 @@ export class NameLayer implements Layer {
|
|||||||
) {
|
) {
|
||||||
this.traitorIconImage = new Image();
|
this.traitorIconImage = new Image();
|
||||||
this.traitorIconImage.src = traitorIcon;
|
this.traitorIconImage.src = traitorIcon;
|
||||||
|
this.disconnectedIconImage = new Image();
|
||||||
|
this.disconnectedIconImage.src = disconnectedIcon;
|
||||||
this.allianceIconImage = new Image();
|
this.allianceIconImage = new Image();
|
||||||
this.allianceIconImage.src = allianceIcon;
|
this.allianceIconImage.src = allianceIcon;
|
||||||
this.allianceRequestBlackIconImage = new Image();
|
this.allianceRequestBlackIconImage = new Image();
|
||||||
@@ -370,6 +374,24 @@ export class NameLayer implements Layer {
|
|||||||
existingTraitor.remove();
|
existingTraitor.remove();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Disconnected icon
|
||||||
|
const existingDisconnected = iconsDiv.querySelector(
|
||||||
|
'[data-icon="disconnected"]',
|
||||||
|
);
|
||||||
|
if (render.player.isDisconnected()) {
|
||||||
|
if (!existingDisconnected) {
|
||||||
|
iconsDiv.appendChild(
|
||||||
|
this.createIconElement(
|
||||||
|
this.disconnectedIconImage.src,
|
||||||
|
iconSize,
|
||||||
|
"disconnected",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else if (existingDisconnected) {
|
||||||
|
existingDisconnected.remove();
|
||||||
|
}
|
||||||
|
|
||||||
// Alliance icon
|
// Alliance icon
|
||||||
const existingAlliance = iconsDiv.querySelector('[data-icon="alliance"]');
|
const existingAlliance = iconsDiv.querySelector('[data-icon="alliance"]');
|
||||||
if (myPlayer !== null && myPlayer.isAlliedWith(render.player)) {
|
if (myPlayer !== null && myPlayer.isAlliedWith(render.player)) {
|
||||||
|
|||||||
+12
-1
@@ -33,7 +33,8 @@ export type Intent =
|
|||||||
| BuildUnitIntent
|
| BuildUnitIntent
|
||||||
| EmbargoIntent
|
| EmbargoIntent
|
||||||
| QuickChatIntent
|
| QuickChatIntent
|
||||||
| MoveWarshipIntent;
|
| MoveWarshipIntent
|
||||||
|
| MarkDisconnectedIntent;
|
||||||
|
|
||||||
export type AttackIntent = z.infer<typeof AttackIntentSchema>;
|
export type AttackIntent = z.infer<typeof AttackIntentSchema>;
|
||||||
export type CancelAttackIntent = z.infer<typeof CancelAttackIntentSchema>;
|
export type CancelAttackIntent = z.infer<typeof CancelAttackIntentSchema>;
|
||||||
@@ -56,6 +57,9 @@ export type TargetTroopRatioIntent = z.infer<
|
|||||||
export type BuildUnitIntent = z.infer<typeof BuildUnitIntentSchema>;
|
export type BuildUnitIntent = z.infer<typeof BuildUnitIntentSchema>;
|
||||||
export type MoveWarshipIntent = z.infer<typeof MoveWarshipIntentSchema>;
|
export type MoveWarshipIntent = z.infer<typeof MoveWarshipIntentSchema>;
|
||||||
export type QuickChatIntent = z.infer<typeof QuickChatIntentSchema>;
|
export type QuickChatIntent = z.infer<typeof QuickChatIntentSchema>;
|
||||||
|
export type MarkDisconnectedIntent = z.infer<
|
||||||
|
typeof MarkDisconnectedIntentSchema
|
||||||
|
>;
|
||||||
|
|
||||||
export type Turn = z.infer<typeof TurnSchema>;
|
export type Turn = z.infer<typeof TurnSchema>;
|
||||||
export type GameConfig = z.infer<typeof GameConfigSchema>;
|
export type GameConfig = z.infer<typeof GameConfigSchema>;
|
||||||
@@ -166,6 +170,7 @@ const BaseIntentSchema = z.object({
|
|||||||
"attack",
|
"attack",
|
||||||
"cancel_attack",
|
"cancel_attack",
|
||||||
"spawn",
|
"spawn",
|
||||||
|
"mark_disconnected",
|
||||||
"boat",
|
"boat",
|
||||||
"cancel_boat",
|
"cancel_boat",
|
||||||
"name",
|
"name",
|
||||||
@@ -290,10 +295,16 @@ export const QuickChatIntentSchema = BaseIntentSchema.extend({
|
|||||||
variables: z.record(SafeString).optional(),
|
variables: z.record(SafeString).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const MarkDisconnectedIntentSchema = BaseIntentSchema.extend({
|
||||||
|
type: z.literal("mark_disconnected"),
|
||||||
|
isDisconnected: z.boolean(),
|
||||||
|
});
|
||||||
|
|
||||||
const IntentSchema = z.union([
|
const IntentSchema = z.union([
|
||||||
AttackIntentSchema,
|
AttackIntentSchema,
|
||||||
CancelAttackIntentSchema,
|
CancelAttackIntentSchema,
|
||||||
SpawnIntentSchema,
|
SpawnIntentSchema,
|
||||||
|
MarkDisconnectedIntentSchema,
|
||||||
BoatAttackIntentSchema,
|
BoatAttackIntentSchema,
|
||||||
CancelBoatIntentSchema,
|
CancelBoatIntentSchema,
|
||||||
AllianceRequestIntentSchema,
|
AllianceRequestIntentSchema,
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { DonateTroopsExecution } from "./DonateTroopExecution";
|
|||||||
import { EmbargoExecution } from "./EmbargoExecution";
|
import { EmbargoExecution } from "./EmbargoExecution";
|
||||||
import { EmojiExecution } from "./EmojiExecution";
|
import { EmojiExecution } from "./EmojiExecution";
|
||||||
import { FakeHumanExecution } from "./FakeHumanExecution";
|
import { FakeHumanExecution } from "./FakeHumanExecution";
|
||||||
|
import { MarkDisconnectedExecution } from "./MarkDisconnectedExecution";
|
||||||
import { MoveWarshipExecution } from "./MoveWarshipExecution";
|
import { MoveWarshipExecution } from "./MoveWarshipExecution";
|
||||||
import { NoOpExecution } from "./NoOpExecution";
|
import { NoOpExecution } from "./NoOpExecution";
|
||||||
import { QuickChatExecution } from "./QuickChatExecution";
|
import { QuickChatExecution } from "./QuickChatExecution";
|
||||||
@@ -120,6 +121,8 @@ export class Executor {
|
|||||||
intent.quickChatKey,
|
intent.quickChatKey,
|
||||||
intent.variables ?? {},
|
intent.variables ?? {},
|
||||||
);
|
);
|
||||||
|
case "mark_disconnected":
|
||||||
|
return new MarkDisconnectedExecution(player, intent.isDisconnected);
|
||||||
default:
|
default:
|
||||||
throw new Error(`intent type ${intent} not found`);
|
throw new Error(`intent type ${intent} not found`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { Execution, Game, Player } from "../game/Game";
|
||||||
|
|
||||||
|
export class MarkDisconnectedExecution implements Execution {
|
||||||
|
constructor(
|
||||||
|
private player: Player,
|
||||||
|
private isDisconnected: boolean,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
init(mg: Game, ticks: number): void {
|
||||||
|
this.player.markDisconnected(this.isDisconnected);
|
||||||
|
}
|
||||||
|
|
||||||
|
tick(ticks: number): void {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isActive(): boolean {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
activeDuringSpawnPhase(): boolean {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -433,6 +433,9 @@ export interface Player {
|
|||||||
largestClusterBoundingBox: { min: Cell; max: Cell } | null;
|
largestClusterBoundingBox: { min: Cell; max: Cell } | null;
|
||||||
lastTileChange(): Tick;
|
lastTileChange(): Tick;
|
||||||
|
|
||||||
|
isDisconnected(): boolean;
|
||||||
|
markDisconnected(isDisconnected: boolean): void;
|
||||||
|
|
||||||
hasSpawned(): boolean;
|
hasSpawned(): boolean;
|
||||||
setHasSpawned(hasSpawned: boolean): void;
|
setHasSpawned(hasSpawned: boolean): void;
|
||||||
|
|
||||||
|
|||||||
@@ -103,6 +103,7 @@ export interface PlayerUpdate {
|
|||||||
smallID: number;
|
smallID: number;
|
||||||
playerType: PlayerType;
|
playerType: PlayerType;
|
||||||
isAlive: boolean;
|
isAlive: boolean;
|
||||||
|
isDisconnected: boolean;
|
||||||
tilesOwned: number;
|
tilesOwned: number;
|
||||||
gold: Gold;
|
gold: Gold;
|
||||||
population: number;
|
population: number;
|
||||||
|
|||||||
@@ -289,6 +289,9 @@ export class PlayerView {
|
|||||||
hasSpawned(): boolean {
|
hasSpawned(): boolean {
|
||||||
return this.data.hasSpawned;
|
return this.data.hasSpawned;
|
||||||
}
|
}
|
||||||
|
isDisconnected(): boolean {
|
||||||
|
return this.data.isDisconnected;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class GameView implements GameMap {
|
export class GameView implements GameMap {
|
||||||
|
|||||||
@@ -98,6 +98,7 @@ export class PlayerImpl implements Player {
|
|||||||
public _outgoingLandAttacks: Attack[] = [];
|
public _outgoingLandAttacks: Attack[] = [];
|
||||||
|
|
||||||
private _hasSpawned = false;
|
private _hasSpawned = false;
|
||||||
|
private _isDisconnected = false;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private mg: GameImpl,
|
private mg: GameImpl,
|
||||||
@@ -135,6 +136,7 @@ export class PlayerImpl implements Player {
|
|||||||
smallID: this.smallID(),
|
smallID: this.smallID(),
|
||||||
playerType: this.type(),
|
playerType: this.type(),
|
||||||
isAlive: this.isAlive(),
|
isAlive: this.isAlive(),
|
||||||
|
isDisconnected: this.isDisconnected(),
|
||||||
tilesOwned: this.numTilesOwned(),
|
tilesOwned: this.numTilesOwned(),
|
||||||
gold: this._gold,
|
gold: this._gold,
|
||||||
population: this.population(),
|
population: this.population(),
|
||||||
@@ -919,6 +921,14 @@ export class PlayerImpl implements Player {
|
|||||||
return this._lastTileChange;
|
return this._lastTileChange;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
isDisconnected(): boolean {
|
||||||
|
return this._isDisconnected;
|
||||||
|
}
|
||||||
|
|
||||||
|
markDisconnected(isDisconnected: boolean): void {
|
||||||
|
this._isDisconnected = isDisconnected;
|
||||||
|
}
|
||||||
|
|
||||||
hash(): number {
|
hash(): number {
|
||||||
return (
|
return (
|
||||||
simpleHash(this.id()) * (this.population() + this.numTilesOwned()) +
|
simpleHash(this.id()) * (this.population() + this.numTilesOwned()) +
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ import { Tick } from "../core/game/Game";
|
|||||||
import { ClientID } from "../core/Schemas";
|
import { ClientID } from "../core/Schemas";
|
||||||
|
|
||||||
export class Client {
|
export class Client {
|
||||||
public lastPing: number;
|
public lastPing: number = Date.now();
|
||||||
|
public isDisconnected: boolean = false;
|
||||||
|
|
||||||
public hashes: Map<Tick, number> = new Map();
|
public hashes: Map<Tick, number> = new Map();
|
||||||
|
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ export class GameServer {
|
|||||||
|
|
||||||
private maxGameDuration = 3 * 60 * 60 * 1000; // 3 hours
|
private maxGameDuration = 3 * 60 * 60 * 1000; // 3 hours
|
||||||
|
|
||||||
|
private disconnectedTimeout = 1 * 30 * 1000; // 30 seconds
|
||||||
|
|
||||||
private turns: Turn[] = [];
|
private turns: Turn[] = [];
|
||||||
private intents: Intent[] = [];
|
private intents: Intent[] = [];
|
||||||
public activeClients: Client[] = [];
|
public activeClients: Client[] = [];
|
||||||
@@ -164,6 +166,10 @@ export class GameServer {
|
|||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
client.isDisconnected = existing.isDisconnected;
|
||||||
|
client.lastPing = existing.lastPing;
|
||||||
|
|
||||||
existing.ws.removeAllListeners("message");
|
existing.ws.removeAllListeners("message");
|
||||||
this.activeClients = this.activeClients.filter((c) => c !== existing);
|
this.activeClients = this.activeClients.filter((c) => c !== existing);
|
||||||
}
|
}
|
||||||
@@ -195,6 +201,12 @@ export class GameServer {
|
|||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (clientMsg.intent.type === "mark_disconnected") {
|
||||||
|
this.log.warn(
|
||||||
|
`Should not receive mark_disconnected intent from client`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
this.addIntent(clientMsg.intent);
|
this.addIntent(clientMsg.intent);
|
||||||
}
|
}
|
||||||
if (clientMsg.type === "ping") {
|
if (clientMsg.type === "ping") {
|
||||||
@@ -357,6 +369,7 @@ export class GameServer {
|
|||||||
this.intents = [];
|
this.intents = [];
|
||||||
|
|
||||||
this.handleSynchronization();
|
this.handleSynchronization();
|
||||||
|
this.checkDisconnectedStatus();
|
||||||
|
|
||||||
let msg = "";
|
let msg = "";
|
||||||
try {
|
try {
|
||||||
@@ -537,6 +550,36 @@ export class GameServer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private checkDisconnectedStatus() {
|
||||||
|
if (this.turns.length % 5 !== 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
for (const [clientID, client] of this.allClients) {
|
||||||
|
if (
|
||||||
|
client.isDisconnected === false &&
|
||||||
|
now - client.lastPing > this.disconnectedTimeout
|
||||||
|
) {
|
||||||
|
this.markClientDisconnected(client, true);
|
||||||
|
} else if (
|
||||||
|
client.isDisconnected &&
|
||||||
|
now - client.lastPing < this.disconnectedTimeout
|
||||||
|
) {
|
||||||
|
this.markClientDisconnected(client, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private markClientDisconnected(client: Client, isDisconnected: boolean) {
|
||||||
|
client.isDisconnected = isDisconnected;
|
||||||
|
this.addIntent({
|
||||||
|
type: "mark_disconnected",
|
||||||
|
clientID: client.clientID,
|
||||||
|
isDisconnected: isDisconnected,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private archiveGame() {
|
private archiveGame() {
|
||||||
this.log.info("archiving game", {
|
this.log.info("archiving game", {
|
||||||
gameID: this.id,
|
gameID: this.id,
|
||||||
|
|||||||
@@ -0,0 +1,163 @@
|
|||||||
|
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 and 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Player view", () => {
|
||||||
|
test("should reflect disconnected state in player view", () => {
|
||||||
|
// Mark player2 as disconnected
|
||||||
|
player2.markDisconnected(true);
|
||||||
|
|
||||||
|
// Get player1's view of player2
|
||||||
|
const player2View = game.player(player2.id());
|
||||||
|
expect(player2View.isDisconnected()).toBe(true);
|
||||||
|
|
||||||
|
// Mark player2 as connected again
|
||||||
|
player2.markDisconnected(false);
|
||||||
|
|
||||||
|
// Verify the view is updated
|
||||||
|
const updatedPlayer2View = game.player(player2.id());
|
||||||
|
expect(updatedPlayer2View.isDisconnected()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should maintain disconnected state in view across game ticks", () => {
|
||||||
|
player2.markDisconnected(true);
|
||||||
|
executeTicks(game, 3);
|
||||||
|
|
||||||
|
const player2View = game.player(player2.id());
|
||||||
|
expect(player2View.isDisconnected()).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("MarkDisconnectedExecution", () => {
|
||||||
|
test("should mark player as disconnected when executed", () => {
|
||||||
|
const execution = new MarkDisconnectedExecution(player1, true);
|
||||||
|
game.addExecution(execution);
|
||||||
|
executeTicks(game, 1);
|
||||||
|
expect(player1.isDisconnected()).toBe(true);
|
||||||
|
expect(execution.isActive()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should handle multiple players with different disconnected states", () => {
|
||||||
|
const execution1 = new MarkDisconnectedExecution(player1, true);
|
||||||
|
const execution2 = new MarkDisconnectedExecution(player2, false);
|
||||||
|
game.addExecution(execution1, execution2);
|
||||||
|
executeTicks(game, 1);
|
||||||
|
expect(player1.isDisconnected()).toBe(true);
|
||||||
|
expect(player2.isDisconnected()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should not be active during spawn phase", () => {
|
||||||
|
const execution = new MarkDisconnectedExecution(player1, true);
|
||||||
|
expect(execution.activeDuringSpawnPhase()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should handle multiple executions for same player in same tick", () => {
|
||||||
|
const execution1 = new MarkDisconnectedExecution(player1, true);
|
||||||
|
const execution2 = new MarkDisconnectedExecution(player1, false);
|
||||||
|
game.addExecution(execution1, execution2);
|
||||||
|
executeTicks(game, 1);
|
||||||
|
// Last execution should win
|
||||||
|
expect(player1.isDisconnected()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Disconnected state persistence", () => {
|
||||||
|
test("should maintain disconnected state across game ticks", () => {
|
||||||
|
player1.markDisconnected(true);
|
||||||
|
executeTicks(game, 5);
|
||||||
|
expect(player1.isDisconnected()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should maintain disconnected state in player updates across ticks", () => {
|
||||||
|
player1.markDisconnected(true);
|
||||||
|
executeTicks(game, 3);
|
||||||
|
const update = player1.toUpdate();
|
||||||
|
expect(update.isDisconnected).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Edge cases", () => {
|
||||||
|
test("should handle marking same disconnected state multiple times", () => {
|
||||||
|
player1.markDisconnected(true);
|
||||||
|
player1.markDisconnected(true);
|
||||||
|
player1.markDisconnected(true);
|
||||||
|
expect(player1.isDisconnected()).toBe(true);
|
||||||
|
|
||||||
|
player1.markDisconnected(false);
|
||||||
|
player1.markDisconnected(false);
|
||||||
|
player1.markDisconnected(false);
|
||||||
|
expect(player1.isDisconnected()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should handle execution with same disconnected state", () => {
|
||||||
|
player1.markDisconnected(true);
|
||||||
|
const execution = new MarkDisconnectedExecution(player1, true);
|
||||||
|
game.addExecution(execution);
|
||||||
|
executeTicks(game, 1);
|
||||||
|
expect(player1.isDisconnected()).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user