Files
OpenFrontIO/src/server/GamePreviewBuilder.ts
T
VariableVince eca5794ebb Chore(deps): Update and remove dependencies (#3819)
## Description:

Only mentioning removals/major updates/notable changes below, not all
minor upgrades.

### Removed:
- "@aws-sdk/client-s3": not used anywhere (was used in Archive.ts
previously)
- chai, "@types/chai", sinon-chai: not used anywhere, probably leftover.
Vitest uses a bundled version of Chai for its expect asserations under
the hood too.
- protobufjs, "@types/google-protobuf": not used anywhere, probably left
from evan's experiment with it? Removed from vite.config.ts too.
- "@types/jquery": not used anywhere, probably leftover
- sinon, "@types/sinon": not used anywhere just like chai, probably
leftover. And Vitest provides us with the same functionality.
- "@types/systeminformation": dependency systeminformation was removed
last year, this is an unneeded, deprecated and unmaintained remainder.
- vite-tsconfig-paths: removed, and removed the import and usage in
vite.config.ts and replaced it by adding `tsconfigPaths: true` to the
`resolve` block. Because of this message displayed on running the tests:
"The plugin "vite-tsconfig-paths" is detected. Vite now supports
tsconfig paths resolution natively via the resolve.tsconfigPaths option.
You can remove the plugin and set resolve.tsconfigPaths: true in your
Vite config instead."
- vite-plugin-static-copy: removed, we don't use it anymore (was used in
our vite.config.ts once,, probably before Vite natively supported
copying static assets via its publicDir configuration)

