mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-06-21 13:50:43 +00:00
add heartbeats, better websocket disconnection handling
This commit is contained in:
@@ -5,7 +5,7 @@ import { EventBus } from "../core/EventBus";
|
||||
import { Config, getConfig } from "../core/configuration/Config";
|
||||
import { createRenderer, GameRenderer } from "./graphics/GameRenderer";
|
||||
import { InputHandler, MouseUpEvent, ZoomEvent, DragEvent, MouseDownEvent } from "./InputHandler"
|
||||
import { ClientID, ClientIntentMessageSchema, ClientJoinMessageSchema, ClientLeaveMessageSchema, ClientMessageSchema, GameID, Intent, ServerMessage, ServerMessageSchema, ServerSyncMessage, Turn } from "../core/Schemas";
|
||||
import { ClientID, ClientIntentMessageSchema, ClientJoinMessageSchema, ClientMessageSchema, GameID, Intent, ServerMessage, ServerMessageSchema, ServerSyncMessage, Turn } from "../core/Schemas";
|
||||
import { loadTerrainMap, TerrainMapImpl } from "../core/game/TerrainMapLoader";
|
||||
import { and, bfs, dist, manhattanDist } from "../core/Util";
|
||||
import { WinCheckExecution } from "../core/execution/WinCheckExecution";
|
||||
|
||||
+33
-10
@@ -1,7 +1,7 @@
|
||||
import { Config } from "../core/configuration/Config"
|
||||
import { EventBus, GameEvent } from "../core/EventBus"
|
||||
import { AllianceRequest, AllPlayers, Cell, Player, PlayerID, PlayerType, Tile, UnitType } from "../core/game/Game"
|
||||
import { ClientID, ClientIntentMessageSchema, ClientJoinMessageSchema, ClientLeaveMessageSchema, BuildUnitIntentSchema, GameID, Intent, ServerMessage, ServerMessageSchema } from "../core/Schemas"
|
||||
import { ClientID, ClientIntentMessageSchema, ClientJoinMessageSchema, GameID, Intent, ServerMessage, ServerMessageSchema, ClientPingMessageSchema } from "../core/Schemas"
|
||||
import { LocalServer } from "./LocalServer"
|
||||
|
||||
|
||||
@@ -93,6 +93,10 @@ export class Transport {
|
||||
private onconnect: () => void
|
||||
private onmessage: (msg: ServerMessage) => void
|
||||
|
||||
|
||||
private pingInterval: number | null = null
|
||||
private lastPingTime: number | null = null
|
||||
|
||||
constructor(
|
||||
private isLocal: boolean,
|
||||
private eventBus: EventBus,
|
||||
@@ -116,7 +120,28 @@ export class Transport {
|
||||
this.eventBus.on(BuildUnitIntentEvent, (e) => this.onBuildUnitIntent(e))
|
||||
}
|
||||
|
||||
connect(onconnect: () => void, onmessage: (message: ServerMessage) => void) {
|
||||
private startPing() {
|
||||
if (this.isLocal || this.pingInterval) return;
|
||||
|
||||
this.pingInterval = window.setInterval(() => {
|
||||
if (this.socket != null && this.socket.readyState === WebSocket.OPEN) {
|
||||
this.sendMsg(JSON.stringify(ClientPingMessageSchema.parse({
|
||||
type: 'ping',
|
||||
clientID: this.clientID,
|
||||
gameID: this.gameID,
|
||||
})))
|
||||
}
|
||||
}, 10000);
|
||||
}
|
||||
|
||||
private stopPing() {
|
||||
if (this.pingInterval) {
|
||||
window.clearInterval(this.pingInterval);
|
||||
this.pingInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
public connect(onconnect: () => void, onmessage: (message: ServerMessage) => void) {
|
||||
if (this.isLocal) {
|
||||
this.connectLocal(onconnect, onmessage)
|
||||
} else {
|
||||
@@ -130,6 +155,7 @@ export class Transport {
|
||||
}
|
||||
|
||||
private connectRemote(onconnect: () => void, onmessage: (message: ServerMessage) => void) {
|
||||
this.startPing()
|
||||
const isFirstConnect = this.socket == null
|
||||
if (isFirstConnect) {
|
||||
const wsHost = process.env.WEBSOCKET_URL || window.location.host;
|
||||
@@ -155,8 +181,10 @@ export class Transport {
|
||||
};
|
||||
this.socket.onclose = (event: CloseEvent) => {
|
||||
console.log(`WebSocket closed. Code: ${event.code}, Reason: ${event.reason}`);
|
||||
console.log(`reconnecting`)
|
||||
this.connect(onconnect, onmessage)
|
||||
if (event.code != 1000) {
|
||||
console.log(`reconnecting`)
|
||||
this.connect(onconnect, onmessage)
|
||||
}
|
||||
};
|
||||
if (!isFirstConnect) {
|
||||
// Socket has already been opened, so simulate new connection.
|
||||
@@ -182,14 +210,9 @@ export class Transport {
|
||||
if (this.isLocal) {
|
||||
return
|
||||
}
|
||||
this.stopPing()
|
||||
if (this.socket.readyState === WebSocket.OPEN) {
|
||||
console.log('on stop: leaving game')
|
||||
const msg = ClientLeaveMessageSchema.parse({
|
||||
type: "leave",
|
||||
clientID: this.clientID,
|
||||
gameID: this.gameID,
|
||||
})
|
||||
this.sendMsg(JSON.stringify(msg))
|
||||
this.socket.close()
|
||||
} else {
|
||||
console.log('WebSocket is not open. Current state:', this.socket.readyState);
|
||||
|
||||
+10
-15
@@ -31,16 +31,15 @@ export type BuildUnitIntent = z.infer<typeof BuildUnitIntentSchema>
|
||||
export type Turn = z.infer<typeof TurnSchema>
|
||||
export type GameConfig = z.infer<typeof GameConfigSchema>
|
||||
|
||||
export type ClientMessage = ClientIntentMessage | ClientJoinMessage | ClientLeaveMessage
|
||||
export type ClientMessage = ClientPingMessage | ClientIntentMessage | ClientJoinMessage
|
||||
export type ServerMessage = ServerSyncMessage | ServerStartGameMessage
|
||||
|
||||
export type ServerSyncMessage = z.infer<typeof ServerTurnMessageSchema>
|
||||
export type ServerStartGameMessage = z.infer<typeof ServerStartGameMessageSchema>
|
||||
|
||||
|
||||
export type ClientPingMessage = z.infer<typeof ClientPingMessageSchema>
|
||||
export type ClientIntentMessage = z.infer<typeof ClientIntentMessageSchema>
|
||||
export type ClientJoinMessage = z.infer<typeof ClientJoinMessageSchema>
|
||||
export type ClientLeaveMessage = z.infer<typeof ClientLeaveMessageSchema>
|
||||
|
||||
const PlayerTypeSchema = z.nativeEnum(PlayerType);
|
||||
|
||||
@@ -204,28 +203,24 @@ export const ServerMessageSchema = z.union([ServerTurnMessageSchema, ServerStart
|
||||
// Client
|
||||
|
||||
const ClientBaseMessageSchema = z.object({
|
||||
type: z.string()
|
||||
type: z.string(),
|
||||
clientID: z.string(),
|
||||
gameID: z.string(),
|
||||
})
|
||||
|
||||
export const ClientPingMessageSchema = ClientBaseMessageSchema.extend({
|
||||
type: z.literal('ping'),
|
||||
})
|
||||
|
||||
export const ClientIntentMessageSchema = ClientBaseMessageSchema.extend({
|
||||
type: z.literal('intent'),
|
||||
clientID: z.string(),
|
||||
gameID: z.string(),
|
||||
intent: IntentSchema
|
||||
})
|
||||
|
||||
export const ClientJoinMessageSchema = ClientBaseMessageSchema.extend({
|
||||
type: z.literal('join'),
|
||||
clientID: z.string(),
|
||||
clientIP: z.string().nullable(),
|
||||
gameID: z.string(),
|
||||
lastTurn: z.number() // The last turn the client saw.
|
||||
})
|
||||
|
||||
export const ClientLeaveMessageSchema = ClientBaseMessageSchema.extend({
|
||||
type: z.literal('leave'),
|
||||
clientID: z.string(),
|
||||
gameID: z.string(),
|
||||
})
|
||||
|
||||
export const ClientMessageSchema = z.union([ClientIntentMessageSchema, ClientJoinMessageSchema, ClientLeaveMessageSchema]);
|
||||
export const ClientMessageSchema = z.union([ClientPingMessageSchema, ClientIntentMessageSchema, ClientJoinMessageSchema]);
|
||||
@@ -1,11 +1,14 @@
|
||||
import WebSocket from 'ws';
|
||||
import {ClientID} from '../core/Schemas';
|
||||
import { ClientID } from '../core/Schemas';
|
||||
|
||||
|
||||
export class Client {
|
||||
|
||||
public lastPing: number
|
||||
|
||||
constructor(
|
||||
public readonly id: ClientID,
|
||||
public readonly ip: string | null,
|
||||
public readonly ws: WebSocket
|
||||
public readonly ws: WebSocket,
|
||||
) { }
|
||||
}
|
||||
+27
-16
@@ -25,7 +25,6 @@ export class GameServer {
|
||||
|
||||
private endTurnIntervalID
|
||||
|
||||
|
||||
constructor(
|
||||
public readonly id: string,
|
||||
public readonly createdAt: number,
|
||||
@@ -59,6 +58,7 @@ export class GameServer {
|
||||
}
|
||||
this.clients = this.clients.filter(c => c.id != client.id)
|
||||
this.clients.push(client)
|
||||
client.lastPing = Date.now()
|
||||
client.ws.on('message', (message: string) => {
|
||||
const clientMsg: ClientMessage = ClientMessageSchema.parse(JSON.parse(message))
|
||||
if (clientMsg.type == "intent") {
|
||||
@@ -68,17 +68,14 @@ export class GameServer {
|
||||
console.warn(`client ${clientMsg.clientID} sent to wrong game`)
|
||||
}
|
||||
}
|
||||
if (clientMsg.type == "leave") {
|
||||
// TODO: get rid of leave message, just use on close?
|
||||
const toRemove = this.clients.filter(c => c.id)
|
||||
if (toRemove.length == 0) {
|
||||
return
|
||||
}
|
||||
toRemove[0].ws.close()
|
||||
console.log(`client ${toRemove[0].id} left game`)
|
||||
this.clients = this.clients.filter(c => c.id != clientMsg.clientID)
|
||||
if (clientMsg.type == "ping") {
|
||||
client.lastPing = Date.now()
|
||||
}
|
||||
})
|
||||
client.ws.on('close', () => {
|
||||
console.log(`client ${client.id} disconnected`)
|
||||
this.clients = this.clients.filter(c => c.id != client.id)
|
||||
})
|
||||
|
||||
// In case a client joined the game late and missed the start message.
|
||||
if (this._hasStarted) {
|
||||
@@ -143,11 +140,12 @@ export class GameServer {
|
||||
this.clients.forEach(client => {
|
||||
client.ws.removeAllListeners('message');
|
||||
if (client.ws.readyState === WebSocket.OPEN) {
|
||||
client.ws.close();
|
||||
client.ws.close(1000, "game has ended");
|
||||
}
|
||||
});
|
||||
console.log(`ending game ${this.id} with ${this.turns.length} turns`)
|
||||
try {
|
||||
if (this.turns.length > 350) {
|
||||
if (this.turns.length > 100) {
|
||||
console.log(`writing game ${this.id} to gcs`)
|
||||
const bucket = storage.bucket(this.config.gameStorageBucketName());
|
||||
const file = bucket.file(this.id);
|
||||
@@ -166,14 +164,27 @@ export class GameServer {
|
||||
}
|
||||
|
||||
phase(): GamePhase {
|
||||
if (Date.now() > this.createdAt + this.config.lobbyLifetime() + this.maxGameDuration) {
|
||||
const now = Date.now()
|
||||
const alive = []
|
||||
for (const client of this.clients) {
|
||||
if (now - client.lastPing > 60_000) {
|
||||
console.log(`no pings from ${client.id}, terminating connection`)
|
||||
if (client.ws.readyState === WebSocket.OPEN) {
|
||||
client.ws.close(1000, "no heartbeats received, closing connection");
|
||||
}
|
||||
} else {
|
||||
alive.push(client)
|
||||
}
|
||||
}
|
||||
this.clients = alive
|
||||
if (now > this.createdAt + this.config.lobbyLifetime() + this.maxGameDuration) {
|
||||
console.warn(`game past max duration ${this.id}`)
|
||||
return GamePhase.Finished
|
||||
}
|
||||
if (!this.isPublic) {
|
||||
if (this._hasStarted) {
|
||||
if (this.clients.length == 0) {
|
||||
console.log()
|
||||
console.log(`private game: ${this.id} complete`)
|
||||
return GamePhase.Finished
|
||||
} else {
|
||||
return GamePhase.Active
|
||||
@@ -183,11 +194,11 @@ export class GameServer {
|
||||
}
|
||||
}
|
||||
|
||||
if (Date.now() - this.createdAt < this.config.lobbyLifetime()) {
|
||||
if (now - this.createdAt < this.config.lobbyLifetime()) {
|
||||
return GamePhase.Lobby
|
||||
}
|
||||
|
||||
if (this.clients.length == 0 && Date.now() > this.createdAt + this.config.lobbyLifetime() + 30 * 60) { // wait at least 30s before ending game
|
||||
if (this.clients.length == 0 && now > this.createdAt + this.config.lobbyLifetime() + 30 * 60) { // wait at least 30s before ending game
|
||||
return GamePhase.Finished
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user