Fix "you didn't enter the lobby in time" when device clock isn't synced (#3451)

## 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
This commit is contained in:
VariableVince
2026-03-17 21:52:35 +01:00
committed by evanpelle
parent 5c01e7a0c9
commit 9f8a2d2d84
4 changed files with 119 additions and 9 deletions
+4 -7
View File
@@ -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 = "";
+14 -2
View File
@@ -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;
+27
View File
@@ -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,
),
);
}
+74
View File
@@ -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);
});
});