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(