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
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ select_permissions:
- config_shipped
- cvars
- forced_cvars
- workshop_addons
- map_rotation
- pairs_with
- panel
Expand Down Expand Up @@ -108,6 +109,7 @@ insert_permissions:
- config_shipped
- cvars
- forced_cvars
- workshop_addons
- map_rotation
- pairs_with
- panel
Expand Down Expand Up @@ -141,6 +143,7 @@ update_permissions:
- config_shipped
- cvars
- forced_cvars
- workshop_addons
- map_rotation
- pairs_with
- panel
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
ALTER TABLE public.game_plugins
DROP COLUMN IF EXISTS workshop_addons;
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
ALTER TABLE public.game_plugins
ADD COLUMN IF NOT EXISTS workshop_addons text[] NOT NULL DEFAULT '{}';
2 changes: 2 additions & 0 deletions src/dedicated-servers/dedicated-servers.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -599,6 +599,7 @@ describe("DedicatedServersService.withServerCfg", () => {
pluginConfigs: null as string | null,
missingRequired: [] as Array<string>,
disableServerGuidelines: false,
workshopAddons: [] as Array<string>,
...overrides,
});

Expand Down Expand Up @@ -738,6 +739,7 @@ describe("DedicatedServersService.setupDedicatedServer", () => {
pluginConfigs: null,
missingRequired: [],
disableServerGuidelines: false,
workshopAddons: [] as Array<string>,
};

const deploy = async (type: string, game = "cs2") => {
Expand Down
131 changes: 130 additions & 1 deletion src/game-plugins/game-modes.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, Array<Record<string, unknown>>>) => {
const seen: Array<{ sql: string; params: Array<unknown> }> = [];

const postgres = {
query: jest.fn(async (sql: string, params: Array<unknown> = []) => {
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<never>,
shuffle: true,
})),
} as never,
);

return { service, seen };
};

const modePlugins: Array<Record<string, unknown>> = [
{
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,
Expand All @@ -488,8 +617,8 @@ describe("GameModesService.environmentFor", () => {
pluginConfigs: "e30=",
missingRequired: [],
disableServerGuidelines: false,
workshopAddons: [],
}),
).toEqual([{ name: "PLUGIN_CONFIGS", value: "e30=" }]);
});
});

70 changes: 68 additions & 2 deletions src/game-plugins/game-modes.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export type ResolvedGameMode = {
pluginConfigs: string | null;
missingRequired: Array<string>;
disableServerGuidelines: boolean;
workshopAddons: Array<string>;
};

// Which kind of match is about to run. An install says which of the three it
Expand Down Expand Up @@ -188,7 +189,7 @@ export class GameModesService {
mode: ResolvedGameMode | null,
scope?: PluginScope,
): Promise<ResolvedGameMode | null> {
return await this.withServerGuidelines(
return await this.withPluginNeeds(
GameModesService.withPlugins(mode, await this.autoLoadPlugins(scope)),
);
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -390,6 +391,7 @@ export class GameModesService {
pluginConfigs: null,
missingRequired: [],
disableServerGuidelines: false,
workshopAddons: [],
};
}

Expand Down Expand Up @@ -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<ResolvedGameMode | null> {
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<ResolvedGameMode | null> {
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<string> | 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<string> = [];

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,
Expand Down Expand Up @@ -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: [],
};
}

Expand Down Expand Up @@ -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;
}
}
6 changes: 4 additions & 2 deletions src/game-plugins/game-plugins.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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 ?? [],
],
);

Expand Down
2 changes: 2 additions & 0 deletions src/game-plugins/types/Registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>;
config_schema?: Record<string, unknown>;
config_path?: string;
config_cvar?: string;
Expand Down
Loading