diff --git a/.gitignore b/.gitignore index b726203ba..57251006f 100644 --- a/.gitignore +++ b/.gitignore @@ -40,6 +40,9 @@ todo.txt *.local thumbs.db +# Auto-generated files (run `npm run generate` to recreate) +/src/main/content/game/generated/ + .eslintcache .browserslistrc .electron-vendors.cache.json diff --git a/README.md b/README.md index f3c31a5aa..619159c24 100644 --- a/README.md +++ b/README.md @@ -149,7 +149,7 @@ If you change node.js version, you **need** to re-run `npm install`. - Runs `electron-builder` to create a self-signed cert for Windows apps. - After selecting "None" in the pop-up, a cert file should be created called `BAR Team.pfx` - Then run `npm run build:win:dev-cert` to build a signed Windows installer -- `npm run generate-i18n-assets` (run when modifying source translation files) +- `npm run generate:i18n` (run when modifying source translation files) - Generates transformed asset files for translations in `src/renderer/assets/languages` ### Mutliplayer diff --git a/REUSE.toml b/REUSE.toml index aeab6e33c..9a184bb67 100644 --- a/REUSE.toml +++ b/REUSE.toml @@ -39,3 +39,10 @@ path = ["lang/**/*.json", "src/renderer/assets/languages/*.json"] precedence = "aggregate" SPDX-FileCopyrightText = "2025 The BAR Lobby Authors" SPDX-License-Identifier = "MIT" + +[[annotations]] +# JSON does not support comments so SPDX headers are declared here instead +path = ["src/main/content/game/schemas/*.json"] +precedence = "aggregate" +SPDX-FileCopyrightText = "2025 The BAR Lobby Authors" +SPDX-License-Identifier = "MIT" diff --git a/package-lock.json b/package-lock.json index 459a98b40..52ae04376 100644 --- a/package-lock.json +++ b/package-lock.json @@ -64,6 +64,7 @@ "eslint-plugin-vue": "^10.3.0", "globals": "^16.3.0", "jsdom": "^26.1.0", + "json-schema-to-typescript": "^15.0.4", "prettier": "^3.6.2", "sass": "^1.89.2", "tsx": "^4.20.3", @@ -95,6 +96,24 @@ "node": ">=6.0.0" } }, + "node_modules/@apidevtools/json-schema-ref-parser": { + "version": "11.9.3", + "resolved": "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-11.9.3.tgz", + "integrity": "sha512-60vepv88RwcJtSHrD6MjIL6Ta3SOYbgfnkHb+ppAVK+o9mXprRtulx7VlRl3lN3bbvysAfCS7WMVfhUYemB0IQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jsdevtools/ono": "^7.1.3", + "@types/json-schema": "^7.0.15", + "js-yaml": "^4.1.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/philsturgeon" + } + }, "node_modules/@asamuzakjp/css-color": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", @@ -2433,6 +2452,13 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@jsdevtools/ono": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/@jsdevtools/ono/-/ono-7.1.3.tgz", + "integrity": "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==", + "dev": true, + "license": "MIT" + }, "node_modules/@malept/cross-spawn-promise": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@malept/cross-spawn-promise/-/cross-spawn-promise-2.0.0.tgz", @@ -3378,6 +3404,13 @@ "@types/node": "*" } }, + "node_modules/@types/lodash": { + "version": "4.17.24", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.24.tgz", + "integrity": "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/luaparse": { "version": "0.2.12", "resolved": "https://registry.npmjs.org/@types/luaparse/-/luaparse-0.2.12.tgz", @@ -9348,6 +9381,30 @@ "dev": true, "license": "MIT" }, + "node_modules/json-schema-to-typescript": { + "version": "15.0.4", + "resolved": "https://registry.npmjs.org/json-schema-to-typescript/-/json-schema-to-typescript-15.0.4.tgz", + "integrity": "sha512-Su9oK8DR4xCmDsLlyvadkXzX6+GGXJpbhwoLtOGArAG61dvbW4YQmSEno2y66ahpIdmLMg6YUf/QHLgiwvkrHQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@apidevtools/json-schema-ref-parser": "^11.5.5", + "@types/json-schema": "^7.0.15", + "@types/lodash": "^4.17.7", + "is-glob": "^4.0.3", + "js-yaml": "^4.1.0", + "lodash": "^4.17.21", + "minimist": "^1.2.8", + "prettier": "^3.2.5", + "tinyglobby": "^0.2.9" + }, + "bin": { + "json2ts": "dist/src/cli.js" + }, + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/json-schema-traverse": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", diff --git a/package.json b/package.json index b0e2260a5..b2bbfed30 100644 --- a/package.json +++ b/package.json @@ -12,8 +12,11 @@ }, "main": ".vite/build/main.cjs", "scripts": { - "start": "cross-env NODE_OPTIONS=--enable-source-maps electron-forge start", - "package": "electron-forge package", + "generate:campaign": "node --import=tsx tools/generate-campaign-types.ts", + "generate:i18n": "node --import=tsx tools/generate-i18n-asset-files.ts", + "generate": "npm run generate:campaign", + "start": "npm run generate && cross-env NODE_OPTIONS=--enable-source-maps electron-forge start", + "package": "npm run generate && electron-forge package", "preview": "electron-forge package && cross-env NODE_OPTIONS=--enable-source-maps NODE_ENV=production electron .", "build:win": "electron-builder --win", "buildall:win": "npm run package && npm run build:win", @@ -32,9 +35,8 @@ "format:check": "prettier --experimental-cli --check .", "typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false", "typecheck:web": "vue-tsc --noEmit -p tsconfig.web.json --composite false", - "typecheck": "concurrently \"npm run typecheck:node\" \"npm run typecheck:web\"", - "checks": "concurrently \"npm run typecheck:web\" \"npm run typecheck:node\" \"npm run lint\" \"npm run format:check\"", - "generate-i18n-assets": "node --import=tsx tools/generate-i18n-asset-files.ts" + "typecheck": "npm run generate && concurrently \"npm run typecheck:node\" \"npm run typecheck:web\"", + "checks": "concurrently \"npm run typecheck\" \"npm run lint\" \"npm run format:check\"" }, "engines": { "node": "22.18.0" @@ -94,6 +96,7 @@ "eslint-plugin-vue": "^10.3.0", "globals": "^16.3.0", "jsdom": "^26.1.0", + "json-schema-to-typescript": "^15.0.4", "prettier": "^3.6.2", "sass": "^1.89.2", "tsx": "^4.20.3", diff --git a/src/main/config/app.ts b/src/main/config/app.ts index 570c6529a..69472b2aa 100644 --- a/src/main/config/app.ts +++ b/src/main/config/app.ts @@ -6,7 +6,7 @@ import path from "path"; import fs from "fs"; import { env } from "process"; import { app } from "electron"; -import { homedir } from "os"; +import { homedir } from "node:os"; // Should be the same as `productName` in electron-builder.ts // and in workaround in installer.nsh. @@ -113,6 +113,7 @@ export const RAPID_INDEX_PATH = path.join(ASSETS_PATH, "rapid"); // Lobby specific cache path for scenario images. Maybe remove from here? export const SCENARIO_IMAGE_PATH = path.join(STATE_PATH, "scenario-images"); +export const CAMPAIGN_IMAGE_PATH = path.join(STATE_PATH, "campaign-images"); /** * Get the path to the bundled CA certificate file for pr-downloader. diff --git a/src/main/config/default-versions.ts b/src/main/config/default-versions.ts index 9804e25c3..aaf7b78ec 100644 --- a/src/main/config/default-versions.ts +++ b/src/main/config/default-versions.ts @@ -12,4 +12,4 @@ export const LATEST = "LATEST"; export const LATEST_GAME_VERSION = "byar:test"; -export const DEFAULT_ENGINE_VERSION = "2025.04.08"; +export const DEFAULT_ENGINE_VERSION = "2026.06.03"; diff --git a/src/main/content/game/campaign-model.ts b/src/main/content/game/campaign-model.ts new file mode 100644 index 000000000..8748a8902 --- /dev/null +++ b/src/main/content/game/campaign-model.ts @@ -0,0 +1,20 @@ +// SPDX-FileCopyrightText: 2025 The BAR Lobby Authors +// +// SPDX-License-Identifier: MIT + +import type { CampaignDefinition } from "@main/content/game/generated/campaign"; +import type { MissionModel } from "@main/content/game/mission"; + +/** Difficulty settings as defined in a campaign file. */ +export type CampaignDifficulty = CampaignDefinition["difficulties"][string]; + +/** + * Campaign as returned to the lobby: extends the schema-validated {@link CampaignDefinition} + * with local-cache paths for images and runtime-populated fields. + */ +export type CampaignModel = CampaignDefinition & { + /** Whether this campaign is currently unlocked (from app state, not from the campaign file). */ + unlocked?: boolean; + /** Runtime-populated map of missionId to MissionModel. */ + missions: Record; +}; diff --git a/src/main/content/game/game-content.ts b/src/main/content/game/game-content.ts index 917acb395..8ab68b6c3 100644 --- a/src/main/content/game/game-content.ts +++ b/src/main/content/game/game-content.ts @@ -17,16 +17,50 @@ import assert from "assert"; import { contentSources } from "@main/config/content-sources"; import { DownloadInfo } from "@main/content/downloads"; import { LuaOptionSection } from "@main/content/game/lua-options"; +import Ajv, { JSONSchemaType } from "ajv"; +import type { CampaignDefinition } from "@main/content/game/generated/campaign"; +import type { CampaignModel } from "@main/content/game/campaign-model"; +import campaignSchema from "@main/content/game/schemas/campaign.schema.json"; +import { AllyTeamModel, MapOptions, MissionDifficulty, MissionModel, MissionModOptions } from "@main/content/game/mission"; import { Scenario } from "@main/content/game/scenario"; -import { SdpFileMeta, SdpFile } from "@main/content/game/sdp"; +import { SdpFile, SdpFileMeta } from "@main/content/game/sdp"; import { PrDownloaderAPI } from "@main/content/pr-downloader"; -import { RAPID_INDEX_PATH, PACKAGE_PATH, GAME_PATHS, POOL_PATH, SCENARIO_IMAGE_PATH } from "@main/config/app"; +import { CAMPAIGN_IMAGE_PATH, GAME_PATHS, PACKAGE_PATH, POOL_PATH, RAPID_INDEX_PATH, SCENARIO_IMAGE_PATH } from "@main/config/app"; import { PoolCdnDownloader } from "@main/content/game/pool-cdn"; import { fileExists } from "@main/utils/file"; const log = logger("game-content.ts"); const gunzip = util.promisify(zlib.gunzip); +const ajv = new Ajv(); + +const validateCampaignFile = ajv.compile(campaignSchema as unknown as JSONSchemaType); + +/** Raw shape of the `lobbyData` table in a mission Lua file. */ +type RawLobbyData = { + missionId?: string | number; + title?: string; + description?: string; + image?: string; + startPos?: { x: number; y: number }; + unlocked?: boolean; +}; + +/** Raw shape of the `startScript` table in a mission Lua file. */ +type RawStartScript = { + mapName?: string; + startPosType?: string; + players?: { min: number; max: number }; + difficulties?: MissionDifficulty[]; + defaultDifficulty?: string; + disableFactionPicker?: boolean; + disableInitialCommanderSpawn?: boolean; + modOptions?: MissionModOptions; + mapOptions?: MapOptions; + unitLimits?: Record; + allyTeams?: Record; +}; + export class GameContentAPI extends PrDownloaderAPI { public packageGameVersionLookup: { [md5: string]: string | undefined } = {}; public gameVersionPackageLookup: { [gameVersion: string]: string | undefined } = {}; @@ -157,46 +191,117 @@ export class GameContentAPI extends PrDownloaderAPI { return this.parseAis(luaai); } - public async getScenarios(gameVersion?: string) { + public async getCampaigns(gameVersion?: string): Promise { try { - const version = this.availableVersions.values().find((version) => version.gameVersion === gameVersion); - assert(version, `No installed version found for game version: ${gameVersion}`); + const packageMd5 = this.getPackageMd5ForGameVersion(gameVersion); + const campaignJsonFiles = await this.getGameFiles(packageMd5, "missions/campaigns/*.json", true); + await fs.promises.mkdir(CAMPAIGN_IMAGE_PATH, { recursive: true }); + + const campaigns: CampaignModel[] = []; + for (const campaignFile of campaignJsonFiles) { + try { + campaigns.push(await this.parseCampaignFile(campaignFile, packageMd5, CAMPAIGN_IMAGE_PATH)); + } catch (err) { + log.error(`Error parsing campaign ${campaignFile.fileName}: ${err}`); + } + } + if (campaigns.length > 0) { + campaigns[0].unlocked = true; // TODO: replace with state once it's ready + } + return campaigns; + } catch (err) { + log.error(`Error getting campaigns: ${err}`); + return []; + } + } + + private async parseCampaignFile(campaignFile: SdpFile, md5: string, cacheDir: string): Promise { + const campaignJson = JSON.parse(campaignFile.data.toString("utf8")) as unknown; + if (!validateCampaignFile(campaignJson)) { + throw new Error(`Invalid campaign JSON (${campaignFile.fileName}): ${ajv.errorsText(validateCampaignFile.errors)}`); + } + // raw is narrowed to Campaign by AJV's type guard — no cast needed + const campaignDirName = path.parse(this.sdpRelativePath(campaignFile)).name; + + const logo = campaignJson.logo ? await this.extractAsset(md5, `missions/campaigns/${campaignDirName}/${campaignJson.logo}`, cacheDir, campaignDirName) : undefined; + const backgroundImage = campaignJson.backgroundImage + ? await this.extractAsset(md5, `missions/campaigns/${campaignDirName}/${campaignJson.backgroundImage}`, cacheDir, campaignDirName) + : undefined; + + const missionLuaFiles = await this.getGameFiles(md5, `missions/campaigns/${campaignDirName}/*.lua`, true); + const missions: Record = {}; + + for (const missionFile of missionLuaFiles) { + const missionFolder = path.parse(this.sdpRelativePath(missionFile)).name; + try { + const mission = await this.parseMissionFile(missionFile, md5, campaignJson.campaignId, campaignDirName, cacheDir); + missions[mission.missionId] = mission; + } catch (err) { + log.error(`Error parsing mission ${missionFolder} in ${campaignDirName}: ${err}`); + } + } + + return { ...campaignJson, logo, backgroundImage, missions }; + } - const scenarioImages = await this.getGameFiles(version.packageMd5, "singleplayer/scenarios/*.{jpg,png}", false); - const scenarioDefinitions = (await this.getGameFiles(version.packageMd5, "singleplayer/scenarios/*.lua", true)).filter(({ fileName }) => /[^/]*scenario[^/]*$/.test(fileName)); - const cacheDir = SCENARIO_IMAGE_PATH; + private async parseMissionFile(missionFile: SdpFile, md5: string, campaignId: string, campaignDirName: string, cacheDir: string): Promise { + const missionFolder = path.parse(this.sdpRelativePath(missionFile)).name; + // TODO: update when mission files are json too + const lobbyData = parseLuaTable(missionFile.data, { tableVariableName: "lobbyData" }) as RawLobbyData; + const startScript = parseLuaTable(missionFile.data, { tableVariableName: "startScript" }) as RawStartScript; + + const image = lobbyData.image + ? await this.extractAsset(md5, `missions/campaigns/${campaignDirName}/${missionFolder}/${lobbyData.image}`, cacheDir, `${campaignDirName}_${missionFolder}`) + : undefined; + + return { + campaignId, + missionId: String(lobbyData.missionId ?? missionFolder), + missionScriptPath: `missions/campaigns/${campaignDirName}/${missionFolder}.lua`, + title: lobbyData.title ?? "", + description: lobbyData.description ?? "", + image, + startPos: lobbyData.startPos ?? { x: 0.25, y: 0.25 }, + unlocked: lobbyData.unlocked ?? false, + mapName: startScript.mapName ?? "", + startPosType: startScript.startPosType ?? "chooseBeforeGame", + players: startScript.players ?? { min: 1, max: 4 }, + ...(Array.isArray(startScript.difficulties) && { + difficulties: startScript.difficulties, + defaultDifficulty: startScript.defaultDifficulty ?? "", + }), + disableFactionPicker: startScript.disableFactionPicker, + disableInitialCommanderSpawn: startScript.disableInitialCommanderSpawn, + modOptions: startScript.modOptions ?? {}, + mapOptions: startScript.mapOptions ?? {}, + unitLimits: startScript.unitLimits ?? {}, + allyTeams: startScript.allyTeams ?? {}, + }; + } + + public async getScenarios(gameVersion?: string): Promise { + try { + const md5 = this.getPackageMd5ForGameVersion(gameVersion); + const scenarioImages = await this.getGameFiles(md5, "singleplayer/scenarios/*.{jpg,png}", false); + const scenarioDefinitions = (await this.getGameFiles(md5, "singleplayer/scenarios/*.lua", true)).filter(({ fileName }) => /[^/]*scenario[^/]*$/.test(fileName)); + + await fs.promises.mkdir(SCENARIO_IMAGE_PATH, { recursive: true }); - await fs.promises.mkdir(cacheDir, { recursive: true }); for (const scenarioImage of scenarioImages) { - let buffer: Buffer; - if (scenarioImage.archivePath.endsWith(".gz")) { - const data = await fs.promises.readFile(scenarioImage.archivePath); - buffer = await gunzip(data); - } else { - buffer = await fs.promises.readFile(scenarioImage.archivePath); - } + const buffer = await this.readFileDecompressed(scenarioImage.archivePath); const fileName = path.parse(scenarioImage.fileName).base; - await fs.promises.writeFile(path.join(cacheDir, fileName), buffer); + await fs.promises.writeFile(path.join(SCENARIO_IMAGE_PATH, fileName), buffer); } + const scenarios: Scenario[] = []; for (const scenarioDefinition of scenarioDefinitions) { try { - const scenario = parseLuaTable(scenarioDefinition.data) as Scenario; - if (scenario.imagepath) { - log.debug(`Imagepath: ${scenario.imagepath}`); - scenario.imagepath = path.join(cacheDir, scenario.imagepath).replaceAll("\\", "/"); - } else { - log.warn(`No imagepath for scenario: ${scenario.title}`); - } - scenario.summary = scenario.summary.replace(/\[|\]/g, ""); - scenario.briefing = scenario.briefing.replace(/\[|\]/g, ""); - scenario.allowedsides = Array.isArray(scenario.allowedsides) && scenario.allowedsides[0] !== "" ? scenario.allowedsides : ["Armada", "Cortext", "Random"]; - scenario.startscript = scenario.startscript.slice(1, -1); - scenarios.push(scenario); + scenarios.push(this.parseScenarioDefinition(scenarioDefinition, SCENARIO_IMAGE_PATH)); } catch (err) { - console.error(`error parsing scenario lua file: ${scenarioDefinition.fileName}`, err); + log.error(`Error parsing scenario lua file: ${scenarioDefinition.fileName}: ${err}`); } } + scenarios.sort((a, b) => a.index - b.index); return scenarios; } catch (err) { @@ -205,6 +310,52 @@ export class GameContentAPI extends PrDownloaderAPI { } } + private parseScenarioDefinition(scenarioDefinition: SdpFile, cacheDir: string): Scenario { + const scenario = parseLuaTable(scenarioDefinition.data) as Scenario; + if (scenario.imagepath) { + log.debug(`Imagepath: ${scenario.imagepath}`); + scenario.imagepath = path.join(cacheDir, scenario.imagepath).replaceAll("\\", "/"); + } else { + log.warn(`No imagepath for scenario: ${scenario.title}`); + } + scenario.summary = scenario.summary.replaceAll(/[[\]]/g, ""); + scenario.briefing = scenario.briefing.replaceAll(/[[\]]/g, ""); + scenario.allowedsides = Array.isArray(scenario.allowedsides) && scenario.allowedsides[0] !== "" ? scenario.allowedsides : ["Armada", "Cortext", "Random"]; + scenario.startscript = scenario.startscript.slice(1, -1); + return scenario; + } + + private getPackageMd5ForGameVersion(gameVersion: string | undefined): string { + const version = this.availableVersions.values().find((v) => v.gameVersion === gameVersion); + assert(version, `No installed version found for game version: ${gameVersion}`); + return version.packageMd5; + } + + private sdpRelativePath(file: SdpFileMeta): string { + return file.fileName.includes("/") ? file.fileName : file.archivePath; + } + + private async extractAsset(md5: string, filePath: string, cacheDir: string, prefix: string): Promise { + try { + const files = await this.getGameFiles(md5, filePath, false); + if (files.length === 0) return undefined; + const file = files[0]; + const buffer = await this.readFileDecompressed(file.archivePath); + const cacheFileName = `${prefix}_${path.basename(filePath)}`; + const cachePath = path.join(cacheDir, cacheFileName); + await fs.promises.writeFile(cachePath, buffer); + return cachePath; + } catch { + return undefined; + } + } + + /** Reads a file, decompressing it if the path ends in `.gz`. */ + private async readFileDecompressed(archivePath: string): Promise { + const data = await fs.promises.readFile(archivePath); + return archivePath.endsWith(".gz") ? gunzip(data) : data; + } + public async uninstallVersionById(gameVersion: string | undefined) { if (!gameVersion) { throw new Error("Game Version is not specified"); @@ -225,9 +376,10 @@ export class GameContentAPI extends PrDownloaderAPI { } /** - * @param filePatterns glob pattern for which files to retrieve * @example getGameFiles("Beyond All Reason test-16289-b154c3d", ["units/CorAircraft/T2/*.lua"]) - * @todo make this work for custom .sdd versions + * @param packageMd5 + * @param filePattern + * @param parseData */ protected async getGameFiles(packageMd5: string, filePattern: string, parseData?: false): Promise; protected async getGameFiles(packageMd5: string, filePattern: string, parseData?: true): Promise; @@ -269,11 +421,11 @@ export class GameContentAPI extends PrDownloaderAPI { const sdpEntries = await this.parseSdpFile(filePath, filePattern); const sdpFiles: Array = []; for (const sdpEntry of sdpEntries) { - const poolDir = sdpEntry.md5.slice(0, 2); - const archiveFileName = `${sdpEntry.md5.slice(2)}.gz`; - const archiveFilePath = path.join(POOL_PATH, poolDir, archiveFileName); - const archiveFile = await fs.promises.readFile(archiveFilePath); if (parseData) { + const poolDir = sdpEntry.md5.slice(0, 2); + const archiveFileName = `${sdpEntry.md5.slice(2)}.gz`; + const archiveFilePath = path.join(POOL_PATH, poolDir, archiveFileName); + const archiveFile = await fs.promises.readFile(archiveFilePath); const data = await gunzip(archiveFile); sdpFiles.push({ ...sdpEntry, data }); } else { diff --git a/src/main/content/game/mission.ts b/src/main/content/game/mission.ts new file mode 100644 index 000000000..8cec8a72a --- /dev/null +++ b/src/main/content/game/mission.ts @@ -0,0 +1,79 @@ +// SPDX-FileCopyrightText: 2025 The BAR Lobby Authors +// +// SPDX-License-Identifier: MIT + +export type MissionDifficulty = { + name: string; + playerhandicap: number; + enemyhandicap: number; +}; + +/** Mission-specific mod options, distinct from the multiplayer {@link ModOptions} in start-script.ts. */ +export type MissionModOptions = { + deathMode?: string; + maxunits?: number; + map_waterlevel?: number; + startenergy?: number; + startmetal?: number; + ruins?: string; +}; + +export type MapOptions = { + roads?: number; + waterlevel?: number; + waterdamage?: number; +}; + +export type TeamModel = { + /** In-game display name for this team. */ + name?: string; + Side?: string; + StartPosX?: number; + StartPosZ?: number; + IncomeMultiplier?: number; + /** AI short-name, or null/undefined when the slot is a human player. */ + ai?: string | null; +}; + +export type AllyTeamModel = { + /** Only needed when startPosType is "chooseInGame". */ + startRectTop?: number; + startRectLeft?: number; + startRectBottom?: number; + startRectRight?: number; + /** Teams keyed by arbitrary custom team name defined in mission.lua. */ + teams: Record; +}; + +// Fields sourced from LobbyData and StartScript tables in mission.lua. +// Triggers and Actions are intentionally ignored. +export type MissionModel = { + // from context / LobbyData + /** Undefined for standalone missions (e.g. future scenarios migrated to MissionModel). */ + campaignId?: string; + missionId: string; + /** Path to the mission.lua file, relative to the root of the game archive. */ + missionScriptPath: string; + title: string; + description: string; + image?: string; // cached local path + startPos: { x: number; y: number }; + unlocked: boolean; + // from StartScript + mapName: string; + startPosType: string; + players: { min: number; max: number }; + /** Overrides the campaign-level difficulties when present. */ + difficulties?: MissionDifficulty[]; + defaultDifficulty?: string; + /** Overrides the campaign-level disableFactionPicker when present. */ + disableFactionPicker?: boolean; + /** Overrides the campaign-level disableInitialCommanderSpawn when present. */ + disableInitialCommanderSpawn?: boolean; + modOptions: MissionModOptions; + mapOptions: MapOptions; + /** Unit def name → maximum allowed count. */ + unitLimits: Record; + /** Ally team name → AllyTeamModel. */ + allyTeams: Record; +}; diff --git a/src/main/content/game/schemas/campaign.schema.json b/src/main/content/game/schemas/campaign.schema.json new file mode 100644 index 000000000..c7c9b2d27 --- /dev/null +++ b/src/main/content/game/schemas/campaign.schema.json @@ -0,0 +1,98 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "campaign.schema.json", + "title": "Campaign Definition", + "description": "Schema for a BAR campaign definition file.", + "type": "object", + "required": ["campaignId", "title", "description", "players", "difficulties", "defaultDifficulty"], + "additionalProperties": false, + "definitions": { + "identifier": { + "type": "string", + "pattern": "^[A-Za-z0-9_-]+$", + "description": "A non-empty string containing only letters, digits, underscores, and hyphens." + } + }, + "properties": { + "$schema": { + "type": "string", + "description": "Path or URL to the JSON Schema for this file." + }, + "campaignId": { + "$ref": "#/definitions/identifier" + }, + "title": { + "type": "string", + "description": "Display title of the campaign." + }, + "description": { + "type": "string", + "description": "Short description shown in the campaign selection UI." + }, + "backgroundImage": { + "type": "string", + "pattern": "^[A-Za-z0-9._-]+$", + "description": "Filename of the background image used on the campaign screen." + }, + "logo": { + "type": "string", + "pattern": "^[A-Za-z0-9._-]+$", + "description": "Filename of the campaign / faction logo image." + }, + "prerequisites": { + "type": "array", + "description": "List of campaign IDs that must be completed before this one is unlocked.", + "items": { + "$ref": "#/definitions/identifier" + } + }, + "players": { + "type": "array", + "description": "Supported player counts.", + "minItems": 1, + "items": { + "type": "integer", + "minimum": 1 + } + }, + "difficulties": { + "type": "object", + "description": "Map of difficulty names to their settings.", + "minProperties": 1, + "additionalProperties": { + "type": "object", + "additionalProperties": false, + "properties": { + "playerHandicap": { + "type": "integer", + "minimum": -100, + "description": "Handicap percentage applied to players (positive for bonus, default 0)" + }, + "enemyHandicap": { + "type": "integer", + "minimum": -100, + "description": "Handicap percentage applied to enemy AI (positive for bonus, default 0)" + } + } + } + }, + "defaultDifficulty": { + "type": "string", + "description": "Name of the difficulty selected by default; must match one entry in 'difficulties'." + }, + "unlocks": { + "type": "object", + "description": "Map of mission IDs to their unlock requirements.", + "propertyNames": { + "$ref": "#/definitions/identifier" + }, + "additionalProperties": { + "type": "array", + "description": "List of mission IDs that must be completed to unlock this mission.", + "items": { + "$ref": "#/definitions/identifier" + } + } + } + } +} diff --git a/src/main/main.ts b/src/main/main.ts index 3961f3490..3700e7e9f 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -2,16 +2,8 @@ // // SPDX-License-Identifier: MIT -import { app } from "electron"; - -const gotTheLock = app.requestSingleInstanceLock(); - -if (!gotTheLock) { - app.exit(0); -} - // Only import after we know we have the lock -import { net, protocol, session } from "electron"; +import { app, net, protocol, session } from "electron"; import path from "path"; import url from "url"; import netFromNode from "node:net"; @@ -25,7 +17,7 @@ import engineService from "./services/engine.service"; import mapsService from "./services/maps.service"; import gameService from "./services/game.service"; import { logger } from "./utils/logger"; -import { APP_NAME, SCENARIO_IMAGE_PATH } from "./config/app"; +import { APP_NAME, CAMPAIGN_IMAGE_PATH, SCENARIO_IMAGE_PATH } from "./config/app"; import { shellService } from "@main/services/shell.service"; import downloadsService from "@main/services/downloads.service"; import replaysService from "@main/services/replays.service"; @@ -36,6 +28,12 @@ import { tachyonService } from "@main/services/tachyon.service"; import { typedWebContents } from "@main/typed-ipc"; import { navigationService } from "@main/services/navigation.service"; +const gotTheLock = app.requestSingleInstanceLock(); + +if (!gotTheLock) { + app.exit(0); +} + // Enable happy eyeballs for IPv6/IPv4 dual stack. netFromNode.setDefaultAutoSelectFamily(true); @@ -73,7 +71,7 @@ function registerBarFileProtocol() { const filePath = decodedUrl.slice("bar://".length); // Security Check: Ensure the file is within the content folder const resolvedFilePath = path.resolve(filePath); - const whitelistedPaths = [SCENARIO_IMAGE_PATH]; + const whitelistedPaths = [SCENARIO_IMAGE_PATH, CAMPAIGN_IMAGE_PATH]; if (!whitelistedPaths.some((p) => resolvedFilePath.startsWith(p + path.sep))) { log.error(`Attempt to access file outside whitelisted paths: ${resolvedFilePath}`); return new Response(); diff --git a/src/main/model/start-script.ts b/src/main/model/start-script.ts index 801298fe8..575e1fb8a 100644 --- a/src/main/model/start-script.ts +++ b/src/main/model/start-script.ts @@ -37,6 +37,7 @@ export interface Game { } export interface ModOptions { + [key: string]: unknown; startmetal?: number; startenergy?: number; maxunits?: number; diff --git a/src/main/services/game.service.ts b/src/main/services/game.service.ts index 98a954377..1f9b323f1 100644 --- a/src/main/services/game.service.ts +++ b/src/main/services/game.service.ts @@ -4,7 +4,7 @@ import { gameContentAPI } from "@main/content/game/game-content"; import { gameAPI, MultiplayerLaunchSettings } from "@main/game/game"; -import { ipcMain, BarIpcWebContents } from "@main/typed-ipc"; +import { BarIpcWebContents, ipcMain } from "@main/typed-ipc"; import { Replay } from "@main/content/replays/replay"; import { BattleWithMetadata } from "@main/game/battle/battle-types"; import { replayContentAPI } from "@main/content/replays/replay-content"; @@ -17,6 +17,7 @@ function registerIpcHandlers(webContents: BarIpcWebContents) { // Content ipcMain.handle("game:downloadGame", (_, version: string) => gameContentAPI.downloadGame(version)); ipcMain.handle("game:getScenarios", (_, version: string) => gameContentAPI.getScenarios(version)); + ipcMain.handle("game:getCampaigns", (_, version: string) => gameContentAPI.getCampaigns(version)); ipcMain.handle("game:getInstalledVersions", () => gameContentAPI.availableVersions.values().toArray()); ipcMain.handle("game:isVersionInstalled", (_, id: string) => gameContentAPI.isVersionInstalled(id)); ipcMain.handle("game:uninstallVersion", (_, version: string) => gameContentAPI.uninstallVersionById(version)); diff --git a/src/main/typed-ipc.ts b/src/main/typed-ipc.ts index d55475a22..891355b45 100644 --- a/src/main/typed-ipc.ts +++ b/src/main/typed-ipc.ts @@ -8,15 +8,14 @@ import type { EngineVersion } from "@main/content/engine/engine-version"; import type { GameVersion } from "@main/content/game/game-version"; import type { Info } from "@main/services/info.service"; import type { IpcMain, IpcMainEvent, IpcMainInvokeEvent, IpcRenderer, IpcRendererEvent, WebContents } from "electron"; +import { ipcMain as electronIpcMain, ipcRenderer as electronIpcRenderer } from "electron"; import type { logLevels } from "@main/services/log.service"; import type { MapData, MapDownloadData } from "@main/content/maps/map-data"; import type { MultiplayerLaunchSettings } from "@main/game/game"; import type { NewsFeedData } from "@main/services/news.service"; import type { Replay } from "@main/content/replays/replay"; -import type { Scenario } from "@main/content/game/scenario"; import type { Settings } from "@main/services/settings.service"; import type { TachyonEvent, TachyonResponse } from "tachyon-protocol"; -import { ipcRenderer as electronIpcRenderer, ipcMain as electronIpcMain } from "electron"; export type IPCEvents = { "downloads:update:progress": (downloadInfo: DownloadInfo) => void; @@ -63,7 +62,8 @@ export type IPCCommands = { "engine:uninstallVersion": (version: EngineVersion) => void; "game:downloadGame": (version: string) => void; "game:getInstalledVersions": () => GameVersion[]; - "game:getScenarios": (version: string) => Scenario[]; + "game:getCampaigns": (version: string) => import("@main/content/game/campaign-model").CampaignModel[]; + "game:getScenarios": (version: string) => import("@main/content/game/scenario").Scenario[]; "game:isVersionInstalled": (version: string) => boolean; "game:launchBattle": (battle: BattleWithMetadata) => Promise; "game:launchMultiplayer": (settings: MultiplayerLaunchSettings) => void; diff --git a/src/main/utils/mission-script-converter.ts b/src/main/utils/mission-script-converter.ts new file mode 100644 index 000000000..0ddd7a05b --- /dev/null +++ b/src/main/utils/mission-script-converter.ts @@ -0,0 +1,253 @@ +// SPDX-FileCopyrightText: 2025 The BAR Lobby Authors +// +// SPDX-License-Identifier: MIT + +import { AllyTeam, Bot, Game, Player, Team } from "@main/model/start-script"; +import { AllyTeamModel, MissionDifficulty, MissionModel, TeamModel } from "@main/content/game/mission"; +import { CampaignDifficulty, CampaignModel } from "@main/content/game/campaign-model"; +import { startScriptConverter } from "@main/utils/start-script-converter"; + +type MissionEffectiveSettings = { + difficulties: MissionDifficulty[]; + defaultDifficulty: string; + disableFactionPicker: boolean; + disableInitialCommanderSpawn: boolean; +}; + +/** + * Resolves the effective settings for a mission, falling back to campaign-level + * defaults where the mission does not override them. + * + * Accepts `undefined` for campaign to support standalone missions + * (e.g. future scenarios migrated to MissionModel) that belong to no campaign. + */ +export function missionEffectiveSettings(campaign: CampaignModel | undefined, mission: MissionModel): MissionEffectiveSettings { + const campaignDifficulties = campaign ? campaignDifficultiesToArray(campaign.difficulties) : []; + return { + difficulties: mission.difficulties ?? campaignDifficulties, + defaultDifficulty: mission.defaultDifficulty ?? campaign?.defaultDifficulty ?? "", + disableFactionPicker: mission.disableFactionPicker ?? false, + disableInitialCommanderSpawn: mission.disableInitialCommanderSpawn ?? false, + }; +} + +function campaignDifficultiesToArray(difficulties: Record): MissionDifficulty[] { + return Object.entries(difficulties).map(([name, d]) => ({ + name, + playerhandicap: d.playerHandicap ?? 0, + enemyhandicap: d.enemyHandicap ?? 0, + })); +} + +/** Mutable accumulator threaded through the ally-team and team loops in {@link missionToGame}. */ +type GameBuildContext = { + allyTeams: AllyTeam[]; + teams: Team[]; + players: Player[]; + bots: Bot[]; + allyTeamsMap: Record; + teamsMap: Record; + aisMap: Record; + playersMap: Record; + allyTeamIdx: number; + teamIdx: number; + playerIdx: number; + botIdx: number; +}; + +function humanTeamNamesForAllyTeam(allyTeam: AllyTeamModel): string[] { + return Object.entries(allyTeam.teams) + .filter(([, teamDef]) => !teamDef.ai) + .map(([teamKey, teamDef]) => teamDef.name ?? teamKey); +} + +function processTeam(ctx: GameBuildContext, atIdx: number, allyTeamHasHuman: boolean, teamKey: string, teamDef: TeamModel, playerHandicap: number, enemyHandicap: number): void { + const thisTeamIdx = ctx.teamIdx++; + ctx.teamsMap[teamKey] = thisTeamIdx; + + const isAi = !!teamDef.ai; + const handicap = allyTeamHasHuman ? playerHandicap : enemyHandicap; + + const teamEntry: Team = { + id: thisTeamIdx, + allyteam: atIdx, + teamleader: 0, + ...(teamDef.Side && { side: teamDef.Side }), + ...(teamDef.StartPosX !== undefined && { startposx: teamDef.StartPosX }), + ...(teamDef.StartPosZ !== undefined && { startposz: teamDef.StartPosZ }), + ...(teamDef.IncomeMultiplier !== undefined && { incomemultiplier: teamDef.IncomeMultiplier }), + ...(handicap !== 0 && { handicap }), + }; + ctx.teams.push(teamEntry); + + const teamName = teamDef.name ?? teamKey; + + if (isAi) { + ctx.aisMap[thisTeamIdx] = teamName; + ctx.bots.push({ + id: ctx.botIdx++, + team: thisTeamIdx, + shortname: teamDef.ai!, + name: teamName, + host: 0, + }); + } else { + ctx.playersMap[ctx.playerIdx] = teamName; + ctx.players.push({ + id: ctx.playerIdx++, + team: thisTeamIdx, + name: teamName, + }); + } +} + +function processAllyTeam(ctx: GameBuildContext, allyTeamName: string, allyTeam: AllyTeamModel, playerHandicap: number, enemyHandicap: number): void { + const atIdx = ctx.allyTeamIdx++; + ctx.allyTeamsMap[allyTeamName] = atIdx; + + const entry: AllyTeam = { id: atIdx, numallies: 0 }; + if (allyTeam.startRectLeft !== undefined) entry.startrectleft = allyTeam.startRectLeft; + if (allyTeam.startRectTop !== undefined) entry.startrecttop = allyTeam.startRectTop; + if (allyTeam.startRectRight !== undefined) entry.startrectright = allyTeam.startRectRight; + if (allyTeam.startRectBottom !== undefined) entry.startrectbottom = allyTeam.startRectBottom; + ctx.allyTeams.push(entry); + + const allyTeamHasHuman = Object.values(allyTeam.teams).some((t) => !t.ai); + + for (const [teamKey, teamDef] of Object.entries(allyTeam.teams)) { + processTeam(ctx, atIdx, allyTeamHasHuman, teamKey, teamDef, playerHandicap, enemyHandicap); + } +} + +/** + * Returns the names of all human (non-AI) team slots in the mission, in definition order. + * + * For single-player missions this is always a one-element array. + * Future co-op callers use this list to present slot selection to the player; + * the chosen name is then passed as `localPlayerTeamName` to {@link missionToScriptStr}. + */ +export function missionHumanTeamNames(mission: MissionModel): string[] { + const names: string[] = []; + for (const allyTeam of Object.values(mission.allyTeams)) { + names.push(...humanTeamNamesForAllyTeam(allyTeam)); + } + return names; +} + +/** + * Converts a {@link MissionModel} to a Spring start-script game object. + * + * All non-AI team slots become {@link Player} entries whose `name` is the + * mission-defined team name. `myplayername` is set to `localPlayerTeamName` + * (the slot the local client occupies). For single-player missions pass + * `missionHumanTeamNames(mission)[0]`; future co-op callers pass the + * server-assigned slot name. + * + * The name→index maps for allyTeams, teams, ais, and players are encoded + * into `modoptions.missionoptions` (base64 JSON) for consumption by the + * game's Lua mission API. + */ +function missionToGame( + mission: MissionModel, + difficulty: MissionDifficulty | undefined, + effectiveSettings: MissionEffectiveSettings, + localPlayerTeamName: string, + gameVersion: string, + mapNameOverride?: string +): Game { + const ctx: GameBuildContext = { + allyTeams: [], + teams: [], + players: [], + bots: [], + allyTeamsMap: {}, + teamsMap: {}, + aisMap: {}, + playersMap: {}, + allyTeamIdx: 0, + teamIdx: 0, + playerIdx: 0, + botIdx: 0, + }; + + const playerHandicap = difficulty?.playerhandicap ?? 0; + const enemyHandicap = difficulty?.enemyhandicap ?? 0; + + for (const [allyTeamName, allyTeam] of Object.entries(mission.allyTeams)) { + processAllyTeam(ctx, allyTeamName, allyTeam, playerHandicap, enemyHandicap); + } + + const missionOptions = { + missionScriptPath: mission.missionScriptPath, + difficulty: difficulty?.name ?? "", + disableFactionPicker: effectiveSettings.disableFactionPicker, + disableInitialCommanderSpawn: effectiveSettings.disableInitialCommanderSpawn, + allyTeams: ctx.allyTeamsMap, + teams: ctx.teamsMap, + ais: ctx.aisMap, + players: ctx.playersMap, + }; + + // Spring.GetModOptions() lowercases the outer modoption key ("missionoptions") but the decoded JSON retains its original casing + console.log("[mission-script-converter] missionoptions:", JSON.stringify(missionOptions, null, 2)); + + // Restrictions must be serialised as a flat indexed object so that + // stringifyScriptObj produces the [restrict] { unit0=...; limit0=...; } format. + const unitLimitEntries = Object.entries(mission.unitLimits); + const restrict = + unitLimitEntries.length > 0 + ? Object.fromEntries([ + ["numrestrictions", unitLimitEntries.length], + ...unitLimitEntries.flatMap(([unitid, limit], i) => [ + [`unit${i}`, unitid], + [`limit${i}`, limit], + ]), + ]) + : undefined; + + return { + gametype: gameVersion, + mapname: mapNameOverride ?? mission.mapName, + startpostype: startPosTypeToInt(mission.startPosType), + ishost: 1, + myplayername: localPlayerTeamName, + modoptions: { + ...mission.modOptions, + missionoptions: btoa(JSON.stringify(missionOptions)), + }, + ...(Object.keys(mission.mapOptions).length > 0 && { mapoptions: mission.mapOptions }), + ...(restrict && { restrict }), + allyTeams: ctx.allyTeams, + teams: ctx.teams, + players: ctx.players, + ais: ctx.bots, + } as unknown as Game; +} + +/** + * Builds a complete Spring start-script string for the given mission. + */ +export function missionToScriptStr( + mission: MissionModel, + difficulty: MissionDifficulty | undefined, + effectiveSettings: MissionEffectiveSettings, + localPlayerTeamName: string, + gameVersion: string, + mapNameOverride?: string +): string { + return startScriptConverter.generateScriptString(missionToGame(mission, difficulty, effectiveSettings, localPlayerTeamName, gameVersion, mapNameOverride)); +} + +function startPosTypeToInt(type: string): number { + switch (type) { + case "fixed": + return 0; + case "random": + return 1; + case "chooseInGame": + return 2; + case "chooseBeforeGame": + default: + return 3; + } +} diff --git a/src/main/utils/start-script-converter.ts b/src/main/utils/start-script-converter.ts index 9458fd87c..639c9982f 100644 --- a/src/main/utils/start-script-converter.ts +++ b/src/main/utils/start-script-converter.ts @@ -161,7 +161,7 @@ class StartScriptConverter { }; } - protected generateScriptString(script: Game): string { + public generateScriptString(script: Game): string { let scriptObj: Record = JSON.parse(JSON.stringify(script)); scriptObj = this.convertGroups(scriptObj); const scriptStr = this.stringifyScriptObj(scriptObj); diff --git a/src/preload/preload.ts b/src/preload/preload.ts index 560c35441..8fc11aa68 100644 --- a/src/preload/preload.ts +++ b/src/preload/preload.ts @@ -8,6 +8,7 @@ import { Replay } from "@main/content/replays/replay"; import { Settings } from "@main/services/settings.service"; import { EngineVersion } from "@main/content/engine/engine-version"; import { GameVersion } from "@main/content/game/game-version"; +import type { CampaignModel } from "@main/content/game/campaign-model"; import { MapData, MapDownloadData } from "@main/content/maps/map-data"; import { DownloadInfo } from "@main/content/downloads"; import { Info } from "@main/services/info.service"; @@ -99,6 +100,7 @@ const gameApi = { // Content downloadGame: (version: string): Promise => ipcRenderer.invoke("game:downloadGame", version), getScenarios: (version: string) => ipcRenderer.invoke("game:getScenarios", version), + getCampaigns: (version: string): Promise => ipcRenderer.invoke("game:getCampaigns", version), getInstalledVersions: (): Promise => ipcRenderer.invoke("game:getInstalledVersions"), isVersionInstalled: (version: string): Promise => ipcRenderer.invoke("game:isVersionInstalled", version), uninstallVersion: (version: string): Promise => ipcRenderer.invoke("game:uninstallVersion", version), diff --git a/src/renderer/components/maps/MapSimplePreview.vue b/src/renderer/components/maps/MapSimplePreview.vue index e58f4ffd6..388a3416d 100644 --- a/src/renderer/components/maps/MapSimplePreview.vue +++ b/src/renderer/components/maps/MapSimplePreview.vue @@ -12,6 +12,7 @@ SPDX-License-Identifier: MIT diff --git a/src/renderer/views/play/campaign/[campaignId]-[missionId].vue b/src/renderer/views/play/campaign/[campaignId]-[missionId].vue new file mode 100644 index 000000000..0c4ced75a --- /dev/null +++ b/src/renderer/views/play/campaign/[campaignId]-[missionId].vue @@ -0,0 +1,128 @@ + + + +{ + path: "/play/campaign/:campaignId/:missionId", + props: true, + meta: { title: "Mission Details", hide: true, transition: { name: "slide-left" } }, +} + + + + + + + diff --git a/src/renderer/views/play/campaign/[campaignId].vue b/src/renderer/views/play/campaign/[campaignId].vue new file mode 100644 index 000000000..6458676b8 --- /dev/null +++ b/src/renderer/views/play/campaign/[campaignId].vue @@ -0,0 +1,99 @@ + + + +{ props: true, meta: { title: "Campaign Details", hide: true, transition: { name: "slide-left" } } } + + + + + + + diff --git a/src/renderer/views/play/campaign/campaign.vue b/src/renderer/views/play/campaign/campaign.vue new file mode 100644 index 000000000..f717d43a7 --- /dev/null +++ b/src/renderer/views/play/campaign/campaign.vue @@ -0,0 +1,104 @@ + + + +{ meta: { title: "Campaign", order: 2, devOnly: true, transition: { name: "slide-left" } } } + + + + + + + diff --git a/src/renderer/views/play/menu.vue b/src/renderer/views/play/menu.vue index 4868ee688..133cdc699 100644 --- a/src/renderer/views/play/menu.vue +++ b/src/renderer/views/play/menu.vue @@ -92,7 +92,7 @@ const startSkirmish = () => { const startCampaign = () => { if (settingsStore.devMode) { - router.push("/play/campaign"); + router.push("/play/campaign/campaign"); } }; diff --git a/tests/unit/main/mission-script-converter.spec.ts b/tests/unit/main/mission-script-converter.spec.ts new file mode 100644 index 000000000..49d25dc81 --- /dev/null +++ b/tests/unit/main/mission-script-converter.spec.ts @@ -0,0 +1,449 @@ +// SPDX-FileCopyrightText: 2026 The BAR Lobby Authors +// +// SPDX-License-Identifier: MIT + +import { describe, expect, it } from "vitest"; +import { missionEffectiveSettings, missionHumanTeamNames, missionToScriptStr } from "@main/utils/mission-script-converter"; +import type { MissionModel } from "@main/content/game/mission"; +import type { CampaignModel } from "@main/content/game/campaign-model"; + +// ─── Constants ──────────────────────────────────────────────────────────────── + +const BASE_MISSION_ID = "m1"; +const BASE_MISSION_SCRIPT_PATH = "missions/m1.lua"; +const BASE_MISSION_TITLE = "Test Mission"; +const BASE_MISSION_DESCRIPTION = "A test"; +const BASE_MAP_NAME = "TestMap_v1"; + +const BASE_CAMPAIGN_ID = "c1"; +const BASE_CAMPAIGN_TITLE = "Test Campaign"; +const BASE_CAMPAIGN_DESCRIPTION = "A test campaign"; + +const CAMPAIGN_DIFFICULTY_EASY = "Easy"; +const CAMPAIGN_DIFFICULTY_HARD = "Hard"; +const MISSION_DIFFICULTY = "Custom"; + +const CAMPAIGN_HANDICAP_EASY_PLAYER = 10; +const CAMPAIGN_HANDICAP_EASY_ENEMY = -10; +const CAMPAIGN_HANDICAP_HARD_ENEMY = 20; +const MISSION_HANDICAP = 5; +const MISSION_PLAYER_HANDICAP = 15; +const MISSION_ENEMY_HANDICAP = 25; + +const TEAM_PLAYER = "Player"; +const TEAM_PLAYER1 = "Player1"; +const TEAM_PLAYER2 = "Player2"; +const TEAM_ENEMY = "Enemy"; +const TEAM_ENEMY_BOT = "EnemyBot"; + +const AI_BARB = "BARb"; + +const MAP_NAME_OVERRIDE = "OverrideMap_v2"; + +const UNIT_ARMCOM = "armcom"; +const UNIT_CORCOM = "corcom"; +const UNIT_ARMCOM_LIMIT = 1; +const UNIT_CORCOM_LIMIT = 0; + +const MAP_WATER_LEVEL = 100; + +const START_RECT_LEFT = 0; +const START_RECT_TOP = 0; +const START_RECT_RIGHT = 0.5; +const START_RECT_BOTTOM = 1; + +const MOD_DEATH_MODE_VALUE = "killall"; + +const GAME_VERSION = "Beyond All Reason test-2026-06-03-stable"; + +// ─── Fixtures ──────────────────────────────────────────────────────────────── + +function baseMission(overrides: Partial = {}): MissionModel { + return { + missionId: BASE_MISSION_ID, + missionScriptPath: BASE_MISSION_SCRIPT_PATH, + title: BASE_MISSION_TITLE, + description: BASE_MISSION_DESCRIPTION, + startPos: { x: 0, y: 0 }, + unlocked: true, + mapName: BASE_MAP_NAME, + startPosType: "fixed", + players: { min: 1, max: 1 }, + modOptions: {}, + mapOptions: {}, + unitLimits: {}, + allyTeams: { + Ally0: { + teams: { + [TEAM_PLAYER]: { name: TEAM_PLAYER }, + }, + }, + }, + ...overrides, + }; +} + +function baseCampaign(overrides: Partial = {}): CampaignModel { + return { + campaignId: BASE_CAMPAIGN_ID, + title: BASE_CAMPAIGN_TITLE, + description: BASE_CAMPAIGN_DESCRIPTION, + players: [1], + difficulties: { + [CAMPAIGN_DIFFICULTY_EASY]: { playerHandicap: CAMPAIGN_HANDICAP_EASY_PLAYER, enemyHandicap: CAMPAIGN_HANDICAP_EASY_ENEMY }, + [CAMPAIGN_DIFFICULTY_HARD]: { playerHandicap: 0, enemyHandicap: CAMPAIGN_HANDICAP_HARD_ENEMY }, + }, + defaultDifficulty: CAMPAIGN_DIFFICULTY_EASY, + missions: {}, + ...overrides, + }; +} + +function noopEffectiveSettings() { + return { + difficulties: [], + defaultDifficulty: "", + disableFactionPicker: false, + disableInitialCommanderSpawn: false, + }; +} + +// ─── missionEffectiveSettings ───────────────────────────────────────────────── + +describe("missionEffectiveSettings", () => { + it("uses mission-level values when the mission provides them, overriding campaign defaults", () => { + const campaign = baseCampaign(); + const mission = baseMission({ + difficulties: [{ name: MISSION_DIFFICULTY, playerhandicap: MISSION_HANDICAP, enemyhandicap: MISSION_HANDICAP }], + defaultDifficulty: MISSION_DIFFICULTY, + disableFactionPicker: true, + disableInitialCommanderSpawn: true, + }); + + const settings = missionEffectiveSettings(campaign, mission); + + expect(settings.difficulties).toEqual([{ name: MISSION_DIFFICULTY, playerhandicap: MISSION_HANDICAP, enemyhandicap: MISSION_HANDICAP }]); + expect(settings.defaultDifficulty).toBe(MISSION_DIFFICULTY); + expect(settings.disableFactionPicker).toBe(true); + expect(settings.disableInitialCommanderSpawn).toBe(true); + }); + + it("falls back to campaign values and safe defaults when the mission does not provide them", () => { + // Campaign difficulties with a missing handicap field should default to 0. + const campaign = baseCampaign(); + + // Mission without any overrides. + const settingsWithCampaign = missionEffectiveSettings(campaign, baseMission()); + expect(settingsWithCampaign.difficulties).toEqual([ + { name: CAMPAIGN_DIFFICULTY_EASY, playerhandicap: CAMPAIGN_HANDICAP_EASY_PLAYER, enemyhandicap: CAMPAIGN_HANDICAP_EASY_ENEMY }, + { name: CAMPAIGN_DIFFICULTY_HARD, playerhandicap: 0, enemyhandicap: CAMPAIGN_HANDICAP_HARD_ENEMY }, + ]); + expect(settingsWithCampaign.defaultDifficulty).toBe(CAMPAIGN_DIFFICULTY_EASY); + expect(settingsWithCampaign.disableFactionPicker).toBe(false); + expect(settingsWithCampaign.disableInitialCommanderSpawn).toBe(false); + + // Standalone mission (no campaign) falls back to empty difficulties and empty string. + const settingsNoCampaign = missionEffectiveSettings(undefined, baseMission()); + expect(settingsNoCampaign.difficulties).toEqual([]); + expect(settingsNoCampaign.defaultDifficulty).toBe(""); + expect(settingsNoCampaign.disableFactionPicker).toBe(false); + expect(settingsNoCampaign.disableInitialCommanderSpawn).toBe(false); + }); +}); + +// ─── missionHumanTeamNames ──────────────────────────────────────────────────── + +describe("missionHumanTeamNames", () => { + it("returns the human team name for a single-player mission with an AI team", () => { + const mission = baseMission(); + expect(missionHumanTeamNames(mission)).toEqual([TEAM_PLAYER]); + }); + + it("uses the teamKey as the name when no explicit name is set", () => { + const mission = baseMission({ + allyTeams: { + Ally0: { + teams: { + HumanTeam: {}, + }, + }, + }, + }); + + expect(missionHumanTeamNames(mission)).toEqual(["HumanTeam"]); + }); + + it("excludes AI teams", () => { + const mission = baseMission({ + allyTeams: { + Ally0: { + teams: { + [TEAM_PLAYER]: { name: TEAM_PLAYER }, + [TEAM_ENEMY]: { name: TEAM_ENEMY, ai: AI_BARB }, + }, + }, + }, + }); + + expect(missionHumanTeamNames(mission)).toEqual([TEAM_PLAYER]); + }); + + it("collects human teams from multiple ally teams in definition order", () => { + const mission = baseMission({ + allyTeams: { + Ally0: { + teams: { + [TEAM_PLAYER1]: { name: TEAM_PLAYER1 }, + }, + }, + Ally1: { + teams: { + [TEAM_PLAYER2]: { name: TEAM_PLAYER2 }, + }, + }, + }, + }); + + expect(missionHumanTeamNames(mission)).toEqual([TEAM_PLAYER1, TEAM_PLAYER2]); + }); + + it("returns an empty array when all teams are AI", () => { + const mission = baseMission({ + allyTeams: { + Ally0: { + teams: { + [TEAM_ENEMY]: { ai: AI_BARB }, + }, + }, + }, + }); + + expect(missionHumanTeamNames(mission)).toEqual([]); + }); + + it("returns an empty array when there are no ally teams", () => { + const mission = baseMission({ allyTeams: {} }); + expect(missionHumanTeamNames(mission)).toEqual([]); + }); +}); + +// ─── missionToScriptStr ─────────────────────────────────────────────────────── + +describe("missionToScriptStr", () => { + it("produces a script string wrapped in [game] { ... }", () => { + const script = missionToScriptStr(baseMission(), undefined, noopEffectiveSettings(), TEAM_PLAYER, GAME_VERSION); + expect(script).toMatch(/^\[game\] \{/); + expect(script).toMatch(/\}$/); + }); + + it("includes the correct gametype and mapname", () => { + const script = missionToScriptStr(baseMission(), undefined, noopEffectiveSettings(), TEAM_PLAYER, GAME_VERSION); + expect(script).toContain(`gametype=${GAME_VERSION}`); + expect(script).toContain(`mapname=${BASE_MAP_NAME}`); + }); + + it("uses mapNameOverride when provided", () => { + const script = missionToScriptStr(baseMission(), undefined, noopEffectiveSettings(), TEAM_PLAYER, GAME_VERSION, MAP_NAME_OVERRIDE); + expect(script).toContain(`mapname=${MAP_NAME_OVERRIDE}`); + expect(script).not.toContain(`mapname=${BASE_MAP_NAME}`); + }); + + it("sets myplayername to the given localPlayerTeamName", () => { + const script = missionToScriptStr(baseMission(), undefined, noopEffectiveSettings(), TEAM_PLAYER, GAME_VERSION); + expect(script).toContain(`myplayername=${TEAM_PLAYER}`); + }); + + it("sets ishost=1", () => { + const script = missionToScriptStr(baseMission(), undefined, noopEffectiveSettings(), TEAM_PLAYER, GAME_VERSION); + expect(script).toContain("ishost=1"); + }); + + it("maps startPosType 'fixed' to startpostype=0", () => { + const script = missionToScriptStr(baseMission({ startPosType: "fixed" }), undefined, noopEffectiveSettings(), TEAM_PLAYER, GAME_VERSION); + expect(script).toContain("startpostype=0"); + }); + + it("maps startPosType 'random' to startpostype=1", () => { + const script = missionToScriptStr(baseMission({ startPosType: "random" }), undefined, noopEffectiveSettings(), TEAM_PLAYER, GAME_VERSION); + expect(script).toContain("startpostype=1"); + }); + + it("maps startPosType 'chooseInGame' to startpostype=2", () => { + const script = missionToScriptStr(baseMission({ startPosType: "chooseInGame" }), undefined, noopEffectiveSettings(), TEAM_PLAYER, GAME_VERSION); + expect(script).toContain("startpostype=2"); + }); + + it("maps startPosType 'chooseBeforeGame' to startpostype=3", () => { + const script = missionToScriptStr(baseMission({ startPosType: "chooseBeforeGame" }), undefined, noopEffectiveSettings(), TEAM_PLAYER, GAME_VERSION); + expect(script).toContain("startpostype=3"); + }); + + it("includes a [player0] section with the human team name", () => { + const script = missionToScriptStr(baseMission(), undefined, noopEffectiveSettings(), TEAM_PLAYER, GAME_VERSION); + expect(script).toContain("[player0]"); + expect(script).toContain(`name=${TEAM_PLAYER}`); + }); + + it("includes an [allyteam0] section", () => { + const script = missionToScriptStr(baseMission(), undefined, noopEffectiveSettings(), TEAM_PLAYER, GAME_VERSION); + expect(script).toContain("[allyteam0]"); + }); + + it("includes an AI bot in [ai0] with the correct shortname", () => { + const mission = baseMission({ + allyTeams: { + Enemy: { + teams: { + [TEAM_ENEMY_BOT]: { ai: AI_BARB, name: TEAM_ENEMY_BOT }, + }, + }, + }, + }); + + const script = missionToScriptStr(mission, undefined, noopEffectiveSettings(), TEAM_PLAYER, GAME_VERSION); + expect(script).toContain("[ai0]"); + expect(script).toContain(`shortname=${AI_BARB}`); + }); + + it("encodes missionoptions as base64 JSON in [modoptions]", () => { + const script = missionToScriptStr(baseMission(), undefined, noopEffectiveSettings(), TEAM_PLAYER, GAME_VERSION); + expect(script).toContain("[modoptions]"); + + const match = script.match(/missionoptions=([^;]+);/); + expect(match).not.toBeNull(); + + const decoded = JSON.parse(atob(match![1])); + expect(decoded.missionScriptPath).toBe(BASE_MISSION_SCRIPT_PATH); + }); + + it("includes difficulty name in encoded missionoptions", () => { + const difficulty = { name: CAMPAIGN_DIFFICULTY_HARD, playerhandicap: 0, enemyhandicap: CAMPAIGN_HANDICAP_HARD_ENEMY }; + const script = missionToScriptStr(baseMission(), difficulty, noopEffectiveSettings(), TEAM_PLAYER, GAME_VERSION); + + const match = script.match(/missionoptions=([^;]+);/); + const decoded = JSON.parse(atob(match![1])); + expect(decoded.difficulty).toBe(CAMPAIGN_DIFFICULTY_HARD); + }); + + it("encodes allyTeam, team, ai, and player index maps inside missionoptions", () => { + const mission = baseMission({ + allyTeams: { + Ally0: { + teams: { + [TEAM_PLAYER]: { name: TEAM_PLAYER }, + [TEAM_ENEMY]: { name: TEAM_ENEMY, ai: AI_BARB }, + }, + }, + }, + }); + + const script = missionToScriptStr(mission, undefined, noopEffectiveSettings(), TEAM_PLAYER, GAME_VERSION); + const match = script.match(/missionoptions=([^;]+);/); + const decoded = JSON.parse(atob(match![1])); + + expect(decoded.allyTeams).toEqual({ Ally0: 0 }); + expect(decoded.teams).toMatchObject({ [TEAM_PLAYER]: 0, [TEAM_ENEMY]: 1 }); + expect(decoded.players).toMatchObject({ "0": TEAM_PLAYER }); + expect(decoded.ais).toMatchObject({ "1": TEAM_ENEMY }); + }); + + it("applies playerHandicap to human-side team", () => { + const mission = baseMission({ + allyTeams: { + Ally0: { + teams: { + [TEAM_PLAYER]: { name: TEAM_PLAYER }, + }, + }, + }, + }); + const difficulty = { name: CAMPAIGN_DIFFICULTY_EASY, playerhandicap: MISSION_PLAYER_HANDICAP, enemyhandicap: 0 }; + + const script = missionToScriptStr(mission, difficulty, noopEffectiveSettings(), TEAM_PLAYER, GAME_VERSION); + expect(script).toContain(`handicap=${MISSION_PLAYER_HANDICAP}`); + }); + + it("applies enemyHandicap to pure-AI ally team", () => { + const mission = baseMission({ + allyTeams: { + Ally0: { + teams: { + [TEAM_PLAYER]: { name: TEAM_PLAYER }, + }, + }, + EnemyAlly: { + teams: { + [TEAM_ENEMY]: { name: TEAM_ENEMY, ai: AI_BARB }, + }, + }, + }, + }); + const difficulty = { name: CAMPAIGN_DIFFICULTY_HARD, playerhandicap: 0, enemyhandicap: MISSION_ENEMY_HANDICAP }; + + const script = missionToScriptStr(mission, difficulty, noopEffectiveSettings(), TEAM_PLAYER, GAME_VERSION); + expect(script).toContain(`handicap=${MISSION_ENEMY_HANDICAP}`); + }); + + it("does not emit handicap when it is zero", () => { + const difficulty = { name: CAMPAIGN_DIFFICULTY_HARD, playerhandicap: 0, enemyhandicap: 0 }; + const script = missionToScriptStr(baseMission(), difficulty, noopEffectiveSettings(), TEAM_PLAYER, GAME_VERSION); + expect(script).not.toContain("handicap="); + }); + + it("emits a [restrict] block when unitLimits are set", () => { + const mission = baseMission({ unitLimits: { [UNIT_ARMCOM]: UNIT_ARMCOM_LIMIT, [UNIT_CORCOM]: UNIT_CORCOM_LIMIT } }); + const script = missionToScriptStr(mission, undefined, noopEffectiveSettings(), TEAM_PLAYER, GAME_VERSION); + + expect(script).toContain("[restrict]"); + expect(script).toContain("numrestrictions=2"); + expect(script).toContain(`unit0=${UNIT_ARMCOM}`); + expect(script).toContain(`limit0=${UNIT_ARMCOM_LIMIT}`); + expect(script).toContain(`unit1=${UNIT_CORCOM}`); + expect(script).toContain(`limit1=${UNIT_CORCOM_LIMIT}`); + }); + + it("does not emit a [restrict] block when unitLimits are empty", () => { + const script = missionToScriptStr(baseMission(), undefined, noopEffectiveSettings(), TEAM_PLAYER, GAME_VERSION); + expect(script).not.toContain("[restrict]"); + }); + + it("emits a [mapoptions] block when mapOptions are present", () => { + const mission = baseMission({ mapOptions: { waterlevel: MAP_WATER_LEVEL } }); + const script = missionToScriptStr(mission, undefined, noopEffectiveSettings(), TEAM_PLAYER, GAME_VERSION); + expect(script).toContain("[mapoptions]"); + expect(script).toContain(`waterlevel=${MAP_WATER_LEVEL}`); + }); + + it("does not emit a [mapoptions] block when mapOptions are empty", () => { + const script = missionToScriptStr(baseMission(), undefined, noopEffectiveSettings(), TEAM_PLAYER, GAME_VERSION); + expect(script).not.toContain("[mapoptions]"); + }); + + it("includes extra modOptions keys alongside missionoptions", () => { + const mission = baseMission({ modOptions: { deathMode: MOD_DEATH_MODE_VALUE } }); + const script = missionToScriptStr(mission, undefined, noopEffectiveSettings(), TEAM_PLAYER, GAME_VERSION); + expect(script).toContain(`deathMode=${MOD_DEATH_MODE_VALUE}`); + }); + + it("includes start-rect coordinates when set on an ally team", () => { + const mission = baseMission({ + allyTeams: { + Ally0: { + startRectLeft: START_RECT_LEFT, + startRectTop: START_RECT_TOP, + startRectRight: START_RECT_RIGHT, + startRectBottom: START_RECT_BOTTOM, + teams: { + [TEAM_PLAYER]: { name: TEAM_PLAYER }, + }, + }, + }, + }); + + const script = missionToScriptStr(mission, undefined, noopEffectiveSettings(), TEAM_PLAYER, GAME_VERSION); + expect(script).toContain(`startrectleft=${START_RECT_LEFT}`); + expect(script).toContain(`startrecttop=${START_RECT_TOP}`); + expect(script).toContain(`startrectright=${START_RECT_RIGHT}`); + expect(script).toContain(`startrectbottom=${START_RECT_BOTTOM}`); + }); +}); diff --git a/tools/generate-campaign-types.ts b/tools/generate-campaign-types.ts new file mode 100644 index 000000000..da9917e55 --- /dev/null +++ b/tools/generate-campaign-types.ts @@ -0,0 +1,39 @@ +// SPDX-FileCopyrightText: 2025 The BAR Lobby Authors +// +// SPDX-License-Identifier: MIT +/** + * Generates src/main/content/game/generated/campaign.ts from the campaign JSON Schema. + * + * Usage: node --import=tsx tools/generate-campaign-types.ts + * + * The output file is auto-generated - do not edit it by hand. + */ +import { compile } from "json-schema-to-typescript"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const SCHEMA_PATH = path.resolve(__dirname, "../src/main/content/game/schemas/campaign.schema.json"); +const OUTPUT_PATH = path.resolve(__dirname, "../src/main/content/game/generated/campaign.ts"); + +const BANNER = `// THIS FILE IS AUTO-GENERATED from schemas/campaign.schema.json. +// Run \`npm run generate:campaign\` (or \`npm start\`) to regenerate. +// Do not edit this file manually.`; +async function main() { + const schemaText = await fs.promises.readFile(SCHEMA_PATH, "utf8"); + const schema = JSON.parse(schemaText) as object; + const generated = await compile(schema, "", { + bannerComment: BANNER, + additionalProperties: false, + unknownAny: false, + strictIndexSignatures: false, + }); + await fs.promises.mkdir(path.dirname(OUTPUT_PATH), { recursive: true }); + await fs.promises.writeFile(OUTPUT_PATH, generated, "utf8"); + console.log("Generated " + path.relative(process.cwd(), OUTPUT_PATH)); +} +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/tsconfig.node.json b/tsconfig.node.json index c7a4df4dd..0a579d2a0 100644 --- a/tsconfig.node.json +++ b/tsconfig.node.json @@ -5,6 +5,7 @@ "electron-builder.config.*", "src/main/**/*", "src/preload/**/*", + "tools/**/*", "vendor/**/*", "tests/**/main/**/*.ts", "tests/**/shared/**/*.ts", diff --git a/vite.main.config.mts b/vite.main.config.mts index 481657bd7..5c979b9b1 100644 --- a/vite.main.config.mts +++ b/vite.main.config.mts @@ -3,6 +3,11 @@ import path from "path"; // https://vitejs.dev/config export default defineConfig({ + server: { + watch: { + ignored: ["**/state/**", "**/assets/**"], + }, + }, resolve: { alias: { "@main": path.join(__dirname, "src/main"), diff --git a/vite.renderer.config.mts b/vite.renderer.config.mts index 9060e75f6..87e8f18b3 100644 --- a/vite.renderer.config.mts +++ b/vite.renderer.config.mts @@ -6,6 +6,11 @@ import vueDevTools from "vite-plugin-vue-devtools"; // https://vitejs.dev/config export default defineConfig({ + server: { + watch: { + ignored: ["**/state/**", "**/assets/**"], + }, + }, resolve: { alias: { "@main": path.join(__dirname, "src/main"),