diff --git a/README.md b/README.md index 8275d54..1807278 100644 --- a/README.md +++ b/README.md @@ -83,6 +83,37 @@ alone: the flag only surfaces a per-install opt-in, off by default, next to that warning. Set it when the plugin genuinely does not function otherwise, not when it merely has a nicer HUD if you do. +### Plugins that run a map rotation + +A dedicated server can carry a map rotation set in the panel. A plugin that can +play one declares where the rotation goes, and the panel writes those files on +every boot of a server that has a rotation (and loads the plugin there unless the +server switches it off): + +```json +"map_rotation": { + "files": { + "addons/swiftlys2/configs/plugins/MapChooser/maps.jsonc": { + "MapChooserMaps": { "Maps": "{{maps}}" } + }, + "addons/swiftlys2/configs/plugins/MapChooser/config.jsonc": { + "MapChooser": { "Cycle": { "Enabled": true, "RandomOrder": "{{shuffle}}" } } + } + }, + "map": { "Name": "{{label}}", "Id": "{{id}}" } +} +``` + +`{{maps}}` becomes the list, one `map` object per entry; `{{shuffle}}` becomes the +server's shuffle switch. In `map`, `{{id}}` is the workshop id or the map name +(whatever `host_workshop_map` / `changelevel` takes), `{{name}}` the map name, +`{{label}}` the display name and `{{workshop_id}}` the workshop id or null. A +string that is exactly one token takes the token's type; anything else is text. + +Each file is written whole, so a file listed here replaces anything an operator +put at that path on that server. Keep documents to the keys the rotation needs: +MapChooser falls back to its defaults for every key the file leaves out. + ### Runtimes are not interchangeable A CounterStrikeSharp plugin will not load under SwiftlyS2 and vice versa, so a diff --git a/registry/game/map-chooser.json b/registry/game/map-chooser.json new file mode 100644 index 0000000..750da5e --- /dev/null +++ b/registry/game/map-chooser.json @@ -0,0 +1,42 @@ +{ + "$schema": "../../schema/plugin.schema.json", + "slug": "map-chooser", + "kind": "game", + "name": "MapChooser", + "author": "aga", + "description": "Map voting for community servers: rock the vote, nominations, end-of-map votes, extends and an automatic map cycle. Workshop maps by ID. Runs the map rotation set on a dedicated server.", + "homepage": "https://github.com/SwiftlyS2-Plugins/MapChooser", + "tags": ["maps", "voting", "rtv", "rotation", "community"], + "verified": false, + "hot_swappable": false, + "config_path": "addons/swiftlys2/configs/plugins/MapChooser/config.jsonc", + "map_rotation": { + "files": { + "addons/swiftlys2/configs/plugins/MapChooser/maps.jsonc": { + "MapChooserMaps": { + "Maps": "{{maps}}" + } + }, + "addons/swiftlys2/configs/plugins/MapChooser/config.jsonc": { + "MapChooser": { + "Cycle": { + "Enabled": true, + "RandomOrder": "{{shuffle}}" + } + } + } + }, + "map": { + "Name": "{{label}}", + "Id": "{{id}}" + } + }, + "variants": { + "swiftlys2": { + "repo": "SwiftlyS2-Plugins/MapChooser", + "asset": "MapChooser-v*.zip", + "layout": "plugin", + "install_path": "addons/swiftlys2/plugins" + } + } +} diff --git a/schema/plugin.schema.json b/schema/plugin.schema.json index 59f226f..f464160 100644 --- a/schema/plugin.schema.json +++ b/schema/plugin.schema.json @@ -98,6 +98,29 @@ }, "description": "Console variables this plugin reads, for the mode cfg editor's autocomplete." }, + "map_rotation": { + "type": "object", + "description": "Declares this plugin runs a dedicated server's map rotation. The panel renders each file from the server's rotation and writes it before the server boots.", + "required": [ + "files", + "map" + ], + "additionalProperties": false, + "properties": { + "files": { + "type": "object", + "minProperties": 1, + "description": "Path relative to game/csgo ({runtime} is substituted) mapped to the JSON document written there. A string that is exactly {{maps}} becomes the rendered map list, exactly {{shuffle}} becomes a boolean.", + "additionalProperties": { + "type": "object" + } + }, + "map": { + "type": "object", + "description": "One rotation entry. {{id}} is the workshop id or the map name, {{name}} the map name, {{label}} the display name, {{workshop_id}} the workshop id or null." + } + } + }, "panel": { "type": "object", "description": "Panel plugin coordinates. Required when kind is panel or bundle.", diff --git a/scripts/build.test.mjs b/scripts/build.test.mjs index 0a27031..f036c7b 100644 --- a/scripts/build.test.mjs +++ b/scripts/build.test.mjs @@ -5,6 +5,7 @@ import assert from "node:assert/strict"; import { selectLinuxAsset } from "./build.mjs"; import { sameIndex } from "./changed.mjs"; +import { validateMapRotation } from "./validate.mjs"; const glob = (pattern) => new RegExp( @@ -13,6 +14,11 @@ const glob = (pattern) => const named = (...names) => names.map((name) => ({ name })); +const rotation = (files, map = { Name: "{{label}}", Id: "{{id}}" }) => ({ + kind: "game", + map_rotation: { files, map }, +}); + let failures = 0; const test = (what, fn) => { try { @@ -101,6 +107,33 @@ test("treats an unreadable published index as changed", () => { assert.equal(sameIndex("404", index([])), false); }); +test("accepts a map rotation that writes the map list", () => { + const problems = validateMapRotation( + rotation({ "a/maps.jsonc": { Maps: "{{maps}}" }, "a/config.jsonc": { Random: "{{shuffle}}" } }), + "x.json", + ); + assert.deepEqual(problems, []); +}); + +test("rejects a map rotation that never writes {{maps}}", () => { + const problems = validateMapRotation(rotation({ "a/config.jsonc": { Random: "{{shuffle}}" } }), "x.json"); + assert.equal(problems.length, 1); + assert.match(problems[0], /never writes \{\{maps\}\}/); +}); + +test("rejects map rotation paths that escape game/csgo", () => { + assert.equal(validateMapRotation(rotation({ "/etc/maps.jsonc": { Maps: "{{maps}}" } }), "x.json").length, 1); + assert.equal(validateMapRotation(rotation({ "../maps.jsonc": { Maps: "{{maps}}" } }), "x.json").length, 1); +}); + +test("rejects map rotation tokens used in the wrong place", () => { + assert.equal(validateMapRotation(rotation({ "a/maps.jsonc": { Maps: "{{maps}}", Id: "{{id}}" } }), "x.json").length, 1); + assert.equal( + validateMapRotation(rotation({ "a/maps.jsonc": { Maps: "{{maps}}" } }, { Name: "{{maps}}" }), "x.json").length, + 1, + ); +}); + if (failures > 0) { console.error(`\n${failures} test(s) failed`); process.exit(1); diff --git a/scripts/validate.mjs b/scripts/validate.mjs index 06485f3..4e4d523 100644 --- a/scripts/validate.mjs +++ b/scripts/validate.mjs @@ -129,6 +129,10 @@ export function validateEntry({ entry, filePath, directory, fileName }) { ); } + if (entry.map_rotation) { + problems.push(...validateMapRotation(entry, filePath)); + } + if (entry.wiring) { if (entry.kind === "game") { fail(`"wiring" describes how a panel plugin configures a game plugin, so it does not belong on a game entry`); @@ -146,6 +150,75 @@ export function validateEntry({ entry, filePath, directory, fileName }) { return problems; } +const MAP_ROTATION_DOCUMENT_TOKENS = new Set(["{{maps}}", "{{shuffle}}"]); +const MAP_ROTATION_MAP_TOKENS = new Set([ + "{{id}}", + "{{name}}", + "{{label}}", + "{{workshop_id}}", +]); + +function tokensIn(value) { + if (typeof value === "string") { + return value.match(/\{\{[^}]*\}\}/g) ?? []; + } + + if (value && typeof value === "object") { + return Object.values(value).flatMap(tokensIn); + } + + return []; +} + +export function validateMapRotation(entry, filePath) { + const problems = []; + const fail = (message) => problems.push(`${filePath}: map_rotation ${message}`); + const { files, map } = entry.map_rotation; + + if (entry.kind === "panel") { + fail(`only belongs on a game or bundle entry`); + } + + if (!files || typeof files !== "object" || Object.keys(files).length === 0) { + fail(`needs at least one file under "files"`); + return problems; + } + + if (!map || typeof map !== "object" || Array.isArray(map)) { + fail(`needs a "map" object describing one entry`); + return problems; + } + + for (const [target, document] of Object.entries(files)) { + if (target.startsWith("/") || target.includes("..")) { + fail(`path "${target}" must be relative to game/csgo`); + } + + if (!document || typeof document !== "object" || Array.isArray(document)) { + fail(`"${target}" must map to a JSON object`); + continue; + } + + for (const token of tokensIn(document)) { + if (!MAP_ROTATION_DOCUMENT_TOKENS.has(token)) { + fail(`"${target}" uses ${token}; files may only use ${[...MAP_ROTATION_DOCUMENT_TOKENS].join(", ")}`); + } + } + } + + if (!Object.values(files).some((document) => tokensIn(document).includes("{{maps}}"))) { + fail(`never writes {{maps}}, so the rotation would go nowhere`); + } + + for (const token of tokensIn(map)) { + if (!MAP_ROTATION_MAP_TOKENS.has(token)) { + fail(`"map" uses ${token}; entries may only use ${[...MAP_ROTATION_MAP_TOKENS].join(", ")}`); + } + } + + return problems; +} + export async function validateAll() { const entries = await loadEntries(); const problems = entries.flatMap(validateEntry);