From da4b8aa5e1254c96e3e5e052950be48601a8f768 Mon Sep 17 00:00:00 2001 From: Ryan <7389646+ryanbarlow97@users.noreply.github.com> Date: Tue, 27 Jan 2026 23:15:35 +0000 Subject: [PATCH] Spectate catchup (#3012) ## Description: https://github.com/user-attachments/assets/dc118d5f-3b7f-4ccb-8579-5b0d8c73fe8e Catchup mechanic for live games and changes replays to have a backlog for more "max" speed ## Please complete the following: - [x] I have added screenshots for all UI updates - [x] I process any text displayed to the user through translateText() and I've added it to the en.json file - [x] I have added relevant tests to the test directory - [x] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced ## Please put your Discord username so you can be contacted if a bug or regression is found: w.o.n --- src/client/LocalServer.ts | 17 +++++++++++++++-- src/core/GameRunner.ts | 14 ++++++++++---- src/core/worker/Worker.worker.ts | 15 +++++++++++++-- 3 files changed, 38 insertions(+), 8 deletions(-) diff --git a/src/client/LocalServer.ts b/src/client/LocalServer.ts index 2514dc695..75121b38a 100644 --- a/src/client/LocalServer.ts +++ b/src/client/LocalServer.ts @@ -20,7 +20,13 @@ import { import { getPersistentID } from "./Auth"; import { LobbyConfig } from "./ClientGameRunner"; import { ReplaySpeedChangeEvent } from "./InputHandler"; -import { defaultReplaySpeedMultiplier } from "./utilities/ReplaySpeedMultiplier"; +import { + defaultReplaySpeedMultiplier, + ReplaySpeedMultiplier, +} from "./utilities/ReplaySpeedMultiplier"; + +// build a small backlog so MAX can catch up. +const MAX_REPLAY_BACKLOG_TURNS = 60; export class LocalServer { // All turns from the game record on replay. @@ -64,9 +70,16 @@ export class LocalServer { const turnIntervalMs = this.lobbyConfig.serverConfig.turnIntervalMs() * this.replaySpeedMultiplier; + const backlog = Math.max(0, this.turns.length - this.turnsExecuted); + const allowReplayBacklog = + this.replaySpeedMultiplier === ReplaySpeedMultiplier.fastest && + this.lobbyConfig.gameRecord !== undefined; + const maxBacklog = allowReplayBacklog ? MAX_REPLAY_BACKLOG_TURNS : 0; + const canQueueNextTurn = + backlog === 0 || (maxBacklog > 0 && backlog < maxBacklog); if ( - this.turnsExecuted === this.turns.length && + canQueueNextTurn && Date.now() > this.turnStartTime + turnIntervalMs ) { this.turnStartTime = Date.now(); diff --git a/src/core/GameRunner.ts b/src/core/GameRunner.ts index 5e45612ea..0f93a94f6 100644 --- a/src/core/GameRunner.ts +++ b/src/core/GameRunner.ts @@ -112,12 +112,12 @@ export class GameRunner { this.turns.push(turn); } - public executeNextTick() { + public executeNextTick(): boolean { if (this.isExecuting) { - return; + return false; } if (this.currTurn >= this.turns.length) { - return; + return false; } this.isExecuting = true; @@ -144,7 +144,8 @@ export class GameRunner { } else { console.error("Game tick error:", error); } - return; + this.isExecuting = false; + return false; } if (this.game.inSpawnPhase() && this.game.ticks() % 2 === 0) { @@ -177,6 +178,11 @@ export class GameRunner { tickExecutionDuration: tickExecutionDuration, }); this.isExecuting = false; + return true; + } + + public pendingTurns(): number { + return Math.max(0, this.turns.length - this.currTurn); } public playerActions( diff --git a/src/core/worker/Worker.worker.ts b/src/core/worker/Worker.worker.ts index a60e63e4b..31fd3f136 100644 --- a/src/core/worker/Worker.worker.ts +++ b/src/core/worker/Worker.worker.ts @@ -16,6 +16,7 @@ import { const ctx: Worker = self as any; let gameRunner: Promise | null = null; const mapLoader = new FetchGameMapLoader(`/maps`, version); +const MAX_TICKS_PER_HEARTBEAT = 4; function gameUpdate(gu: GameUpdateViewData | ErrorUpdate) { // skip if ErrorUpdate @@ -36,9 +37,19 @@ ctx.addEventListener("message", async (e: MessageEvent) => { const message = e.data; switch (message.type) { - case "heartbeat": - (await gameRunner)?.executeNextTick(); + case "heartbeat": { + const gr = await gameRunner; + if (!gr) { + break; + } + const ticksToRun = Math.min(gr.pendingTurns(), MAX_TICKS_PER_HEARTBEAT); + for (let i = 0; i < ticksToRun; i++) { + if (!gr.executeNextTick()) { + break; + } + } break; + } case "init": try { gameRunner = createGameRunner(