From 7e25f6b910922d147b6c068ca1a9b76f2262c4ed Mon Sep 17 00:00:00 2001 From: Scott Anderson <662325+scottanderson@users.noreply.github.com> Date: Sat, 9 Aug 2025 02:13:56 -0400 Subject: [PATCH 01/24] Enable `@total-typescript/ts-reset` (#1761) ## Description: Enable `@total-typescript/ts-reset` Fixes #1760 ## 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 - [ ] I have read and accepted the CLA agreement (only required once). --- package-lock.json | 8 ++++ package.json | 1 + src/client/ClientGameRunner.ts | 2 +- src/client/Cosmetics.ts | 16 +++++++- src/client/InputHandler.ts | 2 +- src/client/JoinPrivateLobbyModal.ts | 16 +++++--- src/client/LocalPersistantStats.ts | 35 ++++++++++++----- src/client/MultiTabDetector.ts | 11 +++++- src/client/PublicLobby.ts | 4 +- src/client/UserSettingModal.ts | 5 ++- src/core/ApiSchemas.ts | 17 +++++++++ src/core/CosmeticSchemas.ts | 2 +- src/core/ExpressSchemas.ts | 15 ++++++++ src/core/Schemas.ts | 27 +++++++------ src/core/WorkerSchemas.ts | 33 +++++++++++++++- src/core/configuration/ConfigLoader.ts | 4 +- src/core/game/FetchGameMapLoader.ts | 3 +- src/core/game/TerrainMapLoader.ts | 38 ++++++++++--------- src/core/game/UnitGrid.ts | 7 +++- src/reset.d.ts | 1 + src/server/Cloudflare.ts | 13 +++++-- src/server/Master.ts | 14 +++++-- src/server/Worker.ts | 8 +++- tests/AutoUpgrade.test.ts | 4 +- tests/LangCode.test.ts | 11 +++++- tests/LangSvg.test.ts | 9 ++++- tests/testdata/maps/big_plains/manifest.json | 1 + .../maps/half_land_half_ocean/manifest.json | 1 + .../maps/ocean_and_land/manifest.json | 1 + tests/testdata/maps/plains/manifest.json | 1 + tests/util/Setup.ts | 14 +++++-- 31 files changed, 248 insertions(+), 76 deletions(-) create mode 100644 src/core/ExpressSchemas.ts create mode 100644 src/reset.d.ts diff --git a/package-lock.json b/package-lock.json index 36f478588..07eac317f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -43,6 +43,7 @@ "@eslint/compat": "^1.2.7", "@eslint/js": "^9.21.0", "@swc/jest": "^0.2.39", + "@total-typescript/ts-reset": "^0.6.1", "@types/benchmark": "^2.1.5", "@types/chai": "^4.3.17", "@types/d3": "^7.4.3", @@ -6604,6 +6605,13 @@ "@swc/counter": "^0.1.3" } }, + "node_modules/@total-typescript/ts-reset": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/@total-typescript/ts-reset/-/ts-reset-0.6.1.tgz", + "integrity": "sha512-cka47fVSo6lfQDIATYqb/vO1nvFfbPw7uWLayIXIhGETj0wcOOlrlkobOMDNQOFr9QOafegUPq13V2+6vtD7yg==", + "dev": true, + "license": "MIT" + }, "node_modules/@tsconfig/node10": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.11.tgz", diff --git a/package.json b/package.json index fda1d731c..1b82703dd 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "@eslint/js": "^9.21.0", "@swc/jest": "^0.2.39", "@types/benchmark": "^2.1.5", + "@total-typescript/ts-reset": "^0.6.1", "@types/chai": "^4.3.17", "@types/d3": "^7.4.3", "@types/express": "^4.17.23", diff --git a/src/client/ClientGameRunner.ts b/src/client/ClientGameRunner.ts index 6d073fd30..eab9a3393 100644 --- a/src/client/ClientGameRunner.ts +++ b/src/client/ClientGameRunner.ts @@ -229,7 +229,7 @@ export class ClientGameRunner { players, // Not saving turns locally [], - startTime(), + startTime() ?? 0, Date.now(), update.winner, this.lobby.serverConfig, diff --git a/src/client/Cosmetics.ts b/src/client/Cosmetics.ts index bfd991c4a..ccd5a6d6d 100644 --- a/src/client/Cosmetics.ts +++ b/src/client/Cosmetics.ts @@ -1,4 +1,8 @@ -import { UserMeResponse } from "../core/ApiSchemas"; +import { z } from "zod"; +import { + StripeCreateCheckoutSessionResponseSchema, + UserMeResponse, +} from "../core/ApiSchemas"; import { Cosmetics, CosmeticsSchema, Pattern } from "../core/CosmeticSchemas"; import { getApiBase, getAuthHeader } from "./jwt"; @@ -59,7 +63,15 @@ export async function handlePurchase(priceId: string) { return; } - const { url } = await response.json(); + const json = await response.json(); + const parsed = StripeCreateCheckoutSessionResponseSchema.safeParse(json); + if (!parsed.success) { + const error = z.prettifyError(parsed.error); + console.error("Invalid checkout session response:", error); + alert("Checkout failed. Please try again later."); + return; + } + const { url } = parsed.data; // Redirect to Stripe checkout window.location.href = url; diff --git a/src/client/InputHandler.ts b/src/client/InputHandler.ts index 15d6bee9d..5b9d397a8 100644 --- a/src/client/InputHandler.ts +++ b/src/client/InputHandler.ts @@ -159,7 +159,7 @@ export class InputHandler { groundAttack: "KeyG", modifierKey: "ControlLeft", altKey: "AltLeft", - ...JSON.parse(localStorage.getItem("settings.keybinds") ?? "{}"), + ...(JSON.parse(localStorage.getItem("settings.keybinds") ?? "{}") ?? {}), }; // Mac users might have different keybinds diff --git a/src/client/JoinPrivateLobbyModal.ts b/src/client/JoinPrivateLobbyModal.ts index 1c875a7e7..8efd71156 100644 --- a/src/client/JoinPrivateLobbyModal.ts +++ b/src/client/JoinPrivateLobbyModal.ts @@ -1,8 +1,12 @@ import { LitElement, html } from "lit"; import { customElement, query, state } from "lit/decorators.js"; import { translateText } from "../client/Utils"; -import { GameInfo, GameRecord } from "../core/Schemas"; +import { GameInfo } from "../core/Schemas"; import { generateID } from "../core/Util"; +import { + WorkerApiArchivedGameLobbySchema, + WorkerApiGameIdExistsSchema, +} from "../core/WorkerSchemas"; import { getServerConfigFromClient } from "../core/configuration/ConfigLoader"; import { JoinLobbyEvent } from "./Main"; import "./components/baseComponents/Button"; @@ -198,7 +202,8 @@ export class JoinPrivateLobbyModal extends LitElement { headers: { "Content-Type": "application/json" }, }); - const gameInfo = await response.json(); + const json = await response.json(); + const gameInfo = WorkerApiGameIdExistsSchema.parse(json); if (gameInfo.exists) { this.message = translateText("private_lobby.joined_waiting"); @@ -231,7 +236,8 @@ export class JoinPrivateLobbyModal extends LitElement { headers: { "Content-Type": "application/json" }, }); - const archiveData = await archiveResponse.json(); + const json = await archiveResponse.json(); + const archiveData = WorkerApiArchivedGameLobbySchema.parse(json); if ( archiveData.success === false && @@ -247,13 +253,11 @@ export class JoinPrivateLobbyModal extends LitElement { } if (archiveData.exists) { - const gameRecord = archiveData.gameRecord as GameRecord; - this.dispatchEvent( new CustomEvent("join-lobby", { detail: { gameID: lobbyId, - gameRecord: gameRecord, + gameRecord: archiveData.gameRecord, clientID: generateID(), } as JoinLobbyEvent, bubbles: true, diff --git a/src/client/LocalPersistantStats.ts b/src/client/LocalPersistantStats.ts index c6dd1df5a..e08a2ff34 100644 --- a/src/client/LocalPersistantStats.ts +++ b/src/client/LocalPersistantStats.ts @@ -1,19 +1,34 @@ -import { GameConfig, GameID, GameRecord } from "../core/Schemas"; +import { z } from "zod"; +import { + GameConfig, + GameConfigSchema, + GameID, + GameRecord, + GameRecordSchema, + ID, +} from "../core/Schemas"; import { replacer } from "../core/Util"; -export interface LocalStatsData { - [key: GameID]: { - lobby: Partial; +const LocalStatsDataSchema = z.record( + ID, + z.object({ + lobby: GameConfigSchema.partial(), // Only once the game is over - gameRecord?: GameRecord; - }; -} + gameRecord: GameRecordSchema.optional(), + }), +); +type LocalStatsData = z.infer; -let _startTime: number; +let _startTime: number | undefined; function getStats(): LocalStatsData { - const statsStr = localStorage.getItem("game-records"); - return statsStr ? JSON.parse(statsStr) : {}; + try { + return LocalStatsDataSchema.parse( + JSON.parse(localStorage.getItem("game-records") ?? "{}"), + ); + } catch (e) { + return {}; + } } function save(stats: LocalStatsData) { diff --git a/src/client/MultiTabDetector.ts b/src/client/MultiTabDetector.ts index 7b98965c6..90917c689 100644 --- a/src/client/MultiTabDetector.ts +++ b/src/client/MultiTabDetector.ts @@ -1,3 +1,10 @@ +import { z } from "zod"; + +const LockSchema = z.object({ + owner: z.string(), + timestamp: z.number(), +}); + export class MultiTabDetector { private readonly tabId = `${Date.now()}-${Math.random()}`; private readonly lockKey = "multi-tab-lock"; @@ -60,7 +67,7 @@ export class MultiTabDetector { if (e.key === this.lockKey && e.newValue) { let other: { owner: string; timestamp: number }; try { - other = JSON.parse(e.newValue); + other = LockSchema.parse(JSON.parse(e.newValue)); } catch (e) { console.error("Failed to parse lock", e); return; @@ -99,7 +106,7 @@ export class MultiTabDetector { const raw = localStorage.getItem(this.lockKey); if (!raw) return null; try { - return JSON.parse(raw); + return LockSchema.parse(JSON.parse(raw)); } catch (e) { console.error("Failed to parse lock", raw, e); return null; diff --git a/src/client/PublicLobby.ts b/src/client/PublicLobby.ts index e94ed794b..512c63444 100644 --- a/src/client/PublicLobby.ts +++ b/src/client/PublicLobby.ts @@ -1,6 +1,7 @@ import { LitElement, html } from "lit"; import { customElement, state } from "lit/decorators.js"; import { translateText } from "../client/Utils"; +import { ApiPublicLobbiesResponseSchema } from "../core/ExpressSchemas"; import { GameMapType, GameMode } from "../core/game/Game"; import { GameID, GameInfo } from "../core/Schemas"; import { generateID } from "../core/Util"; @@ -77,7 +78,8 @@ export class PublicLobby extends LitElement { const response = await fetch(`/api/public_lobbies`); if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`); - const data = await response.json(); + const json = await response.json(); + const data = ApiPublicLobbiesResponseSchema.parse(json); return data.lobbies; } catch (error) { console.error("Error fetching lobbies:", error); diff --git a/src/client/UserSettingModal.ts b/src/client/UserSettingModal.ts index d55f644ea..90e3b2e07 100644 --- a/src/client/UserSettingModal.ts +++ b/src/client/UserSettingModal.ts @@ -1,5 +1,6 @@ import { LitElement, html } from "lit"; import { customElement, query, state } from "lit/decorators.js"; +import { z } from "zod"; import { translateText } from "../client/Utils"; import { UserSettings } from "../core/game/UserSettings"; import "./components/baseComponents/setting/SettingKeybind"; @@ -8,6 +9,8 @@ import "./components/baseComponents/setting/SettingNumber"; import "./components/baseComponents/setting/SettingSlider"; import "./components/baseComponents/setting/SettingToggle"; +const KeybindSchema = z.record(z.string(), z.string()); + @customElement("user-setting") export class UserSettingModal extends LitElement { private userSettings: UserSettings = new UserSettings(); @@ -25,7 +28,7 @@ export class UserSettingModal extends LitElement { const savedKeybinds = localStorage.getItem("settings.keybinds"); if (savedKeybinds) { try { - this.keybinds = JSON.parse(savedKeybinds); + this.keybinds = KeybindSchema.parse(JSON.parse(savedKeybinds)); } catch (e) { console.warn("Invalid keybinds JSON:", e); } diff --git a/src/core/ApiSchemas.ts b/src/core/ApiSchemas.ts index e4d3f74fa..493fab6f4 100644 --- a/src/core/ApiSchemas.ts +++ b/src/core/ApiSchemas.ts @@ -1,3 +1,4 @@ +// This file contains schemas for api.openfront.io import { z } from "zod"; import { base64urlToUuid } from "./Base64"; @@ -48,3 +49,19 @@ export const UserMeResponseSchema = z.object({ }), }); export type UserMeResponse = z.infer; + +export const StripeCreateCheckoutSessionResponseSchema = z.object({ + id: z.string(), + object: z.literal("checkout.session"), + url: z.string(), + payment_status: z.enum(["paid", "unpaid", "no_payment_required"]), + status: z.enum(["open", "complete", "expired"]), + client_reference_id: z.string().optional(), + customer: z.string().optional(), + payment_intent: z.string().optional(), + subscription: z.string().optional(), + metadata: z.partialRecord(z.string(), z.string()), +}); +export type StripeCreateCheckoutSessionResponse = z.infer< + typeof StripeCreateCheckoutSessionResponseSchema +>; diff --git a/src/core/CosmeticSchemas.ts b/src/core/CosmeticSchemas.ts index f2855ab18..c7860c3cf 100644 --- a/src/core/CosmeticSchemas.ts +++ b/src/core/CosmeticSchemas.ts @@ -1,4 +1,4 @@ -import { z } from "zod/v4"; +import { z } from "zod"; import { RequiredPatternSchema } from "./Schemas"; export const ProductSchema = z.object({ diff --git a/src/core/ExpressSchemas.ts b/src/core/ExpressSchemas.ts new file mode 100644 index 000000000..fbbc6fb90 --- /dev/null +++ b/src/core/ExpressSchemas.ts @@ -0,0 +1,15 @@ +// This file contians schemas for the primary openfront express server +import { z } from "zod"; +import { GameInfoSchema } from "./Schemas"; + +export const ApiEnvResponseSchema = z.object({ + game_env: z.string(), +}); +export type ApiEnvResponse = z.infer; + +export const ApiPublicLobbiesResponseSchema = z.object({ + lobbies: GameInfoSchema.array(), +}); +export type ApiPublicLobbiesResponse = z.infer< + typeof ApiPublicLobbiesResponseSchema +>; diff --git a/src/core/Schemas.ts b/src/core/Schemas.ts index e96d0c17d..f46a535ba 100644 --- a/src/core/Schemas.ts +++ b/src/core/Schemas.ts @@ -112,17 +112,6 @@ export type Player = z.infer; export type GameStartInfo = z.infer; const PlayerTypeSchema = z.enum(PlayerType); -export interface GameInfo { - gameID: GameID; - clients?: ClientInfo[]; - numClients?: number; - msUntilStart?: number; - gameConfig?: GameConfig; -} -export interface ClientInfo { - clientID: ClientID; - username: string; -} export enum LogSeverity { Debug = "DEBUG", Info = "INFO", @@ -192,6 +181,22 @@ export const ID = z export const AllPlayersStatsSchema = z.record(ID, PlayerStatsSchema); export const UsernameSchema = SafeString; + +export const ClientInfoSchema = z.object({ + clientID: ID, + username: UsernameSchema, +}); +export type ClientInfo = z.infer; + +export const GameInfoSchema = z.object({ + clients: ClientInfoSchema.array().optional(), + gameConfig: GameConfigSchema.optional(), + gameID: ID, + msUntilStart: z.number().int().nonnegative().optional(), + numClients: z.number().int().nonnegative().optional(), +}); +export type GameInfo = z.infer; + const countryCodes = countries.map((c) => c.code); export const FlagSchema = z .string() diff --git a/src/core/WorkerSchemas.ts b/src/core/WorkerSchemas.ts index 0a06b1571..dbda38b10 100644 --- a/src/core/WorkerSchemas.ts +++ b/src/core/WorkerSchemas.ts @@ -1,5 +1,6 @@ +// This file contians schemas for the openfront worker express server import { z } from "zod"; -import { GameConfigSchema } from "./Schemas"; +import { GameConfigSchema, GameRecordSchema } from "./Schemas"; export const CreateGameInputSchema = GameConfigSchema.or( z @@ -9,3 +10,33 @@ export const CreateGameInputSchema = GameConfigSchema.or( ); export const GameInputSchema = GameConfigSchema.partial(); + +export const WorkerApiGameIdExistsSchema = z.object({ + exists: z.boolean(), +}); +export type WorkerApiGameIdExists = z.infer; + +export const WorkerApiArchivedGameLobbySchema = z.union([ + z.object({ + error: z.literal("Game not found"), + exists: z.literal(false), + success: z.literal(false), + }), + z.object({ + details: z.object({ + actualCommit: z.string(), + expectedCommit: z.string(), + }), + error: z.literal("Version mismatch"), + exists: z.literal(true), + success: z.literal(false), + }), + z.object({ + exists: z.literal(true), + gameRecord: GameRecordSchema, + success: z.literal(true), + }), +]); +export type WorkerApiArchivedGameLobby = z.infer< + typeof WorkerApiArchivedGameLobbySchema +>; diff --git a/src/core/configuration/ConfigLoader.ts b/src/core/configuration/ConfigLoader.ts index 184902694..7fd65c1d6 100644 --- a/src/core/configuration/ConfigLoader.ts +++ b/src/core/configuration/ConfigLoader.ts @@ -1,3 +1,4 @@ +import { ApiEnvResponseSchema } from "../ExpressSchemas"; import { UserSettings } from "../game/UserSettings"; import { GameConfig } from "../Schemas"; import { Config, GameEnv, ServerConfig } from "./Config"; @@ -36,7 +37,8 @@ export async function getServerConfigFromClient(): Promise { `Failed to fetch server config: ${response.status} ${response.statusText}`, ); } - const config = await response.json(); + const json = await response.json(); + const config = ApiEnvResponseSchema.parse(json); // Log the retrieved configuration console.log("Server config loaded:", config); diff --git a/src/core/game/FetchGameMapLoader.ts b/src/core/game/FetchGameMapLoader.ts index 8e218b706..c9692983d 100644 --- a/src/core/game/FetchGameMapLoader.ts +++ b/src/core/game/FetchGameMapLoader.ts @@ -1,5 +1,6 @@ import { GameMapType } from "./Game"; import { GameMapLoader, MapData } from "./GameMapLoader"; +import { MapManifestSchema } from "./TerrainMapLoader"; export class FetchGameMapLoader implements GameMapLoader { private maps: Map; @@ -66,6 +67,6 @@ export class FetchGameMapLoader implements GameMapLoader { throw new Error(`Failed to load ${url}: ${response.statusText}`); } - return response.json(); + return response.json().then(MapManifestSchema.parse); } } diff --git a/src/core/game/TerrainMapLoader.ts b/src/core/game/TerrainMapLoader.ts index f59799b38..588b8b98b 100644 --- a/src/core/game/TerrainMapLoader.ts +++ b/src/core/game/TerrainMapLoader.ts @@ -1,3 +1,4 @@ +import { z } from "zod"; import { GameMapType } from "./Game"; import { GameMap, GameMapImpl } from "./GameMap"; import { GameMapLoader } from "./GameMapLoader"; @@ -10,25 +11,28 @@ export type TerrainMapData = { const loadedMaps = new Map(); -export interface MapMetadata { - width: number; - height: number; - num_land_tiles: number; -} +export const MapMetadataSchema = z.object({ + height: z.number(), + num_land_tiles: z.number(), + width: z.number(), +}); +export type MapMetadata = z.infer; -export interface MapManifest { - name: string; - map: MapMetadata; - mini_map: MapMetadata; - nations: Nation[]; -} +export const NationSchema = z.object({ + coordinates: z.tuple([z.number(), z.number()]), + flag: z.string(), + name: z.string(), + strength: z.number(), +}); +export type Nation = z.infer; -export interface Nation { - coordinates: [number, number]; - flag: string; - name: string; - strength: number; -} +export const MapManifestSchema = z.object({ + map: MapMetadataSchema, + mini_map: MapMetadataSchema, + name: z.string(), + nations: NationSchema.array(), +}); +export type MapManifest = z.infer; export async function loadTerrainMap( map: GameMapType, diff --git a/src/core/game/UnitGrid.ts b/src/core/game/UnitGrid.ts index a60fadf53..f17ce3e1e 100644 --- a/src/core/game/UnitGrid.ts +++ b/src/core/game/UnitGrid.ts @@ -144,7 +144,12 @@ export class UnitGrid { searchRange, ); const rangeSquared = searchRange * searchRange; - const typeSet = Array.isArray(types) ? new Set(types) : new Set([types]); + const typeSet = new Set( + // Using typeof check instead of Array.isArray due to a typescript + // narrowing limitation. For more information, see the full issue + // discussion at https://github.com/mattpocock/ts-reset/issues/48 + typeof types === "object" ? types : [types], + ); for (let cy = startGridY; cy <= endGridY; cy++) { for (let cx = startGridX; cx <= endGridX; cx++) { for (const type of typeSet) { diff --git a/src/reset.d.ts b/src/reset.d.ts new file mode 100644 index 000000000..a3d4a031b --- /dev/null +++ b/src/reset.d.ts @@ -0,0 +1 @@ +import "@total-typescript/ts-reset"; diff --git a/src/server/Cloudflare.ts b/src/server/Cloudflare.ts index eef337974..140e868f6 100644 --- a/src/server/Cloudflare.ts +++ b/src/server/Cloudflare.ts @@ -1,6 +1,7 @@ import { spawn } from "child_process"; import { promises as fs } from "fs"; import yaml from "js-yaml"; +import { z } from "zod"; import { logger } from "./Logger"; const log = logger.child({ @@ -41,6 +42,12 @@ interface CloudflaredConfig { }>; } +const CloudflareTunnelConfigSchema = z.object({ + a: z.string(), + s: z.string(), + t: z.string(), +}); + export class Cloudflare { private baseUrl = "https://api.cloudflare.com/client/v4"; @@ -157,14 +164,12 @@ export class Cloudflare { tunnelName: string, ): Promise { log.info(`Creating local config for tunnel ${subdomain}.${domain}...`); - const tokenData = JSON.parse( - Buffer.from(tunnelToken, "base64").toString("utf8"), + const tokenData = CloudflareTunnelConfigSchema.parse( + JSON.parse(Buffer.from(tunnelToken, "base64").toString("utf8")), ); const credentials = { - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing AccountTag: tokenData.a || this.accountId, - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing TunnelID: tokenData.t || tunnelId, TunnelName: tunnelName, TunnelSecret: tokenData.s, diff --git a/src/server/Master.ts b/src/server/Master.ts index ff693c323..e5d0f6688 100644 --- a/src/server/Master.ts +++ b/src/server/Master.ts @@ -5,6 +5,10 @@ import http from "http"; import path from "path"; import { fileURLToPath } from "url"; import { getServerConfigFromServer } from "../core/configuration/ConfigLoader"; +import { + ApiEnvResponse, + ApiPublicLobbiesResponse, +} from "../core/ExpressSchemas"; import { GameInfo, ID } from "../core/Schemas"; import { generateID } from "../core/Util"; import { gatekeeper, LimiterType } from "./Gatekeeper"; @@ -59,7 +63,9 @@ app.use( }), ); -let publicLobbiesJsonStr = ""; +let publicLobbiesJsonStr = JSON.stringify({ + lobbies: [], +} satisfies ApiPublicLobbiesResponse); const publicLobbyIDs: Set = new Set(); @@ -145,8 +151,8 @@ export async function startMaster() { app.get( "/api/env", gatekeeper.httpHandler(LimiterType.Get, async (req, res) => { - const envConfig = { - game_env: process.env.GAME_ENV, + const envConfig: ApiEnvResponse = { + game_env: process.env.GAME_ENV ?? "", }; if (!envConfig.game_env) return res.sendStatus(500); res.json(envConfig); @@ -266,7 +272,7 @@ async function fetchLobbies(): Promise { // Update the JSON string publicLobbiesJsonStr = JSON.stringify({ lobbies: lobbyInfos, - }); + } satisfies ApiPublicLobbiesResponse); return publicLobbyIDs.size; } diff --git a/src/server/Worker.ts b/src/server/Worker.ts index 9f4bccfbc..a0ef684c1 100644 --- a/src/server/Worker.ts +++ b/src/server/Worker.ts @@ -10,7 +10,11 @@ import { GameEnv } from "../core/configuration/Config"; import { getServerConfigFromServer } from "../core/configuration/ConfigLoader"; import { GameType } from "../core/game/Game"; import { GameRecord, GameRecordSchema, ID } from "../core/Schemas"; -import { CreateGameInputSchema, GameInputSchema } from "../core/WorkerSchemas"; +import { + CreateGameInputSchema, + GameInputSchema, + WorkerApiGameIdExists, +} from "../core/WorkerSchemas"; import { archive, readGameRecord } from "./Archive"; import { GameManager } from "./GameManager"; import { gatekeeper, LimiterType } from "./Gatekeeper"; @@ -201,7 +205,7 @@ export async function startWorker() { const lobbyId = req.params.id; res.json({ exists: gm.game(lobbyId) !== null, - }); + } satisfies WorkerApiGameIdExists); }), ); diff --git a/tests/AutoUpgrade.test.ts b/tests/AutoUpgrade.test.ts index 126929098..2d14933eb 100644 --- a/tests/AutoUpgrade.test.ts +++ b/tests/AutoUpgrade.test.ts @@ -147,9 +147,7 @@ describe("AutoUpgrade Feature", () => { const event = new AutoUpgradeEvent(100, 200); const eventString = JSON.stringify(event); const parsedEvent = JSON.parse(eventString); - - expect(parsedEvent.x).toBe(100); - expect(parsedEvent.y).toBe(200); + expect(parsedEvent).toStrictEqual({ x: 100, y: 200 }); }); }); }); diff --git a/tests/LangCode.test.ts b/tests/LangCode.test.ts index fc4d4520b..d21695000 100644 --- a/tests/LangCode.test.ts +++ b/tests/LangCode.test.ts @@ -1,5 +1,6 @@ import fs from "fs"; import path from "path"; +import { z } from "zod"; describe("LangCode Filename Check", () => { const langDir = path.join(__dirname, "../resources/lang"); @@ -14,9 +15,17 @@ describe("LangCode Filename Check", () => { return; } + const schema = z.object({ + lang: z.object({ + lang_code: z.string(), + }), + }); + for (const file of files) { const filePath = path.join(langDir, file); - const jsonData = JSON.parse(fs.readFileSync(filePath, "utf-8")); + const jsonData = schema.parse( + JSON.parse(fs.readFileSync(filePath, "utf-8")), + ); const fileNameWithoutExt = path.basename(file, ".json"); const langCode = jsonData.lang?.lang_code; diff --git a/tests/LangSvg.test.ts b/tests/LangSvg.test.ts index 3680e8441..07b54b763 100644 --- a/tests/LangSvg.test.ts +++ b/tests/LangSvg.test.ts @@ -21,7 +21,14 @@ describe("Lang SVG Field and File Existence Check", () => { try { const filePath = path.join(langDir, file); const jsonData = JSON.parse(fs.readFileSync(filePath, "utf-8")); - const langSvg = jsonData.lang?.svg; + const langSvg = + jsonData && + typeof jsonData === "object" && + "lang" in jsonData && + jsonData.lang && + typeof jsonData.lang === "object" && + "svg" in jsonData.lang && + jsonData.lang.svg; if (typeof langSvg !== "string" || langSvg.length === 0) { errors.push( `[${file}]: lang.svg is missing or not a non-empty string`, diff --git a/tests/testdata/maps/big_plains/manifest.json b/tests/testdata/maps/big_plains/manifest.json index 3d3f9f3e2..8b2908a4a 100644 --- a/tests/testdata/maps/big_plains/manifest.json +++ b/tests/testdata/maps/big_plains/manifest.json @@ -9,5 +9,6 @@ "num_land_tiles": 10000, "width": 100 }, + "nations": [], "name": "Big Plains" } diff --git a/tests/testdata/maps/half_land_half_ocean/manifest.json b/tests/testdata/maps/half_land_half_ocean/manifest.json index d28f3fdda..ec13acbe7 100644 --- a/tests/testdata/maps/half_land_half_ocean/manifest.json +++ b/tests/testdata/maps/half_land_half_ocean/manifest.json @@ -9,5 +9,6 @@ "num_land_tiles": 48, "width": 8 }, + "nations": [], "name": "Half Land Half Ocean" } diff --git a/tests/testdata/maps/ocean_and_land/manifest.json b/tests/testdata/maps/ocean_and_land/manifest.json index c8bacdc39..7d94ba50e 100644 --- a/tests/testdata/maps/ocean_and_land/manifest.json +++ b/tests/testdata/maps/ocean_and_land/manifest.json @@ -9,5 +9,6 @@ "num_land_tiles": 50, "width": 8 }, + "nations": [], "name": "Ocean and Land" } diff --git a/tests/testdata/maps/plains/manifest.json b/tests/testdata/maps/plains/manifest.json index 3b013ee24..852de2478 100644 --- a/tests/testdata/maps/plains/manifest.json +++ b/tests/testdata/maps/plains/manifest.json @@ -9,5 +9,6 @@ "num_land_tiles": 2500, "width": 50 }, + "nations": [], "name": "Plains" } diff --git a/tests/util/Setup.ts b/tests/util/Setup.ts index 3253eb9a8..bae083f6f 100644 --- a/tests/util/Setup.ts +++ b/tests/util/Setup.ts @@ -1,5 +1,6 @@ import fs from "fs"; import path from "path"; +import { z } from "zod"; import { Difficulty, Game, @@ -12,7 +13,7 @@ import { import { createGame } from "../../src/core/game/GameImpl"; import { genTerrainFromBin, - MapManifest, + MapManifestSchema, } from "../../src/core/game/TerrainMapLoader"; import { UserSettings } from "../../src/core/game/UserSettings"; import { GameConfig } from "../../src/core/Schemas"; @@ -44,9 +45,14 @@ export async function setup( const mapBinBuffer = fs.readFileSync(mapBinPath); const miniMapBinBuffer = fs.readFileSync(miniMapBinPath); - const manifest = JSON.parse( - fs.readFileSync(manifestPath, "utf8"), - ) satisfies MapManifest; + const str = fs.readFileSync(manifestPath, "utf8"); + const raw = JSON.parse(str); + const parsed = MapManifestSchema.safeParse(raw); + if (!parsed.success) { + const error = z.prettifyError(parsed.error); + throw new Error(`Error parsing ${manifestPath}: ${error}`); + } + const manifest = parsed.data; const gameMap = await genTerrainFromBin(manifest.map, mapBinBuffer); const miniGameMap = await genTerrainFromBin( From be073330a61eeda990c49572c4a15be27c3ae756 Mon Sep 17 00:00:00 2001 From: Scott Anderson <662325+scottanderson@users.noreply.github.com> Date: Sat, 9 Aug 2025 03:20:50 -0400 Subject: [PATCH 02/24] strict mode: GameInfoSchema.parse (#1763) ## Description: Update the code for strict mode. ## 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 - [ ] I have read and accepted the CLA agreement (only required once). --- src/client/HostLobbyModal.ts | 2 ++ src/client/JoinPrivateLobbyModal.ts | 4 +++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/client/HostLobbyModal.ts b/src/client/HostLobbyModal.ts index 8b5e256e3..eedbf1849 100644 --- a/src/client/HostLobbyModal.ts +++ b/src/client/HostLobbyModal.ts @@ -18,6 +18,7 @@ import { ClientInfo, GameConfig, GameInfo, + GameInfoSchema, TeamCountConfig, } from "../core/Schemas"; import { generateID } from "../core/Util"; @@ -671,6 +672,7 @@ export class HostLobbyModal extends LitElement { }, }) .then((response) => response.json()) + .then(GameInfoSchema.parse) .then((data: GameInfo) => { console.log(`got game info response: ${JSON.stringify(data)}`); diff --git a/src/client/JoinPrivateLobbyModal.ts b/src/client/JoinPrivateLobbyModal.ts index 8efd71156..a4a4a0809 100644 --- a/src/client/JoinPrivateLobbyModal.ts +++ b/src/client/JoinPrivateLobbyModal.ts @@ -1,7 +1,7 @@ import { LitElement, html } from "lit"; import { customElement, query, state } from "lit/decorators.js"; import { translateText } from "../client/Utils"; -import { GameInfo } from "../core/Schemas"; +import { GameInfo, GameInfoSchema } from "../core/Schemas"; import { generateID } from "../core/Util"; import { WorkerApiArchivedGameLobbySchema, @@ -11,6 +11,7 @@ import { getServerConfigFromClient } from "../core/configuration/ConfigLoader"; import { JoinLobbyEvent } from "./Main"; import "./components/baseComponents/Button"; import "./components/baseComponents/Modal"; + @customElement("join-private-lobby-modal") export class JoinPrivateLobbyModal extends LitElement { @query("o-modal") private modalEl!: HTMLElement & { @@ -285,6 +286,7 @@ export class JoinPrivateLobbyModal extends LitElement { }, ) .then((response) => response.json()) + .then(GameInfoSchema.parse) .then((data: GameInfo) => { this.players = data.clients?.map((p) => p.username) ?? []; }) From ffaabd9bc115994f2d92b7d0de676627e4ea1fe3 Mon Sep 17 00:00:00 2001 From: Scott Anderson <662325+scottanderson@users.noreply.github.com> Date: Sat, 9 Aug 2025 04:08:16 -0400 Subject: [PATCH 03/24] Enable eslint warning for type assertions (#1762) ## Description: Enable eslint warning for `@typescript-eslint/consistent-type-assertions`. While build warnings are generally a discouraged pattern, we are actively working to drive the number of warnings down to zero, at which point we will convert the warnings to errors. ## 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 - [ ] I have read and accepted the CLA agreement (only required once). --- eslint.config.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/eslint.config.js b/eslint.config.js index 2984c0ac7..d0ebfde9a 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -48,6 +48,10 @@ export default [ { rules: { // Enable rules + "@typescript-eslint/consistent-type-assertions": [ + "warn", // TODO: Raise this to error, https://github.com/openfrontio/OpenFrontIO/issues/1033 + { assertionStyle: "never" }, + ], "@typescript-eslint/prefer-nullish-coalescing": "error", eqeqeq: "error", "sort-keys": "error", From f481af1da032eac7a1422da3096bb769a4e9324c Mon Sep 17 00:00:00 2001 From: VariableVince <24507472+VariableVince@users.noreply.github.com> Date: Mon, 11 Aug 2025 19:29:27 +0200 Subject: [PATCH 04/24] Pause replay or SP game when opening Settings modal (#1530) ## Description: Since the in-game Settings became their own modal in v24, it blocks game control but the game goes on in the background. On mobile it is full screen so you don't even notice the game still playing in the background. Of course players understand that a Multiplayer game will move on. But for Single Player, the game mode played by beginners too, you'd expect the game to be paused and are surprised when it isn't. Same goes for Replays. This PR fixes it: - Pause when opening the in-game Settings modal during replay or single player game. - Unpause again when closing the Settings modal (if not already paused before opening the Settings). - The icon for pause/unpause isn't switched in GameRightSideBar during this, as the sidebar is blurred in the background anyway. ## 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 - [ ] I have read and accepted the CLA agreement (only required once). ## Please put your Discord username so you can be contacted if a bug or regression is found: tryout33 --------- Co-authored-by: Drills Kibo <59177241+drillskibo@users.noreply.github.com> --- .../graphics/layers/GameRightSidebar.ts | 4 +++- src/client/graphics/layers/SettingsModal.ts | 24 +++++++++++++++++-- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/src/client/graphics/layers/GameRightSidebar.ts b/src/client/graphics/layers/GameRightSidebar.ts index fc5ffdd30..3ac7ce450 100644 --- a/src/client/graphics/layers/GameRightSidebar.ts +++ b/src/client/graphics/layers/GameRightSidebar.ts @@ -99,7 +99,9 @@ export class GameRightSidebar extends LitElement implements Layer { } private onSettingsButtonClick() { - this.eventBus.emit(new ShowSettingsModalEvent(true)); + this.eventBus.emit( + new ShowSettingsModalEvent(true, this._isSinglePlayer, this.isPaused), + ); } render() { diff --git a/src/client/graphics/layers/SettingsModal.ts b/src/client/graphics/layers/SettingsModal.ts index 54f0205b9..72d4c6cf3 100644 --- a/src/client/graphics/layers/SettingsModal.ts +++ b/src/client/graphics/layers/SettingsModal.ts @@ -1,5 +1,5 @@ import { html, LitElement } from "lit"; -import { customElement, query, state } from "lit/decorators.js"; +import { customElement, property, query, state } from "lit/decorators.js"; import structureIcon from "../../../../resources/images/CityIconWhite.svg"; import darkModeIcon from "../../../../resources/images/DarkModeIconWhite.svg"; import emojiIcon from "../../../../resources/images/EmojiIconWhite.svg"; @@ -12,11 +12,16 @@ import treeIcon from "../../../../resources/images/TreeIconWhite.svg"; import { EventBus } from "../../../core/EventBus"; import { UserSettings } from "../../../core/game/UserSettings"; import { AlternateViewEvent, RefreshGraphicsEvent } from "../../InputHandler"; +import { PauseGameEvent } from "../../Transport"; import { translateText } from "../../Utils"; import { Layer } from "./Layer"; export class ShowSettingsModalEvent { - constructor(public readonly isVisible: boolean = true) {} + constructor( + public readonly isVisible: boolean = true, + public readonly shouldPause: boolean = false, + public readonly isPaused: boolean = false, + ) {} } @customElement("settings-modal") @@ -33,9 +38,18 @@ export class SettingsModal extends LitElement implements Layer { @query(".modal-overlay") private modalOverlay!: HTMLElement; + @property({ type: Boolean }) + shouldPause = false; + + @property({ type: Boolean }) + wasPausedWhenOpened = false; + init() { this.eventBus.on(ShowSettingsModalEvent, (event) => { this.isVisible = event.isVisible; + this.shouldPause = event.shouldPause; + this.wasPausedWhenOpened = event.isPaused; + this.pauseGame(true); }); } @@ -81,6 +95,12 @@ export class SettingsModal extends LitElement implements Layer { this.isVisible = false; document.body.style.overflow = ""; this.requestUpdate(); + this.pauseGame(false); + } + + private pauseGame(pause: boolean) { + if (this.shouldPause && !this.wasPausedWhenOpened) + this.eventBus.emit(new PauseGameEvent(pause)); } private onTerrainButtonClick() { From 0f713abd856a275b4f21aa902261473c2f7bd0c6 Mon Sep 17 00:00:00 2001 From: Scott Anderson <662325+scottanderson@users.noreply.github.com> Date: Mon, 11 Aug 2025 19:09:06 -0400 Subject: [PATCH 05/24] Re-enable strict mode (#1749) ## Description: Re-enable strict mode ## 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 --- Dockerfile | 2 +- tsconfig.json | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index 364ed9b04..8aaf57b8d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -46,7 +46,7 @@ ENV NPM_CONFIG_IGNORE_SCRIPTS=1 # Copy package.json and package-lock.json COPY package*.json ./ # Install dependencies -RUN npm ci --omit=dev +RUN npm ci # Final image FROM base diff --git a/tsconfig.json b/tsconfig.json index 7c73290f3..6de45daab 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -21,7 +21,8 @@ "resolveJsonModule": true, "strictNullChecks": true, "useDefineForClassFields": false, - "strictPropertyInitialization": false + "strictPropertyInitialization": false, + "strict": true }, "include": [ "src/**/*", From ad0ad443b586ceca4ec29ae514bc4300f4c54237 Mon Sep 17 00:00:00 2001 From: Scott Anderson <662325+scottanderson@users.noreply.github.com> Date: Mon, 11 Aug 2025 20:46:53 -0400 Subject: [PATCH 06/24] Remove CLA from the PR description (#1776) ## Description: Remove CLA from the PR description template and PR check. This is handled through a dedicated status check, so the description is not needed. ## 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 --- .github/PULL_REQUEST_TEMPLATE.md | 1 - .github/workflows/pr-description.yml | 1 - 2 files changed, 2 deletions(-) diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 6eb7c411c..98b945676 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -8,7 +8,6 @@ Describe the PR. - [ ] I process any text displayed to the user through translateText() and I've added it to the en.json file - [ ] I have added relevant tests to the test directory - [ ] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced -- [ ] I have read and accepted the CLA agreement (only required once). ## Please put your Discord username so you can be contacted if a bug or regression is found: diff --git a/.github/workflows/pr-description.yml b/.github/workflows/pr-description.yml index d108aaab2..941b931df 100644 --- a/.github/workflows/pr-description.yml +++ b/.github/workflows/pr-description.yml @@ -44,7 +44,6 @@ jobs: /- \[x\] I process any text displayed to the user through translateText\(\) and I\'ve added it to the en\.json file/i, /- \[x\] I have added relevant tests to the test directory/i, /- \[x\] I confirm I have thoroughly tested these changes and take full responsibility for any bugs introduced/i, - /- \[x\] I have read and accepted the CLA agreement \(only required once\)\./i ]; for (const box of requiredBoxes) { From 0b35c13ebb93417688b9a475c40c305f1e293f0f Mon Sep 17 00:00:00 2001 From: Tamer Suliman <84022203+TLS15@users.noreply.github.com> Date: Tue, 12 Aug 2025 02:58:56 +0200 Subject: [PATCH 07/24] Make the player info overlay collapsible (#1768) ## Description: Describe the PR. This PR makes the playerInfoOverlay collapsible by clicking on the player name. This overlay was covering a decent chunk of the mobile screen. ![1754819765364](https://github.com/user-attachments/assets/a74b0853-bdaa-4c1b-b28d-a270665f2955) Now the details can be toggled by just clicking on the name freeing a lot of the screen on mobile. ![1754819765361](https://github.com/user-attachments/assets/800917b9-1a98-4aa5-b4bd-e9c35e47e9d3) ## 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 - [x] I have read and accepted the CLA agreement (only required once). ## Please put your Discord username so you can be contacted if a bug or regression is found: DISCORD_USERNAME tls_15 Co-authored-by: Drills Kibo <59177241+drillskibo@users.noreply.github.com> --- .../graphics/layers/PlayerInfoOverlay.ts | 125 ++++++++++-------- 1 file changed, 69 insertions(+), 56 deletions(-) diff --git a/src/client/graphics/layers/PlayerInfoOverlay.ts b/src/client/graphics/layers/PlayerInfoOverlay.ts index f78b1cb6a..dab035023 100644 --- a/src/client/graphics/layers/PlayerInfoOverlay.ts +++ b/src/client/graphics/layers/PlayerInfoOverlay.ts @@ -66,6 +66,8 @@ export class PlayerInfoOverlay extends LitElement implements Layer { private lastMouseUpdate = 0; + private showDetails = true; + init() { this.eventBus.on(MouseMoveEvent, (e: MouseMoveEvent) => this.onMouseEvent(e), @@ -219,10 +221,14 @@ export class PlayerInfoOverlay extends LitElement implements Layer { return html`
-
{ + this.showDetails = !this.showDetails; + this.requestUpdate?.(); + }} > ${player.cosmetics.flag ? player.cosmetics.flag!.startsWith("!") @@ -242,62 +248,69 @@ export class PlayerInfoOverlay extends LitElement implements Layer { />` : html``} ${player.name()} -
- ${player.team() !== null - ? html`
- ${translateText("player_info_overlay.team")}: ${player.team()} -
` + + + + ${this.showDetails + ? html` + ${player.team() !== null + ? html`
+ ${translateText("player_info_overlay.team")}: + ${player.team()} +
` + : ""} +
+ ${translateText("player_info_overlay.type")}: ${playerType} +
+ ${player.troops() >= 1 + ? html`
+ ${translateText("player_info_overlay.d_troops")}: + ${renderTroops(player.troops())} +
` + : ""} + ${attackingTroops >= 1 + ? html`
+ ${translateText("player_info_overlay.a_troops")}: + ${renderTroops(attackingTroops)} +
` + : ""} +
+ ${translateText("player_info_overlay.gold")}: + ${renderNumber(player.gold())} +
+ ${this.displayUnitCount( + player, + UnitType.Port, + "player_info_overlay.ports", + )} + ${this.displayUnitCount( + player, + UnitType.City, + "player_info_overlay.cities", + )} + ${this.displayUnitCount( + player, + UnitType.Factory, + "player_info_overlay.factories", + )} + ${this.displayUnitCount( + player, + UnitType.MissileSilo, + "player_info_overlay.missile_launchers", + )} + ${this.displayUnitCount( + player, + UnitType.SAMLauncher, + "player_info_overlay.sams", + )} + ${this.displayUnitCount( + player, + UnitType.Warship, + "player_info_overlay.warships", + )} + ${relationHtml} + ` : ""} -
- ${translateText("player_info_overlay.type")}: ${playerType} -
- ${player.troops() >= 1 - ? html`
- ${translateText("player_info_overlay.d_troops")}: - ${renderTroops(player.troops())} -
` - : ""} - ${attackingTroops >= 1 - ? html`
- ${translateText("player_info_overlay.a_troops")}: - ${renderTroops(attackingTroops)} -
` - : ""} -
- ${translateText("player_info_overlay.gold")}: - ${renderNumber(player.gold())} -
- ${this.displayUnitCount( - player, - UnitType.Port, - "player_info_overlay.ports", - )} - ${this.displayUnitCount( - player, - UnitType.City, - "player_info_overlay.cities", - )} - ${this.displayUnitCount( - player, - UnitType.Factory, - "player_info_overlay.factories", - )} - ${this.displayUnitCount( - player, - UnitType.MissileSilo, - "player_info_overlay.missile_launchers", - )} - ${this.displayUnitCount( - player, - UnitType.SAMLauncher, - "player_info_overlay.sams", - )} - ${this.displayUnitCount( - player, - UnitType.Warship, - "player_info_overlay.warships", - )} - ${relationHtml}
`; } From 8a41919ed79e79093a94821dc91062a7ecf07eac Mon Sep 17 00:00:00 2001 From: Cameron Clark Date: Tue, 12 Aug 2025 11:47:45 +1000 Subject: [PATCH 08/24] Private lobby toggle donation (#1752) ## Description: Resolve #1652 1. Add the ability to toggle **gold donations** and **troop donations** for private lobbies ~2. Add relevant translations.~ 3. Refactor `canDonate` to be specific to gold and troop donations 4. Add placeholders for singleplayer mode if this is to be extended to support that too. 5. Add Tests for Donate logic ### Screenshots: image _Private Lobby_ ### Smoke Tests ![donatetroopsprivatelobby](https://github.com/user-attachments/assets/c6690bbc-958e-48a1-9cf1-e2b361dfb1b2) _Testing Troop Send In Private Lobby_ ![donatetroopsprivatelobby2](https://github.com/user-attachments/assets/698c7603-6b4b-4da7-91ab-7bdc38bb49a5) _Troop Send Complete In Private Lobby_ ![testtradepublicteams](https://github.com/user-attachments/assets/1010332c-3f38-4644-9218-46aa7141f578) Confirming that public teams still works ## 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 - [X] I have read and accepted the CLA agreement (only required once). ## Please put your Discord username so you can be contacted if a bug or regression is found: DISCORD_USERNAME: cool_clarky --------- Co-authored-by: Scott Anderson <662325+scottanderson@users.noreply.github.com> Co-authored-by: Drills Kibo <59177241+drillskibo@users.noreply.github.com> --- resources/lang/en.json | 4 + src/client/HostLobbyModal.ts | 46 ++++ src/client/SinglePlayerModal.ts | 4 + src/client/graphics/layers/PlayerPanel.ts | 7 +- .../graphics/layers/RadialMenuElements.ts | 4 +- src/core/GameRunner.ts | 3 +- src/core/Schemas.ts | 2 + src/core/configuration/Config.ts | 2 + src/core/configuration/DefaultConfig.ts | 6 + src/core/execution/DonateGoldExecution.ts | 2 +- src/core/execution/DonateTroopExecution.ts | 2 +- src/core/game/Game.ts | 6 +- src/core/game/PlayerImpl.ts | 32 ++- src/server/GameManager.ts | 2 + src/server/GameServer.ts | 6 + src/server/MapPlaylist.ts | 2 + tests/Donate.test.ts | 252 ++++++++++++++++++ .../graphics/RadialMenuElements.test.ts | 3 +- tests/util/Setup.ts | 2 + 19 files changed, 375 insertions(+), 12 deletions(-) create mode 100644 tests/Donate.test.ts diff --git a/resources/lang/en.json b/resources/lang/en.json index 103f0f579..34eec63f9 100644 --- a/resources/lang/en.json +++ b/resources/lang/en.json @@ -130,7 +130,9 @@ "disable_nations": "Disable Nations", "instant_build": "Instant build", "infinite_gold": "Infinite gold", + "donate_gold": "Donate gold", "infinite_troops": "Infinite troops", + "donate_troops": "Donate troops", "disable_nukes": "Disable Nukes", "enables_title": "Enable Settings", "start": "Start Game" @@ -212,7 +214,9 @@ "disable_nations": "Disable Nations", "instant_build": "Instant build", "infinite_gold": "Infinite gold", + "donate_gold": "Donate gold", "infinite_troops": "Infinite troops", + "donate_troops": "Donate troops", "enables_title": "Enable Settings", "player": "Player", "players": "Players", diff --git a/src/client/HostLobbyModal.ts b/src/client/HostLobbyModal.ts index eedbf1849..63a73731f 100644 --- a/src/client/HostLobbyModal.ts +++ b/src/client/HostLobbyModal.ts @@ -42,7 +42,9 @@ export class HostLobbyModal extends LitElement { @state() private teamCount: TeamCountConfig = 2; @state() private bots: number = 400; @state() private infiniteGold: boolean = false; + @state() private donateGold: boolean = false; @state() private infiniteTroops: boolean = false; + @state() private donateTroops: boolean = false; @state() private instantBuild: boolean = false; @state() private lobbyId = ""; @state() private copySuccess = false; @@ -363,6 +365,38 @@ export class HostLobbyModal extends LitElement { + + + +