Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 18 additions & 46 deletions hasura/enums/game-modes.sql
Original file line number Diff line number Diff line change
@@ -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;
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ select_permissions:
- competitive_safe
- cfg
- extra_game_params
- valve_mode
- system
- created_at
- updated_at
computed_fields:
Expand All @@ -64,6 +66,8 @@ select_permissions:
- competitive_safe
- cfg
- extra_game_params
- valve_mode
- system
- created_at
- updated_at
computed_fields:
Expand All @@ -88,6 +92,7 @@ insert_permissions:
- competitive_safe
- cfg
- extra_game_params
- valve_mode
computed_fields:
- supported_runtimes
- runtime_conflicts
Expand All @@ -105,6 +110,7 @@ update_permissions:
- competitive_safe
- cfg
- extra_game_params
- valve_mode
computed_fields:
- supported_runtimes
- runtime_conflicts
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ALTER TABLE public.game_modes DROP COLUMN IF EXISTS valve_mode;
Original file line number Diff line number Diff line change
@@ -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;
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ALTER TABLE public.game_modes DROP COLUMN IF EXISTS system;
73 changes: 73 additions & 0 deletions hasura/migrations/default/1889000001400_game_mode_templates/up.sql
Original file line number Diff line number Diff line change
@@ -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;
34 changes: 34 additions & 0 deletions hasura/triggers/game_modes.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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();

127 changes: 127 additions & 0 deletions src/dedicated-servers/dedicated-servers.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> = {}) => ({
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<string>,
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_<mode>_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();
});
});

Loading
Loading