From c396f9636eaec13477ca0795fab1038fb28f6697 Mon Sep 17 00:00:00 2001 From: Robert Burnham Date: Tue, 5 May 2026 09:38:59 -0500 Subject: [PATCH 1/9] Allow polygon and spline startboxes alongside legacy rectangles --- schemas/map_list.yaml | 87 ++++++++++++++++++++++------ scripts/js/src/check_startboxes.ts | 26 +++++++-- scripts/js/src/gen_map_boxes_conf.ts | 19 +++++- scripts/js/src/gen_teiserver_maps.ts | 33 ++++++++++- 4 files changed, 140 insertions(+), 25 deletions(-) diff --git a/schemas/map_list.yaml b/schemas/map_list.yaml index 75198e0..d840caa 100644 --- a/schemas/map_list.yaml +++ b/schemas/map_list.yaml @@ -144,26 +144,18 @@ $defs: type: object title: Startbox properties: + # Two shapes are accepted, discriminated by length: + # - 2 points → axis-aligned rectangle (top-left, bottom-right) + # - 3+ points → closed polygon ring; each point may carry an + # optional `strength` field for Catmull-Rom spline + # tessellation (game-side / lobby-side feature). + # Downstream consumers that only understand rectangles + # (TEIServer, SPADS) compute a bounding-box rectangle from the + # polygon vertices in their respective generators. poly: - type: array - minItems: 2 - maxItems: 2 - items: - title: Point - type: object - properties: - x: - type: integer - minimum: 0 - maximum: 200 - y: - type: integer - minimum: 0 - maximum: 200 - additionalProperties: false - required: - - x - - y + oneOf: + - $ref: "#/$defs/startboxRect" + - $ref: "#/$defs/startboxPolygon" additionalProperties: false required: - poly @@ -175,6 +167,63 @@ $defs: required: - startboxes - maxPlayersPerStartbox + startboxRect: + title: StartboxRect + description: > + Legacy 2-point rectangle: top-left and bottom-right corners in the + 0–200 normalized coordinate space. The format every existing map ships + today; remains the only shape the engine, SPADS and TEIServer protocols + understand directly. + type: array + minItems: 2 + maxItems: 2 + items: + title: RectCorner + type: object + properties: + x: + type: integer + minimum: 0 + maximum: 200 + y: + type: integer + minimum: 0 + maximum: 200 + additionalProperties: false + required: + - x + - y + startboxPolygon: + title: StartboxPolygon + description: > + Closed polygon ring of 3 or more anchor points in the 0–200 normalized + coordinate space. Each point may optionally carry a Catmull-Rom spline + strength in [0, 1] — 0 (or omitted) is a sharp polygon corner, 1 is a + full smooth curve. The Rowy editor snaps strength values to multiples + of 0.025; the schema enforces only the range so other tooling can write + arbitrary precision if needed. + type: array + minItems: 3 + items: + title: PolygonPoint + type: object + properties: + x: + type: integer + minimum: 0 + maximum: 200 + y: + type: integer + minimum: 0 + maximum: 200 + strength: + type: number + minimum: 0 + maximum: 1 + additionalProperties: false + required: + - x + - y uploadedFile: title: UploadedFile type: object diff --git a/scripts/js/src/check_startboxes.ts b/scripts/js/src/check_startboxes.ts index dff8748..9dd48c6 100644 --- a/scripts/js/src/check_startboxes.ts +++ b/scripts/js/src/check_startboxes.ts @@ -19,10 +19,28 @@ for (const map of Object.values(maps)) { players.add(startboxes.length); for (const startbox of startboxes) { - const [a, b] = startbox.poly; - if (a.x >= b.x || a.y >= b.y) { - console.error(`Map ${map.springName} has a startbox for players ${startboxes.length} with invalid coordinates: ${JSON.stringify(startbox)}`); - error = true; + const poly = startbox.poly; + if (poly.length === 2) { + // Legacy 2-point rectangle: top-left and bottom-right corners. + const [a, b] = poly; + if (a.x >= b.x || a.y >= b.y) { + console.error(`Map ${map.springName} has a startbox for players ${startboxes.length} with invalid rectangle coordinates: ${JSON.stringify(startbox)}`); + error = true; + } + } else { + // N-point polygon: require non-degenerate area. Self-intersection + // and concavity are not checked here — both are valid shapes for + // game-side containment, which uses ray-casting. + let area2 = 0; + for (let i = 0; i < poly.length; i++) { + const a = poly[i]; + const b = poly[(i + 1) % poly.length]; + area2 += a.x * b.y - b.x * a.y; + } + if (Math.abs(area2) < 1) { + console.error(`Map ${map.springName} has a degenerate polygon startbox for players ${startboxes.length}: ${JSON.stringify(startbox)}`); + error = true; + } } } diff --git a/scripts/js/src/gen_map_boxes_conf.ts b/scripts/js/src/gen_map_boxes_conf.ts index 66e7b47..695b24d 100644 --- a/scripts/js/src/gen_map_boxes_conf.ts +++ b/scripts/js/src/gen_map_boxes_conf.ts @@ -38,9 +38,26 @@ const HEADER = `# #?mapName:nbTeams|boxes `; +// SPADS only understands rectangles in mapBoxes.conf (the format is +// "x1 y1 x2 y2" per startbox). Collapse N-point polygons to their +// bounding-box rectangle so the conf file stays valid; the polygon shape +// is preserved in the map archive's mapconfig/map_startboxes.lua and +// consumed game-side. +function polyToRectCorners(poly: Startbox['poly']): { x: number; y: number }[] { + if (poly.length === 2) return poly; + let xmin = Infinity, ymin = Infinity, xmax = -Infinity, ymax = -Infinity; + for (const p of poly) { + if (p.x < xmin) xmin = p.x; + if (p.x > xmax) xmax = p.x; + if (p.y < ymin) ymin = p.y; + if (p.y > ymax) ymax = p.y; + } + return [{ x: xmin, y: ymin }, { x: xmax, y: ymax }]; +} + function serializeStartboxes(startboxes: Startbox[]): string { return startboxes - .map(s => s.poly.map(p => `${p.x} ${p.y}`).join(' ')) + .map(s => polyToRectCorners(s.poly).map(p => `${p.x} ${p.y}`).join(' ')) .join(';'); } diff --git a/scripts/js/src/gen_teiserver_maps.ts b/scripts/js/src/gen_teiserver_maps.ts index 7d3c6a8..88a90a3 100644 --- a/scripts/js/src/gen_teiserver_maps.ts +++ b/scripts/js/src/gen_teiserver_maps.ts @@ -7,6 +7,37 @@ import type { TeiserverMaps, } from "../../../gen/types/teiserver_maps.js"; import { MapModoptions } from '../../../gen/types/map_modoptions.js'; +import type { StartboxesInfo } from '../../../gen/types/map_list.js'; + +// TEIServer's data model and Tachyon protocol only carry axis-aligned +// rectangles ({top, bottom, left, right} in [0,1] coords). When a map ships +// an N-point polygon (or a Catmull-Rom spline) startbox, collapse it to its +// bounding-box rectangle here so TEIServer keeps validating without a +// schema change on its side. The polygon shape is preserved in the map +// archive's mapconfig/map_startboxes.lua and consumed game-side. +function rectifyStartboxes(set: StartboxesInfo[]): StartboxesInfo[] { + return set.map(info => ({ + ...info, + // The cast covers two unrelated narrowing issues: + // 1) json2ts renders `minItems: 1` as a non-empty tuple + // `[Startbox, ...Startbox[]]`, but `.map()` returns plain `Startbox[]`. + // 2) json2ts renders `minItems: 2 / maxItems: 2` as a tuple too, and the + // array literal `[{x,y},{x,y}]` widens to `{x,y}[]` rather than the + // tuple shape, so the `oneOf` rect branch wouldn't match without help. + // The runtime shape is correct in both cases; we just bypass the inference. + startboxes: info.startboxes.map(box => { + if (box.poly.length === 2) return box; + let xmin = Infinity, ymin = Infinity, xmax = -Infinity, ymax = -Infinity; + for (const p of box.poly) { + if (p.x < xmin) xmin = p.x; + if (p.x > xmax) xmax = p.x; + if (p.y < ymin) ymin = p.y; + if (p.y > ymax) ymax = p.y; + } + return { poly: [{ x: xmin, y: ymin }, { x: xmax, y: ymax }] }; + }) as StartboxesInfo['startboxes'] + })); +} const imagorUrlBase = 'https://maps-metadata.beyondallreason.dev/i/'; const rowyBucket = 'rowy-1f075.appspot.com'; @@ -39,7 +70,7 @@ async function genTeiserverMaps(): Promise { springName: map.springName, displayName: map.displayName, thumbnail: `${imagorUrlBase}fit-in/640x640/filters:format(webp):quality(85)/${rowyBucket}/${encodeURI(map.photo[0].ref)}`, - startboxesSet: Object.values(map.startboxesSet || {}), + startboxesSet: rectifyStartboxes(Object.values(map.startboxesSet || {})), matchmakingQueues, modoptions: mapModoptions[map.springName] }); From 99f7778eeb4c17401cf85018b1e9bea3c3c43594 Mon Sep 17 00:00:00 2001 From: Robert Burnham Date: Wed, 17 Jun 2026 13:30:32 -0500 Subject: [PATCH 2/9] Generate mapmetadata_startboxes_set modoption per map --- scripts/js/src/gen_map_modoptions.ts | 55 +++++++++++++++++++++------- 1 file changed, 41 insertions(+), 14 deletions(-) diff --git a/scripts/js/src/gen_map_modoptions.ts b/scripts/js/src/gen_map_modoptions.ts index 9c1f671..2837767 100644 --- a/scripts/js/src/gen_map_modoptions.ts +++ b/scripts/js/src/gen_map_modoptions.ts @@ -4,25 +4,52 @@ import zlib from 'node:zlib'; import { program } from '@commander-js/extra-typings'; import stringify from "json-stable-stringify"; import { MapModoptions } from '../../../gen/types/map_modoptions.js'; -import { StartPosConf } from '../../../gen/types/map_list.js'; +import { StartPosConf, StartboxesInfo } from '../../../gen/types/map_list.js'; -function encodeStartPos(startPos: StartPosConf) { - const str = stringify(startPos); - const compressed = zlib.deflateSync(str); - const encoded = compressed.toString('base64url').replace(/=+$/, ''); - return encoded; +// JSON -> zlib deflate -> base64url with padding stripped. Matches the transport +// the game decoder expects and the map_modoptions value pattern +// (^[a-zA-Z0-9_.-]+$), which forbids '=' padding. +function encodeModoptionValue(value: unknown): string { + const compressed = zlib.deflateSync(stringify(value)); + return compressed.toString('base64url').replace(/=+$/, ''); +} + +function encodeStartPos(startPos: StartPosConf): string { + return encodeModoptionValue(startPos); +} + +// The game resolves a startboxes arrangement by team count (set[tostring(numTeams)]). +// maps-metadata keys startboxesSet by document id, so re-key by the arrangement's +// team count here. check_startboxes.ts guarantees team counts are unique within a +// set, so no key collides. +function encodeStartboxesSet(set: Record): string { + const byTeamCount: Record = {}; + for (const info of Object.values(set)) { + byTeamCount[String(info.startboxes.length)] = info; + } + return encodeModoptionValue(byTeamCount); } async function genLiveMaps(): Promise { const maps = await readMapList(); - const mapModoptions: MapModoptions[] = Object.values(maps) - .filter(m => m.startPos && m.startPosActive) - .map(m => ({ - springName: m.springName, - modoptions: { - mapmetadata_startpos: encodeStartPos(m.startPos!) - } - })); + const mapModoptions: MapModoptions[] = []; + + for (const m of Object.values(maps)) { + const modoptions: Record = {}; + + if (m.startPos && m.startPosActive) { + modoptions.mapmetadata_startpos = encodeStartPos(m.startPos); + } + + if (m.startboxesSet && Object.keys(m.startboxesSet).length > 0) { + modoptions.mapmetadata_startboxes_set = encodeStartboxesSet(m.startboxesSet); + } + + if (Object.keys(modoptions).length > 0) { + mapModoptions.push({ springName: m.springName, modoptions }); + } + } + mapModoptions.sort((a, b) => a.springName.localeCompare(b.springName)); return stringify(mapModoptions); } From fcf4a8213cea6283fa7745add445429bb5e2b8c3 Mon Sep 17 00:00:00 2001 From: Robert Burnham Date: Wed, 17 Jun 2026 13:45:31 -0500 Subject: [PATCH 3/9] Fix stale startbox comments in rect generators --- scripts/js/src/gen_map_boxes_conf.ts | 6 +++--- scripts/js/src/gen_teiserver_maps.ts | 6 ++++-- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/scripts/js/src/gen_map_boxes_conf.ts b/scripts/js/src/gen_map_boxes_conf.ts index 695b24d..bcc3ae4 100644 --- a/scripts/js/src/gen_map_boxes_conf.ts +++ b/scripts/js/src/gen_map_boxes_conf.ts @@ -40,9 +40,9 @@ const HEADER = `# // SPADS only understands rectangles in mapBoxes.conf (the format is // "x1 y1 x2 y2" per startbox). Collapse N-point polygons to their -// bounding-box rectangle so the conf file stays valid; the polygon shape -// is preserved in the map archive's mapconfig/map_startboxes.lua and -// consumed game-side. +// bounding-box rectangle so the conf file stays valid; the full polygon +// shape is preserved in the mapmetadata_startboxes_set modoption (see +// gen_map_modoptions.ts) and decoded game-side. function polyToRectCorners(poly: Startbox['poly']): { x: number; y: number }[] { if (poly.length === 2) return poly; let xmin = Infinity, ymin = Infinity, xmax = -Infinity, ymax = -Infinity; diff --git a/scripts/js/src/gen_teiserver_maps.ts b/scripts/js/src/gen_teiserver_maps.ts index 88a90a3..daedf73 100644 --- a/scripts/js/src/gen_teiserver_maps.ts +++ b/scripts/js/src/gen_teiserver_maps.ts @@ -13,8 +13,10 @@ import type { StartboxesInfo } from '../../../gen/types/map_list.js'; // rectangles ({top, bottom, left, right} in [0,1] coords). When a map ships // an N-point polygon (or a Catmull-Rom spline) startbox, collapse it to its // bounding-box rectangle here so TEIServer keeps validating without a -// schema change on its side. The polygon shape is preserved in the map -// archive's mapconfig/map_startboxes.lua and consumed game-side. +// schema change on its side. The full polygon shape is preserved in the +// mapmetadata_startboxes_set modoption (see gen_map_modoptions.ts) and +// decoded game-side; this rectified copy is only the rect-only view for +// TEIServer/Tachyon. function rectifyStartboxes(set: StartboxesInfo[]): StartboxesInfo[] { return set.map(info => ({ ...info, From dd340619d9d37728241067436b76a3d6bf5abc26 Mon Sep 17 00:00:00 2001 From: Robert Burnham Date: Fri, 19 Jun 2026 00:30:08 -0500 Subject: [PATCH 4/9] Deploy lobby_maps.validated.json to Chobby --- .github/workflows/ci.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index aadeef5..96466a4 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -88,6 +88,7 @@ jobs: tsx scripts/js/src/update_byar_chobby_images.ts BYAR-Chobby cp gen/mapDetails.lua BYAR-Chobby/LuaMenu/configs/gameConfig/byar/mapDetails.lua cp gen/mapBoxes.conf BYAR-Chobby/LuaMenu/configs/gameConfig/byar/savedBoxes.dat + cp gen/lobby_maps.validated.json BYAR-Chobby/LuaMenu/configs/gameConfig/byar/lobby_maps.validated.json - name: Commit and push uses: stefanzweifel/git-auto-commit-action@v5 with: From 93df56eac3dba7c5b8b77c93d350f3be8162b6eb Mon Sep 17 00:00:00 2001 From: Robert Burnham Date: Mon, 22 Jun 2026 01:31:49 -0400 Subject: [PATCH 5/9] Deliver startboxes set to Chobby via mapDetails modoption field --- .github/workflows/ci.yaml | 1 - Makefile | 2 +- scripts/js/src/gen_map_details_lua.ts | 25 +++++++++++++++++++++---- 3 files changed, 22 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 96466a4..aadeef5 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -88,7 +88,6 @@ jobs: tsx scripts/js/src/update_byar_chobby_images.ts BYAR-Chobby cp gen/mapDetails.lua BYAR-Chobby/LuaMenu/configs/gameConfig/byar/mapDetails.lua cp gen/mapBoxes.conf BYAR-Chobby/LuaMenu/configs/gameConfig/byar/savedBoxes.dat - cp gen/lobby_maps.validated.json BYAR-Chobby/LuaMenu/configs/gameConfig/byar/lobby_maps.validated.json - name: Commit and push uses: stefanzweifel/git-auto-commit-action@v5 with: diff --git a/Makefile b/Makefile index 2613799..5539a0e 100644 --- a/Makefile +++ b/Makefile @@ -17,7 +17,7 @@ gen/schemas/teiserver_maps.json: gen/schemas/map_modoptions.json gen/schemas/map gen/schemas/lobby_maps.json: gen/schemas/map_list.json # Output targets -gen/mapDetails.lua: gen/map_list.validated.json gen/types/map_list.d.ts +gen/mapDetails.lua: gen/map_list.validated.json gen/types/map_list.d.ts gen/map_modoptions.validated.json gen/types/map_modoptions.d.ts tsx scripts/js/src/gen_map_details_lua.ts $@ gen/cdn_maps.json: gen/map_list.validated.json gen/types/map_list.d.ts diff --git a/scripts/js/src/gen_map_details_lua.ts b/scripts/js/src/gen_map_details_lua.ts index 2ef38ee..f9f6d8d 100644 --- a/scripts/js/src/gen_map_details_lua.ts +++ b/scripts/js/src/gen_map_details_lua.ts @@ -4,6 +4,7 @@ import { fetchMapsMetadata, readMapList } from './maps_metadata.js'; import fs from 'node:fs/promises'; import { program } from '@commander-js/extra-typings'; import { MapList } from '../../../gen/types/map_list.js'; +import { MapModoptions } from '../../../gen/types/map_modoptions.js'; export interface MapDetails { [k: string]: { @@ -23,9 +24,22 @@ export interface MapDetails { Author?: string; InfoText?: string; LastUpdate: number; + // base64url(zlib(json)) encoded mapmetadata_startboxes_set modoption + // value (see gen_map_modoptions.ts). Chobby injects it as-is and + // decodes it for the polygon startbox preview; absent for maps with + // no startbox set. + StartboxesSet?: string; }; } +type ModoptionsBySpringName = { [springName: string]: MapModoptions['modoptions'] }; + +async function readMapModoptions(): Promise { + const contents = await fs.readFile('gen/map_modoptions.validated.json', { encoding: 'utf8' }); + const mapModoptions = JSON.parse(contents) as MapModoptions[]; + return Object.fromEntries(mapModoptions.map(m => [m.springName, m.modoptions])); +} + function intersection(a: Iterable, b: Iterable): T[] { const setB = b instanceof Set ? b as Set : new Set(b); const intersection = []; @@ -37,7 +51,7 @@ function intersection(a: Iterable, b: Iterable): T[] { return intersection; } -function buildMapDetails(maps: MapList, mapsMetadata: Map): MapDetails { +function buildMapDetails(maps: MapList, mapsMetadata: Map, modoptions: ModoptionsBySpringName): MapDetails { const mapDetails: MapDetails = {}; for (const id of Object.keys(maps)) { const mapInfo = maps[id]; @@ -62,6 +76,7 @@ function buildMapDetails(maps: MapList, mapsMetadata: Map): MapDeta Author: mapInfo.author != 'UNKNOWN' ? mapInfo.author : undefined, InfoText: mapInfo.description, LastUpdate: Math.round(mapInfo.photo[0].lastModifiedTS / 1000), + StartboxesSet: modoptions[mapInfo.springName]?.mapmetadata_startboxes_set, } } return mapDetails; @@ -84,7 +99,8 @@ function serializeMapDetails(mapDetails: MapDetails): string { 'TeamCount', 'Author', 'InfoText', - 'LastUpdate' + 'LastUpdate', + 'StartboxesSet' ]; function escapeLuaString(str: string): string { @@ -120,7 +136,8 @@ function serializeMapDetails(mapDetails: MapDetails): string { } else { value = `'${escapeLuaString(details[field].toString())}'`; } - if (field !== 'Author' || value !== 'nil') { + const omitWhenNil = field === 'Author' || field === 'StartboxesSet'; + if (!omitWhenNil || value !== 'nil') { fields.push(`${field}=${value}`); } } @@ -139,4 +156,4 @@ const maps = await readMapList(); await fs.writeFile(mapDetailsPath, serializeMapDetails( - buildMapDetails(maps, await fetchMapsMetadata(maps)))); + buildMapDetails(maps, await fetchMapsMetadata(maps), await readMapModoptions()))); From d7bce9604afb12236d8e96a77f3491362832f7da Mon Sep 17 00:00:00 2001 From: Robert Burnham Date: Mon, 22 Jun 2026 01:31:50 -0400 Subject: [PATCH 6/9] Share startbox bounding-box helper across rect generators --- scripts/js/src/gen_map_boxes_conf.ts | 20 ++----------- scripts/js/src/gen_teiserver_maps.ts | 43 +++++++++------------------- scripts/js/src/startbox_utils.ts | 19 ++++++++++++ 3 files changed, 35 insertions(+), 47 deletions(-) create mode 100644 scripts/js/src/startbox_utils.ts diff --git a/scripts/js/src/gen_map_boxes_conf.ts b/scripts/js/src/gen_map_boxes_conf.ts index bcc3ae4..d006e5a 100644 --- a/scripts/js/src/gen_map_boxes_conf.ts +++ b/scripts/js/src/gen_map_boxes_conf.ts @@ -4,6 +4,7 @@ import { readMapList } from './maps_metadata.js'; import fs from 'node:fs/promises'; import { program } from '@commander-js/extra-typings'; import { MapList, Startbox } from '../../../gen/types/map_list.js'; +import { polyBoundingRect } from './startbox_utils.js'; const HEADER = `# # AUTOMATICALLY GENERATED FILE, DO NOT EDIT! @@ -38,26 +39,9 @@ const HEADER = `# #?mapName:nbTeams|boxes `; -// SPADS only understands rectangles in mapBoxes.conf (the format is -// "x1 y1 x2 y2" per startbox). Collapse N-point polygons to their -// bounding-box rectangle so the conf file stays valid; the full polygon -// shape is preserved in the mapmetadata_startboxes_set modoption (see -// gen_map_modoptions.ts) and decoded game-side. -function polyToRectCorners(poly: Startbox['poly']): { x: number; y: number }[] { - if (poly.length === 2) return poly; - let xmin = Infinity, ymin = Infinity, xmax = -Infinity, ymax = -Infinity; - for (const p of poly) { - if (p.x < xmin) xmin = p.x; - if (p.x > xmax) xmax = p.x; - if (p.y < ymin) ymin = p.y; - if (p.y > ymax) ymax = p.y; - } - return [{ x: xmin, y: ymin }, { x: xmax, y: ymax }]; -} - function serializeStartboxes(startboxes: Startbox[]): string { return startboxes - .map(s => polyToRectCorners(s.poly).map(p => `${p.x} ${p.y}`).join(' ')) + .map(s => polyBoundingRect(s.poly).map(p => `${p.x} ${p.y}`).join(' ')) .join(';'); } diff --git a/scripts/js/src/gen_teiserver_maps.ts b/scripts/js/src/gen_teiserver_maps.ts index daedf73..03a99a9 100644 --- a/scripts/js/src/gen_teiserver_maps.ts +++ b/scripts/js/src/gen_teiserver_maps.ts @@ -8,37 +8,22 @@ import type { } from "../../../gen/types/teiserver_maps.js"; import { MapModoptions } from '../../../gen/types/map_modoptions.js'; import type { StartboxesInfo } from '../../../gen/types/map_list.js'; +import { polyBoundingRect } from './startbox_utils.js'; -// TEIServer's data model and Tachyon protocol only carry axis-aligned -// rectangles ({top, bottom, left, right} in [0,1] coords). When a map ships -// an N-point polygon (or a Catmull-Rom spline) startbox, collapse it to its -// bounding-box rectangle here so TEIServer keeps validating without a -// schema change on its side. The full polygon shape is preserved in the -// mapmetadata_startboxes_set modoption (see gen_map_modoptions.ts) and -// decoded game-side; this rectified copy is only the rect-only view for -// TEIServer/Tachyon. +// TEIServer/Tachyon only carry axis-aligned rectangles, so collapse polygon +// startboxes to their bounding box (see polyBoundingRect). Rebuilt via +// [first, ...rest] so the result stays a non-empty tuple (minItems: 1). function rectifyStartboxes(set: StartboxesInfo[]): StartboxesInfo[] { - return set.map(info => ({ - ...info, - // The cast covers two unrelated narrowing issues: - // 1) json2ts renders `minItems: 1` as a non-empty tuple - // `[Startbox, ...Startbox[]]`, but `.map()` returns plain `Startbox[]`. - // 2) json2ts renders `minItems: 2 / maxItems: 2` as a tuple too, and the - // array literal `[{x,y},{x,y}]` widens to `{x,y}[]` rather than the - // tuple shape, so the `oneOf` rect branch wouldn't match without help. - // The runtime shape is correct in both cases; we just bypass the inference. - startboxes: info.startboxes.map(box => { - if (box.poly.length === 2) return box; - let xmin = Infinity, ymin = Infinity, xmax = -Infinity, ymax = -Infinity; - for (const p of box.poly) { - if (p.x < xmin) xmin = p.x; - if (p.x > xmax) xmax = p.x; - if (p.y < ymin) ymin = p.y; - if (p.y > ymax) ymax = p.y; - } - return { poly: [{ x: xmin, y: ymin }, { x: xmax, y: ymax }] }; - }) as StartboxesInfo['startboxes'] - })); + return set.map(info => { + const [first, ...rest] = info.startboxes; + return { + ...info, + startboxes: [ + { poly: polyBoundingRect(first.poly) }, + ...rest.map(box => ({ poly: polyBoundingRect(box.poly) })), + ], + }; + }); } const imagorUrlBase = 'https://maps-metadata.beyondallreason.dev/i/'; diff --git a/scripts/js/src/startbox_utils.ts b/scripts/js/src/startbox_utils.ts new file mode 100644 index 0000000..400aed3 --- /dev/null +++ b/scripts/js/src/startbox_utils.ts @@ -0,0 +1,19 @@ +import type { Startbox } from '../../../gen/types/map_list.js'; + +type RectCorner = { x: number; y: number }; + +// SPADS (mapBoxes.conf) and TEIServer/Tachyon carry only axis-aligned +// rectangles, so collapse an N-point polygon to its bounding box. The full +// polygon shape rides in the mapmetadata_startboxes_set modoption (see +// gen_map_modoptions.ts) and is decoded game-side. Legacy 2-point rectangles +// are already min/max ordered, so the bounding box is the rectangle itself. +export function polyBoundingRect(poly: Startbox['poly']): [RectCorner, RectCorner] { + let xMin = Infinity, yMin = Infinity, xMax = -Infinity, yMax = -Infinity; + for (const p of poly) { + xMin = Math.min(xMin, p.x); + yMin = Math.min(yMin, p.y); + xMax = Math.max(xMax, p.x); + yMax = Math.max(yMax, p.y); + } + return [{ x: xMin, y: yMin }, { x: xMax, y: yMax }]; +} From 91c32e30d31c4739b2f0dcd1bdd806390da909fd Mon Sep 17 00:00:00 2001 From: Robert Burnham Date: Tue, 23 Jun 2026 00:02:14 -0400 Subject: [PATCH 7/9] ASCII-clean and tighten startbox comments --- schemas/map_list.yaml | 27 ++++++++++----------------- scripts/js/src/check_startboxes.ts | 7 ++++--- scripts/js/src/gen_map_modoptions.ts | 12 +++++------- scripts/js/src/startbox_utils.ts | 8 +++----- 4 files changed, 22 insertions(+), 32 deletions(-) diff --git a/schemas/map_list.yaml b/schemas/map_list.yaml index d840caa..b8fdc42 100644 --- a/schemas/map_list.yaml +++ b/schemas/map_list.yaml @@ -144,14 +144,10 @@ $defs: type: object title: Startbox properties: - # Two shapes are accepted, discriminated by length: - # - 2 points → axis-aligned rectangle (top-left, bottom-right) - # - 3+ points → closed polygon ring; each point may carry an - # optional `strength` field for Catmull-Rom spline - # tessellation (game-side / lobby-side feature). - # Downstream consumers that only understand rectangles - # (TEIServer, SPADS) compute a bounding-box rectangle from the - # polygon vertices in their respective generators. + # Two shapes, discriminated by point count: + # - 2 points: axis-aligned rectangle (top-left, bottom-right corner). + # - 3+ points: closed polygon ring; each point may carry an optional + # `strength` for Catmull-Rom spline tessellation (game/lobby side). poly: oneOf: - $ref: "#/$defs/startboxRect" @@ -171,9 +167,8 @@ $defs: title: StartboxRect description: > Legacy 2-point rectangle: top-left and bottom-right corners in the - 0–200 normalized coordinate space. The format every existing map ships - today; remains the only shape the engine, SPADS and TEIServer protocols - understand directly. + 0-200 normalized coordinate space. Every existing map ships this, and it + is the only shape the engine, SPADS and TEIServer understand directly. type: array minItems: 2 maxItems: 2 @@ -196,12 +191,10 @@ $defs: startboxPolygon: title: StartboxPolygon description: > - Closed polygon ring of 3 or more anchor points in the 0–200 normalized - coordinate space. Each point may optionally carry a Catmull-Rom spline - strength in [0, 1] — 0 (or omitted) is a sharp polygon corner, 1 is a - full smooth curve. The Rowy editor snaps strength values to multiples - of 0.025; the schema enforces only the range so other tooling can write - arbitrary precision if needed. + Closed polygon ring of 3 or more anchor points in the 0-200 normalized + coordinate space. Each point may carry a Catmull-Rom spline strength in + [0, 1]: 0 (or omitted) is a sharp corner, 1 a full smooth curve. Rowy + snaps strength to multiples of 0.025; the schema enforces only the range. type: array minItems: 3 items: diff --git a/scripts/js/src/check_startboxes.ts b/scripts/js/src/check_startboxes.ts index 9dd48c6..e83da88 100644 --- a/scripts/js/src/check_startboxes.ts +++ b/scripts/js/src/check_startboxes.ts @@ -28,9 +28,10 @@ for (const map of Object.values(maps)) { error = true; } } else { - // N-point polygon: require non-degenerate area. Self-intersection - // and concavity are not checked here — both are valid shapes for - // game-side containment, which uses ray-casting. + // N-point polygon: reject rings with ~zero signed (shoelace) area. + // Concavity and most self-intersections pass (game-side containment + // uses ray-casting), but a self-intersecting ring whose signed area + // cancels to ~0 is rejected here too. let area2 = 0; for (let i = 0; i < poly.length; i++) { const a = poly[i]; diff --git a/scripts/js/src/gen_map_modoptions.ts b/scripts/js/src/gen_map_modoptions.ts index 2837767..d557c33 100644 --- a/scripts/js/src/gen_map_modoptions.ts +++ b/scripts/js/src/gen_map_modoptions.ts @@ -6,9 +6,8 @@ import stringify from "json-stable-stringify"; import { MapModoptions } from '../../../gen/types/map_modoptions.js'; import { StartPosConf, StartboxesInfo } from '../../../gen/types/map_list.js'; -// JSON -> zlib deflate -> base64url with padding stripped. Matches the transport -// the game decoder expects and the map_modoptions value pattern -// (^[a-zA-Z0-9_.-]+$), which forbids '=' padding. +// base64url(zlib(json)), padding stripped: the transport the game decoder +// expects, and the map_modoptions value pattern (^[a-zA-Z0-9_.-]+$) forbids '='. function encodeModoptionValue(value: unknown): string { const compressed = zlib.deflateSync(stringify(value)); return compressed.toString('base64url').replace(/=+$/, ''); @@ -18,10 +17,9 @@ function encodeStartPos(startPos: StartPosConf): string { return encodeModoptionValue(startPos); } -// The game resolves a startboxes arrangement by team count (set[tostring(numTeams)]). -// maps-metadata keys startboxesSet by document id, so re-key by the arrangement's -// team count here. check_startboxes.ts guarantees team counts are unique within a -// set, so no key collides. +// The game looks up arrangements by team count (set[tostring(numTeams)]), but +// maps-metadata keys startboxesSet by document id; re-key by team count. +// check_startboxes guarantees unique team counts per set, so none collide. function encodeStartboxesSet(set: Record): string { const byTeamCount: Record = {}; for (const info of Object.values(set)) { diff --git a/scripts/js/src/startbox_utils.ts b/scripts/js/src/startbox_utils.ts index 400aed3..74f4bd2 100644 --- a/scripts/js/src/startbox_utils.ts +++ b/scripts/js/src/startbox_utils.ts @@ -2,11 +2,9 @@ import type { Startbox } from '../../../gen/types/map_list.js'; type RectCorner = { x: number; y: number }; -// SPADS (mapBoxes.conf) and TEIServer/Tachyon carry only axis-aligned -// rectangles, so collapse an N-point polygon to its bounding box. The full -// polygon shape rides in the mapmetadata_startboxes_set modoption (see -// gen_map_modoptions.ts) and is decoded game-side. Legacy 2-point rectangles -// are already min/max ordered, so the bounding box is the rectangle itself. +// Rect-only consumers (SPADS, TEIServer/Tachyon) get a polygon's bounding box; +// the full shape still reaches the game via the mapmetadata_startboxes_set +// modoption. A 2-point rect is already min/max ordered, so it is unchanged. export function polyBoundingRect(poly: Startbox['poly']): [RectCorner, RectCorner] { let xMin = Infinity, yMin = Infinity, xMax = -Infinity, yMax = -Infinity; for (const p of poly) { From a48e0a2095d938a35beccf4a2fdbd0911267e2cf Mon Sep 17 00:00:00 2001 From: Robert Burnham Date: Tue, 23 Jun 2026 00:02:15 -0400 Subject: [PATCH 8/9] Consolidate readMapModoptions into maps_metadata --- scripts/js/src/gen_map_details_lua.ts | 20 +++++--------------- scripts/js/src/gen_spads_map_presets.ts | 6 +----- scripts/js/src/gen_teiserver_maps.ts | 16 ++++------------ scripts/js/src/maps_metadata.ts | 12 ++++++++++++ 4 files changed, 22 insertions(+), 32 deletions(-) diff --git a/scripts/js/src/gen_map_details_lua.ts b/scripts/js/src/gen_map_details_lua.ts index f9f6d8d..3056546 100644 --- a/scripts/js/src/gen_map_details_lua.ts +++ b/scripts/js/src/gen_map_details_lua.ts @@ -1,10 +1,9 @@ // Script generating mapDetails.lua used in BYAR-Chobby repo for maps list. -import { fetchMapsMetadata, readMapList } from './maps_metadata.js'; +import { fetchMapsMetadata, readMapList, readMapModoptionsBySpringName, type ModoptionsBySpringName } from './maps_metadata.js'; import fs from 'node:fs/promises'; import { program } from '@commander-js/extra-typings'; import { MapList } from '../../../gen/types/map_list.js'; -import { MapModoptions } from '../../../gen/types/map_modoptions.js'; export interface MapDetails { [k: string]: { @@ -24,22 +23,13 @@ export interface MapDetails { Author?: string; InfoText?: string; LastUpdate: number; - // base64url(zlib(json)) encoded mapmetadata_startboxes_set modoption - // value (see gen_map_modoptions.ts). Chobby injects it as-is and - // decodes it for the polygon startbox preview; absent for maps with - // no startbox set. + // Encoded mapmetadata_startboxes_set modoption value; Chobby injects it + // as-is and decodes it for the polygon startbox preview. Omitted when a + // map has no startbox set. StartboxesSet?: string; }; } -type ModoptionsBySpringName = { [springName: string]: MapModoptions['modoptions'] }; - -async function readMapModoptions(): Promise { - const contents = await fs.readFile('gen/map_modoptions.validated.json', { encoding: 'utf8' }); - const mapModoptions = JSON.parse(contents) as MapModoptions[]; - return Object.fromEntries(mapModoptions.map(m => [m.springName, m.modoptions])); -} - function intersection(a: Iterable, b: Iterable): T[] { const setB = b instanceof Set ? b as Set : new Set(b); const intersection = []; @@ -156,4 +146,4 @@ const maps = await readMapList(); await fs.writeFile(mapDetailsPath, serializeMapDetails( - buildMapDetails(maps, await fetchMapsMetadata(maps), await readMapModoptions()))); + buildMapDetails(maps, await fetchMapsMetadata(maps), await readMapModoptionsBySpringName()))); diff --git a/scripts/js/src/gen_spads_map_presets.ts b/scripts/js/src/gen_spads_map_presets.ts index 6ab7dbe..867023d 100644 --- a/scripts/js/src/gen_spads_map_presets.ts +++ b/scripts/js/src/gen_spads_map_presets.ts @@ -4,11 +4,7 @@ import fs from 'node:fs/promises'; import { program } from '@commander-js/extra-typings'; import { MapModoptions } from '../../../gen/types/map_modoptions.js'; - -async function readMapModoptions(): Promise { - const contents = await fs.readFile('gen/map_modoptions.validated.json', { 'encoding': 'utf8' }); - return JSON.parse(contents) as MapModoptions[]; -} +import { readMapModoptions } from './maps_metadata.js'; const AUTOMATED_HEADER = `# # AUTOMATICALLY GENERATED FILE, DO NOT EDIT! diff --git a/scripts/js/src/gen_teiserver_maps.ts b/scripts/js/src/gen_teiserver_maps.ts index 03a99a9..eb1d1b4 100644 --- a/scripts/js/src/gen_teiserver_maps.ts +++ b/scripts/js/src/gen_teiserver_maps.ts @@ -1,4 +1,4 @@ -import { readMapList } from "./maps_metadata.js"; +import { readMapList, readMapModoptionsBySpringName } from "./maps_metadata.js"; import fs from "node:fs/promises"; import { program } from "@commander-js/extra-typings"; import stringify from "json-stable-stringify"; @@ -6,13 +6,11 @@ import type { TeiserverMapInfo, TeiserverMaps, } from "../../../gen/types/teiserver_maps.js"; -import { MapModoptions } from '../../../gen/types/map_modoptions.js'; import type { StartboxesInfo } from '../../../gen/types/map_list.js'; import { polyBoundingRect } from './startbox_utils.js'; -// TEIServer/Tachyon only carry axis-aligned rectangles, so collapse polygon -// startboxes to their bounding box (see polyBoundingRect). Rebuilt via -// [first, ...rest] so the result stays a non-empty tuple (minItems: 1). +// Collapse polygons to their bounding box for rect-only TEIServer/Tachyon. +// [first, ...rest] keeps the result a non-empty tuple (minItems: 1). function rectifyStartboxes(set: StartboxesInfo[]): StartboxesInfo[] { return set.map(info => { const [first, ...rest] = info.startboxes; @@ -29,15 +27,9 @@ function rectifyStartboxes(set: StartboxesInfo[]): StartboxesInfo[] { const imagorUrlBase = 'https://maps-metadata.beyondallreason.dev/i/'; const rowyBucket = 'rowy-1f075.appspot.com'; -async function readMapModoptions(): Promise<{[springName: string]: MapModoptions['modoptions']}> { - const contents = await fs.readFile('gen/map_modoptions.validated.json', { 'encoding': 'utf8' }); - const mapModoptions = JSON.parse(contents) as MapModoptions[]; - return Object.fromEntries(mapModoptions.map((m) => [m.springName, m.modoptions])); -} - async function genTeiserverMaps(): Promise { const maps = await readMapList(); - const mapModoptions = await readMapModoptions(); + const mapModoptions = await readMapModoptionsBySpringName(); const tMaps: TeiserverMapInfo[] = []; for (const [_rowyId, map] of Object.entries(maps)) { diff --git a/scripts/js/src/maps_metadata.ts b/scripts/js/src/maps_metadata.ts index bab602c..bfb41ef 100644 --- a/scripts/js/src/maps_metadata.ts +++ b/scripts/js/src/maps_metadata.ts @@ -9,6 +9,7 @@ import { randomUUID } from 'node:crypto'; import stream from 'node:stream/promises'; import process from 'node:process'; import type { MapList } from '../../../gen/types/map_list.js'; +import type { MapModoptions } from '../../../gen/types/map_modoptions.js'; import pLimit from 'p-limit'; import { lock } from 'proper-lockfile'; @@ -136,3 +137,14 @@ export async function readMapList(): Promise { const contents = await fs.readFile('gen/map_list.validated.json', { 'encoding': 'utf8' }); return JSON.parse(contents) as MapList; } + +export type ModoptionsBySpringName = { [springName: string]: MapModoptions['modoptions'] }; + +export async function readMapModoptions(): Promise { + const contents = await fs.readFile('gen/map_modoptions.validated.json', { 'encoding': 'utf8' }); + return JSON.parse(contents) as MapModoptions[]; +} + +export async function readMapModoptionsBySpringName(): Promise { + return Object.fromEntries((await readMapModoptions()).map(m => [m.springName, m.modoptions])); +} From 2b5c1cea796583ab1a4f1e73c658a277fe1d8605 Mon Sep 17 00:00:00 2001 From: Robert Burnham Date: Tue, 23 Jun 2026 00:02:15 -0400 Subject: [PATCH 9/9] Use a set for mapDetails omit-when-nil fields --- scripts/js/src/gen_map_details_lua.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/scripts/js/src/gen_map_details_lua.ts b/scripts/js/src/gen_map_details_lua.ts index 3056546..26db87d 100644 --- a/scripts/js/src/gen_map_details_lua.ts +++ b/scripts/js/src/gen_map_details_lua.ts @@ -93,6 +93,9 @@ function serializeMapDetails(mapDetails: MapDetails): string { 'StartboxesSet' ]; + // Optional fields omitted when absent rather than emitted as nil. + const omitWhenNil = new Set(['Author', 'StartboxesSet']); + function escapeLuaString(str: string): string { return str .replaceAll('\\', '\\\\') @@ -126,8 +129,7 @@ function serializeMapDetails(mapDetails: MapDetails): string { } else { value = `'${escapeLuaString(details[field].toString())}'`; } - const omitWhenNil = field === 'Author' || field === 'StartboxesSet'; - if (!omitWhenNil || value !== 'nil') { + if (!omitWhenNil.has(field) || value !== 'nil') { fields.push(`${field}=${value}`); } }