diff --git a/src/core/configuration/Config.ts b/src/core/configuration/Config.ts index ac1d9ee4ab..d3f9d6eea1 100644 --- a/src/core/configuration/Config.ts +++ b/src/core/configuration/Config.ts @@ -2,6 +2,7 @@ import { Colord } from "colord"; import { JWK } from "jose"; import { Game, + GameType, Gold, Player, PlayerInfo, @@ -27,6 +28,8 @@ export interface ServerConfig { turnstileSiteKey(): string; turnstileSecretKey(): string; turnIntervalMs(): number; + spawnPhaseTicks(gameType: GameType): number; + spawnPhaseSeconds(gameType: GameType): number; gameCreationRate(): number; numWorkers(): number; workerIndex(gameID: GameID): number; diff --git a/src/core/configuration/DefaultConfig.ts b/src/core/configuration/DefaultConfig.ts index 6ad7efb27e..2d13d8d601 100644 --- a/src/core/configuration/DefaultConfig.ts +++ b/src/core/configuration/DefaultConfig.ts @@ -117,6 +117,15 @@ export abstract class DefaultServerConfig implements ServerConfig { turnIntervalMs(): number { return 100; } + ticksPerSecond(): number { + return 1000 / this.turnIntervalMs(); + } + spawnPhaseTicks(gameType: GameType): number { + return gameType === GameType.Singleplayer ? 100 : 300; + } + spawnPhaseSeconds(gameType: GameType): number { + return this.spawnPhaseTicks(gameType) / this.ticksPerSecond(); + } gameCreationRate(): number { return 60 * 1000; } @@ -542,7 +551,7 @@ export class DefaultConfig implements Config { return 3; } numSpawnPhaseTurns(): number { - return this._gameConfig.gameType === GameType.Singleplayer ? 100 : 300; + return this._serverConfig.spawnPhaseTicks(this._gameConfig.gameType); } numBots(): number { return this.bots(); diff --git a/src/server/GamePreviewBuilder.ts b/src/server/GamePreviewBuilder.ts index ea99160258..5752c3207b 100644 --- a/src/server/GamePreviewBuilder.ts +++ b/src/server/GamePreviewBuilder.ts @@ -1,6 +1,7 @@ import { z } from "zod"; +import { ServerConfig } from "../core/configuration/Config"; +import { GameMode, GameType } from "../core/game/Game"; import { GameInfo } from "../core/Schemas"; -import { GameMode } from "../core/game/Game"; export const PlayerInfoSchema = z.object({ clientID: z.string().optional(), @@ -132,6 +133,7 @@ export function buildPreview( workerPath: string, lobby: GameInfo | null, publicInfo: ExternalGameInfo | null, + serverConfig: ServerConfig, ): PreviewMeta { const isFinished = !!publicInfo?.info?.end; const isPrivate = lobby?.gameConfig?.gameType === "Private"; @@ -178,6 +180,19 @@ export function buildPreview( const winner = parseWinner(publicInfo?.info?.winner, players); const duration = publicInfo?.info?.duration; + const gameType = lobby?.gameConfig?.gameType ?? config.gameType; + const adjustedDuration = + typeof duration === "number" + ? Math.max( + 0, + duration - + serverConfig.spawnPhaseSeconds( + gameType === GameType.Singleplayer + ? GameType.Singleplayer + : GameType.Public, + ), + ) + : undefined; // Normalize map name to match filesystem (lowercase, no spaces or special chars) const normalizedMap = map ? map.toLowerCase().replace(/[\s.()]+/g, "") : null; @@ -187,7 +202,6 @@ export function buildPreview( : null; const image = mapThumbnail ?? `${origin}/images/GameplayScreenshot.png`; - const gameType = lobby?.gameConfig?.gameType ?? config.gameType; const gameTypeLabel = gameType ? ` (${gameType})` : ""; const title = isFinished @@ -210,7 +224,9 @@ export function buildPreview( const detailParts: string[] = []; const playerCountLabel = `${activePlayers} ${activePlayers === 1 ? "player" : "players"}`; detailParts.push(playerCountLabel); - if (duration !== undefined) detailParts.push(`${formatDuration(duration)}`); + if (adjustedDuration !== undefined) { + detailParts.push(`${formatDuration(adjustedDuration)}`); + } if (matchTimestamp !== undefined) { const dateTime = formatDateTimeParts(matchTimestamp); detailParts.push(`${dateTime.date}`); diff --git a/src/server/GamePreviewRoute.ts b/src/server/GamePreviewRoute.ts index 01837ff19b..acd95c26aa 100644 --- a/src/server/GamePreviewRoute.ts +++ b/src/server/GamePreviewRoute.ts @@ -102,6 +102,7 @@ export function registerGamePreviewRoute(opts: { config.workerPath(gameID), lobby, publicInfo, + config, ); // Always serve HTML with meta tags for /game/:id route diff --git a/tests/util/TestServerConfig.ts b/tests/util/TestServerConfig.ts index 94b6259430..f4c2690b28 100644 --- a/tests/util/TestServerConfig.ts +++ b/tests/util/TestServerConfig.ts @@ -1,6 +1,6 @@ import { JWK } from "jose"; import { GameEnv, ServerConfig } from "../../src/core/configuration/Config"; -import { PublicGameModifiers } from "../../src/core/game/Game"; +import { GameType, PublicGameModifiers } from "../../src/core/game/Game"; import { GameID } from "../../src/core/Schemas"; export class TestServerConfig implements ServerConfig { @@ -44,7 +44,13 @@ export class TestServerConfig implements ServerConfig { throw new Error("Method not implemented."); } turnIntervalMs(): number { - throw new Error("Method not implemented."); + return 100; + } + spawnPhaseTicks(gameType: GameType): number { + return gameType === GameType.Singleplayer ? 100 : 300; + } + spawnPhaseSeconds(gameType: GameType): number { + return this.spawnPhaseTicks(gameType) / (1000 / this.turnIntervalMs()); } gameCreationRate(): number { throw new Error("Method not implemented.");