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
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions schema/plugin.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
55 changes: 54 additions & 1 deletion scripts/build.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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);
Expand Down
48 changes: 48 additions & 0 deletions scripts/validate.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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") {
Expand Down Expand Up @@ -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) {
Expand Down
Loading