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); + }); +});