diff --git a/TODO.txt b/TODO.txt index ab4cfd94f..4c4eb7a8f 100644 --- a/TODO.txt +++ b/TODO.txt @@ -17,8 +17,12 @@ * upload and start server DONE 8/13/2024 * fix multiplayer DONE 8/14/2024 * fix server resource leak DONE 8/14/2024 +* fix bug where game stops after 10s (websocket disconnection) DONE 8/16/2024 * fix server memory leak * balance attacks/expansions better +* Bug: boats not going to destination, coast not being recognized +* double attack add troops +* Have some time for spawning before game starts * delete players when territories too small * better algorithm for name render placement * make boats larger diff --git a/src/client/Client.ts b/src/client/Client.ts index b3dceb4c7..08705d023 100644 --- a/src/client/Client.ts +++ b/src/client/Client.ts @@ -1,7 +1,7 @@ import {defaultConfig} from "../core/configuration/DefaultConfig"; import {TerrainMap} from "../core/Game"; import {PseudoRandom} from "../core/PseudoRandom"; -import {ServerMessage, ServerMessageSchema} from "../core/Schemas"; +import {GameID, ServerMessage, ServerMessageSchema} from "../core/Schemas"; import {loadTerrainMap} from "../core/TerrainMapLoader"; import {ClientGame, createClientGame} from "./ClientGame"; import {v4 as uuidv4} from 'uuid'; @@ -44,15 +44,15 @@ class Client { } } - private updateLobbiesDisplay(lobbies: Array<{id: string}>): void { + private updateLobbiesDisplay(lobbies: GameID[]): void { if (!this.lobbiesContainer) return; this.lobbiesContainer.innerHTML = ''; // Clear existing lobbies lobbies.forEach(lobby => { const button = document.createElement('button'); - button.textContent = `Join Lobby ${lobby.id}`; - button.onclick = () => this.joinLobby(lobby.id); + button.textContent = `Join Lobby ${lobby}`; + button.onclick = () => this.joinLobby(lobby); this.lobbiesContainer.appendChild(button); }); @@ -88,7 +88,7 @@ class Client { } // TODO make id more random, if two player join same millisecond get same id. this.game = createClientGame(getUsername(), new PseudoRandom(Date.now()).nextID(), lobbyID, defaultConfig, map) - this.game.joinLobby() + this.game.join() }) } } diff --git a/src/client/ClientGame.ts b/src/client/ClientGame.ts index 29f1343d8..508cb288d 100644 --- a/src/client/ClientGame.ts +++ b/src/client/ClientGame.ts @@ -1,16 +1,16 @@ import {Executor} from "../core/execution/Executor"; -import {Cell, ClientID, MutableGame, LobbyID, PlayerEvent, PlayerID, PlayerInfo, MutablePlayer, TerrainMap, TileEvent, Player, Game, BoatEvent, TerrainTypes} from "../core/Game"; +import {Cell, MutableGame, PlayerEvent, PlayerID, PlayerInfo, MutablePlayer, TerrainMap, TileEvent, Player, Game, BoatEvent, TerrainTypes} from "../core/Game"; import {createGame} from "../core/GameImpl"; import {Ticker, TickEvent} from "../core/Ticker"; import {EventBus} from "../core/EventBus"; import {Config} from "../core/configuration/Config"; import {GameRenderer} from "./graphics/GameRenderer"; import {InputHandler, MouseUpEvent, ZoomEvent, DragEvent, MouseDownEvent} from "./InputHandler" -import {ClientIntentMessageSchema, ClientJoinMessageSchema, ClientMessageSchema, ServerMessage, ServerMessageSchema, ServerSyncMessage, Turn} from "../core/Schemas"; +import {ClientID, ClientIntentMessageSchema, ClientJoinMessageSchema, ClientMessageSchema, GameID, Intent, ServerMessage, ServerMessageSchema, ServerSyncMessage, Turn} from "../core/Schemas"; -export function createClientGame(name: string, clientID: ClientID, lobbyID: LobbyID, config: Config, terrainMap: TerrainMap): ClientGame { +export function createClientGame(name: string, clientID: ClientID, gameID: GameID, config: Config, terrainMap: TerrainMap): ClientGame { let eventBus = new EventBus() let gs = createGame(terrainMap, eventBus) let gameRenderer = new GameRenderer(gs, config.theme(), document.createElement("canvas")) @@ -18,7 +18,7 @@ export function createClientGame(name: string, clientID: ClientID, lobbyID: Lobb return new ClientGame( name, clientID, - lobbyID, + gameID, eventBus, gs, gameRenderer, @@ -47,7 +47,7 @@ export class ClientGame { constructor( private playerName: string, private id: ClientID, - private gameID: LobbyID, + private gameID: GameID, private eventBus: EventBus, private gs: Game, private renderer: GameRenderer, @@ -56,16 +56,17 @@ export class ClientGame { private config: Config ) { } - public joinLobby() { + public join() { const wsHost = process.env.WEBSOCKET_URL || window.location.host; - this.socket = new WebSocket(`ws://${wsHost}`) + const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + this.socket = new WebSocket(`${wsProtocol}//${wsHost}`) this.socket.onopen = () => { console.log('Connected to game server!'); this.socket.send( JSON.stringify( ClientJoinMessageSchema.parse({ type: "join", - lobbyID: this.gameID, + gameID: this.gameID, clientID: this.id }) ) @@ -81,6 +82,15 @@ export class ClientGame { this.addTurn(message.turn) } }; + this.socket.onerror = (err) => { + console.error('Socket encountered error: ', err, 'Closing socket'); + this.socket.close(); + }; + this.socket.onclose = (event: CloseEvent) => { + console.log(`WebSocket closed. Code: ${event.code}, Reason: ${event.reason}`); + this.join() + }; + } public start() { @@ -128,7 +138,7 @@ export class ClientGame { private playerEvent(event: PlayerEvent) { console.log('received new player event!') - if (event.player.info().clientID == this.id) { + if (event.player.info().gameID == this.id) { console.log('setting name') this.myPlayer = event.player } @@ -167,79 +177,52 @@ export class ClientGame { } private sendSpawnIntent(cell: Cell) { - const spawn = JSON.stringify( - ClientIntentMessageSchema.parse({ - type: "intent", - clientID: this.id, - gameID: this.gameID, - intent: { - type: "spawn", - clientID: this.id, - name: this.playerName, - isBot: false, - x: cell.x, - y: cell.y - } - }) - ) - console.log(spawn) - if (this.socket.readyState === WebSocket.OPEN) { - console.log(`seding spawn intent: ${spawn}`) - this.socket.send(spawn) - } else { - console.log('WebSocket is not open. Current state:', this.socket.readyState); - } + this.sendIntent({ + type: "spawn", + clientID: this.id, + name: this.playerName, + isBot: false, + x: cell.x, + y: cell.y + }) } private sendAttackIntent(targetID: PlayerID, cell: Cell, troops: number) { - const attack = JSON.stringify( - ClientIntentMessageSchema.parse({ - type: "intent", - clientID: this.id, - gameID: this.gameID, - intent: { - type: "attack", - clientID: this.id, - attackerID: this.myPlayer.id(), - targetID: targetID, - troops: troops, - targetX: cell.x, - targetY: cell.y - } - }) - ) - console.log(attack) - if (this.socket.readyState === WebSocket.OPEN) { - console.log(`sending attack intent: ${attack}`) - this.socket.send(attack) - } else { - console.log('WebSocket is not open. Current state:', this.socket.readyState); - } + this.sendIntent({ + type: "attack", + clientID: this.id, + attackerID: this.myPlayer.id(), + targetID: targetID, + troops: troops, + targetX: cell.x, + targetY: cell.y + }) } private sendBoatAttackIntent(targetID: PlayerID, cell: Cell, troops: number) { - const attack = JSON.stringify( - ClientIntentMessageSchema.parse({ + this.sendIntent({ + type: "boat", + clientID: this.id, + attackerID: this.myPlayer.id(), + targetID: targetID, + troops: troops, + x: cell.x, + y: cell.y, + }) + } + + private sendIntent(intent: Intent) { + if (this.socket.readyState === WebSocket.OPEN) { + const msg = ClientIntentMessageSchema.parse({ type: "intent", clientID: this.id, gameID: this.gameID, - intent: { - type: "boat", - clientID: this.id, - attackerID: this.myPlayer.id(), - targetID: targetID, - troops: troops, - x: cell.x, - y: cell.y, - } + intent: intent }) - ) - console.log(attack) - if (this.socket.readyState === WebSocket.OPEN) { - console.log(`sending boat attack intent: ${attack}`) - this.socket.send(attack) + this.socket.send(JSON.stringify(msg)) } else { console.log('WebSocket is not open. Current state:', this.socket.readyState); + console.log('attempting reconnect') } } diff --git a/src/core/Game.ts b/src/core/Game.ts index ef07cf215..3347c45b2 100644 --- a/src/core/Game.ts +++ b/src/core/Game.ts @@ -1,13 +1,8 @@ import {GameEvent} from "./EventBus" - -export type ClientID = string +import {GameID} from "./Schemas" export type PlayerID = number // TODO: make string? -export type GameID = string - -export type LobbyID = string - export class Cell { private strRepr: string @@ -38,7 +33,7 @@ export class PlayerInfo { public readonly name: string, public readonly isBot: boolean, // null if bot. - public readonly clientID: ClientID | null + public readonly gameID: GameID | null ) { } } @@ -133,8 +128,8 @@ export interface Game { forEachTile(fn: (tile: Tile) => void): void executions(): ExecutionView[] terraNullius(): TerraNullius - tick() - addExecution(...exec: Execution[]) + tick(): void + addExecution(...exec: Execution[]): void } export interface MutableGame extends Game { diff --git a/src/core/Schemas.ts b/src/core/Schemas.ts index 999a324e6..de18e18e9 100644 --- a/src/core/Schemas.ts +++ b/src/core/Schemas.ts @@ -1,5 +1,8 @@ import {z} from 'zod'; +export type GameID = string +export type ClientID = string + export type Intent = SpawnIntent | AttackIntent | BoatAttackIntent export type AttackIntent = z.infer @@ -96,7 +99,7 @@ export const ClientIntentMessageSchema = ClientBaseMessageSchema.extend({ export const ClientJoinMessageSchema = ClientBaseMessageSchema.extend({ type: z.literal('join'), clientID: z.string(), - lobbyID: z.string() + gameID: z.string() }) export const ClientMessageSchema = z.union([ClientIntentMessageSchema, ClientJoinMessageSchema]); \ No newline at end of file diff --git a/src/core/configuration/Config.ts b/src/core/configuration/Config.ts index 18b269fb3..6b3357b6a 100644 --- a/src/core/configuration/Config.ts +++ b/src/core/configuration/Config.ts @@ -6,7 +6,7 @@ export interface Config { theme(): Theme; player(): PlayerConfig turnIntervalMs(): number - lobbyCreationRate(): number + gameCreationRate(): number lobbyLifetime(): number } diff --git a/src/core/configuration/DefaultConfig.ts b/src/core/configuration/DefaultConfig.ts index 519988c7a..03e35cf5a 100644 --- a/src/core/configuration/DefaultConfig.ts +++ b/src/core/configuration/DefaultConfig.ts @@ -9,7 +9,7 @@ export const defaultConfig = new class implements Config { turnIntervalMs(): number { return 100 } - lobbyCreationRate(): number { + gameCreationRate(): number { return 2 * 1000 } lobbyLifetime(): number { diff --git a/src/core/execution/SpawnExecution.ts b/src/core/execution/SpawnExecution.ts index 1f5f325f0..840da08e9 100644 --- a/src/core/execution/SpawnExecution.ts +++ b/src/core/execution/SpawnExecution.ts @@ -26,7 +26,6 @@ export class SpawnExecution implements Execution { } const player = this.gs.addPlayer(this.playerInfo, this.playerConfig.startTroops(this.playerInfo)) getSpawnCells(this.gs, this.cell).forEach(c => { - console.log('conquering cell') player.conquer(this.gs.tile(c)) }) this.gs.addExecution(new PlayerExecution(player.id(), this.playerConfig)) diff --git a/src/server/Client.ts b/src/server/Client.ts index 8c1f0cbd0..ede75c3b2 100644 --- a/src/server/Client.ts +++ b/src/server/Client.ts @@ -1,5 +1,5 @@ -import {ClientID} from "../core/Game"; import WebSocket from 'ws'; +import {ClientID} from '../core/Schemas'; export class Client { diff --git a/src/server/GameManager.ts b/src/server/GameManager.ts index 919deedb9..29cb320a8 100644 --- a/src/server/GameManager.ts +++ b/src/server/GameManager.ts @@ -1,73 +1,53 @@ -import {GameID, LobbyID} from "../core/Game"; -import {Client} from "./Client"; -import {Lobby} from "./Lobby"; -import {GameServer} from "./GameServer"; +import {GamePhase, GameServer} from "./GameServer"; import {Config} from "../core/configuration/Config"; -import {defaultConfig} from "../core/configuration/DefaultConfig"; import {PseudoRandom} from "../core/PseudoRandom"; +import WebSocket from 'ws'; +import {ClientID, GameID} from "../core/Schemas"; +import {Client} from "./Client"; + export class GameManager { private lastNewLobby: number = 0 - private _lobbies: Map = new Map() - - private games: Map = new Map() + private games: GameServer[] = [] private random = new PseudoRandom(123) - constructor(private settings: Config) { } + constructor(private config: Config) { } - - public hasLobby(lobbyID: LobbyID): boolean { - return this._lobbies.has(lobbyID) + gamesByPhase(phase: GamePhase): GameServer[] { + return this.games.filter(g => g.phase() == phase) } - public addClientToLobby(client: Client, lobbyID: LobbyID) { - this._lobbies.get(lobbyID).addClient(client) - } - - addLobby(lobby: Lobby) { - this._lobbies.set(lobby.id, lobby) - } - - lobby(id: LobbyID): Lobby { - return this._lobbies.get(id) - } - - lobbies(): Lobby[] { - return Array.from(this._lobbies.values()) - } - - addGame(game: GameServer) { - this.games.set(game.id, game) + addClient(client: Client, gameID: GameID) { + const game = this.games.find(g => g.id == gameID) + if (!game) { + console.log(`game id ${gameID} not found`) + return + } + game.addClient(client) } tick() { + const lobbies = this.gamesByPhase(GamePhase.Lobby) + const active = this.gamesByPhase(GamePhase.Active) + const finished = this.gamesByPhase(GamePhase.Finished) + const now = Date.now() - - const active = this.lobbies().filter(l => !l.isExpired(now - 2000)) - const expired = this.lobbies().filter(l => l.isExpired(now - 2000)) - this._lobbies = new Map(active.map(lobby => [lobby.id, lobby])); - expired.forEach(lobby => { - const game = new GameServer(lobby.id, now, lobby.clients, this.settings) - this.games.set(game.id, game) - game.start() - }) - - if (now > this.lastNewLobby + this.settings.lobbyCreationRate()) { + if (now > this.lastNewLobby + this.config.gameCreationRate()) { this.lastNewLobby = now - this.addLobby(new Lobby(this.random.nextID(), this.settings.lobbyLifetime())) + const id = this.random.nextID() + console.log(`creating game ${id}`) + lobbies.push(new GameServer(id, now, this.config)) } - const activeGames: Map = new Map() - for (const [id, game] of this.games) { - if (game.isActive()) { - activeGames.set(id, game) - } else { - game.endGame() - } - } - //this.games = activeGames + active.filter(g => !g.hasStarted()).forEach(g => { + g.start() + }) + finished.forEach(g => { + g.endGame() + }) + this.games = [...lobbies, ...active] } } \ No newline at end of file diff --git a/src/server/GameServer.ts b/src/server/GameServer.ts index 03a4c41a0..920951a3b 100644 --- a/src/server/GameServer.ts +++ b/src/server/GameServer.ts @@ -1,50 +1,64 @@ -import {EventBus} from "../core/EventBus"; -import {ClientID, GameID} from "../core/Game"; import {ClientMessage, ClientMessageSchema, Intent, ServerStartGameMessage, ServerStartGameMessageSchema, ServerTurnMessageSchema, Turn} from "../core/Schemas"; import {Config} from "../core/configuration/Config"; -import {Ticker, TickEvent} from "../core/Ticker"; import {Client} from "./Client"; +export enum GamePhase { + Lobby = 'LOBBY', + Active = 'ACTIVE', + Finished = 'FINISHED' +} + export class GameServer { + + private gameDuration = 5 * 60 * 1000 // TODO!!! fix this + private turns: Turn[] = [] private intents: Intent[] = [] - private lastUpdate = 0; + private clients: Client[] = [] + private _hasStarted = false constructor( - public readonly id: GameID, - private startTime: number, - private clients: Map, + public readonly id: string, + public readonly createdAt: number, private settings: Config, - ) { - this.lastUpdate = Date.now() + ) { } + + public addClient(client: Client) { + console.log(`game ${this.id} adding client ${client.id}`) + // Remove stale client if this is a reconnect + this.clients = this.clients.filter(c => c.id != client.id) + this.clients.push(client) + client.ws.on('message', (message: string) => { + const clientMsg: ClientMessage = ClientMessageSchema.parse(JSON.parse(message)) + if (clientMsg.type == "intent") { + if (clientMsg.gameID == this.id) { + this.addIntent(clientMsg.intent) + } else { + console.warn(`client ${clientMsg.clientID} sent to wrong game`) + } + } + }) } public start() { - this.clients.forEach(c => { - c.ws.on('message', (message: string) => { - this.lastUpdate = Date.now() - const clientMsg: ClientMessage = ClientMessageSchema.parse(JSON.parse(message)) - if (clientMsg.type == "intent") { - if (clientMsg.gameID == this.id) { - this.addIntent(clientMsg.intent) - } else { - console.warn(`client ${clientMsg.clientID} sent to wrong game`) - } - } - }) - }) - - + this._hasStarted = true const startGame = JSON.stringify(ServerStartGameMessageSchema.parse( { type: "start" } )) this.clients.forEach(c => { + console.log(`game ${this.id} sending start message to ${c.id}`) c.ws.send(startGame) }) setInterval(() => this.endTurn(), this.settings.turnIntervalMs()); + + // setInterval(() => { + // this.clients.forEach(c => { + // c.ws.close(1011, 'Intentional error for testing'); + // }) + // }, 1000) } private addIntent(intent: Intent) { @@ -69,10 +83,7 @@ export class GameServer { this.clients.forEach(c => { c.ws.send(msg) }) - } - public isActive(): boolean { - return Date.now() - this.lastUpdate < 1000 * 60 * 5 // 5 minutes } endGame() { @@ -85,4 +96,18 @@ export class GameServer { }); } + phase(): GamePhase { + if (Date.now() - this.createdAt < this.settings.lobbyLifetime()) { + return GamePhase.Lobby + } + if (Date.now() - this.createdAt < this.settings.lobbyLifetime() + this.gameDuration) { + return GamePhase.Active + } + return GamePhase.Finished + } + + hasStarted(): boolean { + return this._hasStarted + } + } \ No newline at end of file diff --git a/src/server/Lobby.ts b/src/server/Lobby.ts deleted file mode 100644 index 65cf496c2..000000000 --- a/src/server/Lobby.ts +++ /dev/null @@ -1,21 +0,0 @@ -import {ClientID} from "../core/Game"; -import {Client} from "./Client"; - -export class Lobby { - - public clients: Map = new Map() - private startGameTs: number - - - constructor(public readonly id: string, durationMs: number) { - this.startGameTs = Date.now() + durationMs - } - - public addClient(client: Client) { - this.clients.set(client.id, client) - } - - public isExpired(now: number): boolean { - return now > this.startGameTs - } -} \ No newline at end of file diff --git a/src/server/Server.ts b/src/server/Server.ts index 161cf37e0..631132e88 100644 --- a/src/server/Server.ts +++ b/src/server/Server.ts @@ -6,8 +6,8 @@ import {fileURLToPath} from 'url'; import {GameManager} from './GameManager'; import {Client} from './Client'; import {ClientMessage, ClientMessageSchema} from '../core/Schemas'; -import {Lobby} from './Lobby'; import {defaultConfig} from '../core/configuration/DefaultConfig'; +import {GamePhase} from './GameServer'; @@ -27,12 +27,8 @@ const gm = new GameManager(defaultConfig) // New GET endpoint to list lobbies app.get('/lobbies', (req, res) => { - const lobbyList = Array.from(gm.lobbies()).filter(l => !l.isExpired(Date.now())).map(lobby => ({ - id: lobby.id, - })); - res.json({ - lobbies: lobbyList, + lobbies: gm.gamesByPhase(GamePhase.Lobby).map(g => g.id), }); }); @@ -43,12 +39,7 @@ wss.on('connection', (ws) => { const clientMsg: ClientMessage = ClientMessageSchema.parse(JSON.parse(message)) if (clientMsg.type == "join") { console.log('got join request') - if (gm.hasLobby(clientMsg.lobbyID)) { - console.log('client joining lobby') - gm.addClientToLobby(new Client(clientMsg.clientID, ws), clientMsg.lobbyID) - } else { - console.log('lobby not found') - } + gm.addClient(new Client(clientMsg.clientID, ws), clientMsg.gameID) } // TODO: send error message })