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
150 changes: 139 additions & 11 deletions src/dedicated-servers/dedicated-servers.service.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { DedicatedServersService } from "./dedicated-servers.service";
import { GameModesService } from "../game-plugins/game-modes.service";

// Remove-then-create is not atomic. Two overlapping rebuilds of one server used
// to interleave: both removed, one created, and the other's AlreadyExists
Expand Down Expand Up @@ -492,9 +493,10 @@ 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"],
);
expect(DedicatedServersService.launchMode("Custom", "deathmatch")).toEqual([
"+game_type 1",
"+game_mode 2",
]);
});

it("keeps stock Custom when the mode names no Valve mode", () => {
Expand Down Expand Up @@ -528,7 +530,7 @@ describe("DedicatedServersService.launchMode", () => {
});
});

describe("DedicatedServersService.withModeCfg", () => {
describe("DedicatedServersService.withServerCfg", () => {
const mode = (overrides: Record<string, unknown> = {}) => ({
id: "mode-1",
slug: "deathmatch",
Expand All @@ -553,7 +555,7 @@ describe("DedicatedServersService.withModeCfg", () => {
// 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)),
files(DedicatedServersService.withServerCfg(mode(), "Custom" as never)),
).toEqual({
"cfg/gamemode_deathmatch_server.cfg": "mp_teammates_are_enemies 1\n",
});
Expand All @@ -563,7 +565,7 @@ describe("DedicatedServersService.withModeCfg", () => {
expect(
Object.keys(
files(
DedicatedServersService.withModeCfg(
DedicatedServersService.withServerCfg(
mode({ valveMode: null }),
"Custom" as never,
),
Expand All @@ -579,7 +581,7 @@ describe("DedicatedServersService.withModeCfg", () => {

expect(
files(
DedicatedServersService.withModeCfg(
DedicatedServersService.withServerCfg(
mode({ pluginConfigs }),
"Custom" as never,
),
Expand All @@ -592,15 +594,17 @@ describe("DedicatedServersService.withModeCfg", () => {

it("adds nothing for a mode without cvars", () => {
expect(
DedicatedServersService.withModeCfg(mode({ cfg: " " }), "Custom" as never)
?.pluginConfigs,
DedicatedServersService.withServerCfg(
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(
DedicatedServersService.withServerCfg(
mode({ valveMode: "rush" }),
"Custom" as never,
)?.pluginConfigs,
Expand All @@ -609,8 +613,132 @@ describe("DedicatedServersService.withModeCfg", () => {

it("leaves a server with no mode alone", () => {
expect(
DedicatedServersService.withModeCfg(null, "Casual" as never),
DedicatedServersService.withServerCfg(null, "Casual" as never),
).toBeNull();
});

// Same order a match execs them in: the mode's cvars win over a plugin's.
it("runs each loading plugin's cvars ahead of the mode's", () => {
expect(
files(
DedicatedServersService.withServerCfg(mode(), "Custom" as never, [
{ slug: "inventory", cfg: 'invsim_url "https://inv.example"' },
{ slug: "stats", cfg: "mp_teammates_are_enemies 0\n" },
]),
),
).toEqual({
"cfg/gamemode_deathmatch_server.cfg":
'invsim_url "https://inv.example"\nmp_teammates_are_enemies 0\nmp_teammates_are_enemies 1\n',
});
});

it("writes plugin cvars on a server whose mode has none of its own", () => {
expect(
files(
DedicatedServersService.withServerCfg(
mode({ cfg: null, valveMode: null }),
"Casual" as never,
[{ slug: "inventory", cfg: "invsim_ws_enabled 1" }],
),
),
).toEqual({
"cfg/gamemode_casual_server.cfg": "invsim_ws_enabled 1\n",
});
});
});

describe("DedicatedServersService.setupDedicatedServer", () => {
const inventoryCfg = [
'invsim_url "https://inventory.5stack.gg"',
"invsim_ws_enabled 1",
].join("\n");

const pluginsOnly = {
id: "",
slug: "",
name: "",
cfg: null,
extraGameParams: null,
valveMode: null,
enabledPlugins: "inventory-simulator@1.0.0",
pluginConfigs: null,
missingRequired: [],
disableServerGuidelines: false,
};

const setup = async (type: string) => {
const createNamespacedDeployment = jest.fn().mockResolvedValue({});
const gameModes = {
resolveForServer: jest.fn().mockResolvedValue(pluginsOnly),
pluginCfgLayers: jest
.fn()
.mockResolvedValue([
{ slug: "inventory-simulator", cfg: inventoryCfg },
]),
environmentFor: GameModesService.prototype.environmentFor,
};

const service = new DedicatedServersService(
{ log: jest.fn(), verbose: jest.fn(), error: jest.fn() } as never,
{ get: () => ({ namespace: "5stack" }) } as never,
{
query: jest.fn().mockResolvedValue({
servers_by_pk: {
id: "server-1",
type,
port: 27015,
tv_port: 27020,
game: "cs2",
max_players: 10,
api_password: "api",
rcon_password: "rcon",
connect_password: null,
game_server_node: { id: "node-1" },
server_region: { steam_relay: false },
},
}),
mutation: jest.fn().mockResolvedValue({}),
} as never,
{ decrypt: jest.fn().mockResolvedValue("rcon") } as never,
null as never,
{ getConnection: () => ({}) } as never,
null as never,
{
resolvePluginRuntime: jest.fn().mockResolvedValue("swiftly"),
resolveGameServerPluginImage: jest.fn().mockResolvedValue("image"),
} as never,
gameModes as never,
{ forServer: jest.fn().mockResolvedValue({ maps: [] }) } as never,
null as never,
null as never,
);

Object.assign(service, { apps: { createNamespacedDeployment } });
jest
.spyOn(service as never, "waitForPodReady")
.mockReturnValue(new Promise(() => {}) as never);

expect(await service.setupDedicatedServer("server-1")).toBe(true);

const env: Array<{ name: string; value: string }> =
createNamespacedDeployment.mock.calls[0][0].body.spec.template.spec
.containers[0].env;
const pluginConfigs = env.find((entry) => entry.name === "PLUGIN_CONFIGS");

return pluginConfigs
? JSON.parse(Buffer.from(pluginConfigs.value, "base64").toString())
: {};
};

// The inventory plugin's configuration never reached a community server:
// only a match execs plugin cvars, so it ran on the plugin's own defaults.
it("gives a community server the cvars of the plugins it loads", async () => {
expect(await setup("Casual")).toEqual({
"cfg/gamemode_casual_server.cfg": `${inventoryCfg}\n`,
});
});

it("leaves them to the match on a Ranked server", async () => {
expect(await setup("Ranked")).toEqual({});
});
});
33 changes: 23 additions & 10 deletions src/dedicated-servers/dedicated-servers.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -229,9 +229,17 @@ 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 = DedicatedServersService.withModeCfg(
await this.gameModesService.resolveForServer(serverId),
const resolvedMode =
await this.gameModesService.resolveForServer(serverId);

// Ranked and Practice servers host matches, and a match execs each
// plugin's cvars itself.
const gameMode = DedicatedServersService.withServerCfg(
resolvedMode,
server.type,
server.type === "Ranked" || server.type === "Practice"
? []
: await this.gameModesService.pluginCfgLayers(resolvedMode),
);

const gameModeEnvironment =
Expand Down Expand Up @@ -733,29 +741,34 @@ export class DedicatedServersService {
);
}

// 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(
// A community server has no match, so the cvars a match would exec -- each
// loading plugin's, then the mode's -- 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 withServerCfg(
mode: ResolvedGameMode | null,
type: e_server_types_enum,
pluginCfgs: Array<{ slug: string; cfg: string }> = [],
): ResolvedGameMode | null {
const serverCfg = DedicatedServersService.valveModeFor(
type,
mode?.valveMode,
).serverCfg;

if (!mode?.cfg?.trim() || !serverCfg) {
const cfg = [...pluginCfgs.map((layer) => layer.cfg), mode?.cfg ?? ""]
.filter((block) => block.trim())
.map((block) => (block.endsWith("\n") ? block : `${block}\n`))
.join("");

if (!mode || !cfg || !serverCfg) {
return mode;
}

const files: Record<string, string> = mode.pluginConfigs
? JSON.parse(Buffer.from(mode.pluginConfigs, "base64").toString())
: {};

files[`cfg/${serverCfg}.cfg`] = mode.cfg.endsWith("\n")
? mode.cfg
: `${mode.cfg}\n`;
files[`cfg/${serverCfg}.cfg`] = cfg;

return {
...mode,
Expand Down
Loading