diff --git a/src/client/GameRunner.ts b/src/client/GameRunner.ts index f0419c66d..b2d79ffd0 100644 --- a/src/client/GameRunner.ts +++ b/src/client/GameRunner.ts @@ -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"; diff --git a/src/client/Transport.ts b/src/client/Transport.ts index deae97b93..edd88933e 100644 --- a/src/client/Transport.ts +++ b/src/client/Transport.ts @@ -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); diff --git a/src/core/Schemas.ts b/src/core/Schemas.ts index 62c04fd75..3e352a60d 100644 --- a/src/core/Schemas.ts +++ b/src/core/Schemas.ts @@ -31,16 +31,15 @@ export type BuildUnitIntent = z.infer export type Turn = z.infer export type GameConfig = z.infer -export type ClientMessage = ClientIntentMessage | ClientJoinMessage | ClientLeaveMessage +export type ClientMessage = ClientPingMessage | ClientIntentMessage | ClientJoinMessage export type ServerMessage = ServerSyncMessage | ServerStartGameMessage export type ServerSyncMessage = z.infer export type ServerStartGameMessage = z.infer - +export type ClientPingMessage = z.infer export type ClientIntentMessage = z.infer export type ClientJoinMessage = z.infer -export type ClientLeaveMessage = z.infer 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]); \ No newline at end of file +export const ClientMessageSchema = z.union([ClientPingMessageSchema, ClientIntentMessageSchema, ClientJoinMessageSchema]); \ No newline at end of file diff --git a/src/server/Client.ts b/src/server/Client.ts index b38578d02..78cca60be 100644 --- a/src/server/Client.ts +++ b/src/server/Client.ts @@ -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, ) { } } \ No newline at end of file diff --git a/src/server/GameServer.ts b/src/server/GameServer.ts index 8de130f3b..8e88be7f9 100644 --- a/src/server/GameServer.ts +++ b/src/server/GameServer.ts @@ -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 }