diff --git a/hasura/enums/game-modes.sql b/hasura/enums/game-modes.sql index 8a524e12..cce146c5 100644 --- a/hasura/enums/game-modes.sql +++ b/hasura/enums/game-modes.sql @@ -1,48 +1,20 @@ --- Starter modes, seeded on every boot so a fresh install has something to pick --- besides "Competitive". Enum-style upsert: the name and description follow the --- ship, but enabled / competitive_safe / cfg are left alone once an operator has --- touched them, and anything they add of their own is untouched. --- --- Runtime compatibility is NOT declared here. It is derived from the plugins --- each mode selects, so a mode whose plugin has no build for this deployment --- reports that by name rather than booting a server with nothing loaded. -insert into game_modes (slug, name, description, competitive_safe, enabled, cfg) -values - ( - 'retakes', - 'Retakes', - 'Bombsite retakes: the bomb is planted, T''s defend, CT''s retake. Fast rounds, no buy time.', - false, - true, - 'mp_maxrounds 0' || chr(10) || - 'mp_freezetime 3' || chr(10) || - 'mp_round_restart_delay 3' || chr(10) || - 'mp_ignore_round_win_conditions 1' || chr(10) || - 'mp_respawn_on_death_ct 0' || chr(10) || - 'mp_respawn_on_death_t 0' - ), - ( - 'deathmatch', - 'Deathmatch', - 'Free-for-all warmup with instant respawns and a weapon menu.', - false, - true, - 'mp_maxrounds 0' || chr(10) || - 'mp_freezetime 0' || chr(10) || - 'mp_respawn_immunitytime 2' || chr(10) || - 'mp_ignore_round_win_conditions 1' || chr(10) || - 'mp_teammates_are_enemies 1' - ) -on conflict (slug) do update set - name = excluded.name, - description = excluded.description; - --- Wire each starter mode to its plugin, but only once that plugin is in the --- catalog: the registry syncs on its own schedule, so on a first boot these --- modes exist with no plugins and pick them up on a later pass. +-- The starter modes themselves are created once, by the 1889000001400 +-- migration, so an operator can delete one for good. What runs on every boot is +-- only the wiring: the registry syncs on its own schedule, so on a fresh install +-- the modes exist before their plugins do and pick them up on a later pass. A +-- mode that already selects plugins is the operator's, and is left alone. insert into game_mode_plugins (game_mode_id, plugin_slug, load_order) -select m.id, p.slug, 0 - from game_modes m - join game_plugins p on p.slug = m.slug - where m.slug in ('retakes', 'deathmatch') +select m.id, starter.plugin_slug, 0 + from (values + ('deathmatch', 'deathmatch'), + ('retakes', 'retakes'), + ('arenas', 'arenas'), + ('chaos', 'csroll') + ) as starter (mode_slug, plugin_slug) + join game_modes m on m.slug = starter.mode_slug + join game_plugins p on p.slug = starter.plugin_slug + where not exists ( + select 1 from game_mode_plugins existing + where existing.game_mode_id = m.id + ) on conflict (game_mode_id, plugin_slug) do nothing; diff --git a/hasura/metadata/databases/default/tables/public_game_modes.yaml b/hasura/metadata/databases/default/tables/public_game_modes.yaml index d7b2e171..68413f8a 100644 --- a/hasura/metadata/databases/default/tables/public_game_modes.yaml +++ b/hasura/metadata/databases/default/tables/public_game_modes.yaml @@ -43,6 +43,8 @@ select_permissions: - competitive_safe - cfg - extra_game_params + - valve_mode + - system - created_at - updated_at computed_fields: @@ -64,6 +66,8 @@ select_permissions: - competitive_safe - cfg - extra_game_params + - valve_mode + - system - created_at - updated_at computed_fields: @@ -88,6 +92,7 @@ insert_permissions: - competitive_safe - cfg - extra_game_params + - valve_mode computed_fields: - supported_runtimes - runtime_conflicts @@ -105,6 +110,7 @@ update_permissions: - competitive_safe - cfg - extra_game_params + - valve_mode computed_fields: - supported_runtimes - runtime_conflicts diff --git a/hasura/migrations/default/1889000001200_game_mode_valve_mode/down.sql b/hasura/migrations/default/1889000001200_game_mode_valve_mode/down.sql new file mode 100644 index 00000000..90f510dc --- /dev/null +++ b/hasura/migrations/default/1889000001200_game_mode_valve_mode/down.sql @@ -0,0 +1 @@ +ALTER TABLE public.game_modes DROP COLUMN IF EXISTS valve_mode; diff --git a/hasura/migrations/default/1889000001200_game_mode_valve_mode/up.sql b/hasura/migrations/default/1889000001200_game_mode_valve_mode/up.sql new file mode 100644 index 00000000..686849a9 --- /dev/null +++ b/hasura/migrations/default/1889000001200_game_mode_valve_mode/up.sql @@ -0,0 +1,26 @@ +ALTER TABLE public.game_modes + ADD COLUMN IF NOT EXISTS valve_mode text; + +ALTER TABLE public.game_modes + DROP CONSTRAINT IF EXISTS game_modes_valve_mode_check; + +-- Keys of the modes in CS2's gamemodes.txt. NULL is stock Custom. +ALTER TABLE public.game_modes + ADD CONSTRAINT game_modes_valve_mode_check CHECK ( + valve_mode IN ( + 'casual', + 'competitive', + 'wingman', + 'retakes', + 'rush', + 'armsrace', + 'deathmatch' + ) + ); + +-- The Deathmatch plugin patches Valve's deathmatch rules, so the starter mode +-- only spawns players randomly on that Valve mode. +UPDATE public.game_modes + SET valve_mode = 'deathmatch' + WHERE slug = 'deathmatch' + AND valve_mode IS NULL; diff --git a/hasura/migrations/default/1889000001400_game_mode_templates/down.sql b/hasura/migrations/default/1889000001400_game_mode_templates/down.sql new file mode 100644 index 00000000..9be6a38f --- /dev/null +++ b/hasura/migrations/default/1889000001400_game_mode_templates/down.sql @@ -0,0 +1 @@ +ALTER TABLE public.game_modes DROP COLUMN IF EXISTS system; diff --git a/hasura/migrations/default/1889000001400_game_mode_templates/up.sql b/hasura/migrations/default/1889000001400_game_mode_templates/up.sql new file mode 100644 index 00000000..1c4413af --- /dev/null +++ b/hasura/migrations/default/1889000001400_game_mode_templates/up.sql @@ -0,0 +1,73 @@ +ALTER TABLE public.game_modes + ADD COLUMN IF NOT EXISTS system boolean NOT NULL DEFAULT false; + +UPDATE public.game_modes SET system = true WHERE slug = 'utility-practice'; + +-- Starter modes, offered once. From then on they are the operator's like any +-- other mode: deleting one keeps it gone, which an every-boot seed would undo. +-- hasura/enums/game-modes.sql wires each to its plugin once the registry has it. +INSERT INTO public.game_modes (slug, name, description, competitive_safe, enabled, cfg, valve_mode) +VALUES + ( + 'deathmatch', + 'Deathmatch', + 'Free-for-all warmup with instant respawns and a weapon menu.', + false, + true, + 'mp_maxrounds 0' || chr(10) || + 'mp_freezetime 0' || chr(10) || + 'mp_respawn_immunitytime 2' || chr(10) || + 'mp_ignore_round_win_conditions 1' || chr(10) || + 'mp_teammates_are_enemies 1', + 'deathmatch' + ), + ( + 'retakes', + 'Retakes', + 'Bombsite retakes: the bomb is planted, T''s defend, CT''s retake. Fast rounds, no buy time.', + false, + true, + 'mp_maxrounds 0' || chr(10) || + 'mp_freezetime 3' || chr(10) || + 'mp_round_restart_delay 3' || chr(10) || + 'mp_ignore_round_win_conditions 1' || chr(10) || + 'mp_respawn_on_death_ct 0' || chr(10) || + 'mp_respawn_on_death_t 0', + NULL + ), + ( + 'arenas', + '1v1 Arenas', + 'Ladder arenas: win and climb, lose and drop. Aim practice that keeps a full server busy.', + false, + true, + 'mp_maxrounds 0' || chr(10) || + 'mp_freezetime 3' || chr(10) || + 'mp_ignore_round_win_conditions 1' || chr(10) || + 'mp_respawn_on_death_ct 0' || chr(10) || + 'mp_respawn_on_death_t 0', + NULL + ), + ( + 'chaos', + 'Chaos', + 'Players roll gameplay-altering effects — jetpack, wallhack, speedhack, vampire, infinite ammo and ~40 more — scoped to a round, a run of rounds, or the whole match, server-wide or per player.', + false, + true, + NULL, + 'competitive' + ) +ON CONFLICT (slug) DO NOTHING; + +-- An early utility build installed its practice mode as nade-practice; the +-- utility system has used utility-practice since, so the old row is litter. +DELETE FROM public.game_modes m + WHERE m.slug = 'nade-practice' + AND NOT EXISTS ( + SELECT 1 FROM public.match_options mo WHERE mo.game_mode_id = m.id + ); + +UPDATE public.game_modes + SET archived_at = now(), enabled = false + WHERE slug = 'nade-practice' + AND archived_at IS NULL; diff --git a/hasura/triggers/game_modes.sql b/hasura/triggers/game_modes.sql index bcd2ddcc..6efb9253 100644 --- a/hasura/triggers/game_modes.sql +++ b/hasura/triggers/game_modes.sql @@ -133,3 +133,37 @@ $$; DROP TRIGGER IF EXISTS tbd_game_modes ON public.game_modes; CREATE TRIGGER tbd_game_modes BEFORE DELETE ON public.game_modes FOR EACH ROW EXECUTE FUNCTION public.tbd_game_modes(); + +-- A system mode is part of 5Stack itself: the utility system books every +-- practice match on utility-practice. Its cvars can be edited, but it cannot be +-- deleted, retired, disabled or renamed out from under that. +CREATE OR REPLACE FUNCTION public.tbud_game_modes_system() RETURNS TRIGGER + LANGUAGE plpgsql + AS $$ +BEGIN + IF TG_OP = 'DELETE' THEN + IF OLD.system THEN + RAISE EXCEPTION 'Game mode "%" is built into 5Stack and cannot be deleted', OLD.name + USING ERRCODE = '22000'; + END IF; + + RETURN OLD; + END IF; + + IF OLD.system AND ( + NEW.system IS DISTINCT FROM OLD.system + OR NEW.slug IS DISTINCT FROM OLD.slug + OR NEW.archived_at IS NOT NULL + OR NEW.enabled = false + ) THEN + RAISE EXCEPTION 'Game mode "%" is built into 5Stack and cannot be archived, disabled or renamed', OLD.name + USING ERRCODE = '22000'; + END IF; + + RETURN NEW; +END; +$$; + +DROP TRIGGER IF EXISTS tbud_game_modes_system ON public.game_modes; +CREATE TRIGGER tbud_game_modes_system BEFORE UPDATE OR DELETE ON public.game_modes FOR EACH ROW EXECUTE FUNCTION public.tbud_game_modes_system(); + diff --git a/src/dedicated-servers/dedicated-servers.service.spec.ts b/src/dedicated-servers/dedicated-servers.service.spec.ts index 38c17d8f..69418db1 100644 --- a/src/dedicated-servers/dedicated-servers.service.spec.ts +++ b/src/dedicated-servers/dedicated-servers.service.spec.ts @@ -487,3 +487,130 @@ describe("DedicatedServersService.pluginInstallEnvironment", () => { ); }); }); + +describe("DedicatedServersService.launchMode", () => { + // The Deathmatch plugin patches Valve's deathmatch rules; on stock Custom + // they never run and everyone spawns at their team's spawn. + it("boots a custom mode on the Valve mode it names", () => { + expect(DedicatedServersService.launchMode("Custom", "deathmatch")).toEqual( + ["+game_type 1", "+game_mode 2"], + ); + }); + + it("keeps stock Custom when the mode names no Valve mode", () => { + expect(DedicatedServersService.launchMode("Custom", null)).toEqual([ + "+game_type 3", + "+game_mode 0", + ]); + }); + + // CS:GO ran retakes as skirmish 12 on Custom. CS2 has no skirmishes and + // lists retakes as classic game mode 5. + it("boots a Retake server on CS2's retakes mode", () => { + expect(DedicatedServersService.launchMode("Retake", null)).toEqual([ + "+game_type 0", + "+game_mode 5", + ]); + }); + + it.each([ + ["Ranked", "+game_type 0", "+game_mode 1"], + ["Competitive", "+game_type 0", "+game_mode 1"], + ["Casual", "+game_type 0", "+game_mode 0"], + ["Wingman", "+game_type 0", "+game_mode 2"], + ["Deathmatch", "+game_type 1", "+game_mode 2"], + ["ArmsRace", "+game_type 1", "+game_mode 0"], + ])("boots a %s server as %s %s", (type, gameType, gameMode) => { + expect(DedicatedServersService.launchMode(type as never, null)).toEqual([ + gameType, + gameMode, + ]); + }); +}); + +describe("DedicatedServersService.withModeCfg", () => { + const mode = (overrides: Record = {}) => ({ + id: "mode-1", + slug: "deathmatch", + name: "Deathmatch", + cfg: "mp_teammates_are_enemies 1", + extraGameParams: null, + valveMode: "deathmatch", + enabledPlugins: "deathmatch@1.0.0", + pluginConfigs: null as string | null, + missingRequired: [] as Array, + disableServerGuidelines: false, + ...overrides, + }); + + const files = (resolved: { pluginConfigs: string | null } | null) => + resolved?.pluginConfigs + ? JSON.parse(Buffer.from(resolved.pluginConfigs, "base64").toString()) + : {}; + + // A community server has no match, so nothing ever ran the mode's cvars + // there. CS2 execs gamemode__server.cfg after the Valve mode's own + // config on every map load, which is also what keeps them after a map change. + it("writes a mode's cvars where CS2 runs them after its Valve mode's config", () => { + expect( + files(DedicatedServersService.withModeCfg(mode(), "Custom" as never)), + ).toEqual({ + "cfg/gamemode_deathmatch_server.cfg": "mp_teammates_are_enemies 1\n", + }); + }); + + it("uses stock Custom's hook for a mode that names no Valve mode", () => { + expect( + Object.keys( + files( + DedicatedServersService.withModeCfg( + mode({ valveMode: null }), + "Custom" as never, + ), + ), + ), + ).toEqual(["cfg/gamemode_custom_server.cfg"]); + }); + + it("keeps the plugin config files already headed to the server", () => { + const pluginConfigs = Buffer.from( + JSON.stringify({ "addons/swiftlys2/configs/dm.jsonc": "{}" }), + ).toString("base64"); + + expect( + files( + DedicatedServersService.withModeCfg( + mode({ pluginConfigs }), + "Custom" as never, + ), + ), + ).toEqual({ + "addons/swiftlys2/configs/dm.jsonc": "{}", + "cfg/gamemode_deathmatch_server.cfg": "mp_teammates_are_enemies 1\n", + }); + }); + + it("adds nothing for a mode without cvars", () => { + expect( + DedicatedServersService.withModeCfg(mode({ cfg: " " }), "Custom" as never) + ?.pluginConfigs, + ).toBeNull(); + }); + + // Rush runs a Valve map script and execs no server config of its own. + it("adds nothing on Rush, which has no server config to run them from", () => { + expect( + DedicatedServersService.withModeCfg( + mode({ valveMode: "rush" }), + "Custom" as never, + )?.pluginConfigs, + ).toBeNull(); + }); + + it("leaves a server with no mode alone", () => { + expect( + DedicatedServersService.withModeCfg(null, "Casual" as never), + ).toBeNull(); + }); +}); + diff --git a/src/dedicated-servers/dedicated-servers.service.ts b/src/dedicated-servers/dedicated-servers.service.ts index 1bb75d79..66230894 100644 --- a/src/dedicated-servers/dedicated-servers.service.ts +++ b/src/dedicated-servers/dedicated-servers.service.ts @@ -11,7 +11,10 @@ import { RedisManagerService } from "src/redis/redis-manager/redis-manager.servi 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 { + GameModesService, + ResolvedGameMode, +} from "../game-plugins/game-modes.service"; import { MapRotationService } from "../game-plugins/map-rotation.service"; import { NotificationsService } from "src/notifications/notifications.service"; import { DISCORD_COLORS } from "src/notifications/utilities/constants"; @@ -226,7 +229,10 @@ export class DedicatedServersService { // A Ranked server resolves to no mode by design, so matchmaking capacity // always comes up on a clean plugin set. - const gameMode = await this.gameModesService.resolveForServer(serverId); + const gameMode = DedicatedServersService.withModeCfg( + await this.gameModesService.resolveForServer(serverId), + server.type, + ); const gameModeEnvironment = this.gameModesService.environmentFor(gameMode); @@ -338,9 +344,10 @@ export class DedicatedServersService { value: [ `-maxplayers ${server.type === "Ranked" ? 16 : server.max_players}`, `+map ${startMap && !startMap.workshop_map_id ? startMap.name : "de_dust2"}`, - `+game_type ${this.getGameType(server.type)}`, - `+game_mode ${this.getGameMode(server.type)}`, - `+sv_skirmish_id ${this.getWarGameType(server.type)}`, + ...DedicatedServersService.launchMode( + server.type, + gameMode?.valveMode, + ), server.connect_password ? `+sv_password ${server.connect_password}` : null, @@ -658,46 +665,102 @@ export class DedicatedServersService { } } - private getGameType(type: e_server_types_enum): number { - switch (type) { - case "Ranked": - case "Casual": - case "Competitive": - case "Wingman": - return 0; - case "Deathmatch": - case "ArmsRace": - return 1; - case "Retake": - case "Custom": - return 3; - } + // game_type, game_mode and the server config each mode in CS2's + // gamemodes.txt execs after its own on every map load; a game mode's + // valve_mode is one of these keys. Rush execs no server config. + private static readonly VALVE_MODES: Record< + string, + { gameType: number; gameMode: number; serverCfg: string | null } + > = { + casual: { gameType: 0, gameMode: 0, serverCfg: "gamemode_casual_server" }, + competitive: { + gameType: 0, + gameMode: 1, + serverCfg: "gamemode_competitive_server", + }, + wingman: { + gameType: 0, + gameMode: 2, + serverCfg: "gamemode_competitive2v2_server", + }, + retakes: { gameType: 0, gameMode: 5, serverCfg: "gamemode_casual_server" }, + rush: { gameType: 0, gameMode: 6, serverCfg: null }, + armsrace: { + gameType: 1, + gameMode: 0, + serverCfg: "gamemode_armsrace_server", + }, + deathmatch: { + gameType: 1, + gameMode: 2, + serverCfg: "gamemode_deathmatch_server", + }, + custom: { gameType: 3, gameMode: 0, serverCfg: "gamemode_custom_server" }, + }; + + private static readonly SERVER_TYPE_MODES: Record = { + Ranked: "competitive", + Competitive: "competitive", + Casual: "casual", + Practice: "casual", + Wingman: "wingman", + Deathmatch: "deathmatch", + ArmsRace: "armsrace", + Retake: "retakes", + Custom: "custom", + }; + + // A custom mode can name the Valve mode it is built on: the Deathmatch + // plugin patches Valve's deathmatch rules, which stock Custom never runs. + public static launchMode( + type: e_server_types_enum, + valveMode?: string | null, + ): Array { + const mode = DedicatedServersService.valveModeFor(type, valveMode); + + return [`+game_type ${mode.gameType}`, `+game_mode ${mode.gameMode}`]; } - private getWarGameType(type: e_server_types_enum): number { - switch (type) { - case "Retake": - return 12; - default: - return 0; - } + private static valveModeFor( + type: e_server_types_enum, + valveMode?: string | null, + ) { + return ( + DedicatedServersService.VALVE_MODES[valveMode ?? ""] ?? + DedicatedServersService.VALVE_MODES[ + DedicatedServersService.SERVER_TYPE_MODES[type] ?? "casual" + ] + ); } - private getGameMode(type: e_server_types_enum): number { - switch (type) { - case "Ranked": - case "Competitive": - return 1; - case "ArmsRace": - case "Casual": - return 0; - case "Wingman": - case "Deathmatch": - return 2; - case "Retake": - case "Custom": - return 0; + // A community server has no match, so the mode's cvars ride in as the + // server config CS2 execs after the Valve mode's own -- on every map load, + // which is also what keeps them across a rotation. + public static withModeCfg( + mode: ResolvedGameMode | null, + type: e_server_types_enum, + ): ResolvedGameMode | null { + const serverCfg = DedicatedServersService.valveModeFor( + type, + mode?.valveMode, + ).serverCfg; + + if (!mode?.cfg?.trim() || !serverCfg) { + return mode; } + + const files: Record = mode.pluginConfigs + ? JSON.parse(Buffer.from(mode.pluginConfigs, "base64").toString()) + : {}; + + files[`cfg/${serverCfg}.cfg`] = mode.cfg.endsWith("\n") + ? mode.cfg + : `${mode.cfg}\n`; + + return { + ...mode, + pluginConfigs: Buffer.from(JSON.stringify(files)).toString("base64"), + }; } private async getServerStatusInfo( diff --git a/src/game-plugins/game-modes.service.spec.ts b/src/game-plugins/game-modes.service.spec.ts index aa0afaea..6b0c307c 100644 --- a/src/game-plugins/game-modes.service.spec.ts +++ b/src/game-plugins/game-modes.service.spec.ts @@ -59,6 +59,7 @@ describe("GameModesService auto-load plugins", () => { // No mode, so nothing a mode would have contributed comes along with it. expect(resolved?.cfg).toBeNull(); expect(resolved?.extraGameParams).toBeNull(); + expect(resolved?.valveMode).toBeNull(); }); it("returns nothing when there is neither a mode nor an auto-load plugin", async () => { @@ -96,6 +97,34 @@ describe("GameModesService auto-load plugins", () => { expect(resolved?.cfg).toEqual("mp_freezetime 3"); }); + it("carries the Valve mode the mode is built on", async () => { + const { service } = build({ + serverMode: [{ game_mode_id: "mode-1" }], + mode: [ + { + id: "mode-1", + slug: "deathmatch", + name: "Deathmatch", + cfg: null, + extra_game_params: null, + valve_mode: "deathmatch", + }, + ], + modePlugins: [ + { + plugin_slug: "deathmatch", + config: null, + config_path: null, + version: "1.0.0", + }, + ], + }); + + const resolved = await service.resolveForServer("server-1"); + + expect(resolved?.valveMode).toEqual("deathmatch"); + }); + it("lets the mode's version win when both name the same plugin", async () => { const { service } = build({ serverMode: [{ game_mode_id: "mode-1" }], @@ -435,3 +464,32 @@ describe("GameModesService server guidelines", () => { expect(asked?.params[0]).toEqual(["inventory-simulator"]); }); }); + +describe("GameModesService.environmentFor", () => { + const service = new GameModesService( + null as never, + null as never, + null as never, + null as never, + ); + + // A mode can be nothing but cvars; its config file still has to reach the + // server even though there is no plugin to load. + it("writes a mode's files even when it loads no plugins", () => { + expect( + service.environmentFor({ + id: "mode-1", + slug: "fast-rounds", + name: "Fast Rounds", + cfg: "mp_roundtime 1", + extraGameParams: null, + valveMode: null, + enabledPlugins: "", + pluginConfigs: "e30=", + missingRequired: [], + disableServerGuidelines: false, + }), + ).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 66f7afe1..854f9ea3 100644 --- a/src/game-plugins/game-modes.service.ts +++ b/src/game-plugins/game-modes.service.ts @@ -11,6 +11,7 @@ export type ResolvedGameMode = { name: string; cfg: string | null; extraGameParams: string | null; + valveMode: string | null; enabledPlugins: string; pluginConfigs: string | null; missingRequired: Array; @@ -43,6 +44,7 @@ type ModeRow = { name: string; cfg: string | null; extra_game_params: string | null; + valve_mode: string | null; }; type ModePluginRow = { @@ -373,6 +375,7 @@ export class GameModesService { name: "", cfg: null, extraGameParams: null, + valveMode: null, enabledPlugins: "", pluginConfigs: null, missingRequired: [], @@ -488,7 +491,7 @@ export class GameModesService { scope?: PluginScope, ): Promise { const [mode] = await this.postgres.query>( - `SELECT id, slug, name, cfg, extra_game_params + `SELECT id, slug, name, cfg, extra_game_params, valve_mode FROM game_modes WHERE id = $1 AND enabled = true AND archived_at IS NULL`, [gameModeId], @@ -559,6 +562,7 @@ export class GameModesService { name: mode.name, cfg: mode.cfg, extraGameParams: mode.extra_game_params, + valveMode: mode.valve_mode, enabledPlugins: enabled.join(","), pluginConfigs: Object.keys(configs).length > 0 @@ -676,8 +680,11 @@ export class GameModesService { public environmentFor( mode: ResolvedGameMode | null, ): Array<{ name: string; value: string }> { + // A cvar-only mode loads nothing but still has its config file to write. if (!mode?.enabledPlugins) { - return []; + return mode?.pluginConfigs + ? [{ name: "PLUGIN_CONFIGS", value: mode.pluginConfigs }] + : []; } const environment = [ diff --git a/src/utility/utility-practice-mode.service.ts b/src/utility/utility-practice-mode.service.ts index 47191f6b..8552ba23 100644 --- a/src/utility/utility-practice-mode.service.ts +++ b/src/utility/utility-practice-mode.service.ts @@ -71,13 +71,17 @@ export class UtilityPracticeModeService implements OnApplicationBootstrap { >( // The cfg is installed once and is the operator's afterwards: this runs // on every boot, and re-asserting EXCLUDED.cfg would silently undo any - // cvar they had edited on this mode. competitive_safe is the one thing - // held down, because a practice mode that claims to be safe would let - // sv_cheats into a ranked match. - `INSERT INTO public.game_modes (slug, name, description, competitive_safe, cfg, enabled) - VALUES ($1, $2, $3, false, $4, true) + // cvar they had edited on this mode. competitive_safe is held down, + // because a practice mode that claims to be safe would let sv_cheats + // into a ranked match; system keeps it from being deleted or retired, + // and a row from before that existed is brought back into service. + `INSERT INTO public.game_modes (slug, name, description, competitive_safe, cfg, enabled, system) + VALUES ($1, $2, $3, false, $4, true, true) ON CONFLICT (slug) DO UPDATE - SET competitive_safe = false + SET competitive_safe = false, + system = true, + enabled = true, + archived_at = NULL RETURNING id::text AS id, slug`, [ UtilityPracticeModeService.SLUG, diff --git a/test/game-modes.spec.ts b/test/game-modes.spec.ts index 573edb4d..45c2e0d3 100644 --- a/test/game-modes.spec.ts +++ b/test/game-modes.spec.ts @@ -651,7 +651,7 @@ describe("unranked game modes (SQL-driven)", () => { // The starter modes are seeded on every boot, so they have to survive an // operator editing them and re-apply without duplicating anything. -describe("starter game modes (SQL-driven)", () => { +describe("starter and built-in game modes (SQL-driven)", () => { let db: SqlTestDb; let postgres: PostgresService; @@ -671,16 +671,40 @@ describe("starter game modes (SQL-driven)", () => { ); }; + const starters = ["arenas", "chaos", "deathmatch", "retakes"]; + const seeded = async (): Promise>> => await postgres.query( - `SELECT slug, name, enabled, competitive_safe, cfg - FROM game_modes WHERE slug IN ('retakes','deathmatch') + `SELECT slug, name, enabled, competitive_safe, cfg, valve_mode + FROM game_modes WHERE slug = ANY($1) ORDER BY slug`, + [starters], + ); + + const addPlugin = async (slug: string): Promise => { + await postgres.query( + `INSERT INTO game_plugins (slug, kind, name, author, description) + VALUES ($1, 'game', $1, 'tester', 'a test plugin') + ON CONFLICT (slug) DO NOTHING`, + [slug], ); + }; - it("ships retakes and deathmatch out of the box", async () => { + const pluginsOf = async (slug: string): Promise> => + ( + await postgres.query>( + `SELECT mp.plugin_slug + FROM game_mode_plugins mp + JOIN game_modes m ON m.id = mp.game_mode_id + WHERE m.slug = $1 + ORDER BY mp.plugin_slug`, + [slug], + ) + ).map((row) => row.plugin_slug); + + it("offers arenas, chaos, deathmatch and retakes out of the box", async () => { const modes = await seeded(); - expect(modes.map((mode) => mode.slug)).toEqual(["deathmatch", "retakes"]); + expect(modes.map((mode) => mode.slug)).toEqual(starters); }); it("keeps them out of draft lobbies by default", async () => { @@ -688,35 +712,93 @@ describe("starter game modes (SQL-driven)", () => { expect(modes.every((mode) => mode.competitive_safe === false)).toBe(true); }); - it("gives each one a cvar block", async () => { + // The Deathmatch plugin patches Valve's deathmatch rules, so its mode only + // works on that Valve mode. + it("runs Deathmatch on Valve's deathmatch", async () => { const modes = await seeded(); - expect(modes.every((mode) => (mode.cfg ?? "").includes("mp_"))).toBe(true); + expect( + Object.fromEntries(modes.map((mode) => [mode.slug, mode.valve_mode])), + ).toEqual({ + arenas: null, + chaos: "competitive", + deathmatch: "deathmatch", + retakes: null, + }); }); - // The file is re-applied on every boot; a second pass must not duplicate. - it("re-applies without duplicating", async () => { - const before = await seeded(); + it("wires each one to its plugin once the registry has it", async () => { await reapply(); - const after = await seeded(); + expect(await pluginsOf("chaos")).toEqual([]); - expect(after.length).toEqual(before.length); + await addPlugin("csroll"); + await reapply(); + + expect(await pluginsOf("chaos")).toEqual(["csroll"]); }); - it("keeps an operator's edits when it re-applies", async () => { + it("leaves a mode's plugins alone once the operator has picked them", async () => { + await addPlugin("arenas"); + await addPlugin("map-chooser"); await postgres.query( - `UPDATE game_modes - SET enabled = false, competitive_safe = true, cfg = 'mp_freezetime 99' - WHERE slug = 'retakes'`, + `INSERT INTO game_mode_plugins (game_mode_id, plugin_slug, load_order) + SELECT id, 'map-chooser', 0 FROM game_modes WHERE slug = 'arenas'`, ); await reapply(); - const [retakes] = await postgres.query>>( - `SELECT enabled, competitive_safe, cfg FROM game_modes WHERE slug = 'retakes'`, + expect(await pluginsOf("arenas")).toEqual(["map-chooser"]); + }); + + // The seed used to recreate retakes and deathmatch on every boot, so a + // deleted one came straight back. + it("keeps a deleted starter gone after a reboot", async () => { + await postgres.query(`DELETE FROM game_modes WHERE slug = 'retakes'`); + + await reapply(); + + const [row] = await postgres.query>( + `SELECT count(*) FROM game_modes WHERE slug = 'retakes'`, ); + expect(Number(row.count)).toBe(0); + }); - expect(retakes.enabled).toBe(false); - expect(retakes.competitive_safe).toBe(true); - expect(retakes.cfg).toEqual("mp_freezetime 99"); + describe("a built-in mode", () => { + beforeAll(async () => { + await postgres.query( + `INSERT INTO game_modes (slug, name, cfg, system) + VALUES ('utility-practice', 'Utility Practice', 'sv_cheats 1', true)`, + ); + }); + + const attempt = (sql: string) => + postgres.query(`${sql} WHERE slug = 'utility-practice'`); + + it("cannot be deleted", async () => { + await expect(attempt(`DELETE FROM game_modes`)).rejects.toThrow( + /built into 5Stack/, + ); + }); + + it("cannot be archived, disabled or renamed", async () => { + for (const change of [ + `archived_at = now()`, + `enabled = false`, + `slug = 'practice'`, + `system = false`, + ]) { + await expect( + attempt(`UPDATE game_modes SET ${change}`), + ).rejects.toThrow(/built into 5Stack/); + } + }); + + it("can still have its cvars edited", async () => { + await attempt(`UPDATE game_modes SET cfg = 'sv_cheats 1' || chr(10) || 'mp_buytime 10'`); + + const [mode] = await postgres.query>( + `SELECT cfg FROM game_modes WHERE slug = 'utility-practice'`, + ); + expect(mode.cfg).toContain("mp_buytime 10"); + }); }); });