### Updated:
- color.js: v0.5 > v0.6, no breaking change affecting us
- cross-env: v7 > v10. It's a publicly archived repo since Nov 2025. But
before that he got it up-to-date from June 2025, porting to TS, dropping
old Node versions, dependencies etc. Seems still good to use for some
amount of time to come.
- dotenv: v16 > v17, now logs an informational message by default when
it loads an environment file. Can be disabled by using
dotenv.config({quite: true}) if needed.
- ejs: v3 > v5: security patches mostly. Vite still uses v3 btw.
- eslint: v9 > v10. Newly enabled rules by default:
'no-unassigned-vars', 'no-useless-assignment' and
'preserve-caught-error'. Mostly faster and minimum support moved to
higher node versions, which shouldn't be a problem.
- "@eslint/compat": v1 > v2. Minimum supported Node versions, which
should not be a problem.
- intl-messageformat: v10 > v11 no breaking changes that affect us
- jdom: v27 > v29. Faster. Most notably minimum support moved to higher
node v22 version, which should not be a problem. Also, see types/node,
kind of expecting v24 to be installed now.
- nanoid: from v3 to v5, no breaking changes that affect us
- "@opentelemetry/sdk-logs": now that addLogRecordProcessor is removed,
changed Logger.ts to pass an (empty) provider array directly to the
LoggerProvider constructor. Follows the changes in
https://github.com/open-telemetry/opentelemetry-js/pull/5588
- "@tailwindcss/vite": supports vite v8 from 4.2.2, and a fix for it in
4.2.4
- tailwindcss: supports vite v8 from 4.2.2
-- in 4.1.15 (we were already above this version) break-words was
deprecated in favor of wrap-break-word. But break-words, which we use in
15 places, will still work as expected
(https://github.com/tailwindlabs/tailwindcss/pull/19157). Same goes for
also deprecated "order-none".
- "@types/node": from v22 to v24, assuming most now use node 24
- vite v7 > v8: 
-- is now on 8.0.10 so first bugs are out of it, while v8 itself also
fixed a big number of bugs.
-- in vite.config.ts, fixed Ts error/compilation issue by changing the
manualChunks option in build.rollupOptions.output to use the function
syntax, which is required by the updated types instead of the object
syntax.
- zod: no changes that affect us

### Prettier:
Updated only because of (new because of update?) Prettier errors for
files untouched in this PR originally:
- PathFinder.Parabola.ts
- WorkerMessages.ts
- ClanModal.handlers.test.ts
- ClanModal.rendering.test.ts‎
- CONTRIBUTING.md
- README.md

### ESLint:
Fixes needed to silence errors coming from newly enabled recommended
rules 'no-useless-assignment' and 'preserve-caught-error':

For 'no-useless-assignment' (default assignment never used because of
unreachable code or they are guaranteed to get a value, so they can be
undefinedat the start. Exception was AttackExecution, so made the
default value of 0 the default case in the switch statement):
- ClientGameRunner
- GameModeSelector
- NameBoxCalculator
- StructureDrawingUtils
- TerritoryLayer
- Diagnostics
- GameRunner
- ColorAllocator
- DefaultConfig
- AttackExecution
- AiAttackBehavior
- Worker.worker
- GamePreviewBuilder

For 'preserve-caught-error', disabled the rule here because the possible
fix `{cause: error}` was introduced in ES2022 while we're still on
target ES2020 currently:
- GameServer
- Privilege

_Error: The value assigned to 'gameMap' is not used in subsequent
statements. (no-useless-assignment)
Error: The value assigned to 'timeDisplay' is not used in subsequent
statements. (no-useless-assignment)
Error: The value assigned to 'scalingFactor' is not used in subsequent
statements. (no-useless-assignment)
Error: The value assigned to 'radius' is not used in subsequent
statements. (no-useless-assignment)
Error: The value assigned to 'teamColor' is not used in subsequent
statements. (no-useless-assignment)
Error: The value assigned to 'gl' is not used in subsequent statements.
(no-useless-assignment)
Error: The value assigned to 'power' is not used in subsequent
statements. (no-useless-assignment)
Error: The value assigned to 'tickExecutionDuration' is not used in
subsequent statements. (no-useless-assignment)
Error: The value assigned to 'selectedIndex' is not used in subsequent
statements. (no-useless-assignment)
Error: The value assigned to 'mag' is not used in subsequent statements.
(no-useless-assignment)
Error: The value assigned to 'speed' is not used in subsequent
statements. (no-useless-assignment)
Error: The value assigned to 'matchesCriteria' is not used in subsequent
statements. (no-useless-assignment)
Error: The value assigned to 'shouldContinue' is not used in subsequent
statements. (no-useless-assignment)
Error: The value assigned to 'description' is not used in subsequent
statements. (no-useless-assignment)
Error: There is no `cause` attached to the symptom error being thrown.
(preserve-caught-error)
Error: There is no `cause` attached to the symptom error being thrown.
(preserve-caught-error)_

All tests pass. TypeScript and ESLint errors resolved.

## 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

---------

Co-authored-by: Copilot <copilot@github.com>
2026-05-06 09:12:27 -06:00

283 lines
8.9 KiB
TypeScript

import { z } from "zod";
import { buildAssetUrl } from "../core/AssetUrls";
import { ClanTagSchema, GameInfo, UsernameSchema } from "../core/Schemas";
import { formatPlayerDisplayName } from "../core/Util";
import { GameMode } from "../core/game/Game";
import { getRuntimeAssetManifest } from "./RuntimeAssetManifest";
export const PlayerInfoSchema = z.object({
clientID: z.string().optional(),
username: UsernameSchema.optional(),
clanTag: ClanTagSchema,
stats: z.unknown().optional(),
});
export type PlayerInfo = z.infer<typeof PlayerInfoSchema>;
export const ExternalGameInfoSchema = z.object({
info: z
.object({
config: z
.object({
gameMap: z.string().optional(),
gameMode: z.string().optional(),
gameType: z.string().optional(),
maxPlayers: z.number().optional(),
playerTeams: z.union([z.number(), z.string()]).optional(),
})
.optional(),
players: z.array(PlayerInfoSchema).optional(),
winner: z.array(z.string()).optional(),
duration: z.number().optional(),
start: z.number().optional(),
end: z.number().optional(),
lobbyCreatedAt: z.number().optional(),
})
.optional(),
});
export type ExternalGameInfo = z.infer<typeof ExternalGameInfoSchema>;
export type PreviewMeta = {
title: string;
description: string;
image: string;
joinUrl: string;
};
function formatDuration(seconds: number): string {
if (!Number.isFinite(seconds) || seconds < 0) return "Unknown";
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
const hours = Math.floor(mins / 60);
const minutes = mins % 60;
if (hours) return `${hours}h ${minutes}m ${secs}s`;
if (minutes) return `${minutes}m ${secs}s`;
return `${secs}s`;
}
function normalizeTimestamp(timestamp: number): number {
return timestamp < 1e12 ? timestamp * 1000 : timestamp;
}
function formatDateTimeParts(timestamp: number): {
date: string;
time: string;
} {
const date = new Date(normalizeTimestamp(timestamp));
const dateLabel = new Intl.DateTimeFormat("en-US", {
month: "short",
day: "numeric",
year: "numeric",
timeZone: "UTC",
}).format(date);
const timeLabel = new Intl.DateTimeFormat("en-US", {
hour: "2-digit",
minute: "2-digit",
hour12: false,
timeZone: "UTC",
}).format(date);
return { date: dateLabel, time: `${timeLabel} UTC` };
}
type WinnerInfo = { names: string; count: number };
function parseWinner(
winnerArray: string[] | undefined,
players: PlayerInfo[] | undefined,
): WinnerInfo | undefined {
if (!winnerArray || winnerArray.length < 2) return undefined;
const idToName = new Map(
(players ?? []).map((p) => [
p.clientID,
p.username ? formatPlayerDisplayName(p.username, p.clanTag) : undefined,
]),
);
if (winnerArray[0] === "team" && winnerArray.length >= 3) {
const playerIds = winnerArray.slice(2);
const names = playerIds.map((id) => idToName.get(id) ?? id).filter(Boolean);
return names.length > 0
? { names: names.join(", "), count: names.length }
: undefined;
}
if (winnerArray[0] === "player" && winnerArray.length >= 2) {
const clientId = winnerArray[1];
const name = idToName.get(clientId) ?? clientId;
return { names: name, count: 1 };
}
// Unknown winner format - don't display confusing output
return undefined;
}
function countActivePlayers(players: PlayerInfo[] | undefined): number {
return (players ?? []).filter((p) => {
if (!p || p.stats === null || p.stats === undefined) return false;
// Count only when `stats` has at least one property.
if (typeof p.stats === "object") {
return Object.keys(p.stats as Record<string, unknown>).length > 0;
}
return false;
}).length;
}
export function escapeHtml(value: string): string {
return value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
export async function buildPreview(
gameID: string,
origin: string,
workerPath: string,
lobby: GameInfo | null,
publicInfo: ExternalGameInfo | null,
): Promise<PreviewMeta> {
const assetManifest = await getRuntimeAssetManifest();
const cdnBase = process.env.CDN_BASE ?? "";
const buildAbsoluteAssetUrl = (path: string) =>
new URL(buildAssetUrl(path, assetManifest, cdnBase), origin).toString();
const isFinished = !!publicInfo?.info?.end;
const isPrivate = lobby?.gameConfig?.gameType === "Private";
// route directly to the correct worker.
const joinUrl = `${origin}/${workerPath}/game/${gameID}`;
const config = publicInfo?.info?.config ?? {};
const players = publicInfo?.info?.players ?? [];
let activePlayers: number;
if (isFinished) {
activePlayers = countActivePlayers(players);
} else {
activePlayers =
countActivePlayers(players) || (lobby?.clients?.length ?? 0);
}
const map = lobby?.gameConfig?.gameMap ?? config.gameMap;
let mode = lobby?.gameConfig?.gameMode ?? config.gameMode ?? GameMode.FFA;
const playerTeams = lobby?.gameConfig?.playerTeams ?? config.playerTeams;
const numericTeamCount =
typeof playerTeams === "number" && playerTeams > 0
? playerTeams
: undefined;
// For finished games, show "x teams of y". For lobbies, just show "x teams"
const teamBreakdownLabel = numericTeamCount
? isFinished
? `${numericTeamCount} teams of ${Math.max(
1,
Math.ceil(activePlayers / numericTeamCount),
)}`
: `${numericTeamCount} teams`
: undefined;
// Format team mode display
if (mode === "Team" && playerTeams) {
if (typeof playerTeams === "string") {
mode = playerTeams; // e.g., "Quads"
} else if (typeof playerTeams === "number") {
mode = teamBreakdownLabel ?? `${playerTeams} Teams`;
}
}
const winner = parseWinner(publicInfo?.info?.winner, players);
const duration = publicInfo?.info?.duration;
// Normalize map name to match filesystem (lowercase, no spaces or special chars)
const normalizedMap = map ? map.toLowerCase().replace(/[\s.()]+/g, "") : null;
const mapThumbnail = normalizedMap
? buildAbsoluteAssetUrl(
`maps/${encodeURIComponent(normalizedMap)}/thumbnail.webp`,
)
: null;
const image =
mapThumbnail ?? buildAbsoluteAssetUrl("images/GameplayScreenshot.png");
const gameType = lobby?.gameConfig?.gameType ?? config.gameType;
const gameTypeLabel = gameType ? ` (${gameType})` : "";
const title = isFinished
? `${mode ?? "Game"} on ${map ?? "Unknown Map"}${gameTypeLabel}`
: mode && map
? `${mode} on ${map}${gameTypeLabel}`
: "OpenFront Game";
let description: string;
if (isFinished) {
const parts: string[] = [];
if (winner) {
parts.push(`${winner.count > 1 ? "Winners" : "Winner"}: ${winner.names}`);
parts.push(""); // Extra line break after winner
}
const matchTimestamp =
publicInfo?.info?.start ??
publicInfo?.info?.end ??
publicInfo?.info?.lobbyCreatedAt;
const detailParts: string[] = [];
const playerCountLabel = `${activePlayers} ${activePlayers === 1 ? "player" : "players"}`;
detailParts.push(playerCountLabel);
if (duration !== undefined) detailParts.push(`${formatDuration(duration)}`);
if (matchTimestamp !== undefined) {
const dateTime = formatDateTimeParts(matchTimestamp);
detailParts.push(`${dateTime.date}`);
detailParts.push(`${dateTime.time}`);
}
parts.push(detailParts.join(" • "));
description = parts.join("\n");
} else if (lobby) {
const gc = lobby.gameConfig;
if (isPrivate) {
// Private lobby: show detailed game settings
const sections: string[] = [];
// Show host
const hostClient = lobby.clients?.[0];
if (hostClient?.username) {
sections.push(
`Host: ${formatPlayerDisplayName(hostClient.username, hostClient.clanTag)}`,
);
}
const gameOptions: string[] = [];
if (gc?.gameMapSize && gc.gameMapSize !== "Normal") {
gameOptions.push(`${gc.gameMapSize} Map`);
}
if (gc?.infiniteGold) gameOptions.push("Infinite Gold");
if (gc?.infiniteTroops) gameOptions.push("Infinite Troops");
if (gc?.instantBuild) gameOptions.push("Instant Build");
if (gc?.randomSpawn) gameOptions.push("Random Spawn");
if (gc?.nations === "disabled") gameOptions.push("Nations Disabled");
if (gc?.donateTroops) gameOptions.push("Troop Donations Enabled");
if (gameOptions.length > 0) {
sections.push(`Game Options: ${gameOptions.join(" | ")}`);
}
if (Array.isArray(gc?.disabledUnits) && gc.disabledUnits.length > 0) {
sections.push(
`Disabled Units: ${gc.disabledUnits.map(String).join(" | ")}`,
);
}
description = sections.join("\n\n");
} else {
// Public lobby: basic info
description = "";
}
} else {
description = `Game ${gameID}`;
}
return { title, description, image, joinUrl };
}