From 82d0fb385d73f18d31a255c6f2e851d7bf02ec2c Mon Sep 17 00:00:00 2001 From: VariableVince <24507472+VariableVince@users.noreply.github.com> Date: Tue, 17 Mar 2026 21:52:35 +0100 Subject: [PATCH] Fix "you didn't enter the lobby in time" when device clock isn't synced (#3451) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description: If the time on the local device differs from the server time, users may see the message “You did not join the lobby on time.” Resolve this by accounting for the time difference, reusing the logic in `JoinLobbyModal` that was previously in `GameModeSelector`, and centralizing it into `ServerTime.ts`. Bug reports: https://github.com/openfrontio/OpenFrontIO/issues/3428 https://discord.com/channels/1284581928254701718/1482511096597315815 https://discord.com/channels/1284581928254701718/1482382264011591781 Resolves #3428 ## 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: tryout33 --- src/client/GameModeSelector.ts | 11 ++--- src/client/JoinLobbyModal.ts | 16 ++++++- src/client/Utils.ts | 27 +++++++++++ tests/client/JoinLobbyModal.test.ts | 74 +++++++++++++++++++++++++++++ 4 files changed, 119 insertions(+), 9 deletions(-) create mode 100644 tests/client/JoinLobbyModal.test.ts diff --git a/src/client/GameModeSelector.ts b/src/client/GameModeSelector.ts index 6cf39a873..0163e8c68 100644 --- a/src/client/GameModeSelector.ts +++ b/src/client/GameModeSelector.ts @@ -18,8 +18,10 @@ import { JoinLobbyEvent } from "./Main"; import { SinglePlayerModal } from "./SinglePlayerModal"; import { terrainMapFileLoader } from "./TerrainMapFileLoader"; import { + calculateServerTimeOffset, getMapName, getModifierLabels, + getSecondsUntilServerTimestamp, renderDuration, translateText, } from "./Utils"; @@ -81,7 +83,7 @@ export class GameModeSelector extends LitElement { private handleLobbiesUpdate(lobbies: PublicGames) { this.lobbies = lobbies; - this.serverTimeOffset = lobbies.serverTime - Date.now(); + this.serverTimeOffset = calculateServerTimeOffset(lobbies.serverTime); document.dispatchEvent( new CustomEvent("public-lobbies-update", { detail: { payload: lobbies }, @@ -279,12 +281,7 @@ export class GameModeSelector extends LitElement { const useContain = aspectRatio !== undefined && (aspectRatio > 4 || aspectRatio < 0.25); const timeRemaining = lobby.startsAt - ? Math.max( - 0, - Math.floor( - (lobby.startsAt - this.serverTimeOffset - Date.now()) / 1000, - ), - ) + ? getSecondsUntilServerTimestamp(lobby.startsAt, this.serverTimeOffset) : undefined; let timeDisplay: string = ""; diff --git a/src/client/JoinLobbyModal.ts b/src/client/JoinLobbyModal.ts index a05d768c9..4bea53d43 100644 --- a/src/client/JoinLobbyModal.ts +++ b/src/client/JoinLobbyModal.ts @@ -1,9 +1,12 @@ import { html, TemplateResult } from "lit"; import { customElement, property, query, state } from "lit/decorators.js"; import { + calculateServerTimeOffset, getActiveModifiers, getGameModeLabel, getMapName, + getSecondsUntilServerTimestamp, + getServerNow, renderDuration, renderNumber, translateText, @@ -44,6 +47,7 @@ export class JoinLobbyModal extends BaseModal { @state() private currentClientID: string = ""; @state() private nationCount: number = 0; @state() private lobbyStartAt: number | null = null; + @state() private serverTimeOffset: number = 0; @state() private isConnecting: boolean = true; @state() private lobbyCreatorClientID: string | null = null; @@ -77,7 +81,10 @@ export class JoinLobbyModal extends BaseModal { // Post-join state: show lobby info (identical for public & private) const secondsRemaining = this.lobbyStartAt !== null - ? Math.max(0, Math.floor((this.lobbyStartAt - Date.now()) / 1000)) + ? getSecondsUntilServerTimestamp( + this.lobbyStartAt, + this.serverTimeOffset, + ) : null; const statusLabel = secondsRemaining === null @@ -328,6 +335,7 @@ export class JoinLobbyModal extends BaseModal { this.players = []; this.nationCount = 0; this.lobbyStartAt = null; + this.serverTimeOffset = 0; this.lobbyCreatorClientID = null; this.isConnecting = true; this.handledJoinTimeout = false; @@ -377,6 +385,7 @@ export class JoinLobbyModal extends BaseModal { this.currentClientID = ""; this.nationCount = 0; this.lobbyStartAt = null; + this.serverTimeOffset = 0; this.lobbyCreatorClientID = null; this.isConnecting = true; this.leaveLobbyOnClose = true; @@ -513,6 +522,9 @@ export class JoinLobbyModal extends BaseModal { private updateFromLobby(lobby: GameInfo | PublicGameInfo) { this.players = "clients" in lobby ? (lobby.clients ?? []) : []; + if ("serverTime" in lobby && typeof lobby.serverTime === "number") { + this.serverTimeOffset = calculateServerTimeOffset(lobby.serverTime); + } this.lobbyStartAt = lobby.startsAt ?? null; this.syncCountdownTimer(); if (lobby.gameConfig) { @@ -577,7 +589,7 @@ export class JoinLobbyModal extends BaseModal { ) { return; } - if (Date.now() < this.lobbyStartAt) { + if (getServerNow(this.serverTimeOffset) < this.lobbyStartAt) { return; } this.handledJoinTimeout = true; diff --git a/src/client/Utils.ts b/src/client/Utils.ts index c9323bead..a775a6ae6 100644 --- a/src/client/Utils.ts +++ b/src/client/Utils.ts @@ -628,3 +628,30 @@ export function getDiscordAvatarUrl(user: { return null; } +export function calculateServerTimeOffset( + serverTimeMs: number, + localNowMs: number = Date.now(), +): number { + return serverTimeMs - localNowMs; +} + +export function getServerNow( + serverTimeOffsetMs: number, + localNowMs: number = Date.now(), +): number { + return localNowMs + serverTimeOffsetMs; +} + +export function getSecondsUntilServerTimestamp( + targetServerTimestampMs: number, + serverTimeOffsetMs: number, + localNowMs: number = Date.now(), +): number { + return Math.max( + 0, + Math.floor( + (targetServerTimestampMs - getServerNow(serverTimeOffsetMs, localNowMs)) / + 1000, + ), + ); +} diff --git a/tests/client/JoinLobbyModal.test.ts b/tests/client/JoinLobbyModal.test.ts new file mode 100644 index 000000000..53e9b35de --- /dev/null +++ b/tests/client/JoinLobbyModal.test.ts @@ -0,0 +1,74 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { JoinLobbyModal } from "../../src/client/JoinLobbyModal"; + +describe("JoinLobbyModal server time offset", () => { + let nowMs = 0; + + beforeEach(() => { + vi.spyOn(Date, "now").mockImplementation(() => nowMs); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("updates serverTimeOffset from lobby serverTime", () => { + const modal = new JoinLobbyModal(); + (modal as any).syncCountdownTimer = vi.fn(); + + nowMs = 220_000; + (modal as any).updateFromLobby({ + gameID: "g1", + serverTime: 200_000, + startsAt: 230_000, + clients: [], + }); + + expect((modal as any).serverTimeOffset).toBe(-20_000); + expect((modal as any).lobbyStartAt).toBe(230_000); + }); + + it("does not trigger join timeout early when local clock is ahead", () => { + const modal = new JoinLobbyModal(); + const closeSpy = vi + .spyOn(modal, "closeAndLeave") + .mockImplementation(() => undefined); + const dispatchSpy = vi.spyOn(window, "dispatchEvent"); + + (modal as any).isModalOpen = true; + (modal as any).isConnecting = true; + (modal as any).handledJoinTimeout = false; + + // Local clock is +60s ahead of server clock. + nowMs = 160_000; + (modal as any).lobbyStartAt = 105_000; + (modal as any).serverTimeOffset = -60_000; + + (modal as any).checkForJoinTimeout(); + + expect(closeSpy).not.toHaveBeenCalled(); + expect(dispatchSpy).not.toHaveBeenCalled(); + expect((modal as any).handledJoinTimeout).toBe(false); + }); + + it("triggers join timeout once adjusted server time reaches lobbyStartAt", () => { + const modal = new JoinLobbyModal(); + const closeSpy = vi + .spyOn(modal, "closeAndLeave") + .mockImplementation(() => undefined); + const dispatchSpy = vi.spyOn(window, "dispatchEvent"); + + (modal as any).isModalOpen = true; + (modal as any).isConnecting = true; + (modal as any).handledJoinTimeout = false; + (modal as any).lobbyStartAt = 105_000; + (modal as any).serverTimeOffset = -60_000; + + nowMs = 165_000; + (modal as any).checkForJoinTimeout(); + + expect(closeSpy).toHaveBeenCalledTimes(1); + expect(dispatchSpy).toHaveBeenCalledTimes(1); + expect((modal as any).handledJoinTimeout).toBe(true); + }); +});