diff --git a/README.md b/README.md index 3eb20e6..ed72e04 100644 --- a/README.md +++ b/README.md @@ -83,6 +83,28 @@ 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 ship workshop addons + +CS2 cannot send files from a server to a player, so a plugin's custom models, +sounds, particles or Panorama layouts have to reach players as a Steam Workshop +addon. A plugin that needs one lists the workshop ids: + +```json +"workshop_addons": ["3791548068"] +``` + +5Stack's SwiftlyS2 image ships +[AddonsManager](https://github.com/SwiftlyS2-Plugins/AddonsManager), so nothing +else has to be installed: every server that loads the plugin turns it on and +serves exactly the addons its plugins list. Players download them while they +connect, and the server fetches each addon again whenever it mounts it, so a +republished addon reaches players without a new plugin release. + +The ids are strings, because a workshop id is too large for a JSON number. The +entry needs a `swiftlys2` variant, since the SwiftlyS2 image is the only one +with AddonsManager. A CounterStrikeSharp server loading the same plugin starts +without the addons and logs why. + ### Plugins that run a map rotation A dedicated server can carry a map rotation set in the panel. A plugin that can diff --git a/schema/plugin.schema.json b/schema/plugin.schema.json index 53e4d1f..a8cdc0b 100644 --- a/schema/plugin.schema.json +++ b/schema/plugin.schema.json @@ -69,6 +69,15 @@ "default": false, "description": "Needs FollowCS2ServerGuidelines set to false to work at all. The panel hides the plugin's functionality behind a per-install opt-in and warns that Valve can ban every GSLT on the operator's account." }, + "workshop_addons": { + "type": "array", + "uniqueItems": true, + "items": { + "type": "string", + "pattern": "^[0-9]+$" + }, + "description": "Steam Workshop ids of addons the plugin's players need: models, sounds, particles, Panorama layouts. A server loading the plugin turns on the AddonsManager built into 5Stack's SwiftlyS2 image, which has connecting players download them. Strings, not numbers: workshop ids overflow a JSON number." + }, "variants": { "type": "object", "description": "Per-runtime source of the game plugin. Required when kind is game or bundle.", diff --git a/scripts/build.test.mjs b/scripts/build.test.mjs index 72d7d9c..400505b 100644 --- a/scripts/build.test.mjs +++ b/scripts/build.test.mjs @@ -5,7 +5,11 @@ import assert from "node:assert/strict"; import { selectLinuxAsset } from "./build.mjs"; import { sameIndex } from "./changed.mjs"; -import { validateConfig, validateMapRotation } from "./validate.mjs"; +import { + validateConfig, + validateMapRotation, + validateWorkshopAddons, +} from "./validate.mjs"; const glob = (pattern) => new RegExp( @@ -238,6 +242,55 @@ test("rejects forced cvars that are not console variable names", () => { assert.equal(problems.length, 1); }); +const addonsEntry = (fields) => ({ + kind: "game", + variants: { swiftlys2: { repo: "a/b", asset: "B.zip" } }, + ...fields, +}); + +test("accepts workshop addons on a SwiftlyS2 plugin", () => { + const problems = validateWorkshopAddons( + addonsEntry({ workshop_addons: ["3791548068", "3070212801"] }), + "x.json", + ); + assert.deepEqual(problems, []); +}); + +// AddonsManager refuses its whole config over one bad id, so a number that +// lost precision or a pasted URL is caught before it reaches a server. +test("rejects a workshop id that is not a string of digits", () => { + const problems = validateWorkshopAddons( + addonsEntry({ + workshop_addons: [ + 3791548068, + "https://steamcommunity.com/sharedfiles/filedetails/?id=3791548068", + "", + ], + }), + "x.json", + ); + assert.equal(problems.length, 3); +}); + +test("rejects a workshop id listed twice", () => { + const problems = validateWorkshopAddons( + addonsEntry({ workshop_addons: ["3791548068", "3791548068"] }), + "x.json", + ); + assert.equal(problems.length, 1); +}); + +test("rejects workshop addons on a plugin with no SwiftlyS2 build", () => { + const problems = validateWorkshopAddons( + addonsEntry({ + variants: { counterstrikesharp: { repo: "a/b", asset: "B.zip" } }, + workshop_addons: ["3791548068"], + }), + "x.json", + ); + assert.equal(problems.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 c28bfa1..d475383 100644 --- a/scripts/validate.mjs +++ b/scripts/validate.mjs @@ -134,6 +134,7 @@ export function validateEntry({ entry, filePath, directory, fileName }) { } problems.push(...validateConfig(entry, filePath)); + problems.push(...validateWorkshopAddons(entry, filePath)); if (entry.wiring) { if (entry.kind === "game") { @@ -306,6 +307,53 @@ export function validateConfig(entry, filePath) { return problems; } +const WORKSHOP_ID = /^[0-9]+$/; + +// AddonsManager validates its config when it starts, so one id that is not a +// bare number takes it down on every server running the plugin -- and the +// addons with it. Each is held to that here instead. +export function validateWorkshopAddons(entry, filePath) { + const problems = []; + const fail = (message) => problems.push(`${filePath}: ${message}`); + const addons = entry.workshop_addons; + + if (addons === undefined) { + return problems; + } + + if (!Array.isArray(addons)) { + fail(`"workshop_addons" must be a list of workshop ids`); + return problems; + } + + if (entry.kind === "panel") { + fail(`"workshop_addons" only belongs on a game or bundle entry`); + } + + // The only AddonsManager 5Stack ships is in the SwiftlyS2 image. A plugin + // with no SwiftlyS2 build would list addons no server ever serves. + if (!entry.variants?.swiftlys2) { + fail(`"workshop_addons" are served by AddonsManager, which 5Stack only ships for SwiftlyS2, so the entry needs a swiftlys2 variant`); + } + + const seen = new Set(); + + for (const id of addons) { + if (typeof id !== "string" || !WORKSHOP_ID.test(id)) { + fail(`"workshop_addons" has ${JSON.stringify(id)}; a workshop id is a string of digits, e.g. "3791548068"`); + continue; + } + + if (seen.has(id)) { + fail(`"workshop_addons" lists ${id} more than once`); + } + + seen.add(id); + } + + return problems; +} + // The subset of JSON Schema the panel's form understands. A default the form // cannot render is a broken editor, so it is held to the same rules. export function schemaProblems(schema, value, at) {