From 0b44114fd1495ddcb7843711b80810f6b68aaade Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Fri, 2 Oct 2026 18:13:17 -0400 Subject: [PATCH] feature: send the workshop addons of the plugins a server loads --- .../default/tables/public_game_plugins.yaml | 3 + .../down.sql | 2 + .../up.sql | 2 + .../dedicated-servers.service.spec.ts | 2 + src/game-plugins/game-modes.service.spec.ts | 131 +++++++++++++++++- src/game-plugins/game-modes.service.ts | 70 +++++++++- src/game-plugins/game-plugins.service.ts | 6 +- src/game-plugins/types/Registry.ts | 2 + 8 files changed, 213 insertions(+), 5 deletions(-) create mode 100644 hasura/migrations/default/1890000000400_plugin_workshop_addons/down.sql create mode 100644 hasura/migrations/default/1890000000400_plugin_workshop_addons/up.sql diff --git a/hasura/metadata/databases/default/tables/public_game_plugins.yaml b/hasura/metadata/databases/default/tables/public_game_plugins.yaml index a551214d..88211e7e 100644 --- a/hasura/metadata/databases/default/tables/public_game_plugins.yaml +++ b/hasura/metadata/databases/default/tables/public_game_plugins.yaml @@ -72,6 +72,7 @@ select_permissions: - config_shipped - cvars - forced_cvars + - workshop_addons - map_rotation - pairs_with - panel @@ -108,6 +109,7 @@ insert_permissions: - config_shipped - cvars - forced_cvars + - workshop_addons - map_rotation - pairs_with - panel @@ -141,6 +143,7 @@ update_permissions: - config_shipped - cvars - forced_cvars + - workshop_addons - map_rotation - pairs_with - panel diff --git a/hasura/migrations/default/1890000000400_plugin_workshop_addons/down.sql b/hasura/migrations/default/1890000000400_plugin_workshop_addons/down.sql new file mode 100644 index 00000000..eb7bebb5 --- /dev/null +++ b/hasura/migrations/default/1890000000400_plugin_workshop_addons/down.sql @@ -0,0 +1,2 @@ +ALTER TABLE public.game_plugins + DROP COLUMN IF EXISTS workshop_addons; diff --git a/hasura/migrations/default/1890000000400_plugin_workshop_addons/up.sql b/hasura/migrations/default/1890000000400_plugin_workshop_addons/up.sql new file mode 100644 index 00000000..51d19230 --- /dev/null +++ b/hasura/migrations/default/1890000000400_plugin_workshop_addons/up.sql @@ -0,0 +1,2 @@ +ALTER TABLE public.game_plugins + ADD COLUMN IF NOT EXISTS workshop_addons text[] NOT NULL DEFAULT '{}'; diff --git a/src/dedicated-servers/dedicated-servers.service.spec.ts b/src/dedicated-servers/dedicated-servers.service.spec.ts index 20f22020..179d0a29 100644 --- a/src/dedicated-servers/dedicated-servers.service.spec.ts +++ b/src/dedicated-servers/dedicated-servers.service.spec.ts @@ -599,6 +599,7 @@ describe("DedicatedServersService.withServerCfg", () => { pluginConfigs: null as string | null, missingRequired: [] as Array, disableServerGuidelines: false, + workshopAddons: [] as Array, ...overrides, }); @@ -738,6 +739,7 @@ describe("DedicatedServersService.setupDedicatedServer", () => { pluginConfigs: null, missingRequired: [], disableServerGuidelines: false, + workshopAddons: [] as Array, }; const deploy = async (type: string, game = "cs2") => { diff --git a/src/game-plugins/game-modes.service.spec.ts b/src/game-plugins/game-modes.service.spec.ts index 6b0c307c..fb444dc0 100644 --- a/src/game-plugins/game-modes.service.spec.ts +++ b/src/game-plugins/game-modes.service.spec.ts @@ -465,6 +465,135 @@ describe("GameModesService server guidelines", () => { }); }); +// A plugin's models, sounds or Panorama layouts only reach players through a +// workshop addon AddonsManager names to them, so a server has to serve exactly +// the addons of the plugins it ends up loading -- mode and auto-load alike. +describe("GameModesService workshop addons", () => { + const build = (rows: Record>>) => { + const seen: Array<{ sql: string; params: Array }> = []; + + const postgres = { + query: jest.fn(async (sql: string, params: Array = []) => { + seen.push({ sql, params }); + + if (sql.includes("workshop_addons")) { + return rows.addons ?? []; + } + if (sql.includes("COALESCE(")) { + return [{ game_mode_id: "mode-1", game_server_node_id: "node-a" }]; + } + if (sql.includes("FROM game_modes")) { + return [ + { + id: "mode-1", + slug: "chaos", + name: "Chaos", + cfg: null, + extra_game_params: null, + }, + ]; + } + if (sql.includes("FROM game_mode_plugins")) { + return rows.modePlugins ?? []; + } + if (sql.includes("FROM game_plugin_installs")) { + return rows.autoLoad ?? []; + } + return []; + }), + }; + + const service = new GameModesService( + { warn: jest.fn(), log: jest.fn() } as never, + postgres as never, + { + getPluginRuntime: jest.fn(async () => "swiftlys2"), + resolvePluginRuntime: jest.fn(async () => "swiftlys2"), + } as never, + { + forServer: jest.fn(async () => ({ + maps: [] as Array, + shuffle: true, + })), + } as never, + ); + + return { service, seen }; + }; + + const modePlugins: Array> = [ + { + plugin_slug: "csroll", + config: null, + config_path: null, + required: true, + version: "1.37.3", + }, + ]; + + const autoLoad = [{ plugin_slug: "skins", version: "2.0.0" }]; + + it("serves the addons of every plugin loading, in load order, each once", async () => { + const { service } = build({ + modePlugins, + autoLoad, + addons: [ + { slug: "skins", workshop_addons: ["300", "100"] }, + { slug: "csroll", workshop_addons: ["100", "200"] }, + ], + }); + + const resolved = await service.resolveForServer("server-1"); + + expect(resolved?.workshopAddons).toEqual(["100", "200", "300"]); + expect(service.environmentFor(resolved)).toContainEqual({ + name: "WORKSHOP_ADDONS", + value: "100,200,300", + }); + }); + + // A plugin the catalog lists addons for, on a server whose mode does not + // load it, is not a reason to make that server's players download anything. + it("asks only about the plugins this server is actually loading", async () => { + const { service, seen } = build({ modePlugins, autoLoad }); + + await service.resolveForServer("server-1"); + + const asked = seen.find((entry) => entry.sql.includes("workshop_addons")); + + expect(asked?.params[0]).toEqual(["csroll", "skins"]); + }); + + // The registry is whatever URL the operator points at. AddonsManager drops + // its whole config over one entry that is not a number. + it("drops an id that is not a bare workshop id", async () => { + const { service } = build({ + modePlugins, + addons: [ + { + slug: "csroll", + workshop_addons: ["3791548068", "37 91", "../x", ""], + }, + ], + }); + + const resolved = await service.resolveForServer("server-1"); + + expect(resolved?.workshopAddons).toEqual(["3791548068"]); + }); + + it("leaves AddonsManager off when nothing loading ships an addon", async () => { + const { service } = build({ modePlugins }); + + const resolved = await service.resolveForServer("server-1"); + + expect(resolved?.workshopAddons).toEqual([]); + expect(service.environmentFor(resolved).map((env) => env.name)).toEqual([ + "ENABLED_PLUGINS", + ]); + }); +}); + describe("GameModesService.environmentFor", () => { const service = new GameModesService( null as never, @@ -488,8 +617,8 @@ describe("GameModesService.environmentFor", () => { pluginConfigs: "e30=", missingRequired: [], disableServerGuidelines: false, + workshopAddons: [], }), ).toEqual([{ name: "PLUGIN_CONFIGS", value: "e30=" }]); }); }); - diff --git a/src/game-plugins/game-modes.service.ts b/src/game-plugins/game-modes.service.ts index 29284153..c3e48712 100644 --- a/src/game-plugins/game-modes.service.ts +++ b/src/game-plugins/game-modes.service.ts @@ -16,6 +16,7 @@ export type ResolvedGameMode = { pluginConfigs: string | null; missingRequired: Array; disableServerGuidelines: boolean; + workshopAddons: Array; }; // Which kind of match is about to run. An install says which of the three it @@ -188,7 +189,7 @@ export class GameModesService { mode: ResolvedGameMode | null, scope?: PluginScope, ): Promise { - return await this.withServerGuidelines( + return await this.withPluginNeeds( GameModesService.withPlugins(mode, await this.autoLoadPlugins(scope)), ); } @@ -255,7 +256,7 @@ export class GameModesService { off, ); - return await this.withServerGuidelines(resolved); + return await this.withPluginNeeds(resolved); } // A rotation is only played by a plugin that declares map_rotation in the @@ -390,6 +391,7 @@ export class GameModesService { pluginConfigs: null, missingRequired: [], disableServerGuidelines: false, + workshopAddons: [], }; } @@ -496,6 +498,60 @@ export class GameModesService { return { ...mode, disableServerGuidelines: row?.disable ?? false }; } + // What the plugins a server ends up loading ask of the server as a whole. + // Only answerable once that list is final: a mode's own plugins are half of + // it, and auto-load, overrides and a map rotation supply the rest. + private async withPluginNeeds( + mode: ResolvedGameMode | null, + ): Promise { + return await this.withWorkshopAddons(await this.withServerGuidelines(mode)); + } + + // CS2 cannot send a player files, so a plugin's models, sounds or Panorama + // layouts reach them as a workshop addon, which AddonsManager names to each + // connecting client. The catalog says which addons a plugin needs; the server + // serves those of the plugins it is loading, in load order, each once. + // + // From the catalog rather than the installs, like the cvar layers: a mode can + // load a hand-placed plugin with no install row, and it needs its addons all + // the same. setup.sh holds the ids to digits again before AddonsManager sees + // them -- a registry is whatever URL the operator points at. + private async withWorkshopAddons( + mode: ResolvedGameMode | null, + ): Promise { + const slugs = GameModesService.entriesOf(mode).map(GameModesService.slugOf); + + if (!mode || slugs.length === 0) { + return mode; + } + + const rows = await this.postgres.query< + Array<{ slug: string; workshop_addons: Array | null }> + >( + `SELECT p.slug, p.workshop_addons + FROM game_plugins p + WHERE p.slug = ANY($1::text[]) + AND cardinality(p.workshop_addons) > 0`, + [slugs], + ); + + const addonsBySlug = new Map( + rows.map((row) => [row.slug, row.workshop_addons ?? []]), + ); + + const addons: Array = []; + + for (const slug of slugs) { + for (const addon of addonsBySlug.get(slug) ?? []) { + if (/^\d+$/.test(addon) && !addons.includes(addon)) { + addons.push(addon); + } + } + } + + return { ...mode, workshopAddons: addons }; + } + public async resolve( gameModeId: string, scope?: PluginScope, @@ -582,6 +638,7 @@ export class GameModesService { // Set once the whole plugin list is known; a mode's own plugins are only // half of what a server loads. disableServerGuidelines: false, + workshopAddons: [], }; } @@ -878,6 +935,15 @@ export class GameModesService { environment.push({ name: "DISABLE_SERVER_GUIDELINES", value: "true" }); } + // setup.sh turns on the AddonsManager the SwiftlyS2 image ships and points + // it at these. + if (mode.workshopAddons.length > 0) { + environment.push({ + name: "WORKSHOP_ADDONS", + value: mode.workshopAddons.join(","), + }); + } + return environment; } } diff --git a/src/game-plugins/game-plugins.service.ts b/src/game-plugins/game-plugins.service.ts index d1355e0b..f9095ccd 100644 --- a/src/game-plugins/game-plugins.service.ts +++ b/src/game-plugins/game-plugins.service.ts @@ -129,9 +129,9 @@ export class GamePluginsService { hot_swappable, requires_service, requires_server_guidelines_disabled, config_schema, config_path, cvars, panel, wiring, pairs_with, map_rotation, config_cvar, config_default, config_shipped, - forced_cvars, synced_at) + forced_cvars, workshop_addons, synced_at) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18, - $19,$20,$21,$22, now()) + $19,$20,$21,$22,$23, now()) ON CONFLICT (slug) DO UPDATE SET kind = EXCLUDED.kind, name = EXCLUDED.name, @@ -154,6 +154,7 @@ export class GamePluginsService { config_default = EXCLUDED.config_default, config_shipped = EXCLUDED.config_shipped, forced_cvars = EXCLUDED.forced_cvars, + workshop_addons = EXCLUDED.workshop_addons, synced_at = now()`, [ plugin.slug, @@ -178,6 +179,7 @@ export class GamePluginsService { plugin.config_default ? JSON.stringify(plugin.config_default) : null, plugin.config_shipped ? JSON.stringify(plugin.config_shipped) : null, plugin.forced_cvars ?? [], + plugin.workshop_addons ?? [], ], ); diff --git a/src/game-plugins/types/Registry.ts b/src/game-plugins/types/Registry.ts index d250bcbd..25ccda4a 100644 --- a/src/game-plugins/types/Registry.ts +++ b/src/game-plugins/types/Registry.ts @@ -27,6 +27,8 @@ export type RegistryPlugin = { hot_swappable?: boolean; requires_service?: string | null; requires_server_guidelines_disabled?: boolean; + // Steam Workshop ids, as strings: they overflow a JS number. + workshop_addons?: Array; config_schema?: Record; config_path?: string; config_cvar?: string;