@ankorcs/protocol 0.0.0-stage → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,316 @@
1
+ /**
2
+ * What a game server is launched with: the configuration that the controller sends the agent in
3
+ * {@link ControllerStartServer | start_server}. The agent's `container.ts` defines what the agent hands
4
+ * the container and the plugin inside it.
5
+ */
6
+ import { z } from 'zod';
7
+ /**
8
+ * How starting sides are decided.
9
+ *
10
+ * - `knife`: a knife round decides them.
11
+ * - `team1_ct`: team 1 starts as CT.
12
+ * - `team1_t`: team 1 starts as T.
13
+ */
14
+ export declare const SidesMode: z.ZodEnum<{
15
+ knife: "knife";
16
+ team1_ct: "team1_ct";
17
+ team1_t: "team1_t";
18
+ }>;
19
+ export type SidesMode = z.infer<typeof SidesMode>;
20
+ /**
21
+ * How warmup ends.
22
+ *
23
+ * - `auto`: counts down once every rostered player is connected. Players have no ready commands.
24
+ * - `manual`: also waits for each rostered player and each rostered spectator to type `!ready`,
25
+ * which `!unready` withdraws. A captain's `!forceready` readies the captain's team with
26
+ * whichever of its players are connected, and any rostered spectator's `!forceready` readies the
27
+ * spectators.
28
+ * - `external`: only {@link ControllerStartMatch | start_match} from the controller starts the match, and
29
+ * players have no commands for it.
30
+ *
31
+ * In every mode, {@link ControllerStartMatch | start_match} from the controller starts the match.
32
+ */
33
+ export declare const ReadyMode: z.ZodEnum<{
34
+ auto: "auto";
35
+ manual: "manual";
36
+ external: "external";
37
+ }>;
38
+ export type ReadyMode = z.infer<typeof ReadyMode>;
39
+ /**
40
+ * {@link MatchRules} as a launch asks for them, where each rule left out takes the value of a
41
+ * typical competitive match. The reports of {@link GoingLive | going_live} and
42
+ * {@link RoundBackup | round_backup} always carry every rule.
43
+ */
44
+ export declare const LaunchRules: z.ZodObject<{
45
+ max_rounds: z.ZodDefault<z.ZodNumber>;
46
+ overtime: z.ZodDefault<z.ZodBoolean>;
47
+ overtime_max_rounds: z.ZodDefault<z.ZodNumber>;
48
+ friendly_fire: z.ZodDefault<z.ZodBoolean>;
49
+ }, z.core.$strip>;
50
+ export type LaunchRules = z.infer<typeof LaunchRules>;
51
+ /**
52
+ * The match a server is launched with, which covers one map. Each server plays one match. A restore
53
+ * launches the same `match_id` again under a new `allocation_id`, from a
54
+ * {@link RoundBackup | round_backup} that the first server sent.
55
+ */
56
+ export declare const MatchConfig: z.ZodObject<{
57
+ match_id: z.ZodDefault<z.ZodNullable<z.ZodUUID>>;
58
+ send_statistics: z.ZodDefault<z.ZodBoolean>;
59
+ roster: z.ZodObject<{
60
+ team1: z.ZodObject<{
61
+ name: z.ZodString;
62
+ players: z.ZodArray<z.ZodString>;
63
+ }, z.core.$strip>;
64
+ team2: z.ZodObject<{
65
+ name: z.ZodString;
66
+ players: z.ZodArray<z.ZodString>;
67
+ }, z.core.$strip>;
68
+ spectators: z.ZodArray<z.ZodString>;
69
+ }, z.core.$strip>;
70
+ sides: z.ZodEnum<{
71
+ knife: "knife";
72
+ team1_ct: "team1_ct";
73
+ team1_t: "team1_t";
74
+ }>;
75
+ knife_decision_seconds: z.ZodDefault<z.ZodNumber>;
76
+ ready_mode: z.ZodDefault<z.ZodEnum<{
77
+ auto: "auto";
78
+ manual: "manual";
79
+ external: "external";
80
+ }>>;
81
+ spectators_ready_up: z.ZodDefault<z.ZodBoolean>;
82
+ ready_timeout_seconds: z.ZodDefault<z.ZodNullable<z.ZodNumber>>;
83
+ record_demo: z.ZodBoolean;
84
+ debug_capture: z.ZodDefault<z.ZodBoolean>;
85
+ rules: z.ZodPrefault<z.ZodObject<{
86
+ max_rounds: z.ZodDefault<z.ZodNumber>;
87
+ overtime: z.ZodDefault<z.ZodBoolean>;
88
+ overtime_max_rounds: z.ZodDefault<z.ZodNumber>;
89
+ friendly_fire: z.ZodDefault<z.ZodBoolean>;
90
+ }, z.core.$strip>>;
91
+ restore: z.ZodDefault<z.ZodNullable<z.ZodObject<{
92
+ team1_side: z.ZodEnum<{
93
+ t: "t";
94
+ ct: "ct";
95
+ }>;
96
+ rules: z.ZodObject<{
97
+ max_rounds: z.ZodNumber;
98
+ overtime: z.ZodBoolean;
99
+ overtime_max_rounds: z.ZodNumber;
100
+ friendly_fire: z.ZodBoolean;
101
+ }, z.core.$strip>;
102
+ file: z.ZodString;
103
+ }, z.core.$strip>>>;
104
+ }, z.core.$strip>;
105
+ export type MatchConfig = z.infer<typeof MatchConfig>;
106
+ /** A match as a controller builds it, which may leave out the fields that have a default. */
107
+ export type MatchConfigInput = z.input<typeof MatchConfig>;
108
+ /** Control characters, which a launch value must not carry into generated cfg commands. */
109
+ export declare const CFG_CONTROL_CHARS: RegExp;
110
+ /**
111
+ * The password players type. It is printable ASCII without spaces, `"` or `;`, because the server
112
+ * would read those as separators.
113
+ */
114
+ export declare const GamePassword: z.ZodString;
115
+ /** A Steam Web API key, which the server needs to download workshop maps. */
116
+ export declare const SteamWebApiKey: z.ZodString;
117
+ /** The name of a stock map, such as `de_mirage`. A name of all digits would be a workshop id. */
118
+ export declare const StockMapName: z.ZodString;
119
+ /** A Steam Workshop map's id. */
120
+ export declare const WorkshopId: z.ZodString;
121
+ /** The colour names SwiftlyS2 accepts in chat markup. */
122
+ export declare const CHAT_COLORS: readonly ["white", "darkred", "lightpurple", "green", "olive", "lime", "red", "gray", "lightyellow", "yellow", "silver", "bluegrey", "lightblue", "blue", "darkblue", "purple", "magenta", "lightred", "gold", "orange"];
123
+ /**
124
+ * A chat colour name from
125
+ * [SwiftlyS2's chat styling](https://swiftlys2.net/docs/guides/chat-and-html-styling).
126
+ */
127
+ export declare const ChatColor: z.ZodEnum<{
128
+ white: "white";
129
+ darkred: "darkred";
130
+ lightpurple: "lightpurple";
131
+ green: "green";
132
+ olive: "olive";
133
+ lime: "lime";
134
+ red: "red";
135
+ gray: "gray";
136
+ lightyellow: "lightyellow";
137
+ yellow: "yellow";
138
+ silver: "silver";
139
+ bluegrey: "bluegrey";
140
+ lightblue: "lightblue";
141
+ blue: "blue";
142
+ darkblue: "darkblue";
143
+ purple: "purple";
144
+ magenta: "magenta";
145
+ lightred: "lightred";
146
+ gold: "gold";
147
+ orange: "orange";
148
+ }>;
149
+ export type ChatColor = z.infer<typeof ChatColor>;
150
+ /** The bracketed tag in front of every plugin chat line. */
151
+ export declare const ChatTag: z.ZodObject<{
152
+ text: z.ZodDefault<z.ZodString>;
153
+ color: z.ZodDefault<z.ZodEnum<{
154
+ white: "white";
155
+ darkred: "darkred";
156
+ lightpurple: "lightpurple";
157
+ green: "green";
158
+ olive: "olive";
159
+ lime: "lime";
160
+ red: "red";
161
+ gray: "gray";
162
+ lightyellow: "lightyellow";
163
+ yellow: "yellow";
164
+ silver: "silver";
165
+ bluegrey: "bluegrey";
166
+ lightblue: "lightblue";
167
+ blue: "blue";
168
+ darkblue: "darkblue";
169
+ purple: "purple";
170
+ magenta: "magenta";
171
+ lightred: "lightred";
172
+ gold: "gold";
173
+ orange: "orange";
174
+ }>>;
175
+ }, z.core.$strip>;
176
+ export type ChatTag = z.infer<typeof ChatTag>;
177
+ /**
178
+ * A cfg the server runs, relative to `game/csgo/cfg/` and without `.cfg`, such as `node/<name>`
179
+ * from the node's cfgs.
180
+ */
181
+ export declare const CfgName: z.ZodString;
182
+ /**
183
+ * The game mode a server runs, which sets the game's rules. The engine runs the mode's cfg, such
184
+ * as `gamemode_competitive` or `gamemode_competitive2v2` for `wingman`, on every map load, and the
185
+ * plugin runs it again when a match goes live.
186
+ *
187
+ * `custom` is Valve's mode for community content. Its cfg sets little, so the launch's `exec` and
188
+ * `commands` set the game.
189
+ *
190
+ * A match plays only in `casual`, `competitive` or `wingman`. A server without a match runs in any
191
+ * mode.
192
+ */
193
+ export declare const GameMode: z.ZodEnum<{
194
+ custom: "custom";
195
+ casual: "casual";
196
+ competitive: "competitive";
197
+ wingman: "wingman";
198
+ rush: "rush";
199
+ arms_race: "arms_race";
200
+ demolition: "demolition";
201
+ deathmatch: "deathmatch";
202
+ }>;
203
+ export type GameMode = z.infer<typeof GameMode>;
204
+ /**
205
+ * What a game server is launched with: any CS2 server, and the match Ankor's plugin plays on it, if
206
+ * any.
207
+ *
208
+ * Rejected when both `workshop_id` and `map_name` are null. With a `match`, it is also rejected:
209
+ *
210
+ * - when `workshop_id` is set, because a match plays a stock map.
211
+ * - when the roster's players and spectators together outnumber `max_players`.
212
+ * - when `game_mode` is not `casual`, `competitive` or `wingman`.
213
+ * - when `ready_mode` is `external` and `ready_timeout_seconds` is set.
214
+ */
215
+ export declare const ServerLaunchConfig: z.ZodObject<{
216
+ hostname: z.ZodDefault<z.ZodNullable<z.ZodPipe<z.ZodString, z.ZodString>>>;
217
+ steam_web_api_key: z.ZodDefault<z.ZodNullable<z.ZodUnion<[z.ZodString, z.ZodLiteral<"">]>>>;
218
+ game_password: z.ZodDefault<z.ZodNullable<z.ZodString>>;
219
+ chat_tag: z.ZodPrefault<z.ZodObject<{
220
+ text: z.ZodDefault<z.ZodString>;
221
+ color: z.ZodDefault<z.ZodEnum<{
222
+ white: "white";
223
+ darkred: "darkred";
224
+ lightpurple: "lightpurple";
225
+ green: "green";
226
+ olive: "olive";
227
+ lime: "lime";
228
+ red: "red";
229
+ gray: "gray";
230
+ lightyellow: "lightyellow";
231
+ yellow: "yellow";
232
+ silver: "silver";
233
+ bluegrey: "bluegrey";
234
+ lightblue: "lightblue";
235
+ blue: "blue";
236
+ darkblue: "darkblue";
237
+ purple: "purple";
238
+ magenta: "magenta";
239
+ lightred: "lightred";
240
+ gold: "gold";
241
+ orange: "orange";
242
+ }>>;
243
+ }, z.core.$strip>>;
244
+ game_mode: z.ZodDefault<z.ZodEnum<{
245
+ custom: "custom";
246
+ casual: "casual";
247
+ competitive: "competitive";
248
+ wingman: "wingman";
249
+ rush: "rush";
250
+ arms_race: "arms_race";
251
+ demolition: "demolition";
252
+ deathmatch: "deathmatch";
253
+ }>>;
254
+ max_players: z.ZodNumber;
255
+ workshop_id: z.ZodDefault<z.ZodNullable<z.ZodString>>;
256
+ map_name: z.ZodNullable<z.ZodString>;
257
+ gotv: z.ZodDefault<z.ZodNullable<z.ZodObject<{
258
+ password: z.ZodDefault<z.ZodNullable<z.ZodString>>;
259
+ delay_seconds: z.ZodNumber;
260
+ }, z.core.$strip>>>;
261
+ idle_shutdown_seconds: z.ZodNullable<z.ZodNumber>;
262
+ exec: z.ZodArray<z.ZodString>;
263
+ commands: z.ZodArray<z.ZodPipe<z.ZodString, z.ZodString>>;
264
+ match: z.ZodDefault<z.ZodNullable<z.ZodObject<{
265
+ match_id: z.ZodDefault<z.ZodNullable<z.ZodUUID>>;
266
+ send_statistics: z.ZodDefault<z.ZodBoolean>;
267
+ roster: z.ZodObject<{
268
+ team1: z.ZodObject<{
269
+ name: z.ZodString;
270
+ players: z.ZodArray<z.ZodString>;
271
+ }, z.core.$strip>;
272
+ team2: z.ZodObject<{
273
+ name: z.ZodString;
274
+ players: z.ZodArray<z.ZodString>;
275
+ }, z.core.$strip>;
276
+ spectators: z.ZodArray<z.ZodString>;
277
+ }, z.core.$strip>;
278
+ sides: z.ZodEnum<{
279
+ knife: "knife";
280
+ team1_ct: "team1_ct";
281
+ team1_t: "team1_t";
282
+ }>;
283
+ knife_decision_seconds: z.ZodDefault<z.ZodNumber>;
284
+ ready_mode: z.ZodDefault<z.ZodEnum<{
285
+ auto: "auto";
286
+ manual: "manual";
287
+ external: "external";
288
+ }>>;
289
+ spectators_ready_up: z.ZodDefault<z.ZodBoolean>;
290
+ ready_timeout_seconds: z.ZodDefault<z.ZodNullable<z.ZodNumber>>;
291
+ record_demo: z.ZodBoolean;
292
+ debug_capture: z.ZodDefault<z.ZodBoolean>;
293
+ rules: z.ZodPrefault<z.ZodObject<{
294
+ max_rounds: z.ZodDefault<z.ZodNumber>;
295
+ overtime: z.ZodDefault<z.ZodBoolean>;
296
+ overtime_max_rounds: z.ZodDefault<z.ZodNumber>;
297
+ friendly_fire: z.ZodDefault<z.ZodBoolean>;
298
+ }, z.core.$strip>>;
299
+ restore: z.ZodDefault<z.ZodNullable<z.ZodObject<{
300
+ team1_side: z.ZodEnum<{
301
+ t: "t";
302
+ ct: "ct";
303
+ }>;
304
+ rules: z.ZodObject<{
305
+ max_rounds: z.ZodNumber;
306
+ overtime: z.ZodBoolean;
307
+ overtime_max_rounds: z.ZodNumber;
308
+ friendly_fire: z.ZodBoolean;
309
+ }, z.core.$strip>;
310
+ file: z.ZodString;
311
+ }, z.core.$strip>>>;
312
+ }, z.core.$strip>>>;
313
+ }, z.core.$strip>;
314
+ export type ServerLaunchConfig = z.infer<typeof ServerLaunchConfig>;
315
+ /** A launch as a controller builds it, which may leave out the fields that have a default. */
316
+ export type ServerLaunchConfigInput = z.input<typeof ServerLaunchConfig>;
package/dist/launch.js ADDED
@@ -0,0 +1,287 @@
1
+ /**
2
+ * What a game server is launched with: the configuration that the controller sends the agent in
3
+ * {@link ControllerStartServer | start_server}. The agent's `container.ts` defines what the agent hands
4
+ * the container and the plugin inside it.
5
+ */
6
+ import { z } from 'zod';
7
+ import { MatchId, MatchRules, MAX_SERVER_PLAYERS, RestoreFile, Roster, } from './game-events.js';
8
+ /**
9
+ * How starting sides are decided.
10
+ *
11
+ * - `knife`: a knife round decides them.
12
+ * - `team1_ct`: team 1 starts as CT.
13
+ * - `team1_t`: team 1 starts as T.
14
+ */
15
+ export const SidesMode = z.enum(['knife', 'team1_ct', 'team1_t']);
16
+ /**
17
+ * How warmup ends.
18
+ *
19
+ * - `auto`: counts down once every rostered player is connected. Players have no ready commands.
20
+ * - `manual`: also waits for each rostered player and each rostered spectator to type `!ready`,
21
+ * which `!unready` withdraws. A captain's `!forceready` readies the captain's team with
22
+ * whichever of its players are connected, and any rostered spectator's `!forceready` readies the
23
+ * spectators.
24
+ * - `external`: only {@link ControllerStartMatch | start_match} from the controller starts the match, and
25
+ * players have no commands for it.
26
+ *
27
+ * In every mode, {@link ControllerStartMatch | start_match} from the controller starts the match.
28
+ */
29
+ export const ReadyMode = z.enum(['auto', 'manual', 'external']);
30
+ /**
31
+ * {@link MatchRules} as a launch asks for them, where each rule left out takes the value of a
32
+ * typical competitive match. The reports of {@link GoingLive | going_live} and
33
+ * {@link RoundBackup | round_backup} always carry every rule.
34
+ */
35
+ export const LaunchRules = MatchRules.extend({
36
+ /** Rounds in regulation, as `mp_maxrounds`. When absent, 24. */
37
+ max_rounds: MatchRules.shape.max_rounds.default(24),
38
+ /**
39
+ * Whether a tie after regulation goes to overtime, as `mp_overtime_enable`. When absent, `true`.
40
+ */
41
+ overtime: MatchRules.shape.overtime.default(true),
42
+ /** Rounds in one overtime, both halves together, as `mp_overtime_maxrounds`. When absent, 6. */
43
+ overtime_max_rounds: MatchRules.shape.overtime_max_rounds.default(6),
44
+ /** Whether bullets and melee hurt teammates. Grenades always do. When absent, `true`. */
45
+ friendly_fire: MatchRules.shape.friendly_fire.default(true),
46
+ });
47
+ /**
48
+ * The match a server is launched with, which covers one map. Each server plays one match. A restore
49
+ * launches the same `match_id` again under a new `allocation_id`, from a
50
+ * {@link RoundBackup | round_backup} that the first server sent.
51
+ */
52
+ export const MatchConfig = z.object({
53
+ /**
54
+ * The match's id, which names its demos and which {@link AgentSlotStatus | slot_status} and
55
+ * {@link ControllerGetServerStatus | get_server_status} report. Null for none, and when absent.
56
+ */
57
+ match_id: MatchId.nullable().default(null),
58
+ /**
59
+ * Whether the plugin sends the match's gameplay events, its {@link RoundBackup | round_backup}
60
+ * among them, which the agent uploads in an {@link EventBatch | event_batch}. When absent,
61
+ * `false`.
62
+ *
63
+ * Every match reports its {@link AgentPhaseChange | phase_change} and {@link AgentScore | score}.
64
+ * A match without statistics sends each {@link RoundBackup | round_backup} in
65
+ * {@link AgentBackup | backup} instead, and a match with statistics only in its events.
66
+ */
67
+ send_statistics: z.boolean().default(false),
68
+ /** Who may join, and on which team. */
69
+ roster: Roster,
70
+ /** How starting sides are decided. */
71
+ sides: SidesMode,
72
+ /**
73
+ * Seconds the knife winners' captain has to type `!stay`, `!swap`, `!ct` or `!t`, from 5 to 300.
74
+ * The server stays in warmup meanwhile, with its clock counting these seconds down. An answer
75
+ * starts the match immediately. Without one, the team stays on its side when the clock runs out.
76
+ * When absent, 60.
77
+ */
78
+ knife_decision_seconds: z.number().int().min(5).max(300).default(60),
79
+ /** How warmup ends. When absent, `auto`. */
80
+ ready_mode: ReadyMode.default('auto'),
81
+ /**
82
+ * Whether, under `manual`, the match also waits for every rostered spectator to connect and type
83
+ * `!ready`, and any of them can force-ready the spectators. When absent, `false`, so the match
84
+ * waits for the teams alone.
85
+ */
86
+ spectators_ready_up: z.boolean().default(false),
87
+ /**
88
+ * Seconds from warmup until both teams must be ready, up to 1800. Null for no time limit, and
89
+ * when absent.
90
+ *
91
+ * When the time passes, the server kicks every player and the map ends with
92
+ * {@link MapEnd | map_end}, which says who was connected and ready. The controller decides what
93
+ * that means for the match. Must be null with `external`, where only the controller starts the
94
+ * match.
95
+ */
96
+ ready_timeout_seconds: z.number().int().positive().max(1800).nullable().default(null),
97
+ /**
98
+ * Whether GOTV records the live match as a demo.
99
+ */
100
+ record_demo: z.boolean(),
101
+ /**
102
+ * Whether the plugin records a development capture of its callbacks. Only a match that sends
103
+ * statistics records one. When absent, `false`.
104
+ *
105
+ * @internal
106
+ */
107
+ debug_capture: z.boolean().default(false),
108
+ /**
109
+ * The rules the match is played under, applied over those of the game mode. When absent, every
110
+ * rule takes its default.
111
+ */
112
+ rules: LaunchRules.prefault({}),
113
+ /**
114
+ * A live round to play on from, taken from the {@link RoundBackup | round_backup} that reported
115
+ * it. Null for a match that plays the map from its start, and when absent.
116
+ *
117
+ * With a restore, the server skips the knife round and the side choice whatever `sides` says, and
118
+ * after warmup it loads the backup instead of going live. Team 1 takes the restore's
119
+ * `team1_side`, the round and the scores come from the file, and the restore's `rules` replace
120
+ * the match's `rules`. The agent writes `file` to the slot for `mp_backup_restore_load_file`.
121
+ */
122
+ restore: RestoreFile.nullable().default(null),
123
+ });
124
+ /** Control characters, which a launch value must not carry into generated cfg commands. */
125
+ export const CFG_CONTROL_CHARS = /[\r\n\u0000-\u001f\u007f]/;
126
+ const CFG_TEXT_MESSAGE = 'must not contain control characters: a newline would end the console line and the rest would ' +
127
+ 'be parsed as another command';
128
+ const cfgText = z.string().refine((value) => !CFG_CONTROL_CHARS.test(value), CFG_TEXT_MESSAGE);
129
+ /**
130
+ * The password players type. It is printable ASCII without spaces, `"` or `;`, because the server
131
+ * would read those as separators.
132
+ */
133
+ export const GamePassword = z.string().max(128).regex(/^[!#-:<-~]*$/, 'must be printable ASCII without spaces, quotes or semicolons');
134
+ /** A Steam Web API key, which the server needs to download workshop maps. */
135
+ export const SteamWebApiKey = z.string().regex(/^[A-Za-z0-9]{1,64}$/, 'a Steam Web API key is letters and digits');
136
+ /** The name of a stock map, such as `de_mirage`. A name of all digits would be a workshop id. */
137
+ export const StockMapName = z.string().regex(/^(?!\d+$)[A-Za-z0-9_-]{1,64}$/, 'must be a stock map name');
138
+ /** A Steam Workshop map's id. */
139
+ export const WorkshopId = z.string().regex(/^\d{1,20}$/, 'must be a numeric workshop id');
140
+ // The plugin's ChatTagTests mirrors this list by hand; change both together.
141
+ /** The colour names SwiftlyS2 accepts in chat markup. */
142
+ export const CHAT_COLORS = [
143
+ 'white', 'darkred', 'lightpurple', 'green', 'olive', 'lime', 'red', 'gray', 'lightyellow',
144
+ 'yellow', 'silver', 'bluegrey', 'lightblue', 'blue', 'darkblue', 'purple', 'magenta', 'lightred',
145
+ 'gold', 'orange',
146
+ ];
147
+ /**
148
+ * A chat colour name from
149
+ * [SwiftlyS2's chat styling](https://swiftlys2.net/docs/guides/chat-and-html-styling).
150
+ */
151
+ export const ChatColor = z.enum(CHAT_COLORS);
152
+ /** The bracketed tag in front of every plugin chat line. */
153
+ export const ChatTag = z.object({
154
+ /**
155
+ * The tag's text, without brackets or control characters. Brackets would end the tag early in
156
+ * SwiftlyS2's colour markup. When absent, `Ankor`.
157
+ */
158
+ text: z.string().min(1).max(24).regex(/^[^[\]\p{Cc}]+$/u, 'must not contain brackets or control characters').default('Ankor'),
159
+ /** The tag's colour. When absent, `blue`. */
160
+ color: ChatColor.default('blue'),
161
+ });
162
+ /**
163
+ * A cfg the server runs, relative to `game/csgo/cfg/` and without `.cfg`, such as `node/<name>`
164
+ * from the node's cfgs.
165
+ */
166
+ export const CfgName = z.string().max(128).regex(/^[A-Za-z0-9_-]+(\/[A-Za-z0-9_-]+)*$/, 'must be cfg path segments of letters, digits, `_` and `-`, without `.cfg`');
167
+ /**
168
+ * The game mode a server runs, which sets the game's rules. The engine runs the mode's cfg, such
169
+ * as `gamemode_competitive` or `gamemode_competitive2v2` for `wingman`, on every map load, and the
170
+ * plugin runs it again when a match goes live.
171
+ *
172
+ * `custom` is Valve's mode for community content. Its cfg sets little, so the launch's `exec` and
173
+ * `commands` set the game.
174
+ *
175
+ * A match plays only in `casual`, `competitive` or `wingman`. A server without a match runs in any
176
+ * mode.
177
+ */
178
+ export const GameMode = z.enum(['casual', 'competitive', 'wingman', 'rush', 'arms_race', 'demolition', 'deathmatch', 'custom']);
179
+ /** The player capacity of a server. */
180
+ const MaxPlayers = z.number().int().min(1).max(MAX_SERVER_PLAYERS);
181
+ /** The game modes that a match plays in: Counter-Strike's own, whose rounds end. */
182
+ const MATCH_GAME_MODES = ['casual', 'competitive', 'wingman'];
183
+ /**
184
+ * What a game server is launched with: any CS2 server, and the match Ankor's plugin plays on it, if
185
+ * any.
186
+ *
187
+ * Rejected when both `workshop_id` and `map_name` are null. With a `match`, it is also rejected:
188
+ *
189
+ * - when `workshop_id` is set, because a match plays a stock map.
190
+ * - when the roster's players and spectators together outnumber `max_players`.
191
+ * - when `game_mode` is not `casual`, `competitive` or `wingman`.
192
+ * - when `ready_mode` is `external` and `ready_timeout_seconds` is set.
193
+ */
194
+ export const ServerLaunchConfig = z.object({
195
+ /**
196
+ * The server's name, which players see in game, such as on the scoreboard. It is up to 128
197
+ * characters. Null or empty leaves the game's own name, and null is the value when absent.
198
+ * Rejected when it holds an ASCII control character, such as a line break.
199
+ */
200
+ hostname: cfgText.pipe(z.string().max(128)).nullable().default(null),
201
+ /**
202
+ * The Steam Web API key that workshop downloads need. Null or empty skips them, and null is the
203
+ * value when absent. **Whoever runs the node can read the key.**
204
+ */
205
+ steam_web_api_key: SteamWebApiKey.or(z.literal('')).nullable().default(null),
206
+ /** The password players type to join. Null or empty for none, and null when absent. */
207
+ game_password: GamePassword.nullable().default(null),
208
+ /**
209
+ * The tag in front of the plugin's chat lines. When absent, each of its fields takes its default.
210
+ */
211
+ chat_tag: ChatTag.prefault({}),
212
+ /** The game mode, which sets the game's rules. When absent, `competitive`. */
213
+ game_mode: GameMode.default('competitive'),
214
+ /**
215
+ * The player capacity of the server, as `-maxplayers`. A match's roster, players and spectators
216
+ * together, must fit.
217
+ */
218
+ max_players: MaxPlayers,
219
+ /** A workshop map to play. Null for a stock map, and when absent. A match plays a stock map. */
220
+ workshop_id: WorkshopId.nullable().default(null),
221
+ /**
222
+ * The stock map to play, required without `workshop_id`. With a `workshop_id`, the server boots
223
+ * this map before it switches to the workshop map. Null for `de_dust2` in that case.
224
+ */
225
+ map_name: StockMapName.nullable(),
226
+ /**
227
+ * GOTV that spectators can watch. Null for none, and null when absent. A match that
228
+ * records a demo still runs GOTV then, because demos are recorded through it. That GOTV runs with
229
+ * `tv_delay 0` and a random `tv_password` that the agent sends nowhere, so no spectator can join.
230
+ *
231
+ * Whether GOTV runs is set on the command line, because GOTV starts with the map. The other
232
+ * settings apply on every map load and when a match goes live, never while a demo records.
233
+ */
234
+ gotv: z.object({
235
+ /**
236
+ * The GOTV password, as `tv_password`, apart from `game_password`. Null or empty for none, and
237
+ * null when absent.
238
+ */
239
+ password: GamePassword.nullable().default(null),
240
+ /** Seconds GOTV runs behind the game. */
241
+ delay_seconds: z.number().int().nonnegative(),
242
+ }).nullable().default(null),
243
+ /**
244
+ * Seconds the server may stay empty once its slot is `running` before the agent stops it,
245
+ * reporting `idle_empty` as its {@link ServerExitReason}. Null for a server that runs until
246
+ * stopped.
247
+ * Hibernation must stay off. See [When the cfgs run](https://docs.ankor.sh/protocol/#when-the-cfgs-run).
248
+ */
249
+ idle_shutdown_seconds: z.number().int().positive().nullable(),
250
+ /**
251
+ * Cfgs the server runs in order, after the game mode's cfg and the match's `rules`. A server
252
+ * without a match runs them 1 second after every map load, and a match server runs them at warmup
253
+ * and when the match goes live. Warmup and the knife round run Ankor's cfgs over them.
254
+ */
255
+ exec: z.array(CfgName).max(16),
256
+ /**
257
+ * Console lines that the server runs after `exec`, in order and at the same moments, so they
258
+ * override `exec` and `rules`. Rejected when a line holds an ASCII control character, such as a
259
+ * line break, which would end the console line.
260
+ */
261
+ commands: z.array(cfgText.pipe(z.string().min(1).max(256))).max(128),
262
+ /**
263
+ * The match Ankor's plugin plays. Null runs a plain CS2 server, and is the value when absent. The
264
+ * plugin then runs no match, and the map, `exec`, `commands` and the node's plugins set the game.
265
+ */
266
+ match: MatchConfig.nullable().default(null),
267
+ }).superRefine((launch, ctx) => {
268
+ if (launch.workshop_id === null && launch.map_name === null) {
269
+ ctx.addIssue({ code: 'custom', message: 'a launch without a workshop id names its stock map', path: ['map_name'] });
270
+ }
271
+ if (launch.match === null) {
272
+ return;
273
+ }
274
+ if (launch.workshop_id !== null) {
275
+ ctx.addIssue({ code: 'custom', message: 'a match plays a stock map', path: ['workshop_id'] });
276
+ }
277
+ const { team1, team2, spectators } = launch.match.roster;
278
+ if (team1.players.length + team2.players.length + spectators.length > launch.max_players) {
279
+ ctx.addIssue({ code: 'custom', message: 'the roster, players and spectators, must fit in `max_players`', path: ['max_players'] });
280
+ }
281
+ if (!MATCH_GAME_MODES.includes(launch.game_mode)) {
282
+ ctx.addIssue({ code: 'custom', message: 'a match needs `casual`, `competitive` or `wingman`', path: ['game_mode'] });
283
+ }
284
+ if (launch.match.ready_mode === 'external' && launch.match.ready_timeout_seconds !== null) {
285
+ ctx.addIssue({ code: 'custom', message: '`ready_timeout_seconds` must be null with an `external` ready-up, because the controller starts the match', path: ['match', 'ready_timeout_seconds'] });
286
+ }
287
+ });