From 0590b4d213aa29c5c6f5bfb50753a2dfa838b12a Mon Sep 17 00:00:00 2001 From: Einar Andersson <72999+drdator@users.noreply.github.com> Date: Wed, 5 Aug 2026 15:43:02 +0200 Subject: [PATCH] Add Maker game-level export target --- src/release-package-dialog.ts | 39 ++++++++++++++++++++++++++++ tests/release-package-dialog.test.ts | 9 ++++++- tests/release-package.test.ts | 4 ++- 3 files changed, 50 insertions(+), 2 deletions(-) diff --git a/src/release-package-dialog.ts b/src/release-package-dialog.ts index 8e0c16f..de5e459 100644 --- a/src/release-package-dialog.ts +++ b/src/release-package-dialog.ts @@ -27,6 +27,7 @@ export interface ReleasePackageDialogOptions { } interface StoredRelease { + target: ReleaseTarget; metadata: ArenaMetadata; readme: string; license: string; @@ -34,10 +35,13 @@ interface StoredRelease { includeSource: boolean; } +export type ReleaseTarget = 'map' | 'game'; + let releaseDialogRequest = 0; function defaults(mapName: string): StoredRelease { return { + target: 'map', metadata: { title: mapName, gameTypes: ['ffa'], botSupport: true, recommendedPlayers: '', author: '', description: '', @@ -49,6 +53,12 @@ function defaults(mapName: string): StoredRelease { }; } +export function gameReleaseBuildIssue(target: ReleaseTarget, build: BuildRecord | null): string | null { + if (target !== 'game' || !build) return null; + if (!build.aas) return 'Maker game levels require a successful AAS bot-navigation stage. Recompile with Generate AAS enabled.'; + return null; +} + function readStored(editor: Editor, mapName: string): StoredRelease { const source = editor.worldspawn.properties[RELEASE_METADATA_KEY]; if (!source) return defaults(mapName); @@ -253,6 +263,17 @@ export async function openReleasePackageDialog(options: ReleasePackageDialogOpti const filesSection = document.createElement('section'); filesSection.innerHTML = '

Release files

'; + const targetInput = document.createElement('select'); + for (const [value, label] of [ + ['map', 'Quake 3 map package'], + ['game', 'Maker game level (BSP + AAS + source)'], + ] as const) { + const option = document.createElement('option'); + option.value = value; + option.textContent = label; + targetInput.appendChild(option); + } + targetInput.value = stored.target; const readmeInput = textarea(stored.readme); const licenseInput = textarea(stored.license); const attributionInput = textarea(stored.attribution); @@ -262,6 +283,7 @@ export async function openReleasePackageDialog(options: ReleasePackageDialogOpti const fileGrid = document.createElement('div'); fileGrid.className = 'release-fields'; fileGrid.append( + field('Export target', targetInput), field('README', readmeInput), field('License', licenseInput), field('Attribution', attributionInput), @@ -298,7 +320,22 @@ export async function openReleasePackageDialog(options: ReleasePackageDialogOpti ); body.append(metadataSection, levelshotSection, filesSection, auditSection, packageSection); + const applyTargetDefaults = () => { + const gameTarget = targetInput.value === 'game'; + if (gameTarget) { + includeSource.checked = true; + botInput.checked = true; + } + includeSource.disabled = gameTarget; + const issue = gameReleaseBuildIssue(targetInput.value as ReleaseTarget, build); + packageStatus.textContent = issue ?? (build ? 'Ready to validate and build.' : 'A compiled BSP is required.'); + packageStatus.className = issue ? 'error' : ''; + }; + targetInput.onchange = applyTargetDefaults; + applyTargetDefaults(); + const collectStored = (): StoredRelease => ({ + target: targetInput.value as ReleaseTarget, metadata: { title: titleInput.value.trim(), gameTypes: gameTypesInput.value.split(/[\s,]+/).filter(Boolean), @@ -323,6 +360,8 @@ export async function openReleasePackageDialog(options: ReleasePackageDialogOpti if (!build?.bsp) return null; saveMetadata(); try { + const gameBuildIssue = gameReleaseBuildIssue(targetInput.value as ReleaseTarget, build); + if (gameBuildIssue) throw new Error(gameBuildIssue); const activeTextureManager = editor.textureManager ?? textureManager; const packageResult = buildReleasePackage({ mapName, diff --git a/tests/release-package-dialog.test.ts b/tests/release-package-dialog.test.ts index 4801af2..9acb63a 100644 --- a/tests/release-package-dialog.test.ts +++ b/tests/release-package-dialog.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; import { buildSourceFingerprint, type BuildRecord } from '../src/build-history'; -import { selectReleaseBuild } from '../src/release-package-dialog'; +import { gameReleaseBuildIssue, selectReleaseBuild } from '../src/release-package-dialog'; function build(documentRevision: number, source?: string, region = false): BuildRecord { return { @@ -36,4 +36,11 @@ describe('release package build selection', () => { expect(selectReleaseBuild([build(2, source)], 3, buildSourceFingerprint(`${source}// changed`))).toBeNull(); expect(selectReleaseBuild([build(3, source, true)], 3, buildSourceFingerprint(source))).toBeNull(); }); + + it('requires AAS for Maker game-level exports', () => { + const withoutAas = build(2); + expect(gameReleaseBuildIssue('map', withoutAas)).toBeNull(); + expect(gameReleaseBuildIssue('game', withoutAas)).toMatch(/require.*AAS/i); + expect(gameReleaseBuildIssue('game', { ...withoutAas, aas: new Uint8Array([1]) })).toBeNull(); + }); }); diff --git a/tests/release-package.test.ts b/tests/release-package.test.ts index 474d775..1d48318 100644 --- a/tests/release-package.test.ts +++ b/tests/release-package.test.ts @@ -48,7 +48,7 @@ describe('release packaging', () => { mapName: 'test_map', bsp: strToU8('bsp'), aas: strToU8('aas'), entities: [world], assets, textures: textureAdapter(assets), metadata: { title: 'Test Arena', gameTypes: ['ffa', 'team'], botSupport: true, recommendedPlayers: '2-4', author: 'Mapper', description: 'Test' }, - levelshot: strToU8('png'), files: { readme: 'read me' }, + levelshot: strToU8('png'), files: { readme: 'read me' }, includeSourceMap: '{\n"classname" "worldspawn"\n}\n', }; const first = buildReleasePackage(input); const second = buildReleasePackage(input); @@ -56,6 +56,8 @@ describe('release packaging', () => { const files = unzipSync(first.pk3); expect(Object.keys(files)).toEqual([...Object.keys(files)].sort((a, b) => a.localeCompare(b))); expect(files['maps/test_map.bsp']).toBeTruthy(); + expect(files['maps/test_map.aas']).toBeTruthy(); + expect(files['maps/test_map.map']).toBeTruthy(); expect(files['scripts/test_map.arena']).toBeTruthy(); expect(files['textures/custom/wall.tga']).toBeTruthy(); expect(first.report.archiveValidation.valid).toBe(true);