Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
42 changes: 42 additions & 0 deletions registry/game/map-chooser.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
}
23 changes: 23 additions & 0 deletions schema/plugin.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
33 changes: 33 additions & 0 deletions scripts/build.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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 {
Expand Down Expand Up @@ -101,6 +107,33 @@ test("treats an unreadable published index as changed", () => {
assert.equal(sameIndex("<html>404</html>", 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);
Expand Down
73 changes: 73 additions & 0 deletions scripts/validate.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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`);
Expand All @@ -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);
Expand Down
Loading