From 462e3e2fdbec2588bf33b5c037540305afc3fdc8 Mon Sep 17 00:00:00 2001 From: Luke Policinski Date: Fri, 2 Oct 2026 14:11:29 -0400 Subject: [PATCH] feature: config file, config cvar and forced cvars for plugin settings, Deathmatch weapon rotation --- README.md | 27 +++ registry/game/deathmatch.json | 429 ++++++++++++++++++++++++++++++++++ schema/plugin.schema.json | 37 ++- scripts/build.test.mjs | 100 +++++++- scripts/validate.mjs | 142 +++++++++++ 5 files changed, 732 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 1807278..3eb20e6 100644 --- a/README.md +++ b/README.md @@ -114,6 +114,33 @@ Each file is written whole, so a file listed here replaces anything an operator put at that path on that server. Keep documents to the keys the rotation needs: MapChooser falls back to its defaults for every key the file leaves out. +### Settings and config files + +`cvars` lists the console variables a plugin reads. A server running the plugin +reports each one's description and default, and the panel turns the list into a +settings form on the plugin's page. + +A plugin that reads a JSON file describes it with `config_path` (where it is +written, relative to `game/csgo`), `config_default` (what the plugin ships with, +which the editor starts from) and optionally `config_schema` (the form to render; +without one the file is edited as JSON). When the plugin finds the file through a +console variable rather than a fixed path, name it in `config_cvar` and the panel +points it at `config_path` on every server it writes the file to: + +```json +"config_path": "addons/swiftlys2/configs/plugins/Deathmatch/modes.json", +"config_cvar": "dm_modes_file", +"config_default": [{ "name": "Pistols", "weapons": ["deagle"], "duration": 300 }] +``` + +`config_shipped` says where the plugin keeps its own copy (`path`, as installed +under `game/csgo`, and `repo_path`, in its repository), so the plugin's page can +open it on a node and link to it at the release that is installed. + +`forced_cvars` lists the console variables a plugin sets itself on every map +load, after the server's configs have run. Setting one anywhere in the panel does +nothing, so the panel warns when a config tries. + ### Runtimes are not interchangeable A CounterStrikeSharp plugin will not load under SwiftlyS2 and vice versa, so a diff --git a/registry/game/deathmatch.json b/registry/game/deathmatch.json index f786d50..bfc828b 100644 --- a/registry/game/deathmatch.json +++ b/registry/game/deathmatch.json @@ -18,6 +18,435 @@ "dm_replenish_armor", "dm_replenish_armor_headshot" ], + "config_path": "addons/swiftlys2/configs/plugins/Deathmatch/modes.json", + "config_cvar": "dm_modes_file", + "config_schema": { + "type": "array", + "title": "Weapon rotation", + "description": "Each mode runs for its duration, then the next one starts. Players can only pick the weapons their mode allows.", + "items": { + "type": "object", + "title": "Mode", + "required": ["name", "weapons", "duration"], + "properties": { + "name": { + "type": "string", + "title": "Name" + }, + "duration": { + "type": "integer", + "title": "Duration (seconds)", + "minimum": 1 + }, + "helmet": { + "type": "boolean", + "title": "Helmet", + "default": true + }, + "weapons": { + "type": "array", + "title": "Allowed weapons", + "uniqueItems": true, + "items": { + "type": "string", + "enum": [ + "deagle", + "elite", + "fiveseven", + "glock", + "tec9", + "hkp2000", + "p250", + "usp_silencer", + "cz75a", + "revolver", + "mac10", + "mp5sd", + "mp7", + "mp9", + "ump45", + "p90", + "bizon", + "mag7", + "nova", + "sawedoff", + "xm1014", + "m249", + "negev", + "ak47", + "m4a1", + "m4a1_silencer", + "aug", + "sg556", + "famas", + "galilar", + "awp", + "ssg08", + "scar20", + "g3sg1" + ] + } + }, + "bots": { + "type": "object", + "title": "Bot loadout", + "required": ["secondary"], + "properties": { + "secondary": { + "type": "array", + "title": "Secondary", + "items": { + "type": "object", + "required": ["weapon", "probability"], + "properties": { + "weapon": { + "type": "string", + "title": "Weapon", + "enum": [ + "deagle", + "elite", + "fiveseven", + "glock", + "tec9", + "hkp2000", + "p250", + "usp_silencer", + "cz75a", + "revolver", + "mac10", + "mp5sd", + "mp7", + "mp9", + "ump45", + "p90", + "bizon", + "mag7", + "nova", + "sawedoff", + "xm1014", + "m249", + "negev", + "ak47", + "m4a1", + "m4a1_silencer", + "aug", + "sg556", + "famas", + "galilar", + "awp", + "ssg08", + "scar20", + "g3sg1" + ] + }, + "probability": { + "type": "number", + "title": "Chance", + "minimum": 0, + "maximum": 1 + } + } + } + }, + "primary": { + "type": "array", + "title": "Primary", + "items": { + "type": "object", + "required": ["weapon", "probability"], + "properties": { + "weapon": { + "type": "string", + "title": "Weapon", + "enum": [ + "deagle", + "elite", + "fiveseven", + "glock", + "tec9", + "hkp2000", + "p250", + "usp_silencer", + "cz75a", + "revolver", + "mac10", + "mp5sd", + "mp7", + "mp9", + "ump45", + "p90", + "bizon", + "mag7", + "nova", + "sawedoff", + "xm1014", + "m249", + "negev", + "ak47", + "m4a1", + "m4a1_silencer", + "aug", + "sg556", + "famas", + "galilar", + "awp", + "ssg08", + "scar20", + "g3sg1" + ] + }, + "probability": { + "type": "number", + "title": "Chance", + "minimum": 0, + "maximum": 1 + } + } + } + } + } + } + } + } + }, + "config_shipped": { + "path": "addons/swiftlys2/plugins/Deathmatch/resources/configs/default.json", + "repo_path": "resources/configs/default.json" + }, + "config_default": [ + { + "name": "Pistols", + "weapons": [ + "usp_silencer", + "deagle", + "elite", + "fiveseven", + "glock", + "tec9", + "hkp2000", + "p250", + "cz75a", + "revolver" + ], + "helmet": false, + "duration": 300, + "bots": { + "secondary": [ + { + "weapon": "usp_silencer", + "probability": 0.8 + }, + { + "weapon": "glock", + "probability": 0.1 + }, + { + "weapon": "deagle", + "probability": 0.1 + } + ] + } + }, + { + "name": "Mid-Tier", + "weapons": [ + "deagle", + "mp9", + "usp_silencer", + "elite", + "fiveseven", + "glock", + "tec9", + "hkp2000", + "p250", + "cz75a", + "revolver", + "mac10", + "mp5sd", + "mp7", + "ump45" + ], + "duration": 300, + "bots": { + "secondary": [ + { + "weapon": "deagle", + "probability": 1 + } + ], + "primary": [ + { + "weapon": "mp9", + "probability": 0.3 + }, + { + "weapon": "mac10", + "probability": 0.1 + } + ] + } + }, + { + "name": "Rifles", + "weapons": [ + "deagle", + "ak47", + "usp_silencer", + "elite", + "fiveseven", + "glock", + "tec9", + "hkp2000", + "p250", + "cz75a", + "revolver", + "m4a1", + "m4a1_silencer", + "aug", + "sg556", + "famas", + "galilar", + "awp", + "ssg08" + ], + "duration": 1200, + "bots": { + "secondary": [ + { + "weapon": "deagle", + "probability": 1 + } + ], + "primary": [ + { + "weapon": "ak47", + "probability": 0.7 + }, + { + "weapon": "m4a1", + "probability": 0.1 + }, + { + "weapon": "m4a1_silencer", + "probability": 0.1 + }, + { + "weapon": "awp", + "probability": 0.1 + } + ] + } + } + ], + "forced_cvars": [ + "sv_hibernate_when_empty", + "bot_join_after_player", + "mp_warmuptime", + "mp_warmup_online_enabled", + "mp_roundtime", + "mp_roundtime_defuse", + "mp_respawn_immunitytime", + "mp_dm_healthshot_killcount", + "mp_dm_time_between_bonus_min", + "mp_dm_time_between_bonus_max", + "mp_buytime", + "mp_buy_anywhere", + "mp_buy_during_immunity", + "bot_difficulty", + "mp_bot_ai_bt", + "bot_autodifficulty_threshold_high", + "bot_autodifficulty_threshold_low", + "bot_chatter", + "bot_defer_to_human_goals", + "bot_defer_to_human_items", + "bot_quota", + "bot_quota_mode", + "cash_player_bomb_defused", + "cash_player_bomb_planted", + "cash_player_damage_hostage", + "cash_player_interact_with_hostage", + "cash_player_killed_enemy_default", + "cash_player_killed_enemy_factor", + "cash_player_killed_hostage", + "cash_player_killed_teammate", + "cash_player_rescued_hostage", + "cash_team_elimination_bomb_map", + "cash_team_elimination_hostage_map_t", + "cash_team_elimination_hostage_map_ct", + "cash_team_hostage_alive", + "cash_team_hostage_interaction", + "cash_team_loser_bonus", + "cash_team_loser_bonus_consecutive_rounds", + "cash_team_planted_bomb_but_defused", + "cash_team_rescued_hostage", + "cash_team_terrorist_win_bomb", + "cash_team_win_by_defusing_bomb", + "cash_team_win_by_hostage_rescue", + "cash_team_win_by_time_running_out_bomb", + "cash_team_win_by_time_running_out_hostage", + "ff_damage_reduction_bullets", + "ff_damage_reduction_grenade", + "ff_damage_reduction_grenade_self", + "ff_damage_reduction_other", + "mp_afterroundmoney", + "mp_death_drop_defuser", + "mp_death_drop_grenade", + "mp_death_drop_gun", + "mp_defuser_allocation", + "mp_force_pick_time", + "mp_forcecamera", + "mp_freezetime", + "mp_friendlyfire", + "mp_win_panel_display_time", + "mp_halftime", + "mp_match_can_clinch", + "mp_maxmoney", + "mp_maxrounds", + "mp_playercashawards", + "mp_startmoney", + "mp_teamcashawards", + "mp_timelimit", + "mp_weapons_allow_zeus", + "mp_weapons_allow_typecount", + "spec_freeze_time", + "sv_talk_enemy_living", + "sv_talk_enemy_dead", + "sv_deadtalk", + "sv_ignoregrenaderadio", + "mp_randomspawn", + "mp_randomspawn_los", + "tv_delay", + "ammo_grenade_limit_flashbang", + "ammo_grenade_limit_total", + "mp_weapons_allow_map_placed", + "mp_weapons_glow_on_ground", + "mp_display_kill_assists", + "mp_respawn_on_death_t", + "mp_respawn_on_death_ct", + "mp_ct_default_melee", + "mp_ct_default_secondary", + "mp_ct_default_primary", + "mp_t_default_melee", + "mp_t_default_secondary", + "mp_t_default_primary", + "mp_default_team_winner_no_objective", + "mp_buy_allow_grenades", + "mp_teammates_are_enemies", + "mp_solid_teammates", + "sv_max_deathmatch_respawns_per_tick", + "sv_disconnected_players_cleanup_delay", + "r_decals_overide_fadestarttime_params", + "mp_limitteams", + "sv_gameinstructor_enable", + "mp_promoted_item_enabled", + "sv_allow_annotations_access_level", + "mp_give_player_c4", + "sv_warmup_to_freezetime_delay", + "sv_disable_radar", + "sv_minimum_desired_chicken_count", + "sv_infinite_ammo", + "mp_free_armor" + ], "variants": { "swiftlys2": { "repo": "ianlucas/cs2-ss2-deathmatch", diff --git a/schema/plugin.schema.json b/schema/plugin.schema.json index f464160..53e4d1f 100644 --- a/schema/plugin.schema.json +++ b/schema/plugin.schema.json @@ -85,18 +85,51 @@ }, "config_schema": { "type": "object", - "description": "JSON Schema for the plugin's config file. The panel renders a form from it." + "description": "JSON Schema for the plugin's config file. The panel renders a form from it; without one the file is edited as JSON." }, "config_path": { "type": "string", "description": "Where the config file is written, relative to game/csgo. {runtime} is substituted." }, + "config_cvar": { + "type": "string", + "description": "Console variable the plugin reads the config file's path from. The panel sets it to config_path on every server it writes the file to." + }, + "config_shipped": { + "type": "object", + "description": "Where the plugin keeps its own copy of the config file, so the panel can link to it.", + "additionalProperties": false, + "properties": { + "path": { + "type": "string", + "description": "Where the release installs it, relative to game/csgo. {runtime} is substituted." + }, + "repo_path": { + "type": "string", + "description": "Where it lives in the source repository, linked at the installed release's tag." + } + } + }, + "config_default": { + "type": [ + "object", + "array" + ], + "description": "The config file the plugin ships with. The panel starts the editor from it." + }, "cvars": { "type": "array", "items": { "type": "string" }, - "description": "Console variables this plugin reads, for the mode cfg editor's autocomplete." + "description": "Console variables this plugin reads. A server running the plugin reports each one's description, type and default to the panel, which renders the settings form from them." + }, + "forced_cvars": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Console variables the plugin sets itself on every map load, after the server's configs have run. The panel warns when a config sets one, because the plugin overwrites it." }, "map_rotation": { "type": "object", diff --git a/scripts/build.test.mjs b/scripts/build.test.mjs index 888bf07..72d7d9c 100644 --- a/scripts/build.test.mjs +++ b/scripts/build.test.mjs @@ -5,7 +5,7 @@ import assert from "node:assert/strict"; import { selectLinuxAsset } from "./build.mjs"; import { sameIndex } from "./changed.mjs"; -import { validateMapRotation } from "./validate.mjs"; +import { validateConfig, validateMapRotation } from "./validate.mjs"; const glob = (pattern) => new RegExp( @@ -140,6 +140,104 @@ test("rejects map rotation tokens used in the wrong place", () => { ); }); +const modes = { + type: "array", + items: { + type: "object", + required: ["name"], + properties: { + name: { type: "string" }, + duration: { type: "integer", minimum: 1 }, + weapons: { type: "array", items: { type: "string", enum: ["ak47", "awp"] } }, + }, + }, +}; + +const configEntry = (fields) => ({ kind: "game", config_path: "a/modes.json", ...fields }); + +test("accepts a config default the form can render", () => { + const problems = validateConfig( + configEntry({ config_schema: modes, config_default: [{ name: "Rifles", duration: 60, weapons: ["ak47"] }] }), + "x.json", + ); + assert.deepEqual(problems, []); +}); + +// The panel opens the editor on the default, so one that breaks the schema is an +// editor that is broken before anyone touches it. +test("rejects a config default that breaks its own schema", () => { + const problems = validateConfig( + configEntry({ + config_schema: modes, + config_default: [{ duration: 0, weapons: ["knife"], helmet: true }], + }), + "x.json", + ); + assert.equal(problems.length, 4); + assert.match(problems.join("\n"), /name is required/); + assert.match(problems.join("\n"), /below its minimum/); + assert.match(problems.join("\n"), /"knife", which is not one of its options/); + assert.match(problems.join("\n"), /helmet is not described by the schema/); +}); + +test("rejects a list the schema says holds each value once", () => { + const problems = validateConfig( + configEntry({ + config_schema: { + type: "array", + items: { + type: "object", + properties: { + name: { type: "string" }, + weapons: { type: "array", uniqueItems: true, items: { type: "string", enum: ["ak47", "awp"] } }, + }, + }, + }, + config_default: [{ name: "Rifles", weapons: ["ak47", "awp", "ak47"] }], + }), + "x.json", + ); + assert.equal(problems.length, 1); + assert.match(problems[0], /"ak47" more than once/); +}); + +test("treats an empty required value as missing, as the editor does", () => { + const problems = validateConfig( + configEntry({ config_schema: modes, config_default: [{ name: "" }] }), + "x.json", + ); + assert.equal(problems.length, 1); + assert.match(problems[0], /name is required/); +}); + +test("rejects config fields with no file to describe", () => { + const problems = validateConfig( + { kind: "game", config_cvar: "dm_modes_file", config_default: {} }, + "x.json", + ); + assert.equal(problems.length, 2); +}); + +test("rejects a config path that escapes game/csgo", () => { + assert.equal(validateConfig(configEntry({ config_path: "../modes.json" }), "x.json").length, 1); +}); + +test("rejects a shipped config path that escapes game/csgo", () => { + const problems = validateConfig( + configEntry({ config_shipped: { path: "../default.json", repo_path: "resources/default.json" } }), + "x.json", + ); + assert.equal(problems.length, 1); +}); + +test("rejects forced cvars that are not console variable names", () => { + const problems = validateConfig( + configEntry({ forced_cvars: ["mp_timelimit", "mp_timelimit 2"] }), + "x.json", + ); + assert.equal(problems.length, 1); +}); + if (failures > 0) { console.error(`\n${failures} test(s) failed`); process.exit(1); diff --git a/scripts/validate.mjs b/scripts/validate.mjs index ca3e118..c28bfa1 100644 --- a/scripts/validate.mjs +++ b/scripts/validate.mjs @@ -133,6 +133,8 @@ export function validateEntry({ entry, filePath, directory, fileName }) { problems.push(...validateMapRotation(entry, filePath)); } + problems.push(...validateConfig(entry, filePath)); + if (entry.wiring) { if (entry.kind === "game") { fail(`"wiring" describes how a panel plugin configures a game plugin, so it does not belong on a game entry`); @@ -241,6 +243,146 @@ export function validateMapRotation(entry, filePath) { return problems; } +const CVAR = /^[A-Za-z0-9_.]+$/; + +export function validateConfig(entry, filePath) { + const problems = []; + const fail = (message) => problems.push(`${filePath}: ${message}`); + + for (const field of ["cvars", "forced_cvars"]) { + if (entry[field] === undefined) { + continue; + } + + if (!Array.isArray(entry[field])) { + fail(`"${field}" must be a list of console variable names`); + continue; + } + + for (const name of entry[field]) { + if (typeof name !== "string" || !CVAR.test(name)) { + fail(`"${field}" has ${JSON.stringify(name)}, which is not a console variable name`); + } + } + } + + const path = entry.config_path; + + if (path !== undefined && (typeof path !== "string" || path.startsWith("/") || path.includes(".."))) { + fail(`"config_path" must be a relative path inside game/csgo`); + } + + for (const field of ["config_schema", "config_default", "config_cvar", "config_shipped"]) { + if (entry[field] !== undefined && !path) { + fail(`"${field}" describes the config file, so it needs "config_path"`); + } + } + + for (const field of ["path", "repo_path"]) { + const shipped = entry.config_shipped?.[field]; + + if (shipped !== undefined && (typeof shipped !== "string" || shipped.startsWith("/") || shipped.includes(".."))) { + fail(`"config_shipped.${field}" must be a relative path`); + } + } + + if ( + entry.config_shipped !== undefined && + (typeof entry.config_shipped !== "object" || entry.config_shipped === null || Array.isArray(entry.config_shipped)) + ) { + fail(`"config_shipped" must be an object with "path" and "repo_path"`); + } + + if (entry.config_cvar !== undefined && (typeof entry.config_cvar !== "string" || !CVAR.test(entry.config_cvar))) { + fail(`"config_cvar" must be a console variable name`); + } + + if (entry.config_default !== undefined && entry.config_schema) { + for (const problem of schemaProblems(entry.config_schema, entry.config_default, "config_default")) { + fail(problem); + } + } + + return problems; +} + +// The subset of JSON Schema the panel's form understands. A default the form +// cannot render is a broken editor, so it is held to the same rules. +export function schemaProblems(schema, value, at) { + const problems = []; + const type = schema?.type; + + const matches = + type === undefined || + (type === "array" && Array.isArray(value)) || + (type === "object" && value !== null && typeof value === "object" && !Array.isArray(value)) || + (type === "string" && typeof value === "string") || + (type === "boolean" && typeof value === "boolean") || + (type === "number" && typeof value === "number") || + (type === "integer" && Number.isInteger(value)); + + if (!matches) { + return [`${at} should be ${type}, got ${JSON.stringify(value)}`]; + } + + if (schema.enum && !schema.enum.includes(value)) { + problems.push(`${at} is ${JSON.stringify(value)}, which is not one of its options`); + } + + if (typeof value === "number") { + if (schema.minimum !== undefined && value < schema.minimum) { + problems.push(`${at} is below its minimum of ${schema.minimum}`); + } + + if (schema.maximum !== undefined && value > schema.maximum) { + problems.push(`${at} is above its maximum of ${schema.maximum}`); + } + } + + if (type === "array") { + // A duplicate is not a style problem: the Deathmatch plugin keys a mode's + // weapons by item id and drops the whole file on the second one. + if (schema.uniqueItems) { + const seen = new Set(); + + for (const item of value) { + const key = JSON.stringify(item); + + if (seen.has(key)) { + problems.push(`${at} lists ${key} more than once`); + } + + seen.add(key); + } + } + + value.forEach((item, index) => { + problems.push(...schemaProblems(schema.items ?? {}, item, `${at}[${index}]`)); + }); + } + + if (type === "object") { + for (const key of schema.required ?? []) { + if (value[key] === undefined || value[key] === "") { + problems.push(`${at}.${key} is required`); + } + } + + for (const [key, item] of Object.entries(value)) { + const property = schema.properties?.[key]; + + if (!property) { + problems.push(`${at}.${key} is not described by the schema`); + continue; + } + + problems.push(...schemaProblems(property, item, `${at}.${key}`)); + } + } + + return problems; +} + export async function validateAll() { const entries = await loadEntries(); const problems = entries.flatMap(validateEntry);