diff --git a/hasura/metadata/actions.graphql b/hasura/metadata/actions.graphql index 9aaf536e..bc7c827a 100644 --- a/hasura/metadata/actions.graphql +++ b/hasura/metadata/actions.graphql @@ -2635,3 +2635,42 @@ type TournamentInviteCodeOutput { type TournamentDraftOutput { teams_created: Int! } + +type Mutation { + setServerMapRotation( + server_id: uuid! + map_ids: [uuid!]! + shuffle: Boolean! + ): SuccessOutput +} + +type Mutation { + setServerPlugins( + server_id: uuid! + plugins: [ServerPluginInput!]! + ): SuccessOutput +} + +type Mutation { + importWorkshopCollection( + collection: String! + ): ImportWorkshopCollectionOutput +} + +input ServerPluginInput { + slug: String! + enabled: Boolean! +} + +type ImportedWorkshopMap { + id: uuid! + name: String! + label: String + poster: String + workshop_map_id: String! +} + +type ImportWorkshopCollectionOutput { + maps: [ImportedWorkshopMap!]! + skipped: Int! +} diff --git a/hasura/metadata/actions.yaml b/hasura/metadata/actions.yaml index eec95b8a..40f20567 100644 --- a/hasura/metadata/actions.yaml +++ b/hasura/metadata/actions.yaml @@ -1998,6 +1998,30 @@ actions: - role: verified_user - role: user comment: Spend a tournament invite link for an unlock on an invite only tournament + - name: setServerMapRotation + definition: + kind: synchronous + handler: '{{HASURA_GRAPHQL_ACTIONS_HOOK}}' + forward_client_headers: true + permissions: + - role: administrator + comment: Replace a dedicated server's map rotation and restart it + - name: setServerPlugins + definition: + kind: synchronous + handler: '{{HASURA_GRAPHQL_ACTIONS_HOOK}}' + forward_client_headers: true + permissions: + - role: administrator + comment: Replace a dedicated server's per-server plugin overrides and restart it + - name: importWorkshopCollection + definition: + kind: synchronous + handler: '{{HASURA_GRAPHQL_ACTIONS_HOOK}}' + forward_client_headers: true + permissions: + - role: administrator + comment: Add every map in a Steam workshop collection to the map catalog custom_types: enums: [] input_objects: @@ -2009,6 +2033,7 @@ custom_types: - name: ClipSpecInput - name: UtilityPlaybookStepInput - name: UtilitySightlinePairInput + - name: ServerPluginInput objects: - name: Award - name: AwardRecipient @@ -2129,4 +2154,6 @@ custom_types: - name: UtilityPurgeOutput - name: UtilityRenderQueueOutput - name: UtilityRenderClearOutput + - name: ImportedWorkshopMap + - name: ImportWorkshopCollectionOutput scalars: [] diff --git a/hasura/metadata/databases/default/tables/public_game_plugins.yaml b/hasura/metadata/databases/default/tables/public_game_plugins.yaml index 00e569dc..9ddd7258 100644 --- a/hasura/metadata/databases/default/tables/public_game_plugins.yaml +++ b/hasura/metadata/databases/default/tables/public_game_plugins.yaml @@ -61,6 +61,7 @@ select_permissions: - config_schema - config_path - cvars + - map_rotation - pairs_with - panel - wiring @@ -92,6 +93,7 @@ insert_permissions: - config_schema - config_path - cvars + - map_rotation - pairs_with - panel - wiring @@ -120,6 +122,7 @@ update_permissions: - config_schema - config_path - cvars + - map_rotation - pairs_with - panel - wiring diff --git a/hasura/metadata/databases/default/tables/public_server_map_rotation.yaml b/hasura/metadata/databases/default/tables/public_server_map_rotation.yaml new file mode 100644 index 00000000..d78ce1df --- /dev/null +++ b/hasura/metadata/databases/default/tables/public_server_map_rotation.yaml @@ -0,0 +1,25 @@ +table: + name: server_map_rotation + schema: public +object_relationships: + - name: map + using: + foreign_key_constraint_on: map_id + - name: server + using: + foreign_key_constraint_on: server_id +select_permissions: + - role: guest + permission: + columns: + - map_id + - position + - server_id + filter: + server: + type: + _nin: + - Ranked + - Practice + allow_aggregations: true + comment: "" diff --git a/hasura/metadata/databases/default/tables/public_server_plugins.yaml b/hasura/metadata/databases/default/tables/public_server_plugins.yaml new file mode 100644 index 00000000..63d05a5e --- /dev/null +++ b/hasura/metadata/databases/default/tables/public_server_plugins.yaml @@ -0,0 +1,19 @@ +table: + name: server_plugins + schema: public +object_relationships: + - name: install + using: + foreign_key_constraint_on: plugin_slug + - name: server + using: + foreign_key_constraint_on: server_id +select_permissions: + - role: administrator + permission: + columns: + - enabled + - plugin_slug + - server_id + filter: {} + comment: "" diff --git a/hasura/metadata/databases/default/tables/public_servers.yaml b/hasura/metadata/databases/default/tables/public_servers.yaml index 9ad65eab..0b000926 100644 --- a/hasura/metadata/databases/default/tables/public_servers.yaml +++ b/hasura/metadata/databases/default/tables/public_servers.yaml @@ -27,6 +27,13 @@ object_relationships: name: server_regions schema: public array_relationships: + - name: map_rotation + using: + foreign_key_constraint_on: + column: server_id + table: + name: server_map_rotation + schema: public - name: matches using: foreign_key_constraint_on: @@ -34,6 +41,13 @@ array_relationships: table: name: matches schema: public + - name: plugin_overrides + using: + foreign_key_constraint_on: + column: server_id + table: + name: server_plugins + schema: public computed_fields: - name: connection_link definition: @@ -84,6 +98,7 @@ select_permissions: - id - is_dedicated - label + - map_rotation_shuffle - max_players - offline_at - plugin_runtime @@ -112,6 +127,7 @@ select_permissions: - host - id - label + - map_rotation_shuffle - max_players - plugin_runtime - plugin_version @@ -144,6 +160,7 @@ select_permissions: - id - is_dedicated - label + - map_rotation_shuffle - max_players - plugin_runtime - plugin_version diff --git a/hasura/metadata/databases/default/tables/tables.yaml b/hasura/metadata/databases/default/tables/tables.yaml index e90a8ce8..1980e537 100644 --- a/hasura/metadata/databases/default/tables/tables.yaml +++ b/hasura/metadata/databases/default/tables/tables.yaml @@ -175,6 +175,8 @@ - "!include public_plugin_versions.yaml" - "!include public_push_subscriptions.yaml" - "!include public_seasons.yaml" +- "!include public_server_map_rotation.yaml" +- "!include public_server_plugins.yaml" - "!include public_server_regions.yaml" - "!include public_servers.yaml" - "!include public_settings.yaml" diff --git a/hasura/migrations/default/1889000000500_server_map_rotation/down.sql b/hasura/migrations/default/1889000000500_server_map_rotation/down.sql new file mode 100644 index 00000000..a19db805 --- /dev/null +++ b/hasura/migrations/default/1889000000500_server_map_rotation/down.sql @@ -0,0 +1,9 @@ +ALTER TABLE public.game_plugins + DROP COLUMN IF EXISTS map_rotation; + +DROP TABLE IF EXISTS public.server_plugins; + +DROP TABLE IF EXISTS public.server_map_rotation; + +ALTER TABLE public.servers + DROP COLUMN IF EXISTS map_rotation_shuffle; diff --git a/hasura/migrations/default/1889000000500_server_map_rotation/up.sql b/hasura/migrations/default/1889000000500_server_map_rotation/up.sql new file mode 100644 index 00000000..0d938f13 --- /dev/null +++ b/hasura/migrations/default/1889000000500_server_map_rotation/up.sql @@ -0,0 +1,28 @@ +ALTER TABLE public.servers + ADD COLUMN IF NOT EXISTS map_rotation_shuffle boolean NOT NULL DEFAULT true; + +CREATE TABLE IF NOT EXISTS public.server_map_rotation ( + server_id uuid NOT NULL, + map_id uuid NOT NULL, + position integer NOT NULL, + PRIMARY KEY (server_id, map_id), + CONSTRAINT server_map_rotation_server_fkey FOREIGN KEY (server_id) + REFERENCES public.servers (id) ON UPDATE CASCADE ON DELETE CASCADE, + CONSTRAINT server_map_rotation_map_fkey FOREIGN KEY (map_id) + REFERENCES public.maps (id) ON UPDATE CASCADE ON DELETE CASCADE +); + +-- No row means the plugin follows its install's load flags on this server. +CREATE TABLE IF NOT EXISTS public.server_plugins ( + server_id uuid NOT NULL, + plugin_slug text NOT NULL, + enabled boolean NOT NULL, + PRIMARY KEY (server_id, plugin_slug), + CONSTRAINT server_plugins_server_fkey FOREIGN KEY (server_id) + REFERENCES public.servers (id) ON UPDATE CASCADE ON DELETE CASCADE, + CONSTRAINT server_plugins_install_fkey FOREIGN KEY (plugin_slug) + REFERENCES public.game_plugin_installs (plugin_slug) ON UPDATE CASCADE ON DELETE CASCADE +); + +ALTER TABLE public.game_plugins + ADD COLUMN IF NOT EXISTS map_rotation jsonb; diff --git a/src/dedicated-servers/dedicated-server-config.service.ts b/src/dedicated-servers/dedicated-server-config.service.ts new file mode 100644 index 00000000..fba57532 --- /dev/null +++ b/src/dedicated-servers/dedicated-server-config.service.ts @@ -0,0 +1,347 @@ +import { BadRequestException, Injectable, Logger } from "@nestjs/common"; +import { PostgresService } from "../postgres/postgres.service"; +import { DedicatedServersService } from "./dedicated-servers.service"; + +type CommunityServer = { + id: string; + enabled: boolean; + game: string; +}; + +type WorkshopItem = { + publishedfileid: string; + result: number; + consumer_app_id?: number; + banned?: number | boolean; + title?: string; + preview_url?: string; +}; + +export type ImportedWorkshopMap = { + id: string; + name: string; + label: string | null; + poster: string | null; + workshop_map_id: string; +}; + +const STEAM_API = "https://api.steampowered.com/ISteamRemoteStorage"; + +@Injectable() +export class DedicatedServerConfigService { + constructor( + private readonly logger: Logger, + private readonly postgres: PostgresService, + private readonly dedicatedServers: DedicatedServersService, + ) {} + + public async setMapRotation( + serverId: string, + mapIds: Array, + shuffle: boolean, + ): Promise { + const server = await this.communityServer(serverId); + + if (server.game !== "cs2") { + throw new BadRequestException("Map rotations are CS2 only"); + } + + const ids = [...new Set(mapIds)]; + + await this.postgres.transaction(async (client) => { + const { rows } = await client.query<{ count: number }>( + `SELECT count(*)::int AS count + FROM maps + WHERE id = ANY($1::uuid[]) + AND deleted_at IS NULL`, + [ids], + ); + + if (rows[0].count !== ids.length) { + throw new BadRequestException( + "The rotation names a map that does not exist or was deleted", + ); + } + + await client.query( + `DELETE FROM server_map_rotation WHERE server_id = $1`, + [serverId], + ); + + await client.query( + `INSERT INTO server_map_rotation (server_id, map_id, position) + SELECT $1, rotation.map_id, rotation.position - 1 + FROM unnest($2::uuid[]) WITH ORDINALITY AS rotation(map_id, position)`, + [serverId, ids], + ); + + await client.query( + `UPDATE servers SET map_rotation_shuffle = $2 WHERE id = $1`, + [serverId, shuffle], + ); + }); + + await this.restart(server); + } + + public async setPlugins( + serverId: string, + plugins: Array<{ slug: string; enabled: boolean }>, + ): Promise { + const server = await this.communityServer(serverId); + + const overrides = new Map( + plugins.map((plugin) => [plugin.slug, plugin.enabled]), + ); + + await this.postgres.transaction(async (client) => { + await client.query(`DELETE FROM server_plugins WHERE server_id = $1`, [ + serverId, + ]); + + await client.query( + `INSERT INTO server_plugins (server_id, plugin_slug, enabled) + SELECT $1, override.slug, override.enabled + FROM unnest($2::text[], $3::boolean[]) AS override(slug, enabled)`, + [serverId, [...overrides.keys()], [...overrides.values()]], + ); + }); + + await this.restart(server); + } + + // Accepts a collection or a single map, as a link or a bare id: Steam answers + // a collection lookup for a plain item with result 9 and no children. + public async importWorkshopCollection( + collection: string, + ): Promise<{ maps: Array; skipped: number }> { + const collectionId = DedicatedServerConfigService.workshopId(collection); + + if (!collectionId) { + throw new BadRequestException( + "Paste a Steam workshop collection link or id", + ); + } + + const children = await this.collectionChildren(collectionId); + const itemIds = children ?? [collectionId]; + const details = await this.publishedFileDetails(itemIds); + + const maps: Array = []; + let skipped = 0; + + for (const itemId of itemIds) { + const item = details.get(itemId); + + if ( + !item || + item.result !== 1 || + item.consumer_app_id !== 730 || + item.banned || + !item.title + ) { + skipped++; + continue; + } + + maps.push( + await this.upsertWorkshopMap(itemId, item.title, item.preview_url), + ); + } + + if (maps.length === 0) { + throw new BadRequestException( + "That link has no public CS2 workshop maps in it", + ); + } + + return { maps, skipped }; + } + + public static workshopId(input: string): string | null { + const trimmed = (input ?? "").trim(); + + if (/^\d+$/.test(trimmed)) { + return trimmed; + } + + return trimmed.match(/[?&]id=(\d+)/)?.[1] ?? null; + } + + private async communityServer(serverId: string): Promise { + const [server] = await this.postgres.query< + Array< + CommunityServer & { + is_dedicated: boolean; + type: string; + game_server_node_id: string | null; + } + > + >( + `SELECT id, enabled, game, is_dedicated, type, game_server_node_id + FROM servers + WHERE id = $1`, + [serverId], + ); + + if (!server?.is_dedicated) { + throw new BadRequestException("Not a dedicated server"); + } + + if (server.type === "Ranked" || server.type === "Practice") { + throw new BadRequestException( + `${server.type} servers run 5Stack's own plugin set`, + ); + } + + // The settings are delivered in the pod spec, and an external server has + // no pod. + if (!server.game_server_node_id) { + throw new BadRequestException( + "Only servers running on a game server node can be configured here", + ); + } + + return server; + } + + // Mirrors the servers event: a disabled server picks the settings up when it + // is next enabled. + private async restart(server: CommunityServer): Promise { + if (!server.enabled) { + return; + } + + await this.dedicatedServers.removeDedicatedServer(server.id); + + if (!(await this.dedicatedServers.setupDedicatedServer(server.id))) { + throw new BadRequestException( + "Saved, but the server failed to start again; check the API logs", + ); + } + } + + private async collectionChildren( + collectionId: string, + ): Promise | null> { + const data = await this.steam<{ + response?: { + collectiondetails?: Array<{ + result: number; + children?: Array<{ + publishedfileid: string; + sortorder: number; + filetype: number; + }>; + }>; + }; + }>("GetCollectionDetails", { + collectioncount: "1", + "publishedfileids[0]": collectionId, + }); + + const collection = data.response?.collectiondetails?.[0]; + + if (collection?.result !== 1 || !collection.children?.length) { + return null; + } + + return collection.children + .filter((child) => child.filetype === 0) + .sort((a, b) => a.sortorder - b.sortorder) + .map((child) => child.publishedfileid); + } + + private async publishedFileDetails( + itemIds: Array, + ): Promise> { + const body: Record = { + itemcount: itemIds.length.toString(), + }; + + itemIds.forEach((itemId, index) => { + body[`publishedfileids[${index}]`] = itemId; + }); + + const data = await this.steam<{ + response?: { publishedfiledetails?: Array }; + }>("GetPublishedFileDetails", body); + + return new Map( + (data.response?.publishedfiledetails ?? []).map((item) => [ + item.publishedfileid, + item, + ]), + ); + } + + private async steam( + method: string, + body: Record, + ): Promise { + const response = await fetch(`${STEAM_API}/${method}/v1/`, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams(body), + signal: AbortSignal.timeout(15_000), + }); + + if (!response.ok) { + this.logger.warn(`steam ${method} answered ${response.status}`); + throw new BadRequestException("Steam did not answer; try again"); + } + + return (await response.json()) as T; + } + + // A workshop map is catalogued once, under Competitive and disabled, the same + // way the map form adds one: that keeps it out of every match map pool until + // an admin opts it in. An existing row is reused, and restored if deleted. + private async upsertWorkshopMap( + workshopId: string, + title: string, + poster: string | undefined, + ): Promise { + const [existing] = await this.postgres.query< + Array + >( + `SELECT id, name, label, poster, workshop_map_id, + deleted_at IS NOT NULL AS deleted + FROM maps + WHERE workshop_map_id = $1 + ORDER BY deleted_at IS NULL DESC, type = 'Competitive' DESC + LIMIT 1`, + [workshopId], + ); + + if (existing) { + if (existing.deleted) { + await this.postgres.query( + `UPDATE maps SET deleted_at = NULL WHERE workshop_map_id = $1`, + [workshopId], + ); + } + + return { + id: existing.id, + name: existing.name, + label: existing.label, + poster: existing.poster, + workshop_map_id: existing.workshop_map_id, + }; + } + + const [inserted] = await this.postgres.query>( + `INSERT INTO maps + (name, label, workshop_map_id, poster, type, enabled, active_pool) + VALUES ($1, $2, $1, $3, 'Competitive', false, false) + ON CONFLICT (name, type) DO UPDATE + SET workshop_map_id = EXCLUDED.workshop_map_id, + deleted_at = NULL + RETURNING id, name, label, poster, workshop_map_id`, + [workshopId, title, poster ?? null], + ); + + return inserted; + } +} diff --git a/src/dedicated-servers/dedicated-servers.controller.ts b/src/dedicated-servers/dedicated-servers.controller.ts index 4503b298..ea497d53 100644 --- a/src/dedicated-servers/dedicated-servers.controller.ts +++ b/src/dedicated-servers/dedicated-servers.controller.ts @@ -1,8 +1,9 @@ -import { Controller } from "@nestjs/common"; +import { Controller, ForbiddenException } from "@nestjs/common"; import { HasuraEvent } from "src/hasura/hasura.controller"; import { HasuraEventData } from "src/hasura/types/HasuraEventData"; import { server_regions_set_input, servers_set_input } from "generated"; import { DedicatedServersService } from "./dedicated-servers.service"; +import { DedicatedServerConfigService } from "./dedicated-server-config.service"; import { HasuraService } from "src/hasura/hasura.service"; import { HasuraAction } from "src/hasura/hasura.controller"; import { game_server_nodes_set_input } from "generated/schema"; @@ -14,6 +15,7 @@ export class DedicatedServersController { constructor( private readonly hasura: HasuraService, private readonly dedicatedServersService: DedicatedServersService, + private readonly dedicatedServerConfig: DedicatedServerConfigService, ) {} @HasuraEvent() @@ -145,4 +147,53 @@ export class DedicatedServersController { name: player.name, })); } + + @HasuraAction() + public async setServerMapRotation(data: { + user: User; + server_id: string; + map_ids: Array; + shuffle: boolean; + }) { + this.assertAdministrator(data.user); + + await this.dedicatedServerConfig.setMapRotation( + data.server_id, + data.map_ids, + data.shuffle, + ); + + return { success: true }; + } + + @HasuraAction() + public async setServerPlugins(data: { + user: User; + server_id: string; + plugins: Array<{ slug: string; enabled: boolean }>; + }) { + this.assertAdministrator(data.user); + + await this.dedicatedServerConfig.setPlugins(data.server_id, data.plugins); + + return { success: true }; + } + + @HasuraAction() + public async importWorkshopCollection(data: { + user: User; + collection: string; + }) { + this.assertAdministrator(data.user); + + return await this.dedicatedServerConfig.importWorkshopCollection( + data.collection, + ); + } + + private assertAdministrator(user: User): void { + if (!user || !isRoleAbove(user.role, "administrator")) { + throw new ForbiddenException("Administrator access required"); + } + } } diff --git a/src/dedicated-servers/dedicated-servers.module.ts b/src/dedicated-servers/dedicated-servers.module.ts index 4eff8110..cdaa73ce 100644 --- a/src/dedicated-servers/dedicated-servers.module.ts +++ b/src/dedicated-servers/dedicated-servers.module.ts @@ -1,5 +1,6 @@ import { Module } from "@nestjs/common"; import { DedicatedServersService } from "./dedicated-servers.service"; +import { DedicatedServerConfigService } from "./dedicated-server-config.service"; import { DedicatedServersController } from "./dedicated-servers.controller"; import { HasuraModule } from "src/hasura/hasura.module"; import { loggerFactory } from "src/utilities/LoggerFactory"; @@ -16,6 +17,7 @@ import { RedisModule } from "src/redis/redis.module"; import { SystemModule } from "src/system/system.module"; import { PluginRuntimeModule } from "src/plugin-runtime/plugin-runtime.module"; import { GamePluginsModule } from "../game-plugins/game-plugins.module"; +import { PostgresModule } from "../postgres/postgres.module"; @Module({ imports: [ @@ -33,9 +35,11 @@ import { GamePluginsModule } from "../game-plugins/game-plugins.module"; SystemModule, PluginRuntimeModule, GamePluginsModule, + PostgresModule, ], providers: [ DedicatedServersService, + DedicatedServerConfigService, PingDedicatedServers, ...getQueuesProcessors("DedicatedServers"), loggerFactory(), diff --git a/src/dedicated-servers/dedicated-servers.service.ts b/src/dedicated-servers/dedicated-servers.service.ts index 6df3d676..3863e7ef 100644 --- a/src/dedicated-servers/dedicated-servers.service.ts +++ b/src/dedicated-servers/dedicated-servers.service.ts @@ -12,6 +12,7 @@ import { Redis } from "ioredis"; import { SystemService } from "src/system/system.service"; import { PluginRuntimeService } from "src/plugin-runtime/plugin-runtime.service"; import { GameModesService } from "../game-plugins/game-modes.service"; +import { MapRotationService } from "../game-plugins/map-rotation.service"; @Injectable() export class DedicatedServersService { @@ -34,6 +35,7 @@ export class DedicatedServersService { private readonly systemService: SystemService, private readonly pluginRuntimeService: PluginRuntimeService, private readonly gameModesService: GameModesService, + private readonly mapRotationService: MapRotationService, ) { this.redis = this.redisManager.getConnection(); @@ -165,6 +167,10 @@ export class DedicatedServersService { const gameModeEnvironment = this.gameModesService.environmentFor(gameMode); + const startMap = MapRotationService.startMap( + await this.mapRotationService.forServer(serverId), + ); + const dedicatedServerDeploymentName = this.getDedicatedServerDeploymentName(serverId); @@ -272,7 +278,26 @@ export class DedicatedServersService { // TODO - number of players { name: "EXTRA_GAME_PARAMS", - value: `-maxplayers ${server.type === "Ranked" ? 16 : server.max_players} +map de_dust2 +game_type ${this.getGameType(server.type)} +game_mode ${this.getGameMode(server.type)} +sv_skirmish_id ${this.getWarGameType(server.type)} ${server.connect_password ? ` +sv_password ${server.connect_password}` : ""}${gameMode?.extraGameParams ? ` ${gameMode.extraGameParams}` : ""}`, + value: [ + `-maxplayers ${server.type === "Ranked" ? 16 : server.max_players}`, + startMap?.workshop_map_id + ? null + : `+map ${startMap?.name ?? "de_dust2"}`, + `+game_type ${this.getGameType(server.type)}`, + `+game_mode ${this.getGameMode(server.type)}`, + `+sv_skirmish_id ${this.getWarGameType(server.type)}`, + server.connect_password + ? `+sv_password ${server.connect_password}` + : null, + gameMode?.extraGameParams, + // Runs in command-line order, unlike +map, so it + // goes after game_type/game_mode have been set. + startMap?.workshop_map_id + ? `+host_workshop_map ${startMap.workshop_map_id}` + : null, + ] + .filter(Boolean) + .join(" "), }, { name: "SERVER_ID", value: server.id }, { diff --git a/src/game-plugins/game-modes.service.spec.ts b/src/game-plugins/game-modes.service.spec.ts index 46dc2d46..aa0afaea 100644 --- a/src/game-plugins/game-modes.service.spec.ts +++ b/src/game-plugins/game-modes.service.spec.ts @@ -36,6 +36,12 @@ describe("GameModesService auto-load plugins", () => { pin?.pin_plugin_runtime ?? "swiftlys2", ), } as never, + { + forServer: jest.fn(async () => ({ + maps: [] as Array, + shuffle: true, + })), + } as never, ); return { service, postgres }; @@ -210,6 +216,12 @@ describe("GameModesService node scoping", () => { pin?.pin_plugin_runtime ?? "swiftlys2", ), } as never, + { + forServer: jest.fn(async () => ({ + maps: [] as Array, + shuffle: true, + })), + } as never, ); return { service, seen }; @@ -364,6 +376,12 @@ describe("GameModesService server guidelines", () => { 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 }; diff --git a/src/game-plugins/game-modes.service.ts b/src/game-plugins/game-modes.service.ts index eceab959..c453cc71 100644 --- a/src/game-plugins/game-modes.service.ts +++ b/src/game-plugins/game-modes.service.ts @@ -2,6 +2,8 @@ import { Injectable, Logger } from "@nestjs/common"; import { PostgresService } from "../postgres/postgres.service"; import { PluginRuntimeService } from "../plugin-runtime/plugin-runtime.service"; import { PluginRuntime } from "../configs/types/GameServersConfig"; +import { MapRotationService } from "./map-rotation.service"; +import { MapRotationSpec } from "./types/Registry"; export type ResolvedGameMode = { id: string; @@ -67,6 +69,7 @@ export class GameModesService { private readonly logger: Logger, private readonly postgres: PostgresService, private readonly pluginRuntime: PluginRuntimeService, + private readonly mapRotation: MapRotationService, ) {} // A match's own mode always wins. A Ranked server never falls back to a @@ -84,6 +87,7 @@ export class GameModesService { is_ranked: boolean; is_tournament: boolean; is_ranked_server: boolean; + is_community_server: boolean; }> >( `SELECT COALESCE( @@ -104,7 +108,9 @@ export class GameModesService { EXISTS ( SELECT 1 FROM tournament_brackets tb WHERE tb.match_id = $2 ) AS is_tournament, - s.type = 'Ranked' AS is_ranked_server + s.type = 'Ranked' AS is_ranked_server, + s.is_dedicated AND s.type NOT IN ('Ranked', 'Practice') + AS is_community_server FROM servers s LEFT JOIN game_server_nodes n ON n.id = s.game_server_node_id WHERE s.id = $1`, @@ -147,6 +153,10 @@ export class GameModesService { ); } + if (!matchId && row?.is_community_server) { + return await this.withServerPlugins(serverId, mode, scope); + } + return await this.withAutoLoad(mode, scope); } @@ -166,42 +176,237 @@ export class GameModesService { mode: ResolvedGameMode | null, scope?: PluginScope, ): Promise { - const always = await this.autoLoadPlugins(scope); + return await this.withServerGuidelines( + GameModesService.withPlugins(mode, await this.autoLoadPlugins(scope)), + ); + } - if (always.length === 0) { - return await this.withServerGuidelines(mode); + // A community server can switch a plugin on or off for itself; with no + // override it follows the install's load flags like every other server. + // Its mode's plugins are not overridable: picking the mode is the choice. + private async withServerPlugins( + serverId: string, + mode: ResolvedGameMode | null, + scope: PluginScope, + ): Promise { + const overrides = await this.postgres.query< + Array<{ plugin_slug: string; enabled: boolean; version: string | null }> + >( + `SELECT sp.plugin_slug, + sp.enabled, + CASE WHEN i.enabled THEN + (SELECT n.version + FROM game_server_node_plugins n + WHERE n.plugin_slug = sp.plugin_slug + AND n.runtime = $2 + AND n.status = 'Installed' + AND n.version IS NOT NULL + AND ($3::text IS NULL OR n.game_server_node_id = $3) + ORDER BY n.updated_at DESC + LIMIT 1) + END AS version + FROM server_plugins sp + INNER JOIN game_plugin_installs i ON i.plugin_slug = sp.plugin_slug + WHERE sp.server_id = $1 + ORDER BY sp.plugin_slug`, + [serverId, scope.runtime, scope.nodeId], + ); + + const off = new Set( + overrides + .filter((override) => !override.enabled) + .map((override) => override.plugin_slug), + ); + + for (const override of overrides) { + if (override.enabled && !override.version) { + this.logger.warn( + `server ${serverId}: ${override.plugin_slug} is switched on but not installed on ${ + scope.nodeId ?? `any node for ${scope.runtime}` + }`, + ); + } } - const fromMode = (mode?.enabledPlugins ?? "").split(",").filter(Boolean); - const modeSlugs = new Set(fromMode.map((entry) => entry.split("@")[0])); + const always = (await this.autoLoadPlugins(scope)).filter( + (entry) => !off.has(GameModesService.slugOf(entry)), + ); - const enabled = [ - ...fromMode, - // The mode wins a duplicate: it may pin a different version, and it is - // the more specific statement of what this match should run. - ...always.filter((entry) => !modeSlugs.has(entry.split("@")[0])), - ]; + const on = overrides + .filter((override) => override.enabled && override.version) + .map((override) => `${override.plugin_slug}@${override.version}`); + + const resolved = await this.withMapRotation( + serverId, + GameModesService.withPlugins(mode, [...always, ...on]), + scope, + off, + ); + + return await this.withServerGuidelines(resolved); + } - if (mode) { - return await this.withServerGuidelines({ - ...mode, - enabledPlugins: enabled.join(","), - }); + // A rotation is only played by a plugin that declares map_rotation in the + // registry. If none is loading yet the first installed one is added, since + // a rotation with nothing to run it would leave the server on its first map + // forever -- unless the server switched that plugin off. + private async withMapRotation( + serverId: string, + resolved: ResolvedGameMode | null, + scope: PluginScope, + off: Set, + ): Promise { + const rotation = await this.mapRotation.forServer(serverId); + + if (rotation.maps.length === 0) { + return resolved; } - // No mode, but plugins that load regardless. Everything else is empty so - // the server gets the plugins and none of a mode's cfg or launch params. - return await this.withServerGuidelines({ + const players = await this.postgres.query< + Array<{ + slug: string; + map_rotation: MapRotationSpec; + installed: boolean; + version: string | null; + }> + >( + `SELECT p.slug, + p.map_rotation, + COALESCE(i.enabled, false) AS installed, + (SELECT n.version + FROM game_server_node_plugins n + WHERE n.plugin_slug = p.slug + AND n.runtime = $1 + AND n.status = 'Installed' + AND n.version IS NOT NULL + AND ($2::text IS NULL OR n.game_server_node_id = $2) + ORDER BY n.updated_at DESC + LIMIT 1) AS version + FROM game_plugins p + LEFT JOIN game_plugin_installs i ON i.plugin_slug = p.slug + WHERE p.map_rotation IS NOT NULL + ORDER BY p.slug`, + [scope.runtime, scope.nodeId], + ); + + const loading = GameModesService.entriesOf(resolved); + const loadingSlugs = new Set(loading.map(GameModesService.slugOf)); + + let runners = players.filter((player) => loadingSlugs.has(player.slug)); + + if (runners.length === 0) { + const runner = players.find( + (player) => player.installed && player.version && !off.has(player.slug), + ); + + if (!runner) { + this.logger.warn( + `server ${serverId} has a map rotation but no map rotation plugin is installed on ${ + scope.nodeId ?? `any node for ${scope.runtime}` + }`, + ); + + return resolved; + } + + runners = [runner]; + loading.push(`${runner.slug}@${runner.version}`); + } + + const files: Record = {}; + + for (const runner of runners) { + Object.assign( + files, + MapRotationService.render(runner.map_rotation, rotation, scope.runtime), + ); + } + + const base = resolved ?? GameModesService.noMode(); + + return { + ...base, + enabledPlugins: loading.join(","), + pluginConfigs: GameModesService.withConfigFiles( + base.pluginConfigs, + files, + ), + }; + } + + // The mode wins a duplicate: it may pin a different version, and it is the + // more specific statement of what this server should run. + private static withPlugins( + mode: ResolvedGameMode | null, + extra: Array, + ): ResolvedGameMode | null { + if (extra.length === 0) { + return mode; + } + + const enabled = GameModesService.entriesOf(mode); + const taken = new Set(enabled.map(GameModesService.slugOf)); + + for (const entry of extra) { + const slug = GameModesService.slugOf(entry); + + if (taken.has(slug)) { + continue; + } + + taken.add(slug); + enabled.push(entry); + } + + return { + ...(mode ?? GameModesService.noMode()), + enabledPlugins: enabled.join(","), + }; + } + + // No mode, but plugins that load regardless. Everything else is empty so the + // server gets the plugins and none of a mode's cfg or launch params. + private static noMode(): ResolvedGameMode { + return { id: "", slug: "", name: "", cfg: null, extraGameParams: null, - enabledPlugins: enabled.join(","), + enabledPlugins: "", pluginConfigs: null, missingRequired: [], disableServerGuidelines: false, - }); + }; + } + + private static entriesOf(mode: ResolvedGameMode | null): Array { + return (mode?.enabledPlugins ?? "").split(",").filter(Boolean); + } + + private static slugOf(entry: string): string { + return entry.split("@")[0]; + } + + // A file the rotation writes replaces the same path from the mode's plugin + // config: the rotation is about this server, the mode about every server. + private static withConfigFiles( + encoded: string | null, + files: Record, + ): string | null { + const merged = { + ...(encoded + ? (JSON.parse(Buffer.from(encoded, "base64").toString()) as Record< + string, + string + >) + : {}), + ...files, + }; + + return Object.keys(merged).length > 0 + ? Buffer.from(JSON.stringify(merged)).toString("base64") + : null; } // Both frameworks refuse the calls a HUD or scoreboard plugin needs while diff --git a/src/game-plugins/game-plugins.module.ts b/src/game-plugins/game-plugins.module.ts index c73e9318..9b863022 100644 --- a/src/game-plugins/game-plugins.module.ts +++ b/src/game-plugins/game-plugins.module.ts @@ -13,6 +13,7 @@ import { loggerFactory } from "../utilities/LoggerFactory"; import { GamePluginQueues } from "./enums/GamePluginQueues"; import { GamePluginsService } from "./game-plugins.service"; import { GameModesService } from "./game-modes.service"; +import { MapRotationService } from "./map-rotation.service"; import { GamePluginsController } from "./game-plugins.controller"; import { SyncGamePluginRegistry } from "./jobs/SyncGamePluginRegistry"; import { CheckGamePluginUpdates } from "./jobs/CheckGamePluginUpdates"; @@ -22,6 +23,7 @@ import { NotifyGamePluginUpdate } from "./jobs/NotifyGamePluginUpdate"; providers: [ GamePluginsService, GameModesService, + MapRotationService, SyncGamePluginRegistry, CheckGamePluginUpdates, NotifyGamePluginUpdate, @@ -42,7 +44,7 @@ import { NotifyGamePluginUpdate } from "./jobs/NotifyGamePluginUpdate"; { name: GamePluginQueues.Registry, adapter: BullMQAdapter }, ), ], - exports: [GamePluginsService, GameModesService], + exports: [GamePluginsService, GameModesService, MapRotationService], controllers: [GamePluginsController], }) export class GamePluginsModule { diff --git a/src/game-plugins/game-plugins.service.ts b/src/game-plugins/game-plugins.service.ts index a6ae294c..8db39df6 100644 --- a/src/game-plugins/game-plugins.service.ts +++ b/src/game-plugins/game-plugins.service.ts @@ -127,8 +127,9 @@ export class GamePluginsService { `INSERT INTO public.game_plugins (slug, kind, name, author, description, homepage, tags, verified, hot_swappable, requires_service, requires_server_guidelines_disabled, - config_schema, config_path, cvars, panel, wiring, pairs_with, synced_at) - VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17, now()) + config_schema, config_path, cvars, panel, wiring, pairs_with, + map_rotation, synced_at) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18, now()) ON CONFLICT (slug) DO UPDATE SET kind = EXCLUDED.kind, name = EXCLUDED.name, @@ -146,6 +147,7 @@ export class GamePluginsService { panel = EXCLUDED.panel, wiring = EXCLUDED.wiring, pairs_with = EXCLUDED.pairs_with, + map_rotation = EXCLUDED.map_rotation, synced_at = now()`, [ plugin.slug, @@ -165,6 +167,7 @@ export class GamePluginsService { plugin.panel ? JSON.stringify(plugin.panel) : null, plugin.wiring ? JSON.stringify(plugin.wiring) : null, plugin.pairs_with ?? [], + plugin.map_rotation ? JSON.stringify(plugin.map_rotation) : null, ], ); diff --git a/src/game-plugins/map-rotation.service.spec.ts b/src/game-plugins/map-rotation.service.spec.ts new file mode 100644 index 00000000..62db2c15 --- /dev/null +++ b/src/game-plugins/map-rotation.service.spec.ts @@ -0,0 +1,119 @@ +import { MapRotationService } from "./map-rotation.service"; + +const mapChooser = { + files: { + "addons/{runtime}/configs/plugins/MapChooser/maps.jsonc": { + MapChooserMaps: { Maps: "{{maps}}" }, + }, + "addons/{runtime}/configs/plugins/MapChooser/config.jsonc": { + MapChooser: { Cycle: { Enabled: true, RandomOrder: "{{shuffle}}" } }, + }, + }, + map: { Name: "{{label}}", Id: "{{id}}" }, +}; + +const rotation = { + maps: [ + { name: "de_dust2", label: null, workshop_map_id: null }, + { + name: "3615968422", + label: "Prophunt Mirage", + workshop_map_id: "3615968422", + }, + ], + shuffle: true, +}; + +describe("MapRotationService", () => { + describe("render", () => { + it("writes each file with the runtime substituted into its path", () => { + const files = MapRotationService.render( + mapChooser, + rotation, + "swiftlys2", + ); + + expect(Object.keys(files).sort()).toEqual([ + "addons/swiftlys2/configs/plugins/MapChooser/config.jsonc", + "addons/swiftlys2/configs/plugins/MapChooser/maps.jsonc", + ]); + }); + + it("renders the map list as an array, using the workshop id where there is one", () => { + const files = MapRotationService.render( + mapChooser, + rotation, + "swiftlys2", + ); + + expect( + JSON.parse( + files["addons/swiftlys2/configs/plugins/MapChooser/maps.jsonc"], + ), + ).toEqual({ + MapChooserMaps: { + Maps: [ + { Name: "de_dust2", Id: "de_dust2" }, + { Name: "Prophunt Mirage", Id: "3615968422" }, + ], + }, + }); + }); + + it("keeps shuffle a boolean", () => { + const files = MapRotationService.render( + mapChooser, + { ...rotation, shuffle: false }, + "swiftlys2", + ); + + expect( + JSON.parse( + files["addons/swiftlys2/configs/plugins/MapChooser/config.jsonc"], + ).MapChooser.Cycle, + ).toEqual({ Enabled: true, RandomOrder: false }); + }); + + it("interpolates tokens inside longer strings and leaves unknown ones alone", () => { + const files = MapRotationService.render( + { + files: { "maps.txt.json": { lines: "{{maps}}" } }, + map: { line: "ws:{{workshop_id}} {{name}} {{nope}}" }, + }, + rotation, + "counterstrikesharp", + ); + + expect(JSON.parse(files["maps.txt.json"]).lines).toEqual([ + { line: "ws: de_dust2 {{nope}}" }, + { line: "ws:3615968422 3615968422 {{nope}}" }, + ]); + }); + }); + + describe("startMap", () => { + it("starts on the first map when the rotation plays in order", () => { + expect( + MapRotationService.startMap( + { ...rotation, shuffle: false }, + () => 0.99, + ), + ).toEqual(rotation.maps[0]); + }); + + it("starts on a random map when shuffled", () => { + expect(MapRotationService.startMap(rotation, () => 0.99)).toEqual( + rotation.maps[1], + ); + expect(MapRotationService.startMap(rotation, () => 0)).toEqual( + rotation.maps[0], + ); + }); + + it("has nothing to start on without a rotation", () => { + expect( + MapRotationService.startMap({ maps: [], shuffle: true }), + ).toBeNull(); + }); + }); +}); diff --git a/src/game-plugins/map-rotation.service.ts b/src/game-plugins/map-rotation.service.ts new file mode 100644 index 00000000..43173160 --- /dev/null +++ b/src/game-plugins/map-rotation.service.ts @@ -0,0 +1,149 @@ +import { Injectable } from "@nestjs/common"; +import { PostgresService } from "../postgres/postgres.service"; +import { MapRotationSpec } from "./types/Registry"; + +export type RotationMap = { + name: string; + label: string | null; + workshop_map_id: string | null; +}; + +export type ServerMapRotation = { + maps: Array; + shuffle: boolean; +}; + +const TOKEN = /\{\{(\w+)\}\}/g; +const WHOLE_TOKEN = /^\{\{(\w+)\}\}$/; + +@Injectable() +export class MapRotationService { + constructor(private readonly postgres: PostgresService) {} + + // Only a CS2 community server plays a rotation. Rows left behind by a server + // that was since switched to Ranked or Practice are ignored, not deleted, so + // switching back restores them. + public async forServer(serverId: string): Promise { + const rows = await this.postgres.query< + Array + >( + `SELECT m.name, m.label, m.workshop_map_id, + s.map_rotation_shuffle AS shuffle + FROM servers s + INNER JOIN server_map_rotation r ON r.server_id = s.id + INNER JOIN maps m ON m.id = r.map_id + WHERE s.id = $1 + AND s.is_dedicated = true + AND s.game = 'cs2' + AND s.type NOT IN ('Ranked', 'Practice') + AND m.deleted_at IS NULL + ORDER BY r.position ASC, m.name ASC`, + [serverId], + ); + + const seen = new Set(); + const maps: Array = []; + + for (const row of rows) { + const map: RotationMap = { + name: row.name, + label: row.label, + workshop_map_id: row.workshop_map_id, + }; + const id = MapRotationService.mapId(map); + + if (seen.has(id)) { + continue; + } + + seen.add(id); + maps.push(map); + } + + return { maps, shuffle: rows[0]?.shuffle ?? true }; + } + + // What host_workshop_map or changelevel takes. + public static mapId(map: RotationMap): string { + return map.workshop_map_id || map.name; + } + + public static startMap( + rotation: ServerMapRotation, + random: () => number = Math.random, + ): RotationMap | null { + if (rotation.maps.length === 0) { + return null; + } + + if (!rotation.shuffle) { + return rotation.maps[0]; + } + + return rotation.maps[Math.floor(random() * rotation.maps.length)]; + } + + public static render( + spec: MapRotationSpec, + rotation: ServerMapRotation, + runtime: string, + ): Record { + const maps = rotation.maps.map((map) => + MapRotationService.fill(spec.map, { + id: MapRotationService.mapId(map), + name: map.name, + label: map.label || map.name, + workshop_id: map.workshop_map_id, + }), + ); + + const files: Record = {}; + + for (const [path, document] of Object.entries(spec.files)) { + files[path.replaceAll("{runtime}", runtime)] = JSON.stringify( + MapRotationService.fill(document, { + maps, + shuffle: rotation.shuffle, + }), + null, + 2, + ); + } + + return files; + } + + // A string that is exactly one token takes the token's own type, so + // "{{maps}}" becomes an array and "{{shuffle}}" a boolean rather than text. + private static fill( + value: unknown, + tokens: Record, + ): unknown { + if (typeof value === "string") { + const whole = value.match(WHOLE_TOKEN); + + if (whole && whole[1] in tokens) { + return tokens[whole[1]]; + } + + return value.replace(TOKEN, (match, key: string) => + key in tokens ? String(tokens[key] ?? "") : match, + ); + } + + if (Array.isArray(value)) { + return value.map((item) => MapRotationService.fill(item, tokens)); + } + + if (value && typeof value === "object") { + return Object.fromEntries( + Object.entries(value).map(([key, item]) => [ + key, + MapRotationService.fill(item, tokens), + ]), + ); + } + + return value; + } +} diff --git a/src/game-plugins/types/Registry.ts b/src/game-plugins/types/Registry.ts index 776cd329..a6b55d15 100644 --- a/src/game-plugins/types/Registry.ts +++ b/src/game-plugins/types/Registry.ts @@ -10,6 +10,11 @@ export type RegistryVersion = { install_path?: string; }; +export type MapRotationSpec = { + files: Record>; + map: Record; +}; + export type RegistryPlugin = { slug: string; kind: "game" | "panel" | "bundle"; @@ -25,6 +30,7 @@ export type RegistryPlugin = { config_schema?: Record; config_path?: string; cvars?: Array; + map_rotation?: MapRotationSpec; panel?: Record; wiring?: Record; pairs_with?: Array; diff --git a/test/game-mode-resolution.spec.ts b/test/game-mode-resolution.spec.ts index 2fd482a7..03562494 100644 --- a/test/game-mode-resolution.spec.ts +++ b/test/game-mode-resolution.spec.ts @@ -3,6 +3,7 @@ import { GameModesService, RequiredPluginMissing, } from "./../src/game-plugins/game-modes.service"; +import { MapRotationService } from "./../src/game-plugins/map-rotation.service"; import { bootMigratedDb, SqlTestDb } from "./utils/sql-test-db"; // What a server is actually told to load, resolved against a real schema. The @@ -26,6 +27,7 @@ describe("game mode resolution (SQL-driven)", () => { pin_plugin_runtime?: string | null; }) => pin?.pin_plugin_runtime ?? "swiftlys2", } as never, + new MapRotationService(postgres), ); }, 600_000); @@ -42,6 +44,7 @@ describe("game mode resolution (SQL-driven)", () => { ); await postgres.query("DELETE FROM matches"); await postgres.query("DELETE FROM servers"); + await postgres.query("DELETE FROM maps WHERE name LIKE 'rotation-%'"); await postgres.query("DELETE FROM game_server_node_plugins"); await postgres.query("DELETE FROM game_plugin_installs"); await postgres.query("DELETE FROM game_mode_plugins"); @@ -537,4 +540,269 @@ describe("game mode resolution (SQL-driven)", () => { expect(await service.pluginCfgLayers(null)).toEqual([]); }); }); + // A dedicated server that is not Ranked or Practice: the public (or private) + // server an operator runs on its own terms. + describe("community servers", () => { + const mapChooserRotation = { + files: { + "addons/{runtime}/configs/plugins/MapChooser/maps.jsonc": { + MapChooserMaps: { Maps: "{{maps}}" }, + }, + "addons/{runtime}/configs/plugins/MapChooser/config.jsonc": { + MapChooser: { Cycle: { Enabled: true, RandomOrder: "{{shuffle}}" } }, + }, + }, + map: { Name: "{{label}}", Id: "{{id}}" }, + }; + + const community = async (port: number): Promise => { + const [server] = await postgres.query>( + `INSERT INTO servers + (host, label, rcon_password, port, tv_port, region, type, + is_dedicated, enabled, game_server_node_id) + VALUES ('127.0.0.1', 'public', $1, $2, $2 + 1, 'TestRegion', 'Casual', + false, true, 'node-a') + RETURNING id`, + [Buffer.from("password"), port], + ); + + await postgres.query( + `UPDATE servers SET is_dedicated = true WHERE id = $1`, + [server.id], + ); + + return server.id; + }; + + const override = async ( + server: string, + slug: string, + enabled: boolean, + ): Promise => { + await postgres.query( + `INSERT INTO server_plugins (server_id, plugin_slug, enabled) + VALUES ($1, $2, $3)`, + [server, slug, enabled], + ); + }; + + const workshopMap = async ( + name: string, + label: string, + workshopId: string, + ): Promise => { + const [map] = await postgres.query>( + `INSERT INTO maps (name, label, workshop_map_id, type, enabled, active_pool) + VALUES ($1, $2, $3, 'Competitive', false, false) + RETURNING id`, + [name, label, workshopId], + ); + + return map.id; + }; + + const rotate = async ( + server: string, + mapIds: Array, + shuffle = true, + ): Promise => { + await postgres.query( + `INSERT INTO server_map_rotation (server_id, map_id, position) + SELECT $1, rotation.map_id, rotation.position + FROM unnest($2::uuid[]) WITH ORDINALITY AS rotation(map_id, position)`, + [server, mapIds], + ); + await postgres.query( + `UPDATE servers SET map_rotation_shuffle = $2 WHERE id = $1`, + [server, shuffle], + ); + }; + + const files = (encoded: string | null): Record => + Object.fromEntries( + Object.entries( + JSON.parse(Buffer.from(encoded ?? "", "base64").toString() || "{}"), + ).map(([path, content]) => [path, JSON.parse(content as string)]), + ); + + let publicServer: string; + + beforeEach(async () => { + publicServer = await community(27100); + + await catalog("csroll"); + await installed("csroll"); + await onNode("node-a", "csroll", "1.0.0"); + + await catalog("map-chooser"); + await postgres.query( + `UPDATE game_plugins SET map_rotation = $1 WHERE slug = 'map-chooser'`, + [JSON.stringify(mapChooserRotation)], + ); + await installed("map-chooser"); + await onNode("node-a", "map-chooser", "1.3.2"); + }); + + it("loads a plugin switched on for that server and no other", async () => { + const otherServer = await community(27200); + await override(publicServer, "csroll", true); + + expect( + (await service.resolveForServer(publicServer))?.enabledPlugins, + ).toEqual("csroll@1.0.0"); + expect( + (await service.resolveForServer(otherServer))?.enabledPlugins ?? "", + ).toEqual(""); + }); + + it("keeps an auto-load plugin off a server that switched it off", async () => { + const otherServer = await community(27200); + await autoLoads("csroll"); + await override(publicServer, "csroll", false); + + expect( + (await service.resolveForServer(publicServer))?.enabledPlugins ?? "", + ).toEqual(""); + expect( + (await service.resolveForServer(otherServer))?.enabledPlugins, + ).toEqual("csroll@1.0.0"); + }); + + it("does not let a server switch off a plugin its mode needs", async () => { + await postgres.query(`UPDATE servers SET type = 'Custom' WHERE id = $1`, [ + publicServer, + ]); + const [mode] = await postgres.query>( + `INSERT INTO game_modes (slug, name) VALUES ('chaos', 'Chaos') RETURNING id`, + ); + await postgres.query( + `INSERT INTO game_mode_plugins (game_mode_id, plugin_slug) VALUES ($1, 'csroll')`, + [mode.id], + ); + await postgres.query( + `UPDATE servers SET game_mode_id = $1 WHERE id = $2`, + [mode.id, publicServer], + ); + await override(publicServer, "csroll", false); + + expect( + (await service.resolveForServer(publicServer))?.enabledPlugins, + ).toEqual("csroll@1.0.0"); + }); + + it("ignores overrides on a Ranked server", async () => { + await override(publicServer, "csroll", true); + await postgres.query(`UPDATE servers SET type = 'Ranked' WHERE id = $1`, [ + publicServer, + ]); + + expect( + (await service.resolveForServer(publicServer))?.enabledPlugins ?? "", + ).toEqual(""); + }); + + it("loads the installed rotation plugin and writes the rotation into its files", async () => { + const mirage = await workshopMap( + "rotation-mirage", + "Prophunt Mirage", + "3615968422", + ); + const office = await workshopMap( + "rotation-office", + "Prophunt Office", + "3644811896", + ); + await rotate(publicServer, [office, mirage], false); + + const resolved = await service.resolveForServer(publicServer); + + expect(resolved?.enabledPlugins).toEqual("map-chooser@1.3.2"); + expect(files(resolved?.pluginConfigs ?? null)).toEqual({ + "addons/swiftlys2/configs/plugins/MapChooser/maps.jsonc": { + MapChooserMaps: { + Maps: [ + { Name: "Prophunt Office", Id: "3644811896" }, + { Name: "Prophunt Mirage", Id: "3615968422" }, + ], + }, + }, + "addons/swiftlys2/configs/plugins/MapChooser/config.jsonc": { + MapChooser: { Cycle: { Enabled: true, RandomOrder: false } }, + }, + }); + expect(service.environmentFor(resolved).map((env) => env.name)).toEqual([ + "ENABLED_PLUGINS", + "PLUGIN_CONFIGS", + ]); + }); + + it("drops a deleted map from the rotation", async () => { + const mirage = await workshopMap( + "rotation-mirage", + "Prophunt Mirage", + "3615968422", + ); + const office = await workshopMap( + "rotation-office", + "Prophunt Office", + "3644811896", + ); + await rotate(publicServer, [mirage, office]); + await postgres.query(`UPDATE maps SET deleted_at = now() WHERE id = $1`, [ + office, + ]); + + const resolved = await service.resolveForServer(publicServer); + + expect( + files(resolved?.pluginConfigs ?? null)[ + "addons/swiftlys2/configs/plugins/MapChooser/maps.jsonc" + ], + ).toEqual({ + MapChooserMaps: { + Maps: [{ Name: "Prophunt Mirage", Id: "3615968422" }], + }, + }); + }); + + it("leaves the rotation plugin off a server that switched it off", async () => { + await rotate(publicServer, [ + await workshopMap("rotation-mirage", "Prophunt Mirage", "3615968422"), + ]); + await override(publicServer, "map-chooser", false); + + const resolved = await service.resolveForServer(publicServer); + + expect(resolved?.enabledPlugins ?? "").toEqual(""); + expect(resolved?.pluginConfigs ?? null).toBeNull(); + }); + + it("writes the rotation for a rotation plugin already loading, without loading it twice", async () => { + await autoLoads("map-chooser"); + await rotate(publicServer, [ + await workshopMap("rotation-mirage", "Prophunt Mirage", "3615968422"), + ]); + + const resolved = await service.resolveForServer(publicServer); + + expect(resolved?.enabledPlugins).toEqual("map-chooser@1.3.2"); + expect(Object.keys(files(resolved?.pluginConfigs ?? null))).toHaveLength( + 2, + ); + }); + + it("plays no rotation on a Practice server", async () => { + await rotate(publicServer, [ + await workshopMap("rotation-mirage", "Prophunt Mirage", "3615968422"), + ]); + await postgres.query( + `UPDATE servers SET type = 'Practice' WHERE id = $1`, + [publicServer], + ); + + expect( + (await service.resolveForServer(publicServer))?.enabledPlugins ?? "", + ).toEqual(""); + }); + }); }); diff --git a/test/server-map-rotation.spec.ts b/test/server-map-rotation.spec.ts new file mode 100644 index 00000000..ba0f22f8 --- /dev/null +++ b/test/server-map-rotation.spec.ts @@ -0,0 +1,368 @@ +import { PostgresService } from "./../src/postgres/postgres.service"; +import { DedicatedServerConfigService } from "./../src/dedicated-servers/dedicated-server-config.service"; +import { bootMigratedDb, SqlTestDb } from "./utils/sql-test-db"; + +// What the dedicated server page saves: a map rotation, per-server plugin +// overrides, and workshop maps imported from a Steam collection. +describe("dedicated server config (SQL-driven)", () => { + let db: SqlTestDb; + let postgres: PostgresService; + let service: DedicatedServerConfigService; + + const dedicatedServers = { + removeDedicatedServer: jest.fn(async (): Promise => undefined), + setupDedicatedServer: jest.fn(async () => true), + }; + + beforeAll(async () => { + db = await bootMigratedDb("DedicatedServerConfig"); + postgres = db.postgres; + + service = new DedicatedServerConfigService( + { warn: jest.fn(), log: jest.fn() } as never, + postgres, + dedicatedServers as never, + ); + }, 600_000); + + afterAll(async () => { + await db?.stop(); + }); + + let serverId: string; + + beforeEach(async () => { + jest.clearAllMocks(); + + await postgres.query("DELETE FROM servers"); + await postgres.query("DELETE FROM game_plugin_installs"); + await postgres.query("DELETE FROM game_plugins"); + await postgres.query("DELETE FROM game_server_nodes"); + await postgres.query("DELETE FROM maps WHERE workshop_map_id LIKE '9%'"); + await postgres.query("DELETE FROM maps WHERE name LIKE 'rotation-%'"); + + await postgres.query( + `INSERT INTO server_regions (value, description) + VALUES ('TestRegion', 'TestRegion') ON CONFLICT (value) DO NOTHING`, + ); + await postgres.query( + `INSERT INTO game_server_nodes (id, status, enabled, region) + VALUES ('node-a', 'Online', true, 'TestRegion')`, + ); + + const [server] = await postgres.query>( + `INSERT INTO servers + (host, label, rcon_password, port, tv_port, region, type, is_dedicated, + enabled, game_server_node_id) + VALUES ('127.0.0.1', 'public', $1, 27015, 27020, 'TestRegion', 'Casual', + false, true, 'node-a') + RETURNING id`, + [Buffer.from("password")], + ); + await postgres.query( + `UPDATE servers SET is_dedicated = true WHERE id = $1`, + [server.id], + ); + + serverId = server.id; + }); + + const map = async (name: string, deleted = false): Promise => { + const [row] = await postgres.query>( + `INSERT INTO maps (name, type, enabled, active_pool, deleted_at) + VALUES ($1, 'Competitive', false, false, $2) + RETURNING id`, + [name, deleted ? new Date() : null], + ); + + return row.id; + }; + + const rotation = async (): Promise> => + ( + await postgres.query>( + `SELECT map_id FROM server_map_rotation + WHERE server_id = $1 ORDER BY position`, + [serverId], + ) + ).map((row) => row.map_id); + + describe("setMapRotation", () => { + it("replaces the rotation in the order given, once per map, and restarts the server", async () => { + const a = await map("rotation-a"); + const b = await map("rotation-b"); + const c = await map("rotation-c"); + + await service.setMapRotation(serverId, [a, b], true); + await service.setMapRotation(serverId, [c, a, c], false); + + expect(await rotation()).toEqual([c, a]); + + const [server] = await postgres.query>( + `SELECT map_rotation_shuffle AS shuffle FROM servers WHERE id = $1`, + [serverId], + ); + expect(server.shuffle).toBe(false); + expect(dedicatedServers.setupDedicatedServer).toHaveBeenCalledTimes(2); + }); + + it("clears the rotation when given no maps", async () => { + await service.setMapRotation(serverId, [await map("rotation-a")], true); + await service.setMapRotation(serverId, [], true); + + expect(await rotation()).toEqual([]); + }); + + it("refuses a deleted map and keeps the rotation it had", async () => { + const a = await map("rotation-a"); + await service.setMapRotation(serverId, [a], true); + + await expect( + service.setMapRotation( + serverId, + [a, await map("rotation-gone", true)], + true, + ), + ).rejects.toThrow(/does not exist or was deleted/); + + expect(await rotation()).toEqual([a]); + }); + + // The settings reach the server in its pod spec, so a disabled server + // picks them up the next time it is enabled rather than being started. + it("saves without starting a disabled server", async () => { + await postgres.query(`UPDATE servers SET enabled = false WHERE id = $1`, [ + serverId, + ]); + + await service.setMapRotation(serverId, [await map("rotation-a")], true); + + expect(dedicatedServers.setupDedicatedServer).not.toHaveBeenCalled(); + }); + + it("refuses Ranked servers", async () => { + await postgres.query(`UPDATE servers SET type = 'Ranked' WHERE id = $1`, [ + serverId, + ]); + + await expect( + service.setMapRotation(serverId, [await map("rotation-a")], true), + ).rejects.toThrow(/Ranked servers/); + }); + + it("refuses an external server, which has no pod to configure", async () => { + const [external] = await postgres.query>( + `INSERT INTO servers + (host, label, rcon_password, port, tv_port, region, type, + is_dedicated, enabled) + VALUES ('10.0.0.1', 'external', $1, 27015, 27020, 'TestRegion', + 'Casual', true, true) + RETURNING id`, + [Buffer.from("password")], + ); + + await expect( + service.setMapRotation(external.id, [await map("rotation-a")], true), + ).rejects.toThrow(/game server node/); + }); + }); + + describe("setPlugins", () => { + beforeEach(async () => { + for (const slug of ["csroll", "map-chooser"]) { + await postgres.query( + `INSERT INTO game_plugins (slug, kind, name, author, description) + VALUES ($1, 'game', $1, 'tester', 'a test plugin')`, + [slug], + ); + await postgres.query( + `INSERT INTO game_plugin_installs (plugin_slug, version, channel) + VALUES ($1, NULL, 'Auto')`, + [slug], + ); + } + }); + + const overrides = async () => + await postgres.query>( + `SELECT plugin_slug, enabled FROM server_plugins + WHERE server_id = $1 ORDER BY plugin_slug`, + [serverId], + ); + + it("replaces every override with the ones given", async () => { + await service.setPlugins(serverId, [ + { slug: "csroll", enabled: true }, + { slug: "map-chooser", enabled: false }, + ]); + await service.setPlugins(serverId, [{ slug: "csroll", enabled: false }]); + + expect(await overrides()).toEqual([ + { plugin_slug: "csroll", enabled: false }, + ]); + }); + + it("refuses a plugin that is not installed", async () => { + await expect( + service.setPlugins(serverId, [{ slug: "nope", enabled: true }]), + ).rejects.toThrow(/server_plugins_install_fkey/); + }); + + it("forgets a server's override when the plugin is uninstalled", async () => { + await service.setPlugins(serverId, [{ slug: "csroll", enabled: true }]); + await postgres.query( + `DELETE FROM game_plugin_installs WHERE plugin_slug = 'csroll'`, + ); + + expect(await overrides()).toEqual([]); + }); + }); + + describe("importWorkshopCollection", () => { + const steam = (responses: Record) => + jest + .spyOn(global, "fetch") + .mockImplementation(async (url: string | URL | Request) => { + const method = Object.keys(responses).find((name) => + url.toString().includes(`/${name}/`), + ); + + return new Response(JSON.stringify(responses[method]), { + status: 200, + }); + }); + + const item = (id: string, title: string, extra = {}) => ({ + publishedfileid: id, + result: 1, + consumer_app_id: 730, + banned: 0, + title, + preview_url: `https://img/${id}.jpg`, + ...extra, + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it("catalogues every CS2 map in the collection in order, disabled for matches", async () => { + steam({ + GetCollectionDetails: { + response: { + collectiondetails: [ + { + result: 1, + children: [ + { publishedfileid: "92", sortorder: 2, filetype: 0 }, + { publishedfileid: "91", sortorder: 1, filetype: 0 }, + { publishedfileid: "93", sortorder: 3, filetype: 2 }, + { publishedfileid: "94", sortorder: 4, filetype: 0 }, + ], + }, + ], + }, + }, + GetPublishedFileDetails: { + response: { + publishedfiledetails: [ + item("91", "Prophunt Dust2"), + item("92", "Prophunt Mirage"), + item("94", "A CS:GO map", { consumer_app_id: 4000 }), + ], + }, + }, + }); + + const result = await service.importWorkshopCollection( + "https://steamcommunity.com/sharedfiles/filedetails/?id=90", + ); + + expect(result.skipped).toBe(1); + expect( + result.maps.map((m) => [m.workshop_map_id, m.name, m.label]), + ).toEqual([ + ["91", "91", "Prophunt Dust2"], + ["92", "92", "Prophunt Mirage"], + ]); + + const rows = await postgres.query< + Array<{ type: string; enabled: boolean; active_pool: boolean }> + >( + `SELECT type, enabled, active_pool FROM maps + WHERE workshop_map_id IN ('91', '92')`, + ); + expect(rows).toEqual([ + { type: "Competitive", enabled: false, active_pool: false }, + { type: "Competitive", enabled: false, active_pool: false }, + ]); + }); + + it("reuses a map already in the catalog and restores one that was deleted", async () => { + const [existing] = await postgres.query>( + `INSERT INTO maps + (name, label, workshop_map_id, type, enabled, active_pool, deleted_at) + VALUES ('91', 'My Label', '91', 'Wingman', true, false, now()) + RETURNING id`, + ); + + steam({ + GetCollectionDetails: { + response: { collectiondetails: [{ result: 9 }] }, + }, + GetPublishedFileDetails: { + response: { publishedfiledetails: [item("91", "Prophunt Dust2")] }, + }, + }); + + const result = await service.importWorkshopCollection("91"); + + expect(result.maps).toEqual([ + { + id: existing.id, + name: "91", + label: "My Label", + poster: null, + workshop_map_id: "91", + }, + ]); + + const [row] = await postgres.query>( + `SELECT deleted_at FROM maps WHERE id = $1`, + [existing.id], + ); + expect(row.deleted_at).toBeNull(); + }); + + it("says so when nothing in the link is a CS2 map", async () => { + steam({ + GetCollectionDetails: { + response: { collectiondetails: [{ result: 9 }] }, + }, + GetPublishedFileDetails: { + response: { + publishedfiledetails: [{ publishedfileid: "99", result: 9 }], + }, + }, + }); + + await expect(service.importWorkshopCollection("99")).rejects.toThrow( + /no public CS2 workshop maps/, + ); + }); + }); + + describe("workshopId", () => { + it("reads a bare id or the id out of a workshop link", () => { + expect(DedicatedServerConfigService.workshopId(" 3615968422 ")).toEqual( + "3615968422", + ); + expect( + DedicatedServerConfigService.workshopId( + "https://steamcommunity.com/sharedfiles/filedetails/?id=3615968422&searchtext=", + ), + ).toEqual("3615968422"); + expect(DedicatedServerConfigService.workshopId("prophunt")).toBeNull(); + }); + }); +});