Skip to content
Open
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
80 changes: 61 additions & 19 deletions schemas/map_list.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -144,26 +144,14 @@ $defs:
type: object
title: Startbox
properties:
# 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:
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
Expand All @@ -175,6 +163,60 @@ $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. Every existing map ships this, and it
is the only shape the engine, SPADS and TEIServer 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 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:
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
Expand Down
27 changes: 23 additions & 4 deletions scripts/js/src/check_startboxes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,29 @@ 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: 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];
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;
}
}
}

Expand Down
3 changes: 2 additions & 1 deletion scripts/js/src/gen_map_boxes_conf.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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!
Expand Down Expand Up @@ -40,7 +41,7 @@ const HEADER = `#

function serializeStartboxes(startboxes: Startbox[]): string {
return startboxes
.map(s => s.poly.map(p => `${p.x} ${p.y}`).join(' '))
.map(s => polyBoundingRect(s.poly).map(p => `${p.x} ${p.y}`).join(' '))
.join(';');
}

Expand Down
19 changes: 14 additions & 5 deletions scripts/js/src/gen_map_details_lua.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// 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';
Expand All @@ -23,6 +23,10 @@ export interface MapDetails {
Author?: string;
InfoText?: string;
LastUpdate: number;
// 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;
};
}

Expand All @@ -37,7 +41,7 @@ function intersection<T>(a: Iterable<T>, b: Iterable<T>): T[] {
return intersection;
}

function buildMapDetails(maps: MapList, mapsMetadata: Map<string, any>): MapDetails {
function buildMapDetails(maps: MapList, mapsMetadata: Map<string, any>, modoptions: ModoptionsBySpringName): MapDetails {
const mapDetails: MapDetails = {};
for (const id of Object.keys(maps)) {
const mapInfo = maps[id];
Expand All @@ -62,6 +66,7 @@ function buildMapDetails(maps: MapList, mapsMetadata: Map<string, any>): 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;
Expand All @@ -84,9 +89,13 @@ function serializeMapDetails(mapDetails: MapDetails): string {
'TeamCount',
'Author',
'InfoText',
'LastUpdate'
'LastUpdate',
'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('\\', '\\\\')
Expand Down Expand Up @@ -120,7 +129,7 @@ function serializeMapDetails(mapDetails: MapDetails): string {
} else {
value = `'${escapeLuaString(details[field].toString())}'`;
}
if (field !== 'Author' || value !== 'nil') {
if (!omitWhenNil.has(field) || value !== 'nil') {
fields.push(`${field}=${value}`);
}
}
Expand All @@ -139,4 +148,4 @@ const maps = await readMapList();

await fs.writeFile(mapDetailsPath,
serializeMapDetails(
buildMapDetails(maps, await fetchMapsMetadata(maps))));
buildMapDetails(maps, await fetchMapsMetadata(maps), await readMapModoptionsBySpringName())));
53 changes: 39 additions & 14 deletions scripts/js/src/gen_map_modoptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,25 +4,50 @@ 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;
// 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(/=+$/, '');
}

function encodeStartPos(startPos: StartPosConf): string {
return encodeModoptionValue(startPos);
}

// 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, StartboxesInfo>): string {
const byTeamCount: Record<string, StartboxesInfo> = {};
for (const info of Object.values(set)) {
byTeamCount[String(info.startboxes.length)] = info;
}
return encodeModoptionValue(byTeamCount);
}

async function genLiveMaps(): Promise<string> {
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<string, string> = {};

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);
}
Expand Down
6 changes: 1 addition & 5 deletions scripts/js/src/gen_spads_map_presets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<MapModoptions[]> {
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!
Expand Down
30 changes: 20 additions & 10 deletions scripts/js/src/gen_teiserver_maps.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,35 @@
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";
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';

// 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;
return {
...info,
startboxes: [
{ poly: polyBoundingRect(first.poly) },
...rest.map(box => ({ poly: polyBoundingRect(box.poly) })),
],
};
});
}

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<string> {
const maps = await readMapList();
const mapModoptions = await readMapModoptions();
const mapModoptions = await readMapModoptionsBySpringName();

const tMaps: TeiserverMapInfo[] = [];
for (const [_rowyId, map] of Object.entries(maps)) {
Expand All @@ -39,7 +49,7 @@ async function genTeiserverMaps(): Promise<string> {
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]
});
Expand Down
12 changes: 12 additions & 0 deletions scripts/js/src/maps_metadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -136,3 +137,14 @@ export async function readMapList(): Promise<MapList> {
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<MapModoptions[]> {
const contents = await fs.readFile('gen/map_modoptions.validated.json', { 'encoding': 'utf8' });
return JSON.parse(contents) as MapModoptions[];
}

export async function readMapModoptionsBySpringName(): Promise<ModoptionsBySpringName> {
return Object.fromEntries((await readMapModoptions()).map(m => [m.springName, m.modoptions]));
}
17 changes: 17 additions & 0 deletions scripts/js/src/startbox_utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import type { Startbox } from '../../../gen/types/map_list.js';

type RectCorner = { x: number; y: number };

// 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) {
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 }];
}
Loading