diff --git a/generated/schema.graphql b/generated/schema.graphql index 4c84f29d..ccea139b 100644 --- a/generated/schema.graphql +++ b/generated/schema.graphql @@ -9186,6 +9186,9 @@ enum e_game_cfg_types_enum { """Duel game configuration""" Duel + """Applies to every match, on top of the type configuration""" + Global + """Lan game configuration""" Lan @@ -20722,7 +20725,10 @@ input game_modes_updates { columns and relationships of "game_plugin_installs" """ type game_plugin_installs { - always_load: Boolean! + cfg: String, + load_custom: Boolean!, + load_ranked: Boolean!, + load_tournaments: Boolean! channel: e_game_plugin_channels_enum! created_at: timestamptz! disable_server_guidelines: Boolean! @@ -20759,7 +20765,10 @@ input game_plugin_installs_bool_exp { _and: [game_plugin_installs_bool_exp!] _not: game_plugin_installs_bool_exp _or: [game_plugin_installs_bool_exp!] - always_load: Boolean_comparison_exp + cfg: String_comparison_exp, + load_custom: Boolean_comparison_exp, + load_ranked: Boolean_comparison_exp, + load_tournaments: Boolean_comparison_exp channel: e_game_plugin_channels_enum_comparison_exp created_at: timestamptz_comparison_exp disable_server_guidelines: Boolean_comparison_exp @@ -20784,7 +20793,10 @@ enum game_plugin_installs_constraint { input type for inserting data into table "game_plugin_installs" """ input game_plugin_installs_insert_input { - always_load: Boolean + cfg: String, + load_custom: Boolean, + load_ranked: Boolean, + load_tournaments: Boolean channel: e_game_plugin_channels_enum created_at: timestamptz disable_server_guidelines: Boolean @@ -20833,7 +20845,10 @@ input game_plugin_installs_on_conflict { """Ordering options when selecting data from "game_plugin_installs".""" input game_plugin_installs_order_by { - always_load: order_by + cfg: order_by, + load_custom: order_by, + load_ranked: order_by, + load_tournaments: order_by channel: order_by created_at: order_by disable_server_guidelines: order_by @@ -20854,7 +20869,10 @@ select columns of table "game_plugin_installs" """ enum game_plugin_installs_select_column { """column name""" - always_load + cfg + load_custom + load_ranked + load_tournaments """column name""" channel @@ -20882,7 +20900,10 @@ enum game_plugin_installs_select_column { input type for updating data in table "game_plugin_installs" """ input game_plugin_installs_set_input { - always_load: Boolean + cfg: String, + load_custom: Boolean, + load_ranked: Boolean, + load_tournaments: Boolean channel: e_game_plugin_channels_enum created_at: timestamptz disable_server_guidelines: Boolean @@ -20905,7 +20926,10 @@ input game_plugin_installs_stream_cursor_input { """Initial value of the column from where the streaming should start""" input game_plugin_installs_stream_cursor_value_input { - always_load: Boolean + cfg: String, + load_custom: Boolean, + load_ranked: Boolean, + load_tournaments: Boolean channel: e_game_plugin_channels_enum created_at: timestamptz disable_server_guidelines: Boolean @@ -20920,7 +20944,10 @@ update columns of table "game_plugin_installs" """ enum game_plugin_installs_update_column { """column name""" - always_load + cfg + load_custom + load_ranked + load_tournaments """column name""" channel diff --git a/generated/schema.ts b/generated/schema.ts index 97a551a3..5647ad4a 100644 --- a/generated/schema.ts +++ b/generated/schema.ts @@ -3944,7 +3944,7 @@ export interface e_game_cfg_types_aggregate_fields { /** unique or primary key constraints on table "e_game_cfg_types" */ export type e_game_cfg_types_constraint = 'e_game_cfg_types_pkey' -export type e_game_cfg_types_enum = 'Base' | 'Competitive' | 'Duel' | 'Lan' | 'Live' | 'Wingman' +export type e_game_cfg_types_enum = 'Base' | 'Competitive' | 'Duel' | 'Global' | 'Lan' | 'Live' | 'Wingman' /** aggregate max on columns */ @@ -7739,7 +7739,10 @@ export type game_modes_update_column = 'archived_at' | 'cfg' | 'competitive_safe /** columns and relationships of "game_plugin_installs" */ export interface game_plugin_installs { - always_load: Scalars['Boolean'] + cfg: Scalars['String'], + load_custom: Scalars['Boolean'], + load_ranked: Scalars['Boolean'], + load_tournaments: Scalars['Boolean'] channel: e_game_plugin_channels_enum created_at: Scalars['timestamptz'] disable_server_guidelines: Scalars['Boolean'] @@ -7805,11 +7808,11 @@ export interface game_plugin_installs_mutation_response { /** select columns of table "game_plugin_installs" */ -export type game_plugin_installs_select_column = 'always_load' | 'channel' | 'created_at' | 'disable_server_guidelines' | 'enabled' | 'plugin_slug' | 'updated_at' | 'version' +export type game_plugin_installs_select_column = 'cfg' | 'load_custom' | 'load_ranked' | 'load_tournaments' | 'channel' | 'created_at' | 'disable_server_guidelines' | 'enabled' | 'plugin_slug' | 'updated_at' | 'version' /** update columns of table "game_plugin_installs" */ -export type game_plugin_installs_update_column = 'always_load' | 'channel' | 'created_at' | 'disable_server_guidelines' | 'enabled' | 'plugin_slug' | 'updated_at' | 'version' +export type game_plugin_installs_update_column = 'cfg' | 'load_custom' | 'load_ranked' | 'load_tournaments' | 'channel' | 'created_at' | 'disable_server_guidelines' | 'enabled' | 'plugin_slug' | 'updated_at' | 'version' /** columns and relationships of "game_plugin_versions" */ @@ -50569,7 +50572,10 @@ where: game_modes_bool_exp} /** columns and relationships of "game_plugin_installs" */ export interface game_plugin_installsGenqlSelection{ - always_load?: boolean | number + cfg?: string | number, + load_custom?: boolean | number, + load_ranked?: boolean | number, + load_tournaments?: boolean | number channel?: boolean | number created_at?: boolean | number disable_server_guidelines?: boolean | number @@ -50604,11 +50610,11 @@ export interface game_plugin_installs_aggregate_fieldsGenqlSelection{ /** Boolean expression to filter rows from the table "game_plugin_installs". All fields are combined with a logical 'AND'. */ -export interface game_plugin_installs_bool_exp {_and?: (game_plugin_installs_bool_exp[] | null),_not?: (game_plugin_installs_bool_exp | null),_or?: (game_plugin_installs_bool_exp[] | null),always_load?: (Boolean_comparison_exp | null),channel?: (e_game_plugin_channels_enum_comparison_exp | null),created_at?: (timestamptz_comparison_exp | null),disable_server_guidelines?: (Boolean_comparison_exp | null),enabled?: (Boolean_comparison_exp | null),plugin?: (game_plugins_bool_exp | null),plugin_slug?: (String_comparison_exp | null),updated_at?: (timestamptz_comparison_exp | null),version?: (String_comparison_exp | null)} +export interface game_plugin_installs_bool_exp {_and?: (game_plugin_installs_bool_exp[] | null),_not?: (game_plugin_installs_bool_exp | null),_or?: (game_plugin_installs_bool_exp[] | null),cfg?: (String_comparison_exp | null),load_custom?: (Boolean_comparison_exp | null),load_ranked?: (Boolean_comparison_exp | null),load_tournaments?: (Boolean_comparison_exp | null),channel?: (e_game_plugin_channels_enum_comparison_exp | null),created_at?: (timestamptz_comparison_exp | null),disable_server_guidelines?: (Boolean_comparison_exp | null),enabled?: (Boolean_comparison_exp | null),plugin?: (game_plugins_bool_exp | null),plugin_slug?: (String_comparison_exp | null),updated_at?: (timestamptz_comparison_exp | null),version?: (String_comparison_exp | null)} /** input type for inserting data into table "game_plugin_installs" */ -export interface game_plugin_installs_insert_input {always_load?: (Scalars['Boolean'] | null),channel?: (e_game_plugin_channels_enum | null),created_at?: (Scalars['timestamptz'] | null),disable_server_guidelines?: (Scalars['Boolean'] | null),enabled?: (Scalars['Boolean'] | null),plugin?: (game_plugins_obj_rel_insert_input | null),plugin_slug?: (Scalars['String'] | null),updated_at?: (Scalars['timestamptz'] | null),version?: (Scalars['String'] | null)} +export interface game_plugin_installs_insert_input {cfg?: (Scalars['String'] | null),load_custom?: (Scalars['Boolean'] | null),load_ranked?: (Scalars['Boolean'] | null),load_tournaments?: (Scalars['Boolean'] | null),channel?: (e_game_plugin_channels_enum | null),created_at?: (Scalars['timestamptz'] | null),disable_server_guidelines?: (Scalars['Boolean'] | null),enabled?: (Scalars['Boolean'] | null),plugin?: (game_plugins_obj_rel_insert_input | null),plugin_slug?: (Scalars['String'] | null),updated_at?: (Scalars['timestamptz'] | null),version?: (Scalars['String'] | null)} /** aggregate max on columns */ @@ -50649,7 +50655,7 @@ export interface game_plugin_installs_on_conflict {constraint: game_plugin_insta /** Ordering options when selecting data from "game_plugin_installs". */ -export interface game_plugin_installs_order_by {always_load?: (order_by | null),channel?: (order_by | null),created_at?: (order_by | null),disable_server_guidelines?: (order_by | null),enabled?: (order_by | null),plugin?: (game_plugins_order_by | null),plugin_slug?: (order_by | null),updated_at?: (order_by | null),version?: (order_by | null)} +export interface game_plugin_installs_order_by {cfg?: (order_by | null),load_custom?: (order_by | null),load_ranked?: (order_by | null),load_tournaments?: (order_by | null),channel?: (order_by | null),created_at?: (order_by | null),disable_server_guidelines?: (order_by | null),enabled?: (order_by | null),plugin?: (game_plugins_order_by | null),plugin_slug?: (order_by | null),updated_at?: (order_by | null),version?: (order_by | null)} /** primary key columns input for table: game_plugin_installs */ @@ -50657,7 +50663,7 @@ export interface game_plugin_installs_pk_columns_input {plugin_slug: Scalars['St /** input type for updating data in table "game_plugin_installs" */ -export interface game_plugin_installs_set_input {always_load?: (Scalars['Boolean'] | null),channel?: (e_game_plugin_channels_enum | null),created_at?: (Scalars['timestamptz'] | null),disable_server_guidelines?: (Scalars['Boolean'] | null),enabled?: (Scalars['Boolean'] | null),plugin_slug?: (Scalars['String'] | null),updated_at?: (Scalars['timestamptz'] | null),version?: (Scalars['String'] | null)} +export interface game_plugin_installs_set_input {cfg?: (Scalars['String'] | null),load_custom?: (Scalars['Boolean'] | null),load_ranked?: (Scalars['Boolean'] | null),load_tournaments?: (Scalars['Boolean'] | null),channel?: (e_game_plugin_channels_enum | null),created_at?: (Scalars['timestamptz'] | null),disable_server_guidelines?: (Scalars['Boolean'] | null),enabled?: (Scalars['Boolean'] | null),plugin_slug?: (Scalars['String'] | null),updated_at?: (Scalars['timestamptz'] | null),version?: (Scalars['String'] | null)} /** Streaming cursor of the table "game_plugin_installs" */ @@ -50669,7 +50675,7 @@ ordering?: (cursor_ordering | null)} /** Initial value of the column from where the streaming should start */ -export interface game_plugin_installs_stream_cursor_value_input {always_load?: (Scalars['Boolean'] | null),channel?: (e_game_plugin_channels_enum | null),created_at?: (Scalars['timestamptz'] | null),disable_server_guidelines?: (Scalars['Boolean'] | null),enabled?: (Scalars['Boolean'] | null),plugin_slug?: (Scalars['String'] | null),updated_at?: (Scalars['timestamptz'] | null),version?: (Scalars['String'] | null)} +export interface game_plugin_installs_stream_cursor_value_input {cfg?: (Scalars['String'] | null),load_custom?: (Scalars['Boolean'] | null),load_ranked?: (Scalars['Boolean'] | null),load_tournaments?: (Scalars['Boolean'] | null),channel?: (e_game_plugin_channels_enum | null),created_at?: (Scalars['timestamptz'] | null),disable_server_guidelines?: (Scalars['Boolean'] | null),enabled?: (Scalars['Boolean'] | null),plugin_slug?: (Scalars['String'] | null),updated_at?: (Scalars['timestamptz'] | null),version?: (Scalars['String'] | null)} export interface game_plugin_installs_updates { /** sets the columns of the filtered rows to the given values */ @@ -127597,7 +127603,10 @@ export const enumGamePluginInstallsConstraint = { } export const enumGamePluginInstallsSelectColumn = { - always_load: 'always_load' as const, + cfg: 'cfg' | 'load_custom' | 'load_ranked' | 'load_tournaments' as const, + load_custom: 'cfg' | 'load_custom' | 'load_ranked' | 'load_tournaments' as const, + load_ranked: 'cfg' | 'load_custom' | 'load_ranked' | 'load_tournaments' as const, + load_tournaments: 'cfg' | 'load_custom' | 'load_ranked' | 'load_tournaments' as const, channel: 'channel' as const, created_at: 'created_at' as const, disable_server_guidelines: 'disable_server_guidelines' as const, @@ -127608,7 +127617,10 @@ export const enumGamePluginInstallsSelectColumn = { } export const enumGamePluginInstallsUpdateColumn = { - always_load: 'always_load' as const, + cfg: 'cfg' | 'load_custom' | 'load_ranked' | 'load_tournaments' as const, + load_custom: 'cfg' | 'load_custom' | 'load_ranked' | 'load_tournaments' as const, + load_ranked: 'cfg' | 'load_custom' | 'load_ranked' | 'load_tournaments' as const, + load_tournaments: 'cfg' | 'load_custom' | 'load_ranked' | 'load_tournaments' as const, channel: 'channel' as const, created_at: 'created_at' as const, disable_server_guidelines: 'disable_server_guidelines' as const, diff --git a/generated/types.ts b/generated/types.ts index 458188fd..b440604d 100644 --- a/generated/types.ts +++ b/generated/types.ts @@ -29694,7 +29694,16 @@ export default { ] }, "game_plugin_installs": { - "always_load": [ + "cfg": [ + 84 + ], + "load_custom": [ + 6 + ], + "load_ranked": [ + 6 + ], + "load_tournaments": [ 6 ], "channel": [ @@ -29769,7 +29778,16 @@ export default { "_or": [ 1943 ], - "always_load": [ + "cfg": [ + 86 + ], + "load_custom": [ + 7 + ], + "load_ranked": [ + 7 + ], + "load_tournaments": [ 7 ], "channel": [ @@ -29802,7 +29820,16 @@ export default { }, "game_plugin_installs_constraint": {}, "game_plugin_installs_insert_input": { - "always_load": [ + "cfg": [ + 84 + ], + "load_custom": [ + 6 + ], + "load_ranked": [ + 6 + ], + "load_tournaments": [ 6 ], "channel": [ @@ -29893,7 +29920,16 @@ export default { ] }, "game_plugin_installs_order_by": { - "always_load": [ + "cfg": [ + 3373 + ], + "load_custom": [ + 3373 + ], + "load_ranked": [ + 3373 + ], + "load_tournaments": [ 3373 ], "channel": [ @@ -29934,7 +29970,16 @@ export default { }, "game_plugin_installs_select_column": {}, "game_plugin_installs_set_input": { - "always_load": [ + "cfg": [ + 84 + ], + "load_custom": [ + 6 + ], + "load_ranked": [ + 6 + ], + "load_tournaments": [ 6 ], "channel": [ @@ -29974,7 +30019,16 @@ export default { ] }, "game_plugin_installs_stream_cursor_value_input": { - "always_load": [ + "cfg": [ + 84 + ], + "load_custom": [ + 6 + ], + "load_ranked": [ + 6 + ], + "load_tournaments": [ 6 ], "channel": [ diff --git a/hasura/enums/maps.sql b/hasura/enums/maps.sql index 422f49f2..2b9dfddf 100644 --- a/hasura/enums/maps.sql +++ b/hasura/enums/maps.sql @@ -12,7 +12,8 @@ insert into e_game_cfg_types ("value", "description") values ('Live', 'Live game configuration'), ('Competitive', 'Competitive game configuration'), ('Wingman', 'Wingman game configuration'), - ('Duel', 'Duel game configuration') + ('Duel', 'Duel game configuration'), + ('Global', 'Applies to every match, on top of the type configuration') on conflict(value) do update set "description" = EXCLUDED."description"; WITH map_data AS ( diff --git a/hasura/metadata/databases/default/tables/public_game_plugin_installs.yaml b/hasura/metadata/databases/default/tables/public_game_plugin_installs.yaml index a69f8a74..0537f28b 100644 --- a/hasura/metadata/databases/default/tables/public_game_plugin_installs.yaml +++ b/hasura/metadata/databases/default/tables/public_game_plugin_installs.yaml @@ -13,8 +13,11 @@ select_permissions: - version - channel - enabled - - always_load - disable_server_guidelines + - cfg + - load_ranked + - load_tournaments + - load_custom - created_at - updated_at filter: {} @@ -29,8 +32,11 @@ insert_permissions: - version - channel - enabled - - always_load - disable_server_guidelines + - cfg + - load_ranked + - load_tournaments + - load_custom comment: "" update_permissions: - role: administrator @@ -40,8 +46,11 @@ update_permissions: - version - channel - enabled - - always_load - disable_server_guidelines + - cfg + - load_ranked + - load_tournaments + - load_custom filter: {} check: {} comment: "" diff --git a/hasura/migrations/default/1880000002000_global_and_plugin_cfgs/down.sql b/hasura/migrations/default/1880000002000_global_and_plugin_cfgs/down.sql new file mode 100644 index 00000000..1ec3643f --- /dev/null +++ b/hasura/migrations/default/1880000002000_global_and_plugin_cfgs/down.sql @@ -0,0 +1,17 @@ +ALTER TABLE "public"."game_plugin_installs" + ADD COLUMN IF NOT EXISTS "always_load" boolean NOT NULL DEFAULT false; + +-- always_load reached ranked, so only a plugin already allowed on ranked may +-- come back as always_load. Collapsing the union instead would silently put a +-- plugin the operator kept off ranked onto ranked servers on rollback. +UPDATE "public"."game_plugin_installs" + SET "always_load" = true + WHERE "load_ranked"; + +ALTER TABLE "public"."game_plugin_installs" + DROP COLUMN IF EXISTS "cfg", + DROP COLUMN IF EXISTS "load_ranked", + DROP COLUMN IF EXISTS "load_tournaments", + DROP COLUMN IF EXISTS "load_custom"; + +DELETE FROM "public"."match_type_cfgs" WHERE "type" = 'Global'; diff --git a/hasura/migrations/default/1880000002000_global_and_plugin_cfgs/up.sql b/hasura/migrations/default/1880000002000_global_and_plugin_cfgs/up.sql new file mode 100644 index 00000000..0d1cd8d3 --- /dev/null +++ b/hasura/migrations/default/1880000002000_global_and_plugin_cfgs/up.sql @@ -0,0 +1,37 @@ +-- A cvar block that applies to every match, and one per installed game plugin. +-- The 'Global' e_game_cfg_types value is seeded from hasura/enums/maps.sql, +-- which is re-applied on every boot -- enum rows are not a migration's to own. + +-- The plugin's own cvars, exec'd only on servers that load it. Global is +-- deliberately unscoped: it is the operator's "every match" layer, and a +-- global with exceptions is just a type config with extra steps. +ALTER TABLE "public"."game_plugin_installs" + ADD COLUMN IF NOT EXISTS "cfg" text, + -- Which kinds of match load this plugin without a game mode asking for it. + -- This replaces the single always_load flag, which could only say "every + -- match, ranked included" -- and ranked is the one an operator most often + -- wants to leave out. Default off: installing a plugin is not consent to + -- run it on every server, and a game mode that names it still loads it. + ADD COLUMN IF NOT EXISTS "load_ranked" boolean NOT NULL DEFAULT false, + ADD COLUMN IF NOT EXISTS "load_tournaments" boolean NOT NULL DEFAULT false, + ADD COLUMN IF NOT EXISTS "load_custom" boolean NOT NULL DEFAULT false; + +-- always_load meant all three, so that is what it becomes. +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'public' + AND table_name = 'game_plugin_installs' + AND column_name = 'always_load' + ) THEN + UPDATE "public"."game_plugin_installs" + SET "load_ranked" = true, + "load_tournaments" = true, + "load_custom" = true + WHERE "always_load" = true; + END IF; +END $$; + +ALTER TABLE "public"."game_plugin_installs" + DROP COLUMN IF EXISTS "always_load"; diff --git a/src/game-plugins/game-modes.service.spec.ts b/src/game-plugins/game-modes.service.spec.ts index 006debf9..46dc2d46 100644 --- a/src/game-plugins/game-modes.service.spec.ts +++ b/src/game-plugins/game-modes.service.spec.ts @@ -1,9 +1,9 @@ import { GameModesService, RequiredPluginMissing } from "./game-modes.service"; -// always_load shipped broken: the merge lived in environmentFor(), which nothing -// called, so a plugin marked "load on every match" never reached a server. -// These pin the merge to resolveForServer, which is what the pod specs use. -describe("GameModesService always-load plugins", () => { +// Auto-loading shipped broken: the merge lived in environmentFor(), which +// nothing called, so a plugin set to load without a mode never reached a +// server. These pin the merge to resolveForServer, which the pod specs use. +describe("GameModesService auto-load plugins", () => { const build = (rows: Record>>) => { const postgres = { query: jest.fn(async (sql: string) => { @@ -20,7 +20,7 @@ describe("GameModesService always-load plugins", () => { return rows.guidelines ?? [{ disable: false }]; } if (sql.includes("FROM game_plugin_installs")) { - return rows.alwaysLoad ?? []; + return rows.autoLoad ?? []; } return []; }), @@ -41,10 +41,10 @@ describe("GameModesService always-load plugins", () => { return { service, postgres }; }; - it("loads an always-load plugin on a server with no mode at all", async () => { + it("loads an auto-load plugin on a server with no mode at all", async () => { const { service } = build({ serverMode: [{ game_mode_id: null }], - alwaysLoad: [{ plugin_slug: "stats", version: "1.0.0" }], + autoLoad: [{ plugin_slug: "stats", version: "1.0.0" }], }); const resolved = await service.resolveForServer("server-1"); @@ -55,13 +55,13 @@ describe("GameModesService always-load plugins", () => { expect(resolved?.extraGameParams).toBeNull(); }); - it("returns nothing when there is neither a mode nor an always-load plugin", async () => { + it("returns nothing when there is neither a mode nor an auto-load plugin", async () => { const { service } = build({ serverMode: [{ game_mode_id: null }] }); await expect(service.resolveForServer("server-1")).resolves.toBeNull(); }); - it("appends always-load plugins to a mode's own set", async () => { + it("appends auto-load plugins to a mode's own set", async () => { const { service } = build({ serverMode: [{ game_mode_id: "mode-1" }], mode: [ @@ -81,7 +81,7 @@ describe("GameModesService always-load plugins", () => { version: "2.0.0", }, ], - alwaysLoad: [{ plugin_slug: "stats", version: "1.0.0" }], + autoLoad: [{ plugin_slug: "stats", version: "1.0.0" }], }); const resolved = await service.resolveForServer("server-1"); @@ -110,7 +110,7 @@ describe("GameModesService always-load plugins", () => { version: "2.0.0", }, ], - alwaysLoad: [{ plugin_slug: "stats", version: "1.0.0" }], + autoLoad: [{ plugin_slug: "stats", version: "1.0.0" }], }); const resolved = await service.resolveForServer("server-1"); @@ -118,16 +118,16 @@ describe("GameModesService always-load plugins", () => { expect(resolved?.enabledPlugins).toEqual("stats@2.0.0"); }); - it("skips an always-load plugin no node has installed", async () => { + it("skips an auto-load plugin no node has installed", async () => { const { service } = build({ serverMode: [{ game_mode_id: null }], - alwaysLoad: [{ plugin_slug: "stats", version: null }], + autoLoad: [{ plugin_slug: "stats", version: null }], }); await expect(service.resolveForServer("server-1")).resolves.toBeNull(); }); - it("preview merges always-load plugins, which is the whole point of it", async () => { + it("preview merges auto-load plugins, which is the whole point of it", async () => { const { service } = build({ mode: [ { @@ -146,7 +146,7 @@ describe("GameModesService always-load plugins", () => { version: "2.0.0", }, ], - alwaysLoad: [{ plugin_slug: "stats", version: "1.0.0" }], + autoLoad: [{ plugin_slug: "stats", version: "1.0.0" }], }); const preview = await service.previewForMode("mode-1"); @@ -351,7 +351,7 @@ describe("GameModesService server guidelines", () => { return rows.modePlugins ?? []; } if (sql.includes("FROM game_plugin_installs")) { - return rows.alwaysLoad ?? []; + return rows.autoLoad ?? []; } return []; }), diff --git a/src/game-plugins/game-modes.service.ts b/src/game-plugins/game-modes.service.ts index bdc43298..eceab959 100644 --- a/src/game-plugins/game-modes.service.ts +++ b/src/game-plugins/game-modes.service.ts @@ -15,12 +15,24 @@ export type ResolvedGameMode = { disableServerGuidelines: boolean; }; +// Which kind of match is about to run. An install says which of the three it +// loads on by itself, so this is what that answer is looked up against. +// +// "ranked" means the match counts toward ranking -- competitive play -- not +// that matchmaking created it. A draft lobby playing for Elo is as ranked as a +// queued match, and an operator keeping a cosmetics plugin out of competitive +// means both. "custom" is what is left once competitive and tournament play +// are accounted for. +export type MatchScope = "ranked" | "tournaments" | "custom"; + // Which node's disk decides the answer, and which framework it is running. // A null node means "any node has it", which is only ever right when nothing is // about to boot -- a preview. type PluginScope = { nodeId: string | null; runtime: PluginRuntime; + // Absent on a preview, where there is no match to resolve against. + match?: MatchScope; }; type ModeRow = { @@ -69,6 +81,9 @@ export class GameModesService { game_mode_id: string | null; game_server_node_id: string | null; pin_plugin_runtime: string | null; + is_ranked: boolean; + is_tournament: boolean; + is_ranked_server: boolean; }> >( `SELECT COALESCE( @@ -81,7 +96,15 @@ export class GameModesService { END ) AS game_mode_id, s.game_server_node_id, - n.pin_plugin_runtime + n.pin_plugin_runtime, + COALESCE( + (SELECT m.counts_toward_ranking FROM matches m WHERE m.id = $2), + false + ) AS is_ranked, + EXISTS ( + SELECT 1 FROM tournament_brackets tb WHERE tb.match_id = $2 + ) AS is_tournament, + s.type = 'Ranked' AS is_ranked_server FROM servers s LEFT JOIN game_server_nodes n ON n.id = s.game_server_node_id WHERE s.id = $1`, @@ -97,6 +120,19 @@ export class GameModesService { runtime: await this.pluginRuntime.resolvePluginRuntime({ pin_plugin_runtime: row?.pin_plugin_runtime ?? null, }), + // A dedicated server is built before it has a match, and its plugin set + // is baked into the pod -- so "decide later, per match" is not available + // there. A Ranked server only ever hosts matchmaking, which is answer + // enough; anything else falls through to the union of the other two + // (see the ELSE in autoLoadPlugins), never to ranked. + match: matchId + ? GameModesService.matchScope({ + isTournament: row?.is_tournament ?? false, + isRanked: row?.is_ranked ?? false, + }) + : row?.is_ranked_server + ? "ranked" + : undefined, }; const mode = row?.game_mode_id @@ -111,26 +147,26 @@ export class GameModesService { ); } - return await this.withAlwaysLoad(mode, scope); + return await this.withAutoLoad(mode, scope); } - // What a server would boot with if it ran this mode right now. Always-load + // What a server would boot with if it ran this mode right now. Auto-load // plugins belong in the answer even though they are not part of the mode: // leaving them out is the one thing a preview exists to prevent. public async previewForMode( gameModeId: string, ): Promise { - return await this.withAlwaysLoad(await this.resolve(gameModeId)); + return await this.withAutoLoad(await this.resolve(gameModeId)); } - // Always-load plugins are merged in here rather than at the call sites, + // Auto-load plugins are merged in here rather than at the call sites, // because they apply whether or not a mode is selected -- a server with no - // mode at all still has to load them, which is the entire point of the flag. - private async withAlwaysLoad( + // mode at all still has to load them, which is the entire point of them. + private async withAutoLoad( mode: ResolvedGameMode | null, scope?: PluginScope, ): Promise { - const always = await this.alwaysLoadPlugins(scope); + const always = await this.autoLoadPlugins(scope); if (always.length === 0) { return await this.withServerGuidelines(mode); @@ -304,10 +340,71 @@ export class GameModesService { }; } - // Plugins marked always_load are loaded by every server regardless of mode, - // ranked included. That is the point of the flag: a stats collector is not a - // game mode, and hand-placing it in custom-plugins is what this replaces. - public async alwaysLoadPlugins(scope?: PluginScope): Promise> { + // The buckets do not overlap, so a match that is both a tournament game and + // a ranked one has to land in exactly one. Tournament wins: it is the more + // specific statement about the match, and it has a switch of its own. + public static matchScope(match: { + isTournament: boolean; + isRanked: boolean; + }): MatchScope { + if (match.isTournament) { + return "tournaments"; + } + + if (match.isRanked) { + return "ranked"; + } + + return "custom"; + } + + // The cvars each loading plugin carries, in the order the plugins load. + // + // Keyed off what the mode actually resolved to rather than off the install + // table, so a plugin's cvars reach exactly the servers running it: out of + // scope it never made the list, and a mode that names it explicitly gets its + // cvars even where the blanket flag was turned off, because the plugin is + // running there either way and half-configured is worse than not loaded. + public async pluginCfgLayers( + mode: ResolvedGameMode | null, + ): Promise> { + const slugs = (mode?.enabledPlugins ?? "") + .split(",") + .filter(Boolean) + .map((entry) => entry.split("@")[0]); + + if (slugs.length === 0) { + return []; + } + + const rows = await this.postgres.query< + Array<{ plugin_slug: string; cfg: string }> + >( + `SELECT plugin_slug, cfg + FROM game_plugin_installs + WHERE plugin_slug = ANY($1::text[]) + AND enabled = true + AND cfg IS NOT NULL + AND btrim(cfg) <> ''`, + [slugs], + ); + + const cfgs = new Map(rows.map((row) => [row.plugin_slug, row.cfg])); + + return slugs + .filter((slug) => cfgs.has(slug)) + .map((slug) => ({ slug, cfg: cfgs.get(slug) as string })); + } + + // Plugins that load without a game mode asking for them: a stats collector + // is not a game mode, and hand-placing it in custom-plugins is what this + // replaces. Each install says which of the three kinds of match it wants, + // so a plugin can sit on customs and tournaments and stay off ranked. + // + // With no match in scope the caller is building a server, not a match. Ranked + // is excluded from that union deliberately: a plugin the operator kept off + // ranked must not be baked into a pod that might later host a ranked match. + public async autoLoadPlugins(scope?: PluginScope): Promise> { const runtime = scope?.runtime ?? (await this.pluginRuntime.getPluginRuntime()); const nodeId = scope?.nodeId ?? null; @@ -326,9 +423,15 @@ export class GameModesService { ORDER BY n.updated_at DESC LIMIT 1) AS version FROM game_plugin_installs i - WHERE i.enabled = true AND i.always_load = true + WHERE i.enabled = true + AND CASE $3::text + WHEN 'ranked' THEN i.load_ranked + WHEN 'tournaments' THEN i.load_tournaments + WHEN 'custom' THEN i.load_custom + ELSE i.load_tournaments OR i.load_custom + END ORDER BY i.plugin_slug`, - [runtime, nodeId], + [runtime, nodeId, scope?.match ?? null], ); return rows diff --git a/src/matches/matches.controller.ts b/src/matches/matches.controller.ts index 8227b44b..3cf1eb7b 100644 --- a/src/matches/matches.controller.ts +++ b/src/matches/matches.controller.ts @@ -5,7 +5,10 @@ import { User } from "../auth/types/User"; import { HasuraEventData } from "../hasura/types/HasuraEventData"; import { safeJsonStringify } from "../utilities/safeJsonStringify"; import { HasuraService } from "../hasura/hasura.service"; -import { GameModesService } from "../game-plugins/game-modes.service"; +import { + GameModesService, + RequiredPluginMissing, +} from "../game-plugins/game-modes.service"; import { MatchAssistantService } from "./match-assistant/match-assistant.service"; import { DiscordBotOverviewService } from "../discord-bot/discord-bot-overview/discord-bot-overview.service"; import { DiscordBotMessagingService } from "../discord-bot/discord-bot-messaging/discord-bot-messaging.service"; @@ -317,6 +320,10 @@ export class MatchesController { use_playcast: boolean; show_elo_ranks: boolean; cfg_overrides: Record; + // The layers to exec after the type cfg, in order. Ordered separately + // from cfg_overrides because the map only says which files to write, + // and last-exec-wins is the whole contract. + cfg_execs: Array; game_mode: { slug: string; name: string } | null; }; lineup_1: typeof matches_by_pk.lineup_1 & { @@ -375,7 +382,7 @@ export class MatchesController { __args: { where: { type: { - _in: ["Lan", cfgType], + _in: ["Lan", "Global", cfgType], }, }, }, @@ -384,34 +391,55 @@ export class MatchesController { }, }); - if (match_type_cfgs) { - match.options.cfg_overrides = { - Lan: "", - Competitive: "", - Duel: "", - Wingman: "", - }; + match.options.cfg_overrides = { + Lan: "", + Competitive: "", + Duel: "", + Wingman: "", + }; + match.options.cfg_execs = []; + + for (const cfg of match_type_cfgs ?? []) { + // Global is a layer of its own rather than a type, so it is exec'd on + // top of whichever type config this match already got. + if (cfg.type === "Global") { + if (cfg.cfg?.trim()) { + match.options.cfg_overrides.Global = cfg.cfg; + match.options.cfg_execs.push("global"); + } - for (const cfg of match_type_cfgs) { - match.options.cfg_overrides[cfg.type] = cfg.cfg; + continue; } + + match.options.cfg_overrides[cfg.type] = cfg.cfg; } - // The mode's cvars ride the same channel as the type cfgs: the plugin - // writes each override to 5stack..cfg and execs it after the type cfg, - // so a mode's settings land last and win. - const gameMode = match.options.game_mode_id - ? await this.gameModesService.resolve( - match.options.game_mode_id as string, - ) - : null; + // Resolved against the server rather than the mode id alone, because the + // answer has to include always-load plugins to know whose cvars apply. + const gameMode = await this.resolveMatchGameMode( + serverId as string, + match.id, + match.options.game_mode_id as string | null, + ); + + // A loading plugin's own cvars, then the mode's. The plugin writes each + // override to 5stack..cfg and execs the keys named in cfg_execs in + // order, so the more specific layer lands last and wins. + for (const layer of await this.gameModesService.pluginCfgLayers(gameMode)) { + const key = `Plugin.${layer.slug}`; + + match.options.cfg_overrides[key] = layer.cfg; + match.options.cfg_execs.push(key.toLowerCase()); + } if (gameMode?.cfg) { - match.options.cfg_overrides = match.options.cfg_overrides ?? {}; match.options.cfg_overrides.Mode = gameMode.cfg; + match.options.cfg_execs.push("mode"); } - match.options.game_mode = gameMode + // withAlwaysLoad answers with a nameless mode when the only thing to load + // is an always-load plugin; that is not a mode the match is playing under. + match.options.game_mode = gameMode?.id ? { slug: gameMode.slug, name: gameMode.name } : null; @@ -501,6 +529,32 @@ export class MatchesController { response.status(200).json(data); } + // A mode whose required plugin is missing is fatal when a server is being + // built, but not here: the server is already up, and refusing to answer its + // match poll over a plugin it may well have would strand a live match. Fall + // back to the mode's own cvars and let the boot-time path own the complaint. + private async resolveMatchGameMode( + serverId: string, + matchId: string, + gameModeId: string | null, + ) { + try { + return await this.gameModesService.resolveForServer(serverId, matchId); + } catch (error) { + if (!(error instanceof RequiredPluginMissing)) { + throw error; + } + + this.logger.warn( + `unable to resolve plugins for match ${matchId}: ${error.message}`, + ); + + return gameModeId + ? await this.gameModesService.resolve(gameModeId) + : null; + } + } + // Per-player camera health is read straight from Redis: it is the monitor's // running state, not a column, and the plugin needs it to pick its own state // back up after a restart or a map load mid-match. diff --git a/test/game-mode-resolution.spec.ts b/test/game-mode-resolution.spec.ts index 0753a1ab..2fd482a7 100644 --- a/test/game-mode-resolution.spec.ts +++ b/test/game-mode-resolution.spec.ts @@ -40,6 +40,7 @@ describe("game mode resolution (SQL-driven)", () => { `DELETE FROM settings WHERE name IN ('fivestack_ranks_matches', 'fivestack_ranks_tournaments')`, ); + await postgres.query("DELETE FROM matches"); await postgres.query("DELETE FROM servers"); await postgres.query("DELETE FROM game_server_node_plugins"); await postgres.query("DELETE FROM game_plugin_installs"); @@ -98,6 +99,18 @@ describe("game mode resolution (SQL-driven)", () => { ); }; + const autoLoads = async ( + slug: string, + { ranked = true, tournaments = true, custom = true } = {}, + ): Promise => { + await postgres.query( + `UPDATE game_plugin_installs + SET load_ranked = $2, load_tournaments = $3, load_custom = $4 + WHERE plugin_slug = $1`, + [slug, ranked, tournaments, custom], + ); + }; + const onNode = async ( nodeId: string, slug: string, @@ -250,19 +263,278 @@ describe("game mode resolution (SQL-driven)", () => { expect(resolved?.disableServerGuidelines).toBe(false); }); - // always_load reaches every server including ranked, so the opt-in has to + // Auto-loading reaches every server including ranked, so the opt-in has to // follow it there rather than only applying to modes. - it("follows an always-load plugin onto a server with no mode", async () => { + it("follows an auto-load plugin onto a server with no mode", async () => { await installed("inventory-simulator", { disableServerGuidelines: true }); + await autoLoads("inventory-simulator"); + + const resolved = await service.resolveForServer(serverId); + + expect(resolved?.enabledPlugins).toEqual("inventory-simulator@1.0.0"); + expect(resolved?.disableServerGuidelines).toBe(true); + }); + }); + // Which kinds of match a plugin loads on by itself. Ranked means the match + // counts toward ranking, not that matchmaking made it. + describe("auto-load targets", () => { + const forScope = async (match?: "ranked" | "tournaments" | "custom") => + await service.autoLoadPlugins({ + nodeId: "node-a", + runtime: "swiftlys2", + match, + }); + + // Goes through resolveForServer so the is_ranked / is_tournament subqueries + // are actually executed. session_replication_role skips the veto trigger on + // matches, which needs a whole region/map-pool fixture this does not. + const matchOn = async ( + server: string, + { ranked = true, tournament = false } = {}, + ): Promise => { + const [l1] = await postgres.query>( + "INSERT INTO match_lineups DEFAULT VALUES RETURNING id", + ); + const [l2] = await postgres.query>( + "INSERT INTO match_lineups DEFAULT VALUES RETURNING id", + ); + + await postgres.query("ALTER TABLE public.matches DISABLE TRIGGER USER"); + let match: { id: string }; + try { + [match] = await postgres.query>( + `INSERT INTO matches + (lineup_1_id, lineup_2_id, server_id, counts_toward_ranking) + VALUES ($1, $2, $3, $4) RETURNING id`, + [l1.id, l2.id, server, ranked], + ); + } finally { + await postgres.query("ALTER TABLE public.matches ENABLE TRIGGER USER"); + } + + if (tournament) { + const [pool] = await postgres.query>( + `INSERT INTO map_pools (type) VALUES ('Competitive') RETURNING id`, + ); + const [options] = await postgres.query>( + `INSERT INTO match_options + (mr, best_of, type, map_veto, region_veto, map_pool_id) + VALUES (12, 1, 'Competitive', false, false, $1) RETURNING id`, + [pool.id], + ); + await postgres.query( + `INSERT INTO players (steam_id, name) + VALUES (76561190000000000, 'organizer') + ON CONFLICT (steam_id) DO NOTHING`, + ); + const [t] = await postgres.query>( + `INSERT INTO tournaments (name, organizer_steam_id, start, match_options_id) + VALUES ('T', 76561190000000000, now(), $1) RETURNING id`, + [options.id], + ); + const [stage] = await postgres.query>( + `INSERT INTO tournament_stages + (tournament_id, type, "order", min_teams, max_teams) + VALUES ($1, 'SingleElimination', 1, 4, 8) RETURNING id`, + [t.id], + ); + await postgres.query( + `INSERT INTO tournament_brackets (tournament_stage_id, match_id, round) + VALUES ($1, $2, 1)`, + [stage.id, match.id], + ); + } + return match.id; + }; + + it("buckets a real match by counts_toward_ranking, not by how it was made", async () => { + await autoLoads("inventory-simulator", { + ranked: true, + tournaments: false, + custom: false, + }); + + const ranked = await matchOn(serverId, { ranked: true }); + expect( + (await service.resolveForServer(serverId, ranked))?.enabledPlugins, + ).toEqual("inventory-simulator@1.0.0"); + + const casual = await matchOn(serverId, { ranked: false }); + expect( + (await service.resolveForServer(serverId, casual))?.enabledPlugins ?? + "", + ).toEqual(""); + }); + + // Both flags true must resolve to the tournament bucket, so a ranked-only + // plugin stays out of a tournament game. + it("sends a ranked tournament match to the tournament bucket", async () => { + await autoLoads("inventory-simulator", { + ranked: true, + tournaments: false, + custom: false, + }); + + const match = await matchOn(serverId, { ranked: true, tournament: true }); + + expect( + (await service.resolveForServer(serverId, match))?.enabledPlugins ?? "", + ).toEqual(""); + }); + + beforeEach(async () => { + await catalog("inventory-simulator"); + await onNode("node-a", "inventory-simulator", "1.0.0"); + await installed("inventory-simulator"); + }); + + // Installing a plugin is not consent to run it on every server. + it("loads nowhere until a target is turned on", async () => { + expect(await forScope("ranked")).toEqual([]); + expect(await forScope("tournaments")).toEqual([]); + expect(await forScope("custom")).toEqual([]); + }); + + it("loads only where its target says", async () => { + await autoLoads("inventory-simulator", { + ranked: true, + tournaments: false, + custom: false, + }); + + expect(await forScope("ranked")).toEqual(["inventory-simulator@1.0.0"]); + expect(await forScope("tournaments")).toEqual([]); + expect(await forScope("custom")).toEqual([]); + }); + + it("keeps a customs-only plugin out of ranked and tournaments", async () => { + await autoLoads("inventory-simulator", { + ranked: false, + tournaments: false, + custom: true, + }); + + expect(await forScope("custom")).toEqual(["inventory-simulator@1.0.0"]); + expect(await forScope("ranked")).toEqual([]); + expect(await forScope("tournaments")).toEqual([]); + }); + + // A dedicated server is built before it has a match, so gating it on a + // guess would drop plugins it is meant to carry. + it("falls back to anything that loads somewhere with no match", async () => { + await autoLoads("inventory-simulator", { + ranked: false, + tournaments: false, + custom: true, + }); + + expect(await forScope()).toEqual(["inventory-simulator@1.0.0"]); + }); + + // The pod's plugin set is fixed at build time, so a Ranked dedicated server + // has to be resolved as ranked even though it has no match yet -- otherwise + // a customs-only plugin is baked into the server matchmaking draws from. + it("keeps a customs-only plugin out of a dedicated ranked server", async () => { + await autoLoads("inventory-simulator", { + ranked: false, + tournaments: false, + custom: true, + }); + + const [ranked] = await postgres.query>( + `INSERT INTO servers + (host, label, rcon_password, port, region, type, is_dedicated, enabled, + game_server_node_id) + VALUES ('127.0.0.1', 'ranked', $1, 27017, 'TestRegion', 'Ranked', false, + true, 'node-a') + RETURNING id`, + [Buffer.from("password")], + ); + + expect( + (await service.resolveForServer(ranked.id))?.enabledPlugins ?? "", + ).toEqual(""); + }); + + it("stays out of the answer once the install is disabled", async () => { + await autoLoads("inventory-simulator"); await postgres.query( - `UPDATE game_plugin_installs SET always_load = true + `UPDATE game_plugin_installs SET enabled = false WHERE plugin_slug = 'inventory-simulator'`, ); + expect(await forScope("ranked")).toEqual([]); + }); + + // The buckets do not overlap: a ranked tournament game is a tournament + // game, so a plugin set to ranked-only stays out of it. + it("reads tournament ahead of ranked, and custom last", () => { + expect( + GameModesService.matchScope({ isTournament: true, isRanked: true }), + ).toEqual("tournaments"); + expect( + GameModesService.matchScope({ isTournament: false, isRanked: true }), + ).toEqual("ranked"); + expect( + GameModesService.matchScope({ isTournament: false, isRanked: false }), + ).toEqual("custom"); + }); + }); + + describe("plugin cfg layers", () => { + beforeEach(async () => { + await catalog("inventory-simulator"); + await catalog("retakes"); + await onNode("node-a", "inventory-simulator", "1.0.0"); + await onNode("node-a", "retakes", "2.0.0"); + }); + + it("hands back the cvars of every plugin that loads, in load order", async () => { + await postgres.query( + `INSERT INTO game_plugin_installs (plugin_slug, version, channel, cfg) + VALUES ('retakes', NULL, 'Auto', 'mp_freezetime 3'), + ('inventory-simulator', NULL, 'Auto', 'invsim_ws_enabled 1')`, + ); + + const [mode] = await postgres.query>( + `INSERT INTO game_modes (slug, name) VALUES ('fun', 'Fun') RETURNING id`, + ); + await postgres.query( + `INSERT INTO game_mode_plugins (game_mode_id, plugin_slug, load_order) + VALUES ($1, 'inventory-simulator', 1), ($1, 'retakes', 2)`, + [mode.id], + ); + await postgres.query( + `UPDATE servers SET game_mode_id = $1 WHERE id = $2`, + [mode.id, serverId], + ); + const resolved = await service.resolveForServer(serverId); - expect(resolved?.enabledPlugins).toEqual("inventory-simulator@1.0.0"); - expect(resolved?.disableServerGuidelines).toBe(true); + expect(await service.pluginCfgLayers(resolved)).toEqual([ + { slug: "inventory-simulator", cfg: "invsim_ws_enabled 1" }, + { slug: "retakes", cfg: "mp_freezetime 3" }, + ]); + }); + + // A blank cfg would write an empty file over the layer and exec nothing, + // which is not the same as having no layer at all. + it("skips a plugin whose cvars are empty or unset", async () => { + await postgres.query( + `INSERT INTO game_plugin_installs + (plugin_slug, version, channel, load_custom, cfg) + VALUES ('retakes', NULL, 'Auto', true, ' '), + ('inventory-simulator', NULL, 'Auto', true, NULL)`, + ); + + const resolved = await service.resolveForServer(serverId); + + expect(resolved?.enabledPlugins).toContain("retakes@2.0.0"); + expect(await service.pluginCfgLayers(resolved)).toEqual([]); + }); + + it("has nothing to say when no plugin loads", async () => { + expect(await service.pluginCfgLayers(null)).toEqual([]); }); }); });