From 1e2f8776c578fe3d1d7e10908641087fed5c35a6 Mon Sep 17 00:00:00 2001 From: YoussfeJob Date: Sat, 20 Dec 2025 14:01:27 +0100 Subject: [PATCH 1/8] Added pause functionality for private multiplayer games - Only lobby creator can pause/unpause the game - Show pause overlay when game is paused - Pause state communicated via worker messages - Server validates lobby creator before accepting pause intent --- resources/lang/en.json | 3 + src/client/ClientGameRunner.ts | 13 ++++- src/client/Transport.ts | 33 +++++++---- src/client/graphics/GameRenderer.ts | 10 ++++ .../graphics/layers/GameRightSidebar.ts | 55 +++++++++++------- src/client/graphics/layers/PauseOverlay.ts | 47 +++++++++++++++ src/client/graphics/layers/SettingsModal.ts | 4 +- src/client/index.html | 2 + src/core/Schemas.ts | 12 +++- src/core/execution/ExecutionManager.ts | 3 + src/core/worker/Worker.worker.ts | 11 ++++ src/core/worker/WorkerClient.ts | 11 ++++ src/core/worker/WorkerMessages.ts | 7 +++ src/server/GameServer.ts | 57 ++++++++++++++++++- 14 files changed, 231 insertions(+), 37 deletions(-) create mode 100644 src/client/graphics/layers/PauseOverlay.ts diff --git a/resources/lang/en.json b/resources/lang/en.json index 8a81345f38..be2a80b505 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -740,6 +740,9 @@ "choose_spawn": "Choose a starting location", "random_spawn": "Random spawn is enabled. Selecting starting location for you..." }, + "pause": { + "game_paused": "Game paused by Lobby Creator" + }, "territory_patterns": { "title": "Skins", "colors": "Colors", diff --git a/src/client/ClientGameRunner.ts b/src/client/ClientGameRunner.ts index 5089b6be22..2815f2bbb2 100644 --- a/src/client/ClientGameRunner.ts +++ b/src/client/ClientGameRunner.ts @@ -39,6 +39,7 @@ import { import { endGame, startGame, startTime } from "./LocalPersistantStats"; import { terrainMapFileLoader } from "./TerrainMapFileLoader"; import { + GamePausedEvent, SendAttackIntentEvent, SendBoatAttackIntentEvent, SendHashEvent, @@ -194,7 +195,12 @@ async function createClientGame( ); const canvas = createCanvas(); - const gameRenderer = createRenderer(canvas, gameView, eventBus); + const gameRenderer = createRenderer( + canvas, + gameView, + eventBus, + lobbyConfig.gameStartInfo.isLobbyCreator ?? false, + ); console.log( `creating private game got difficulty: ${lobbyConfig.gameStartInfo.config.difficulty}`, @@ -328,6 +334,11 @@ export class ClientGameRunner { this.saveGame(gu.updates[GameUpdateType.Win][0]); } }); + + this.worker.onPauseStateChange((paused: boolean) => { + this.eventBus.emit(new GamePausedEvent(paused)); + }); + const worker = this.worker; const keepWorkerAlive = () => { if (this.isActive) { diff --git a/src/client/Transport.ts b/src/client/Transport.ts index 8c94d215f7..6bb25ddd9f 100644 --- a/src/client/Transport.ts +++ b/src/client/Transport.ts @@ -29,7 +29,11 @@ import { getPlayToken } from "./Auth"; import { LobbyConfig } from "./ClientGameRunner"; import { LocalServer } from "./LocalServer"; -export class PauseGameEvent implements GameEvent { +export class PauseGameIntentEvent implements GameEvent { + constructor(public readonly paused: boolean) {} +} + +export class GamePausedEvent implements GameEvent { constructor(public readonly paused: boolean) {} } @@ -186,6 +190,7 @@ export class Transport { private pingInterval: number | null = null; public readonly isLocal: boolean; + constructor( private lobbyConfig: LobbyConfig, private eventBus: EventBus, @@ -237,7 +242,7 @@ export class Transport { ); this.eventBus.on(BuildUnitIntentEvent, (e) => this.onBuildUnitIntent(e)); - this.eventBus.on(PauseGameEvent, (e) => this.onPauseGameEvent(e)); + this.eventBus.on(PauseGameIntentEvent, (e) => this.onPauseGameIntent(e)); this.eventBus.on(SendWinnerEvent, (e) => this.onSendWinnerEvent(e)); this.eventBus.on(SendHashEvent, (e) => this.onSendHashEvent(e)); this.eventBus.on(CancelAttackIntentEvent, (e) => @@ -575,15 +580,23 @@ export class Transport { }); } - private onPauseGameEvent(event: PauseGameEvent) { - if (!this.isLocal) { - console.log(`cannot pause multiplayer games`); - return; - } - if (event.paused) { - this.localServer.pause(); + private onPauseGameIntent(event: PauseGameIntentEvent) { + if (this.isLocal) { + // Local (singleplayer) game pause + if (event.paused) { + this.localServer.pause(); + } else { + this.localServer.resume(); + } + // Emit GamePausedEvent for UI to update + this.eventBus.emit(new GamePausedEvent(event.paused)); } else { - this.localServer.resume(); + // Multiplayer game - send toggle_pause intent to server + this.sendIntent({ + type: "toggle_pause", + clientID: this.lobbyConfig.clientID, + paused: event.paused, + }); } } diff --git a/src/client/graphics/GameRenderer.ts b/src/client/graphics/GameRenderer.ts index 84fff4175c..7fd87e0edb 100644 --- a/src/client/graphics/GameRenderer.ts +++ b/src/client/graphics/GameRenderer.ts @@ -24,6 +24,7 @@ import { MainRadialMenu } from "./layers/MainRadialMenu"; import { MultiTabModal } from "./layers/MultiTabModal"; import { NameLayer } from "./layers/NameLayer"; import { NukeTrajectoryPreviewLayer } from "./layers/NukeTrajectoryPreviewLayer"; +import { PauseOverlay } from "./layers/PauseOverlay"; import { PerformanceOverlay } from "./layers/PerformanceOverlay"; import { PlayerInfoOverlay } from "./layers/PlayerInfoOverlay"; import { PlayerPanel } from "./layers/PlayerPanel"; @@ -46,6 +47,7 @@ export function createRenderer( canvas: HTMLCanvasElement, game: GameView, eventBus: EventBus, + isLobbyCreator: boolean = false, ): GameRenderer { const transformHandler = new TransformHandler(game, eventBus, canvas); const userSettings = new UserSettings(); @@ -154,6 +156,7 @@ export function createRenderer( } gameRightSidebar.game = game; gameRightSidebar.eventBus = eventBus; + gameRightSidebar.isLobbyCreator = isLobbyCreator; const settingsModal = document.querySelector( "settings-modal", @@ -234,6 +237,12 @@ export function createRenderer( spawnTimer.game = game; spawnTimer.transformHandler = transformHandler; + const pauseOverlay = document.querySelector("pause-overlay") as PauseOverlay; + if (!(pauseOverlay instanceof PauseOverlay)) { + console.error("pause overlay not found"); + } + pauseOverlay.eventBus = eventBus; + // When updating these layers please be mindful of the order. // Try to group layers by the return value of shouldTransform. // Not grouping the layers may cause excessive calls to context.save() and context.restore(). @@ -275,6 +284,7 @@ export function createRenderer( playerPanel, headsUpMessage, multiTabModal, + pauseOverlay, new AdTimer(game), alertFrame, performanceOverlay, diff --git a/src/client/graphics/layers/GameRightSidebar.ts b/src/client/graphics/layers/GameRightSidebar.ts index 394668e03e..3b59951689 100644 --- a/src/client/graphics/layers/GameRightSidebar.ts +++ b/src/client/graphics/layers/GameRightSidebar.ts @@ -9,7 +9,7 @@ import { EventBus } from "../../../core/EventBus"; import { GameType } from "../../../core/game/Game"; import { GameUpdateType } from "../../../core/game/GameUpdates"; import { GameView } from "../../../core/game/GameView"; -import { PauseGameEvent } from "../../Transport"; +import { PauseGameIntentEvent } from "../../Transport"; import { translateText } from "../../Utils"; import { Layer } from "./Layer"; import { ShowReplayPanelEvent } from "./ReplayPanel"; @@ -19,6 +19,7 @@ import { ShowSettingsModalEvent } from "./SettingsModal"; export class GameRightSidebar extends LitElement implements Layer { public game: GameView; public eventBus: EventBus; + public isLobbyCreator: boolean = false; @state() private _isSinglePlayer: boolean = false; @@ -95,7 +96,7 @@ export class GameRightSidebar extends LitElement implements Layer { private onPauseButtonClick() { this.isPaused = !this.isPaused; - this.eventBus.emit(new PauseGameEvent(this.isPaused)); + this.eventBus.emit(new PauseGameIntentEvent(this.isPaused)); } private onExitButtonClick() { @@ -150,25 +151,35 @@ export class GameRightSidebar extends LitElement implements Layer { } maybeRenderReplayButtons() { - if (this._isSinglePlayer || this.game?.config()?.isReplay()) { - return html`
- replay -
-
- play/pause -
`; - } else { - return html``; - } + const isReplayOrSingleplayer = + this._isSinglePlayer || this.game?.config()?.isReplay(); + const showPauseButton = isReplayOrSingleplayer || this.isLobbyCreator; + + return html` + ${isReplayOrSingleplayer + ? html` +
+ replay +
+ ` + : ""} + ${showPauseButton + ? html` +
+ play/pause +
+ ` + : ""} + `; } } diff --git a/src/client/graphics/layers/PauseOverlay.ts b/src/client/graphics/layers/PauseOverlay.ts new file mode 100644 index 0000000000..9672acd33b --- /dev/null +++ b/src/client/graphics/layers/PauseOverlay.ts @@ -0,0 +1,47 @@ +import { LitElement, html } from "lit"; +import { customElement } from "lit/decorators.js"; +import { EventBus } from "../../../core/EventBus"; +import { GamePausedEvent } from "../../Transport"; +import { translateText } from "../../Utils"; +import { Layer } from "./Layer"; + +@customElement("pause-overlay") +export class PauseOverlay extends LitElement implements Layer { + public eventBus: EventBus; + private isPaused = false; + + createRenderRoot() { + return this; + } + + init() { + this.eventBus.on(GamePausedEvent, (e) => { + this.isPaused = e.paused; + this.requestUpdate(); + }); + } + + render() { + if (!this.isPaused) { + return html``; + } + + return html` +
e.preventDefault()} + > +
+

+ ${translateText("pause.game_paused")} +

+
+
+ `; + } +} diff --git a/src/client/graphics/layers/SettingsModal.ts b/src/client/graphics/layers/SettingsModal.ts index 23ee9f5e75..e0f97cc03c 100644 --- a/src/client/graphics/layers/SettingsModal.ts +++ b/src/client/graphics/layers/SettingsModal.ts @@ -14,7 +14,7 @@ import musicIcon from "../../../../resources/images/music.svg"; import { EventBus } from "../../../core/EventBus"; import { UserSettings } from "../../../core/game/UserSettings"; import { AlternateViewEvent, RefreshGraphicsEvent } from "../../InputHandler"; -import { PauseGameEvent } from "../../Transport"; +import { PauseGameIntentEvent } from "../../Transport"; import { translateText } from "../../Utils"; import SoundManager from "../../sound/SoundManager"; import { Layer } from "./Layer"; @@ -107,7 +107,7 @@ export class SettingsModal extends LitElement implements Layer { private pauseGame(pause: boolean) { if (this.shouldPause && !this.wasPausedWhenOpened) - this.eventBus.emit(new PauseGameEvent(pause)); + this.eventBus.emit(new PauseGameIntentEvent(pause)); } private onTerrainButtonClick() { diff --git a/src/client/index.html b/src/client/index.html index 1124859104..eb11c34de1 100644 --- a/src/client/index.html +++ b/src/client/index.html @@ -353,6 +353,8 @@ + +
; export type CancelAttackIntent = z.infer; @@ -79,6 +80,7 @@ export type AllianceExtensionIntent = z.infer< >; export type DeleteUnitIntent = z.infer; export type KickPlayerIntent = z.infer; +export type TogglePauseIntent = z.infer; export type Turn = z.infer; export type GameConfig = z.infer; @@ -91,6 +93,7 @@ export type ClientMessage = | ClientRejoinMessage | ClientLogMessage | ClientHashMessage; + export type ServerMessage = | ServerTurnMessage | ServerStartGameMessage @@ -354,6 +357,11 @@ export const KickPlayerIntentSchema = BaseIntentSchema.extend({ target: ID, }); +export const TogglePauseIntentSchema = BaseIntentSchema.extend({ + type: z.literal("toggle_pause"), + paused: z.boolean().default(false), +}); + const IntentSchema = z.discriminatedUnion("type", [ AttackIntentSchema, CancelAttackIntentSchema, @@ -377,6 +385,7 @@ const IntentSchema = z.discriminatedUnion("type", [ AllianceExtensionIntentSchema, DeleteUnitIntentSchema, KickPlayerIntentSchema, + TogglePauseIntentSchema, ]); // @@ -437,6 +446,7 @@ export const GameStartInfoSchema = z.object({ lobbyCreatedAt: z.number(), config: GameConfigSchema, players: PlayerSchema.array(), + isLobbyCreator: z.boolean().optional(), }); export const WinnerSchema = z diff --git a/src/core/execution/ExecutionManager.ts b/src/core/execution/ExecutionManager.ts index e0e93f764d..3138d2cedb 100644 --- a/src/core/execution/ExecutionManager.ts +++ b/src/core/execution/ExecutionManager.ts @@ -123,6 +123,9 @@ export class Executor { ); case "mark_disconnected": return new MarkDisconnectedExecution(player, intent.isDisconnected); + case "toggle_pause": + // Pause is handled server-side by stopping turn execution + return new NoOpExecution(); default: throw new Error(`intent type ${intent} not found`); } diff --git a/src/core/worker/Worker.worker.ts b/src/core/worker/Worker.worker.ts index 1014968fb2..b065de48c3 100644 --- a/src/core/worker/Worker.worker.ts +++ b/src/core/worker/Worker.worker.ts @@ -67,6 +67,17 @@ ctx.addEventListener("message", async (e: MessageEvent) => { try { const gr = await gameRunner; await gr.addTurn(message.turn); + + // Check for pause intent and notify main thread + const pauseIntent = message.turn.intents.find( + (intent) => intent.type === "toggle_pause", + ); + if (pauseIntent && "paused" in pauseIntent) { + sendMessage({ + type: "pause_state", + paused: pauseIntent.paused, + }); + } } catch (error) { console.error("Failed to process turn:", error); throw error; diff --git a/src/core/worker/WorkerClient.ts b/src/core/worker/WorkerClient.ts index bde436f398..781d7af243 100644 --- a/src/core/worker/WorkerClient.ts +++ b/src/core/worker/WorkerClient.ts @@ -18,6 +18,7 @@ export class WorkerClient { private gameUpdateCallback?: ( update: GameUpdateViewData | ErrorUpdate, ) => void; + private pauseStateCallback?: (paused: boolean) => void; constructor( private gameStartInfo: GameStartInfo, @@ -43,6 +44,12 @@ export class WorkerClient { } break; + case "pause_state": + if (this.pauseStateCallback) { + this.pauseStateCallback(message.paused); + } + break; + case "initialized": default: if (message.id && this.messageHandlers.has(message.id)) { @@ -89,6 +96,10 @@ export class WorkerClient { this.gameUpdateCallback = gameUpdate; } + onPauseStateChange(callback: (paused: boolean) => void) { + this.pauseStateCallback = callback; + } + sendTurn(turn: Turn) { if (!this.isInitialized) { throw new Error("Worker not initialized"); diff --git a/src/core/worker/WorkerMessages.ts b/src/core/worker/WorkerMessages.ts index a8d30e9b1f..7d3355817a 100644 --- a/src/core/worker/WorkerMessages.ts +++ b/src/core/worker/WorkerMessages.ts @@ -14,6 +14,7 @@ export type WorkerMessageType = | "initialized" | "turn" | "game_update" + | "pause_state" | "player_actions" | "player_actions_result" | "player_profile" @@ -57,6 +58,11 @@ export interface GameUpdateMessage extends BaseWorkerMessage { gameUpdate: GameUpdateViewData; } +export interface PauseStateMessage extends BaseWorkerMessage { + type: "pause_state"; + paused: boolean; +} + export interface PlayerActionsMessage extends BaseWorkerMessage { type: "player_actions"; playerID: PlayerID; @@ -127,6 +133,7 @@ export type MainThreadMessage = export type WorkerMessage = | InitializedMessage | GameUpdateMessage + | PauseStateMessage | PlayerActionsResultMessage | PlayerProfileResultMessage | PlayerBorderTilesResultMessage diff --git a/src/server/GameServer.ts b/src/server/GameServer.ts index 0f26df3688..e7d9d5eeb0 100644 --- a/src/server/GameServer.ts +++ b/src/server/GameServer.ts @@ -62,6 +62,8 @@ export class GameServer { private kickedClients: Set = new Set(); private outOfSyncClients: Set = new Set(); + private isPaused = false; + private websockets: Set = new Set(); private winnerVotes: Map< @@ -346,6 +348,37 @@ export class GameServer { this.kickClient(clientMsg.intent.target); return; } + case "toggle_pause": { + // Only lobby creator can pause/resume + if (client.clientID !== this.lobbyCreatorID) { + this.log.warn(`Only lobby creator can toggle pause`, { + clientID: client.clientID, + creatorID: this.lobbyCreatorID, + gameID: this.id, + }); + return; + } + + const paused = !!clientMsg.intent.paused; + + if (paused) { + // Pausing: send intent while game is still running, then pause + this.addIntent(clientMsg.intent); + this.endTurn(); + this.isPaused = true; + } else { + // Unpausing: unpause first, then send intent + this.isPaused = false; + this.addIntent(clientMsg.intent); + this.endTurn(); + } + + this.log.info(`Game ${this.isPaused ? "paused" : "resumed"}`, { + clientID: client.clientID, + gameID: this.id, + }); + break; + } default: { this.addIntent(clientMsg.intent); break; @@ -488,12 +521,29 @@ export class GameServer { } private sendStartGameMsg(ws: WebSocket, lastTurn: number) { + // Find which client this websocket belongs to + const client = this.activeClients.find((c) => c.ws === ws); + if (!client) { + this.log.warn("Could not find client for websocket in sendStartGameMsg"); + return; + } + + const isLobbyCreator = client.clientID === this.lobbyCreatorID; + this.log.info(`Sending start message to client`, { + clientID: client.clientID, + lobbyCreatorID: this.lobbyCreatorID, + isLobbyCreator, + }); + try { ws.send( JSON.stringify({ type: "start", turns: this.turns.slice(lastTurn), - gameStartInfo: this.gameStartInfo, + gameStartInfo: { + ...this.gameStartInfo, + isLobbyCreator, + }, lobbyCreatedAt: this.createdAt, } satisfies ServerStartGameMessage), ); @@ -508,6 +558,11 @@ export class GameServer { } private endTurn() { + // Skip turn execution if game is paused + if (this.isPaused) { + return; + } + const pastTurn: Turn = { turnNumber: this.turns.length, intents: this.intents, From 24f75099798c5d0e1888ba1ab334f5ea5d775e0d Mon Sep 17 00:00:00 2001 From: YoussfeJob Date: Sat, 20 Dec 2025 20:35:37 +0100 Subject: [PATCH 2/8] PauseOverlay changes - removed background blur - added text for singleplayer game type - removed completely the overlay in replays --- resources/lang/en.json | 3 ++- src/client/graphics/GameRenderer.ts | 1 + src/client/graphics/layers/PauseOverlay.ts | 11 ++++++++--- 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/resources/lang/en.json b/resources/lang/en.json index be2a80b505..f4e7cb8529 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -741,7 +741,8 @@ "random_spawn": "Random spawn is enabled. Selecting starting location for you..." }, "pause": { - "game_paused": "Game paused by Lobby Creator" + "singleplayer_game_paused": "Game paused", + "multiplayer_game_paused": "Game paused by Lobby Creator" }, "territory_patterns": { "title": "Skins", diff --git a/src/client/graphics/GameRenderer.ts b/src/client/graphics/GameRenderer.ts index 7fd87e0edb..585a3f4f81 100644 --- a/src/client/graphics/GameRenderer.ts +++ b/src/client/graphics/GameRenderer.ts @@ -241,6 +241,7 @@ export function createRenderer( if (!(pauseOverlay instanceof PauseOverlay)) { console.error("pause overlay not found"); } + pauseOverlay.game = game; pauseOverlay.eventBus = eventBus; // When updating these layers please be mindful of the order. diff --git a/src/client/graphics/layers/PauseOverlay.ts b/src/client/graphics/layers/PauseOverlay.ts index 9672acd33b..9e672db9da 100644 --- a/src/client/graphics/layers/PauseOverlay.ts +++ b/src/client/graphics/layers/PauseOverlay.ts @@ -1,12 +1,14 @@ import { LitElement, html } from "lit"; import { customElement } from "lit/decorators.js"; import { EventBus } from "../../../core/EventBus"; +import { GameView } from "../../../core/game/GameView"; import { GamePausedEvent } from "../../Transport"; import { translateText } from "../../Utils"; import { Layer } from "./Layer"; @customElement("pause-overlay") export class PauseOverlay extends LitElement implements Layer { + public game: GameView; public eventBus: EventBus; private isPaused = false; @@ -22,14 +24,15 @@ export class PauseOverlay extends LitElement implements Layer { } render() { - if (!this.isPaused) { + // Don't show overlay for replays - just pause without blocking the view + if (!this.isPaused || this.game?.config()?.isReplay()) { return html``; } return html`
e.preventDefault()} >

- ${translateText("pause.game_paused")} + ${this.game?.config().gameConfig().gameType === "Singleplayer" + ? translateText("pause.singleplayer_game_paused") + : translateText("pause.multiplayer_game_paused")}

From 0e294bc008003aec65439c4c3041c4c74c7573bc Mon Sep 17 00:00:00 2001 From: YoussfeJob Date: Sat, 20 Dec 2025 21:07:09 +0100 Subject: [PATCH 3/8] followed coderabbit suggestions --- src/client/graphics/layers/PauseOverlay.ts | 19 ++++++++++++++----- src/server/GameServer.ts | 4 ++-- 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/src/client/graphics/layers/PauseOverlay.ts b/src/client/graphics/layers/PauseOverlay.ts index 9e672db9da..85df1e44c6 100644 --- a/src/client/graphics/layers/PauseOverlay.ts +++ b/src/client/graphics/layers/PauseOverlay.ts @@ -1,6 +1,7 @@ import { LitElement, html } from "lit"; import { customElement } from "lit/decorators.js"; import { EventBus } from "../../../core/EventBus"; +import { GameType } from "../../../core/game/Game"; import { GameView } from "../../../core/game/GameView"; import { GamePausedEvent } from "../../Transport"; import { translateText } from "../../Utils"; @@ -12,15 +13,22 @@ export class PauseOverlay extends LitElement implements Layer { public eventBus: EventBus; private isPaused = false; + private gamePausedHandler = (e: GamePausedEvent) => { + this.isPaused = e.paused; + this.requestUpdate(); + }; + createRenderRoot() { return this; } init() { - this.eventBus.on(GamePausedEvent, (e) => { - this.isPaused = e.paused; - this.requestUpdate(); - }); + this.eventBus.on(GamePausedEvent, this.gamePausedHandler); + } + + disconnectedCallback() { + super.disconnectedCallback(); + this.eventBus.off(GamePausedEvent, this.gamePausedHandler); } render() { @@ -41,7 +49,8 @@ export class PauseOverlay extends LitElement implements Layer { border-2 border-gray-600" >

- ${this.game?.config().gameConfig().gameType === "Singleplayer" + ${this.game?.config()?.gameConfig()?.gameType === + GameType.Singleplayer ? translateText("pause.singleplayer_game_paused") : translateText("pause.multiplayer_game_paused")}

diff --git a/src/server/GameServer.ts b/src/server/GameServer.ts index e7d9d5eeb0..f7247b2850 100644 --- a/src/server/GameServer.ts +++ b/src/server/GameServer.ts @@ -362,12 +362,12 @@ export class GameServer { const paused = !!clientMsg.intent.paused; if (paused) { - // Pausing: send intent while game is still running, then pause + // Pausing: send intent and complete current turn before pause takes effect this.addIntent(clientMsg.intent); this.endTurn(); this.isPaused = true; } else { - // Unpausing: unpause first, then send intent + // Unpausing: clear pause flag before sending intent so next turn can execute this.isPaused = false; this.addIntent(clientMsg.intent); this.endTurn(); From 779ad4e4dca9e16da6596feeb3ce4ce05a486767 Mon Sep 17 00:00:00 2001 From: YoussfeJob Date: Mon, 22 Dec 2025 11:54:34 +0100 Subject: [PATCH 4/8] implementation data flow refactor - Store isLobbyCreator on PlayerView via PlayerSchema data pipeline - Replace PauseStateMessage with GamePausedUpdate in game update stream - Create PauseExecution with Game.setPaused()/isPaused() methods - Unify pause handling: LocalServer detects toggle_pause intents - Remove unused pause()/resume() methods from LocalServer - Simplify Transport to always send pause intent - Optimize Client constructor parameter order --- src/client/ClientGameRunner.ts | 15 +++++------ src/client/LocalServer.ts | 26 ++++++++++++------- src/client/Transport.ts | 22 ++++------------ src/client/graphics/GameRenderer.ts | 2 -- .../graphics/layers/GameRightSidebar.ts | 8 +++++- src/core/GameRunner.ts | 1 + src/core/Schemas.ts | 2 +- src/core/execution/ExecutionManager.ts | 4 +-- src/core/execution/PauseExecution.ts | 19 ++++++++++++++ src/core/game/Game.ts | 4 +++ src/core/game/GameImpl.ts | 11 ++++++++ src/core/game/GameUpdates.ts | 10 ++++++- src/core/game/GameView.ts | 4 +++ src/core/game/PlayerImpl.ts | 6 +++++ src/core/worker/Worker.worker.ts | 11 -------- src/core/worker/WorkerClient.ts | 11 -------- src/core/worker/WorkerMessages.ts | 6 ----- src/server/Client.ts | 1 + src/server/GameServer.ts | 13 +++++----- src/server/Worker.ts | 6 +++++ 20 files changed, 106 insertions(+), 76 deletions(-) create mode 100644 src/core/execution/PauseExecution.ts diff --git a/src/client/ClientGameRunner.ts b/src/client/ClientGameRunner.ts index 2815f2bbb2..58dd2680a1 100644 --- a/src/client/ClientGameRunner.ts +++ b/src/client/ClientGameRunner.ts @@ -195,12 +195,7 @@ async function createClientGame( ); const canvas = createCanvas(); - const gameRenderer = createRenderer( - canvas, - gameView, - eventBus, - lobbyConfig.gameStartInfo.isLobbyCreator ?? false, - ); + const gameRenderer = createRenderer(canvas, gameView, eventBus); console.log( `creating private game got difficulty: ${lobbyConfig.gameStartInfo.config.difficulty}`, @@ -333,10 +328,12 @@ export class ClientGameRunner { if (gu.updates[GameUpdateType.Win].length > 0) { this.saveGame(gu.updates[GameUpdateType.Win][0]); } - }); - this.worker.onPauseStateChange((paused: boolean) => { - this.eventBus.emit(new GamePausedEvent(paused)); + // Handle pause state changes + if (gu.updates[GameUpdateType.GamePaused].length > 0) { + const pauseUpdate = gu.updates[GameUpdateType.GamePaused][0]; + this.eventBus.emit(new GamePausedEvent(pauseUpdate.paused)); + } }); const worker = this.worker; diff --git a/src/client/LocalServer.ts b/src/client/LocalServer.ts index cba4287393..49a99b56fa 100644 --- a/src/client/LocalServer.ts +++ b/src/client/LocalServer.ts @@ -97,14 +97,6 @@ export class LocalServer { } satisfies ServerStartGameMessage); } - pause() { - this.paused = true; - } - - resume() { - this.paused = false; - } - onMessage(clientMsg: ClientMessage) { if (clientMsg.type === "rejoin") { this.clientMessage({ @@ -119,9 +111,25 @@ export class LocalServer { // If we are replaying a game, we don't want to process intents return; } - if (this.paused) { + + // Handle pause/unpause intents specially (like server-side logic) + if (clientMsg.intent.type === "toggle_pause") { + const paused = !!clientMsg.intent.paused; + + if (paused) { + // Pausing: add intent and end turn before pause takes effect + this.intents.push(clientMsg.intent); + this.endTurn(); + this.paused = true; + } else { + // Unpausing: clear pause flag before adding intent so next turn can execute + this.paused = false; + this.intents.push(clientMsg.intent); + this.endTurn(); + } return; } + this.intents.push(clientMsg.intent); } if (clientMsg.type === "hash") { diff --git a/src/client/Transport.ts b/src/client/Transport.ts index 6bb25ddd9f..7e0aa90f87 100644 --- a/src/client/Transport.ts +++ b/src/client/Transport.ts @@ -581,23 +581,11 @@ export class Transport { } private onPauseGameIntent(event: PauseGameIntentEvent) { - if (this.isLocal) { - // Local (singleplayer) game pause - if (event.paused) { - this.localServer.pause(); - } else { - this.localServer.resume(); - } - // Emit GamePausedEvent for UI to update - this.eventBus.emit(new GamePausedEvent(event.paused)); - } else { - // Multiplayer game - send toggle_pause intent to server - this.sendIntent({ - type: "toggle_pause", - clientID: this.lobbyConfig.clientID, - paused: event.paused, - }); - } + this.sendIntent({ + type: "toggle_pause", + clientID: this.lobbyConfig.clientID, + paused: event.paused, + }); } private onSendWinnerEvent(event: SendWinnerEvent) { diff --git a/src/client/graphics/GameRenderer.ts b/src/client/graphics/GameRenderer.ts index 585a3f4f81..efc064e417 100644 --- a/src/client/graphics/GameRenderer.ts +++ b/src/client/graphics/GameRenderer.ts @@ -47,7 +47,6 @@ export function createRenderer( canvas: HTMLCanvasElement, game: GameView, eventBus: EventBus, - isLobbyCreator: boolean = false, ): GameRenderer { const transformHandler = new TransformHandler(game, eventBus, canvas); const userSettings = new UserSettings(); @@ -156,7 +155,6 @@ export function createRenderer( } gameRightSidebar.game = game; gameRightSidebar.eventBus = eventBus; - gameRightSidebar.isLobbyCreator = isLobbyCreator; const settingsModal = document.querySelector( "settings-modal", diff --git a/src/client/graphics/layers/GameRightSidebar.ts b/src/client/graphics/layers/GameRightSidebar.ts index 3b59951689..364f0b79b2 100644 --- a/src/client/graphics/layers/GameRightSidebar.ts +++ b/src/client/graphics/layers/GameRightSidebar.ts @@ -19,7 +19,6 @@ import { ShowSettingsModalEvent } from "./SettingsModal"; export class GameRightSidebar extends LitElement implements Layer { public game: GameView; public eventBus: EventBus; - public isLobbyCreator: boolean = false; @state() private _isSinglePlayer: boolean = false; @@ -37,6 +36,7 @@ export class GameRightSidebar extends LitElement implements Layer { private timer: number = 0; private hasWinner = false; + private isLobbyCreator = false; createRenderRoot() { return this; @@ -57,6 +57,12 @@ export class GameRightSidebar extends LitElement implements Layer { if (updates) { this.hasWinner = this.hasWinner || updates[GameUpdateType.Win].length > 0; } + + if (!this.isLobbyCreator && this.game.myPlayer()?.isLobbyCreator()) { + this.isLobbyCreator = true; + this.requestUpdate(); + } + const maxTimerValue = this.game.config().gameConfig().maxTimerValue; if (maxTimerValue !== undefined) { if (this.game.inSpawnPhase()) { diff --git a/src/core/GameRunner.ts b/src/core/GameRunner.ts index ed8c8cd7b2..92eba215b7 100644 --- a/src/core/GameRunner.ts +++ b/src/core/GameRunner.ts @@ -52,6 +52,7 @@ export async function createGameRunner( PlayerType.Human, p.clientID, random.nextID(), + p.isLobbyCreator ?? false, ); }); diff --git a/src/core/Schemas.ts b/src/core/Schemas.ts index 8744e7fba6..27ceb7c053 100644 --- a/src/core/Schemas.ts +++ b/src/core/Schemas.ts @@ -439,6 +439,7 @@ export const PlayerSchema = z.object({ clientID: ID, username: UsernameSchema, cosmetics: PlayerCosmeticsSchema.optional(), + isLobbyCreator: z.boolean().optional(), }); export const GameStartInfoSchema = z.object({ @@ -446,7 +447,6 @@ export const GameStartInfoSchema = z.object({ lobbyCreatedAt: z.number(), config: GameConfigSchema, players: PlayerSchema.array(), - isLobbyCreator: z.boolean().optional(), }); export const WinnerSchema = z diff --git a/src/core/execution/ExecutionManager.ts b/src/core/execution/ExecutionManager.ts index 093bb8825b..31dcab998e 100644 --- a/src/core/execution/ExecutionManager.ts +++ b/src/core/execution/ExecutionManager.ts @@ -20,6 +20,7 @@ import { MarkDisconnectedExecution } from "./MarkDisconnectedExecution"; import { MoveWarshipExecution } from "./MoveWarshipExecution"; import { NationExecution } from "./NationExecution"; import { NoOpExecution } from "./NoOpExecution"; +import { PauseExecution } from "./PauseExecution"; import { QuickChatExecution } from "./QuickChatExecution"; import { RetreatExecution } from "./RetreatExecution"; import { SpawnExecution } from "./SpawnExecution"; @@ -124,8 +125,7 @@ export class Executor { case "mark_disconnected": return new MarkDisconnectedExecution(player, intent.isDisconnected); case "toggle_pause": - // Pause is handled server-side by stopping turn execution - return new NoOpExecution(); + return new PauseExecution(intent.paused); default: throw new Error(`intent type ${intent} not found`); } diff --git a/src/core/execution/PauseExecution.ts b/src/core/execution/PauseExecution.ts new file mode 100644 index 0000000000..1affc4e912 --- /dev/null +++ b/src/core/execution/PauseExecution.ts @@ -0,0 +1,19 @@ +import { Execution, Game } from "../game/Game"; + +export class PauseExecution implements Execution { + constructor(private paused: boolean) {} + + isActive(): boolean { + return false; + } + + activeDuringSpawnPhase(): boolean { + return true; + } + + init(game: Game, ticks: number): void { + game.setPaused(this.paused); + } + + tick(ticks: number): void {} +} diff --git a/src/core/game/Game.ts b/src/core/game/Game.ts index 9c5ef95ffd..1dff9aee39 100644 --- a/src/core/game/Game.ts +++ b/src/core/game/Game.ts @@ -418,6 +418,7 @@ export class PlayerInfo { public readonly clientID: ClientID | null, // TODO: make player id the small id public readonly id: PlayerID, + public readonly isLobbyCreator: boolean = false, ) { this.clan = getClanTag(name); } @@ -538,6 +539,7 @@ export interface Player { type(): PlayerType; isPlayer(): this is Player; toString(): string; + isLobbyCreator(): boolean; // State & Properties isAlive(): boolean; @@ -707,6 +709,8 @@ export interface Game extends GameMap { executeNextTick(): GameUpdates; setWinner(winner: Player | Team, allPlayersStats: AllPlayersStats): void; config(): Config; + isPaused(): boolean; + setPaused(paused: boolean): void; // Units units(...types: UnitType[]): Unit[]; diff --git a/src/core/game/GameImpl.ts b/src/core/game/GameImpl.ts index 61a6e38d72..9100767092 100644 --- a/src/core/game/GameImpl.ts +++ b/src/core/game/GameImpl.ts @@ -85,6 +85,8 @@ export class GameImpl implements Game { // Used to assign unique IDs to each new alliance private nextAllianceID: number = 0; + private _isPaused: boolean = false; + constructor( private _humans: PlayerInfo[], private _nations: Nation[], @@ -337,6 +339,15 @@ export class GameImpl implements Game { return this._config; } + isPaused(): boolean { + return this._isPaused; + } + + setPaused(paused: boolean): void { + this._isPaused = paused; + this.addUpdate({ type: GameUpdateType.GamePaused, paused }); + } + inSpawnPhase(): boolean { return this._ticks <= this.config().numSpawnPhaseTurns(); } diff --git a/src/core/game/GameUpdates.ts b/src/core/game/GameUpdates.ts index c558a33911..6cba024bcf 100644 --- a/src/core/game/GameUpdates.ts +++ b/src/core/game/GameUpdates.ts @@ -47,6 +47,7 @@ export enum GameUpdateType { RailroadEvent, ConquestEvent, EmbargoEvent, + GamePaused, } export type GameUpdate = @@ -68,7 +69,8 @@ export type GameUpdate = | BonusEventUpdate | RailroadUpdate | ConquestUpdate - | EmbargoUpdate; + | EmbargoUpdate + | GamePausedUpdate; export interface BonusEventUpdate { type: GameUpdateType.BonusEvent; @@ -172,6 +174,7 @@ export interface PlayerUpdate { hasSpawned: boolean; betrayals: number; lastDeleteUnitTick: Tick; + isLobbyCreator: boolean; } export interface AllianceView { @@ -269,3 +272,8 @@ export interface EmbargoUpdate { playerID: number; embargoedID: number; } + +export interface GamePausedUpdate { + type: GameUpdateType.GamePaused; + paused: boolean; +} diff --git a/src/core/game/GameView.ts b/src/core/game/GameView.ts index d1d2fb83ef..11ef762246 100644 --- a/src/core/game/GameView.ts +++ b/src/core/game/GameView.ts @@ -391,6 +391,10 @@ export class PlayerView { return this.smallID() === this.game.myPlayer()?.smallID(); } + isLobbyCreator(): boolean { + return this.data.isLobbyCreator; + } + isAlliedWith(other: PlayerView): boolean { return this.data.allies.some((n) => other.smallID() === n); } diff --git a/src/core/game/PlayerImpl.ts b/src/core/game/PlayerImpl.ts index 9dab88905d..e771012eca 100644 --- a/src/core/game/PlayerImpl.ts +++ b/src/core/game/PlayerImpl.ts @@ -179,6 +179,7 @@ export class PlayerImpl implements Player { hasSpawned: this.hasSpawned(), betrayals: this._betrayalCount, lastDeleteUnitTick: this.lastDeleteUnitTick, + isLobbyCreator: this.isLobbyCreator(), }; } @@ -338,6 +339,11 @@ export class PlayerImpl implements Player { info(): PlayerInfo { return this.playerInfo; } + + isLobbyCreator(): boolean { + return this.playerInfo.isLobbyCreator; + } + isAlive(): boolean { return this._tiles.size > 0; } diff --git a/src/core/worker/Worker.worker.ts b/src/core/worker/Worker.worker.ts index b065de48c3..1014968fb2 100644 --- a/src/core/worker/Worker.worker.ts +++ b/src/core/worker/Worker.worker.ts @@ -67,17 +67,6 @@ ctx.addEventListener("message", async (e: MessageEvent) => { try { const gr = await gameRunner; await gr.addTurn(message.turn); - - // Check for pause intent and notify main thread - const pauseIntent = message.turn.intents.find( - (intent) => intent.type === "toggle_pause", - ); - if (pauseIntent && "paused" in pauseIntent) { - sendMessage({ - type: "pause_state", - paused: pauseIntent.paused, - }); - } } catch (error) { console.error("Failed to process turn:", error); throw error; diff --git a/src/core/worker/WorkerClient.ts b/src/core/worker/WorkerClient.ts index 781d7af243..bde436f398 100644 --- a/src/core/worker/WorkerClient.ts +++ b/src/core/worker/WorkerClient.ts @@ -18,7 +18,6 @@ export class WorkerClient { private gameUpdateCallback?: ( update: GameUpdateViewData | ErrorUpdate, ) => void; - private pauseStateCallback?: (paused: boolean) => void; constructor( private gameStartInfo: GameStartInfo, @@ -44,12 +43,6 @@ export class WorkerClient { } break; - case "pause_state": - if (this.pauseStateCallback) { - this.pauseStateCallback(message.paused); - } - break; - case "initialized": default: if (message.id && this.messageHandlers.has(message.id)) { @@ -96,10 +89,6 @@ export class WorkerClient { this.gameUpdateCallback = gameUpdate; } - onPauseStateChange(callback: (paused: boolean) => void) { - this.pauseStateCallback = callback; - } - sendTurn(turn: Turn) { if (!this.isInitialized) { throw new Error("Worker not initialized"); diff --git a/src/core/worker/WorkerMessages.ts b/src/core/worker/WorkerMessages.ts index 7d3355817a..62d03ac159 100644 --- a/src/core/worker/WorkerMessages.ts +++ b/src/core/worker/WorkerMessages.ts @@ -58,11 +58,6 @@ export interface GameUpdateMessage extends BaseWorkerMessage { gameUpdate: GameUpdateViewData; } -export interface PauseStateMessage extends BaseWorkerMessage { - type: "pause_state"; - paused: boolean; -} - export interface PlayerActionsMessage extends BaseWorkerMessage { type: "player_actions"; playerID: PlayerID; @@ -133,7 +128,6 @@ export type MainThreadMessage = export type WorkerMessage = | InitializedMessage | GameUpdateMessage - | PauseStateMessage | PlayerActionsResultMessage | PlayerProfileResultMessage | PlayerBorderTilesResultMessage diff --git a/src/server/Client.ts b/src/server/Client.ts index 9f879ddddb..c6527c1f14 100644 --- a/src/server/Client.ts +++ b/src/server/Client.ts @@ -20,6 +20,7 @@ export class Client { public readonly username: string, public ws: WebSocket, public readonly cosmetics: PlayerCosmetics | undefined, + public readonly isLobbyCreator: boolean = false, public readonly isRejoin: boolean = false, ) {} } diff --git a/src/server/GameServer.ts b/src/server/GameServer.ts index f7247b2850..69fcb655cf 100644 --- a/src/server/GameServer.ts +++ b/src/server/GameServer.ts @@ -132,6 +132,10 @@ export class GameServer { } } + public isLobbyCreator(clientID: string): boolean { + return clientID === this.lobbyCreatorID; + } + public joinClient(client: Client) { this.websockets.add(client.ws); if (this.kickedClients.has(client.clientID)) { @@ -494,6 +498,7 @@ export class GameServer { username: c.username, clientID: c.clientID, cosmetics: c.cosmetics, + isLobbyCreator: c.isLobbyCreator, })), }); if (!result.success) { @@ -528,11 +533,10 @@ export class GameServer { return; } - const isLobbyCreator = client.clientID === this.lobbyCreatorID; this.log.info(`Sending start message to client`, { clientID: client.clientID, lobbyCreatorID: this.lobbyCreatorID, - isLobbyCreator, + isLobbyCreator: client.isLobbyCreator, }); try { @@ -540,10 +544,7 @@ export class GameServer { JSON.stringify({ type: "start", turns: this.turns.slice(lastTurn), - gameStartInfo: { - ...this.gameStartInfo, - isLobbyCreator, - }, + gameStartInfo: this.gameStartInfo, lobbyCreatedAt: this.createdAt, } satisfies ServerStartGameMessage), ); diff --git a/src/server/Worker.ts b/src/server/Worker.ts index 50ba20b901..e8944c9781 100644 --- a/src/server/Worker.ts +++ b/src/server/Worker.ts @@ -443,6 +443,11 @@ export async function startWorker() { } } + // Check if client is lobby creator + const game = gm.game(clientMsg.gameID); + const isLobbyCreator = + game?.isLobbyCreator(clientMsg.clientID) ?? false; + // Create client and add to game const client = new Client( clientMsg.clientID, @@ -454,6 +459,7 @@ export async function startWorker() { clientMsg.username, ws, cosmeticResult.cosmetics, + isLobbyCreator, ); const wasFound = gm.joinClient(client, clientMsg.gameID); From f793682b2c7f6b7ecba19ca82c9e80fd2fc93d01 Mon Sep 17 00:00:00 2001 From: YoussfeJob Date: Mon, 22 Dec 2025 12:30:18 +0100 Subject: [PATCH 5/8] removed remnant from previous integration and followed CodeRabbit's suggestions --- src/client/graphics/layers/GameRightSidebar.ts | 6 ++++++ src/core/worker/WorkerMessages.ts | 1 - 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/client/graphics/layers/GameRightSidebar.ts b/src/client/graphics/layers/GameRightSidebar.ts index 364f0b79b2..64e78f925f 100644 --- a/src/client/graphics/layers/GameRightSidebar.ts +++ b/src/client/graphics/layers/GameRightSidebar.ts @@ -48,6 +48,12 @@ export class GameRightSidebar extends LitElement implements Layer { this.game.config().isReplay(); this._isVisible = true; this.game.inSpawnPhase(); + + // Try to get lobby creator status early but may not be available yet + if (this.game.myPlayer()?.isLobbyCreator()) { + this.isLobbyCreator = true; + } + this.requestUpdate(); } diff --git a/src/core/worker/WorkerMessages.ts b/src/core/worker/WorkerMessages.ts index 62d03ac159..a8d30e9b1f 100644 --- a/src/core/worker/WorkerMessages.ts +++ b/src/core/worker/WorkerMessages.ts @@ -14,7 +14,6 @@ export type WorkerMessageType = | "initialized" | "turn" | "game_update" - | "pause_state" | "player_actions" | "player_actions_result" | "player_profile" From 877e5d6c4184aad129d36bbc1c85895034777bc9 Mon Sep 17 00:00:00 2001 From: YoussfeJob Date: Wed, 24 Dec 2025 12:42:21 +0100 Subject: [PATCH 6/8] refactor: Follow code review feedback for pause implementation - Remove GamePausedEvent, use GameUpdateType.GamePaused directly in UI layers - Remove isLobbyCreator variable from Client - Fix pause functionality in replays by handling pause intents before replay check --- src/client/ClientGameRunner.ts | 7 ------- src/client/LocalServer.ts | 13 ++++++------ src/client/Transport.ts | 4 ---- .../graphics/layers/GameRightSidebar.ts | 6 +----- src/client/graphics/layers/PauseOverlay.ts | 21 +++++++------------ src/server/Client.ts | 1 - src/server/GameServer.ts | 4 ++-- src/server/Worker.ts | 6 ------ 8 files changed, 17 insertions(+), 45 deletions(-) diff --git a/src/client/ClientGameRunner.ts b/src/client/ClientGameRunner.ts index 58dd2680a1..c826c62bd0 100644 --- a/src/client/ClientGameRunner.ts +++ b/src/client/ClientGameRunner.ts @@ -39,7 +39,6 @@ import { import { endGame, startGame, startTime } from "./LocalPersistantStats"; import { terrainMapFileLoader } from "./TerrainMapFileLoader"; import { - GamePausedEvent, SendAttackIntentEvent, SendBoatAttackIntentEvent, SendHashEvent, @@ -328,12 +327,6 @@ export class ClientGameRunner { if (gu.updates[GameUpdateType.Win].length > 0) { this.saveGame(gu.updates[GameUpdateType.Win][0]); } - - // Handle pause state changes - if (gu.updates[GameUpdateType.GamePaused].length > 0) { - const pauseUpdate = gu.updates[GameUpdateType.GamePaused][0]; - this.eventBus.emit(new GamePausedEvent(pauseUpdate.paused)); - } }); const worker = this.worker; diff --git a/src/client/LocalServer.ts b/src/client/LocalServer.ts index 49a99b56fa..794c4b3925 100644 --- a/src/client/LocalServer.ts +++ b/src/client/LocalServer.ts @@ -107,14 +107,8 @@ export class LocalServer { } satisfies ServerStartGameMessage); } if (clientMsg.type === "intent") { - if (this.lobbyConfig.gameRecord) { - // If we are replaying a game, we don't want to process intents - return; - } - - // Handle pause/unpause intents specially (like server-side logic) if (clientMsg.intent.type === "toggle_pause") { - const paused = !!clientMsg.intent.paused; + const paused = clientMsg.intent.paused; if (paused) { // Pausing: add intent and end turn before pause takes effect @@ -130,6 +124,11 @@ export class LocalServer { return; } + // Don't process non-pause intents during replays + if (this.lobbyConfig.gameRecord) { + return; + } + this.intents.push(clientMsg.intent); } if (clientMsg.type === "hash") { diff --git a/src/client/Transport.ts b/src/client/Transport.ts index 7e0aa90f87..15bba631ec 100644 --- a/src/client/Transport.ts +++ b/src/client/Transport.ts @@ -33,10 +33,6 @@ export class PauseGameIntentEvent implements GameEvent { constructor(public readonly paused: boolean) {} } -export class GamePausedEvent implements GameEvent { - constructor(public readonly paused: boolean) {} -} - export class SendAllianceRequestIntentEvent implements GameEvent { constructor( public readonly requestor: PlayerView, diff --git a/src/client/graphics/layers/GameRightSidebar.ts b/src/client/graphics/layers/GameRightSidebar.ts index 64e78f925f..01a1750560 100644 --- a/src/client/graphics/layers/GameRightSidebar.ts +++ b/src/client/graphics/layers/GameRightSidebar.ts @@ -49,11 +49,6 @@ export class GameRightSidebar extends LitElement implements Layer { this._isVisible = true; this.game.inSpawnPhase(); - // Try to get lobby creator status early but may not be available yet - if (this.game.myPlayer()?.isLobbyCreator()) { - this.isLobbyCreator = true; - } - this.requestUpdate(); } @@ -64,6 +59,7 @@ export class GameRightSidebar extends LitElement implements Layer { this.hasWinner = this.hasWinner || updates[GameUpdateType.Win].length > 0; } + // Check if the player is the lobby creator if (!this.isLobbyCreator && this.game.myPlayer()?.isLobbyCreator()) { this.isLobbyCreator = true; this.requestUpdate(); diff --git a/src/client/graphics/layers/PauseOverlay.ts b/src/client/graphics/layers/PauseOverlay.ts index 85df1e44c6..e6daaead82 100644 --- a/src/client/graphics/layers/PauseOverlay.ts +++ b/src/client/graphics/layers/PauseOverlay.ts @@ -2,8 +2,8 @@ import { LitElement, html } from "lit"; import { customElement } from "lit/decorators.js"; import { EventBus } from "../../../core/EventBus"; import { GameType } from "../../../core/game/Game"; +import { GameUpdateType } from "../../../core/game/GameUpdates"; import { GameView } from "../../../core/game/GameView"; -import { GamePausedEvent } from "../../Transport"; import { translateText } from "../../Utils"; import { Layer } from "./Layer"; @@ -13,22 +13,17 @@ export class PauseOverlay extends LitElement implements Layer { public eventBus: EventBus; private isPaused = false; - private gamePausedHandler = (e: GamePausedEvent) => { - this.isPaused = e.paused; - this.requestUpdate(); - }; - createRenderRoot() { return this; } - init() { - this.eventBus.on(GamePausedEvent, this.gamePausedHandler); - } - - disconnectedCallback() { - super.disconnectedCallback(); - this.eventBus.off(GamePausedEvent, this.gamePausedHandler); + tick() { + const updates = this.game.updatesSinceLastTick(); + if (updates && updates[GameUpdateType.GamePaused].length > 0) { + const pauseUpdate = updates[GameUpdateType.GamePaused][0]; + this.isPaused = pauseUpdate.paused; + this.requestUpdate(); + } } render() { diff --git a/src/server/Client.ts b/src/server/Client.ts index c6527c1f14..9f879ddddb 100644 --- a/src/server/Client.ts +++ b/src/server/Client.ts @@ -20,7 +20,6 @@ export class Client { public readonly username: string, public ws: WebSocket, public readonly cosmetics: PlayerCosmetics | undefined, - public readonly isLobbyCreator: boolean = false, public readonly isRejoin: boolean = false, ) {} } diff --git a/src/server/GameServer.ts b/src/server/GameServer.ts index 69fcb655cf..63c0b23f53 100644 --- a/src/server/GameServer.ts +++ b/src/server/GameServer.ts @@ -498,7 +498,7 @@ export class GameServer { username: c.username, clientID: c.clientID, cosmetics: c.cosmetics, - isLobbyCreator: c.isLobbyCreator, + isLobbyCreator: this.isLobbyCreator(c.clientID), })), }); if (!result.success) { @@ -536,7 +536,7 @@ export class GameServer { this.log.info(`Sending start message to client`, { clientID: client.clientID, lobbyCreatorID: this.lobbyCreatorID, - isLobbyCreator: client.isLobbyCreator, + isLobbyCreator: this.isLobbyCreator(client.clientID), }); try { diff --git a/src/server/Worker.ts b/src/server/Worker.ts index e8944c9781..50ba20b901 100644 --- a/src/server/Worker.ts +++ b/src/server/Worker.ts @@ -443,11 +443,6 @@ export async function startWorker() { } } - // Check if client is lobby creator - const game = gm.game(clientMsg.gameID); - const isLobbyCreator = - game?.isLobbyCreator(clientMsg.clientID) ?? false; - // Create client and add to game const client = new Client( clientMsg.clientID, @@ -459,7 +454,6 @@ export async function startWorker() { clientMsg.username, ws, cosmeticResult.cosmetics, - isLobbyCreator, ); const wasFound = gm.joinClient(client, clientMsg.gameID); From c90ad49bc4c89e7201f879328f22395c1e126372 Mon Sep 17 00:00:00 2001 From: YoussfeJob Date: Thu, 25 Dec 2025 20:13:09 +0100 Subject: [PATCH 7/8] Replace PauseOverlay with HeadsUpMessage for pause notifications --- src/client/LocalServer.ts | 5 +- src/client/graphics/GameRenderer.ts | 9 ---- src/client/graphics/layers/HeadsUpMessage.ts | 38 ++++++++++--- src/client/graphics/layers/PauseOverlay.ts | 56 -------------------- src/client/index.html | 2 - src/core/execution/ExecutionManager.ts | 2 +- src/core/execution/PauseExecution.ts | 14 +++-- src/server/GameServer.ts | 12 ++--- 8 files changed, 46 insertions(+), 92 deletions(-) delete mode 100644 src/client/graphics/layers/PauseOverlay.ts diff --git a/src/client/LocalServer.ts b/src/client/LocalServer.ts index 794c4b3925..3758470098 100644 --- a/src/client/LocalServer.ts +++ b/src/client/LocalServer.ts @@ -108,9 +108,7 @@ export class LocalServer { } if (clientMsg.type === "intent") { if (clientMsg.intent.type === "toggle_pause") { - const paused = clientMsg.intent.paused; - - if (paused) { + if (clientMsg.intent.paused) { // Pausing: add intent and end turn before pause takes effect this.intents.push(clientMsg.intent); this.endTurn(); @@ -123,7 +121,6 @@ export class LocalServer { } return; } - // Don't process non-pause intents during replays if (this.lobbyConfig.gameRecord) { return; diff --git a/src/client/graphics/GameRenderer.ts b/src/client/graphics/GameRenderer.ts index b2b8619525..301dc34861 100644 --- a/src/client/graphics/GameRenderer.ts +++ b/src/client/graphics/GameRenderer.ts @@ -24,7 +24,6 @@ import { MainRadialMenu } from "./layers/MainRadialMenu"; import { MultiTabModal } from "./layers/MultiTabModal"; import { NameLayer } from "./layers/NameLayer"; import { NukeTrajectoryPreviewLayer } from "./layers/NukeTrajectoryPreviewLayer"; -import { PauseOverlay } from "./layers/PauseOverlay"; import { PerformanceOverlay } from "./layers/PerformanceOverlay"; import { PlayerInfoOverlay } from "./layers/PlayerInfoOverlay"; import { PlayerPanel } from "./layers/PlayerPanel"; @@ -230,13 +229,6 @@ export function createRenderer( spawnTimer.game = game; spawnTimer.transformHandler = transformHandler; - const pauseOverlay = document.querySelector("pause-overlay") as PauseOverlay; - if (!(pauseOverlay instanceof PauseOverlay)) { - console.error("pause overlay not found"); - } - pauseOverlay.game = game; - pauseOverlay.eventBus = eventBus; - // When updating these layers please be mindful of the order. // Try to group layers by the return value of shouldTransform. // Not grouping the layers may cause excessive calls to context.save() and context.restore(). @@ -278,7 +270,6 @@ export function createRenderer( playerPanel, headsUpMessage, multiTabModal, - pauseOverlay, new AdTimer(game), alertFrame, performanceOverlay, diff --git a/src/client/graphics/layers/HeadsUpMessage.ts b/src/client/graphics/layers/HeadsUpMessage.ts index 9ab20dae35..d9c224bea7 100644 --- a/src/client/graphics/layers/HeadsUpMessage.ts +++ b/src/client/graphics/layers/HeadsUpMessage.ts @@ -1,5 +1,7 @@ import { LitElement, html } from "lit"; import { customElement, state } from "lit/decorators.js"; +import { GameType } from "../../../core/game/Game"; +import { GameUpdateType } from "../../../core/game/GameUpdates"; import { GameView } from "../../../core/game/GameView"; import { translateText } from "../../Utils"; import { Layer } from "./Layer"; @@ -11,6 +13,9 @@ export class HeadsUpMessage extends LitElement implements Layer { @state() private isVisible = false; + @state() + private isPaused = false; + createRenderRoot() { return this; } @@ -21,10 +26,27 @@ export class HeadsUpMessage extends LitElement implements Layer { } tick() { - if (!this.game.inSpawnPhase()) { - this.isVisible = false; - this.requestUpdate(); + const updates = this.game.updatesSinceLastTick(); + if (updates && updates[GameUpdateType.GamePaused].length > 0) { + const pauseUpdate = updates[GameUpdateType.GamePaused][0]; + this.isPaused = pauseUpdate.paused; + } + + this.isVisible = this.game.inSpawnPhase() || this.isPaused; + this.requestUpdate(); + } + + private getMessage(): string { + if (this.isPaused) { + if (this.game.config().gameConfig().gameType === GameType.Singleplayer) { + return translateText("pause.singleplayer_game_paused"); + } else { + return translateText("pause.multiplayer_game_paused"); + } } + return this.game.config().isRandomSpawn() + ? translateText("heads_up_message.random_spawn") + : translateText("heads_up_message.choose_spawn"); } render() { @@ -32,17 +54,17 @@ export class HeadsUpMessage extends LitElement implements Layer { return html``; } + const message = this.getMessage(); + return html`
e.preventDefault()} > - ${this.game.config().isRandomSpawn() - ? translateText("heads_up_message.random_spawn") - : translateText("heads_up_message.choose_spawn")} + ${message}
`; } diff --git a/src/client/graphics/layers/PauseOverlay.ts b/src/client/graphics/layers/PauseOverlay.ts deleted file mode 100644 index e6daaead82..0000000000 --- a/src/client/graphics/layers/PauseOverlay.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { LitElement, html } from "lit"; -import { customElement } from "lit/decorators.js"; -import { EventBus } from "../../../core/EventBus"; -import { GameType } from "../../../core/game/Game"; -import { GameUpdateType } from "../../../core/game/GameUpdates"; -import { GameView } from "../../../core/game/GameView"; -import { translateText } from "../../Utils"; -import { Layer } from "./Layer"; - -@customElement("pause-overlay") -export class PauseOverlay extends LitElement implements Layer { - public game: GameView; - public eventBus: EventBus; - private isPaused = false; - - createRenderRoot() { - return this; - } - - tick() { - const updates = this.game.updatesSinceLastTick(); - if (updates && updates[GameUpdateType.GamePaused].length > 0) { - const pauseUpdate = updates[GameUpdateType.GamePaused][0]; - this.isPaused = pauseUpdate.paused; - this.requestUpdate(); - } - } - - render() { - // Don't show overlay for replays - just pause without blocking the view - if (!this.isPaused || this.game?.config()?.isReplay()) { - return html``; - } - - return html` -
e.preventDefault()} - > -
-

- ${this.game?.config()?.gameConfig()?.gameType === - GameType.Singleplayer - ? translateText("pause.singleplayer_game_paused") - : translateText("pause.multiplayer_game_paused")} -

-
-
- `; - } -} diff --git a/src/client/index.html b/src/client/index.html index eb11c34de1..1124859104 100644 --- a/src/client/index.html +++ b/src/client/index.html @@ -353,8 +353,6 @@
- -
Date: Fri, 26 Dec 2025 14:32:02 +0100 Subject: [PATCH 8/8] fix: Filter pause intents in replays and prevent intent stacking during pause - Filter out pause intents from archived replays in ClientGameRunner - Prevent processing intents while game is paused in LocalServer and GameServer --- src/client/ClientGameRunner.ts | 12 +++++++++++- src/client/LocalServer.ts | 4 ++-- src/server/GameServer.ts | 5 ++++- 3 files changed, 17 insertions(+), 4 deletions(-) diff --git a/src/client/ClientGameRunner.ts b/src/client/ClientGameRunner.ts index c826c62bd0..27c2f01cbd 100644 --- a/src/client/ClientGameRunner.ts +++ b/src/client/ClientGameRunner.ts @@ -433,7 +433,17 @@ export class ClientGameRunner { `got wrong turn have turns ${this.turnsSeen}, received turn ${message.turn.turnNumber}`, ); } else { - this.worker.sendTurn(message.turn); + this.worker.sendTurn( + // Filter out pause intents in replays + this.gameView.config().isReplay() + ? { + ...message.turn, + intents: message.turn.intents.filter( + (i) => i.type !== "toggle_pause", + ), + } + : message.turn, + ); this.turnsSeen++; } } diff --git a/src/client/LocalServer.ts b/src/client/LocalServer.ts index 3758470098..100bbc3f98 100644 --- a/src/client/LocalServer.ts +++ b/src/client/LocalServer.ts @@ -121,8 +121,8 @@ export class LocalServer { } return; } - // Don't process non-pause intents during replays - if (this.lobbyConfig.gameRecord) { + // Don't process non-pause intents during replays or while paused + if (this.lobbyConfig.gameRecord || this.paused) { return; } diff --git a/src/server/GameServer.ts b/src/server/GameServer.ts index bb6ca7ed13..428275f3c0 100644 --- a/src/server/GameServer.ts +++ b/src/server/GameServer.ts @@ -378,7 +378,10 @@ export class GameServer { break; } default: { - this.addIntent(clientMsg.intent); + // Don't process intents while game is paused + if (!this.isPaused) { + this.addIntent(clientMsg.intent); + } break; } }