@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,1082 @@
1
+ /**
2
+ * Plugin events use decimal-string SteamIDs, numeric roster teams and `t`, `ct` and `spec` sides.
3
+ *
4
+ * `round` is one-based, `timestamp` is Unix milliseconds, and `round_time` is seconds derived from
5
+ * the engine, rounded to milliseconds. `tick` is the server tick when the event happened, as the
6
+ * engine's map tick plus its sub-tick fraction. `seq` is the order in which the events were sent.
7
+ */
8
+ import { z } from 'zod';
9
+ /** A SteamID64 as 17 decimal digits. */
10
+ export const SteamId64 = z.string().regex(/^\d{17}$/, 'steamid64');
11
+ /**
12
+ * The side a player is on.
13
+ *
14
+ * - `t`: terrorists, a playing side.
15
+ * - `ct`: counter-terrorists, a playing side.
16
+ * - `spec`: spectators and players on no team.
17
+ */
18
+ export const Side = z.enum(['t', 'ct', 'spec']);
19
+ /**
20
+ * A playing side, for a field that never holds `spec`.
21
+ *
22
+ * - `t`: terrorists.
23
+ * - `ct`: counter-terrorists.
24
+ */
25
+ export const PlayingSide = Side.exclude(['spec']);
26
+ /** A team of the {@link Roster}, team 1 or team 2, whichever side it plays. */
27
+ export const TeamNumber = z.union([z.literal(1), z.literal(2)]);
28
+ /**
29
+ * The id of one map's match, which the controller chooses. A series of maps is a grouping that the
30
+ * controller keeps. The id is a lowercase UUID, because demo file names carry it and case matters
31
+ * there.
32
+ */
33
+ export const MatchId = z.uuid().regex(/^[^A-F]*$/, 'must be lowercase');
34
+ /**
35
+ * Why a round ended, as the engine names it. When the engine gives a reason that this list does not
36
+ * name, the plugin sends `unknown`.
37
+ */
38
+ export const RoundEndReason = z.enum({
39
+ unknown: 'unknown',
40
+ /** The bomb exploded. */
41
+ target_bombed: 'target_bombed',
42
+ terrorists_escaped: 'terrorists_escaped',
43
+ cts_prevent_escape: 'cts_prevent_escape',
44
+ escaping_terrorists_neutralized: 'escaping_terrorists_neutralized',
45
+ /** The bomb was defused. */
46
+ bomb_defused: 'bomb_defused',
47
+ /** Every T was killed. */
48
+ cts_win: 'cts_win',
49
+ /** Every CT was killed. */
50
+ terrorists_win: 'terrorists_win',
51
+ round_draw: 'round_draw',
52
+ all_hostages_rescued: 'all_hostages_rescued',
53
+ /** Time ran out without the bomb planted. */
54
+ target_saved: 'target_saved',
55
+ hostages_not_rescued: 'hostages_not_rescued',
56
+ terrorists_not_escaped: 'terrorists_not_escaped',
57
+ game_commencing: 'game_commencing',
58
+ /** The Ts voted to surrender. The map ends after this round. */
59
+ terrorists_surrender: 'terrorists_surrender',
60
+ /** The CTs voted to surrender. The map ends after this round. */
61
+ cts_surrender: 'cts_surrender',
62
+ terrorists_planted: 'terrorists_planted',
63
+ cts_reached_hostage: 'cts_reached_hostage',
64
+ survival_win: 'survival_win',
65
+ survival_draw: 'survival_draw',
66
+ });
67
+ /** A UTF-16 surrogate without its other half, which no text encoding can store. */
68
+ const LONE_SURROGATE = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/;
69
+ /** Text of up to `max` characters that any store can hold, so no NUL and no lone surrogate. */
70
+ function wireText(max) {
71
+ return z.string().max(max).refine((text) => !text.includes('\u0000') && !LONE_SURROGATE.test(text), 'must be text without NUL or lone surrogates');
72
+ }
73
+ /** Spectator slots on a CS2 server, for casters and anyone else who watches from the server. */
74
+ export const MAX_SPECTATORS = 2;
75
+ /** Players that a CS2 server holds. The players and spectators of a roster fit in it together. */
76
+ export const MAX_SERVER_PLAYERS = 64;
77
+ /** Characters in the longest team name. */
78
+ export const MAX_TEAM_NAME_LENGTH = 30;
79
+ /**
80
+ * The most rounds that `mp_maxrounds` can set for a match, ten times the stock 24. A launch that
81
+ * asks for more fails validation.
82
+ */
83
+ export const MAX_ROUNDS = 240;
84
+ /*
85
+ * Bounds on gameplay values. Ankor runs competitive Counter-Strike: where a game setting governs a
86
+ * value, the range that setting allows is its bound; where none does, about ten times the stock
87
+ * value.
88
+ */
89
+ /** Unix milliseconds from 2020-01-01 to 2100-01-01. */
90
+ const Timestamp = z.number().int().min(1_577_836_800_000).max(4_102_444_800_000);
91
+ /** A one-based round number. A map's history holds far fewer than the 1000 it allows. */
92
+ export const RoundNumber = z.number().int().positive().max(1000);
93
+ /**
94
+ * Seconds within one round. The bound covers the 60 minutes that `mp_roundtime` allows, the 90
95
+ * seconds of `mp_c4timer` after a late plant and a few more of `mp_round_restart_delay`.
96
+ */
97
+ const RoundSeconds = z.number().nonnegative().max(4000);
98
+ /** Rounds won by a team, bounded like a {@link RoundNumber}. */
99
+ const Score = z.number().int().nonnegative().max(1000);
100
+ /** The most dollars that `mp_maxmoney` allows. */
101
+ const MAX_CASH = 65_535;
102
+ /** Dollars in hand, up to what `mp_maxmoney` allows. */
103
+ const Cash = z.number().int().nonnegative().max(MAX_CASH);
104
+ /**
105
+ * The highest equipment value, in dollars. The engine keeps a pawn's equipment value as a 16-bit
106
+ * unsigned integer, which holds over ten times a stock loadout.
107
+ */
108
+ const MAX_EQUIPMENT_VALUE = 65_535;
109
+ /**
110
+ * Dollars of equipment that one player carries, the engine's figure. A stock loadout comes to about
111
+ * 10,000.
112
+ */
113
+ const EquipmentValue = z.number().int().nonnegative().max(MAX_EQUIPMENT_VALUE);
114
+ /** The most health or armor that a player can have, ten times the stock 100. */
115
+ export const MAX_HEALTH = 1000;
116
+ /** Points of health or armor, up to {@link MAX_HEALTH}. */
117
+ const Health = z.number().int().nonnegative().max(MAX_HEALTH);
118
+ /** Seconds that a flashbang blinds for. A point-blank flashbang blinds for about 5. */
119
+ const BlindSeconds = z.number().nonnegative().max(10);
120
+ /**
121
+ * The most damage that one hit reports, equal to {@link MAX_HEALTH}, so only overkill past that is
122
+ * cut.
123
+ */
124
+ export const MAX_DAMAGE = MAX_HEALTH;
125
+ /** Damage that one hit deals, overkill included, up to {@link MAX_DAMAGE}. */
126
+ const Damage = z.number().int().nonnegative().max(MAX_DAMAGE);
127
+ /** Engine units along one axis. Maps lie within ±16,384. */
128
+ const WorldUnits = z.number().min(-32_768).max(32_768);
129
+ /** Characters in the longest player name. Steam allows 32, which UTF-16 writes in at most 64. */
130
+ export const MAX_PLAYER_NAME_CHARS = 64;
131
+ /**
132
+ * A player's name, cut to up to {@link MAX_PLAYER_NAME_CHARS} characters, without control
133
+ * characters. Rejected when it holds a lone UTF-16 surrogate.
134
+ */
135
+ export const PlayerName = wireText(MAX_PLAYER_NAME_CHARS).regex(/^[^\p{Cc}]*$/u, 'must not contain control characters');
136
+ /** Characters in the longest version string that {@link MapStart | map_start} carries. */
137
+ export const MAX_VERSION_CHARS = 128;
138
+ /** Characters in the longest `reason` of a {@link PlayerDisconnect | player_disconnect}. */
139
+ export const MAX_DISCONNECT_REASON_CHARS = 64;
140
+ /** Characters in the longest `message` of a {@link PluginLog | log}. */
141
+ export const MAX_LOG_MESSAGE_CHARS = 8192;
142
+ /**
143
+ * The most shots that one player fires in a round, over all their weapons. It is about ten times
144
+ * the most ammunition that a stock weapon carries, the Negev's 450. A
145
+ * {@link RoundShots | round_shots} with more fails validation, so the plugin counts no more.
146
+ */
147
+ export const MAX_ROUND_SHOTS = 4096;
148
+ /** Whether no two entries name the same player. */
149
+ function uniquePlayers(players) {
150
+ return new Set(players.map(({ player }) => player.steam_id)).size === players.length;
151
+ }
152
+ /** One team of a {@link Roster}. */
153
+ export const RosterTeam = z.object({
154
+ /**
155
+ * The team name that players see, up to {@link MAX_TEAM_NAME_LENGTH} characters. Rejected when it
156
+ * is blank, or when it holds a NUL or a lone UTF-16 surrogate.
157
+ */
158
+ name: wireText(MAX_TEAM_NAME_LENGTH).regex(/\S/, 'must not be blank'),
159
+ /**
160
+ * The players of the team, captain first. The captain may use `!forceready` and picks the side
161
+ * after the knife round. A team has at least one player, so it always has a captain.
162
+ */
163
+ players: z.array(SteamId64).min(1),
164
+ });
165
+ /**
166
+ * The teams and spectators of a match. Each player holds one place on the server, whichever team or
167
+ * list the player is in.
168
+ *
169
+ * Rejected when a SteamID appears twice across both teams and `spectators`, or when they hold more
170
+ * than {@link MAX_SERVER_PLAYERS} together.
171
+ */
172
+ export const Roster = z.object({
173
+ /** Team 1, which events number `1`. */
174
+ team1: RosterTeam,
175
+ /** Team 2, which events number `2`. */
176
+ team2: RosterTeam,
177
+ /**
178
+ * Players allowed in as spectators, who watch with the game's free camera, up to
179
+ * {@link MAX_SPECTATORS}. When `ready_mode` is `manual`, they ready up too.
180
+ */
181
+ spectators: z.array(SteamId64).max(MAX_SPECTATORS),
182
+ }).superRefine((roster, ctx) => {
183
+ const ids = [...roster.team1.players, ...roster.team2.players, ...roster.spectators];
184
+ if (new Set(ids).size !== ids.length) {
185
+ ctx.addIssue({ code: 'custom', message: 'a SteamID appears more than once in the roster' });
186
+ }
187
+ if (ids.length > MAX_SERVER_PLAYERS) {
188
+ ctx.addIssue({ code: 'custom', message: `a roster holds at most ${MAX_SERVER_PLAYERS} players and spectators` });
189
+ }
190
+ });
191
+ const base = {
192
+ /** Unix milliseconds on the server's wall clock. */
193
+ timestamp: Timestamp,
194
+ /**
195
+ * The server tick when the event happened, as the engine's map tick plus its sub-tick fraction.
196
+ *
197
+ * The plugin sends events as they happen, so `seq` follows the same order.
198
+ * {@link RoundShots | round_shots} is the exception, because the plugin sends it when the round
199
+ * closes, and the `ticks` of each weapon place each shot.
200
+ */
201
+ tick: z.number().finite().nonnegative().max(Number.MAX_SAFE_INTEGER),
202
+ };
203
+ const roundScoped = {
204
+ ...base,
205
+ /** The round that the event happened in, even when the event arrives later. */
206
+ round: RoundNumber,
207
+ /** Seconds since the round started. */
208
+ round_time: RoundSeconds,
209
+ };
210
+ /** A player as the event saw them. */
211
+ const PlayerRef = z.object({
212
+ /** The player's SteamID64. */
213
+ steam_id: SteamId64,
214
+ /** The player's name when the event happened. */
215
+ name: PlayerName,
216
+ /** The side that the player was on. */
217
+ side: Side,
218
+ /**
219
+ * The roster team of the player, which the plugin looks up in its roster. Null for spectators and
220
+ * unknown players.
221
+ */
222
+ team: TeamNumber.nullable(),
223
+ });
224
+ /** A point in the world, in engine units. */
225
+ export const WorldPos = z.object({
226
+ /** Engine units along the map's x axis. */
227
+ x: WorldUnits,
228
+ /** Engine units along the map's y axis. */
229
+ y: WorldUnits,
230
+ /** Engine units along the map's z axis, the height. */
231
+ z: WorldUnits,
232
+ });
233
+ /** Where a player stood, as a {@link WorldPos}, and where the player looked. */
234
+ export const PlayerPosition = WorldPos.extend({
235
+ /** Degrees of eye pitch. A positive pitch looks down. */
236
+ pitch: z.number().min(-90).max(90),
237
+ /** Degrees of eye yaw, counter-clockwise from the +x axis. */
238
+ yaw: z.number().min(-360).max(360),
239
+ });
240
+ /** A {@link PlayerRef} with the position of the player when the event happened. */
241
+ const PlayerAt = PlayerRef.extend({
242
+ /** Where the player stood and looked. Null when the player had no body in the world. */
243
+ position: PlayerPosition.nullable(),
244
+ });
245
+ /**
246
+ * A {@link PlayerRef} with the position of the player, for a player who acted in the world when the
247
+ * event happened, so the position is always known.
248
+ */
249
+ const PlacedPlayer = PlayerRef.extend({
250
+ /** Where the player stood and looked. */
251
+ position: PlayerPosition,
252
+ });
253
+ /**
254
+ * Sent when a map has loaded on the server, as the first event of the map's history.
255
+ *
256
+ * The plugin sends one on every load. A workshop map loads twice, and an admin's `changelevel`
257
+ * loads another map.
258
+ */
259
+ export const MapStart = z.object({
260
+ type: z.literal('map_start'),
261
+ ...base,
262
+ /**
263
+ * The version of the Ankor plugin, up to {@link MAX_VERSION_CHARS} characters. Rejected when it holds a NUL or a lone UTF-16 surrogate.
264
+ */
265
+ plugin_version: wireText(MAX_VERSION_CHARS),
266
+ /**
267
+ * The version of SwiftlyS2, up to {@link MAX_VERSION_CHARS} characters. Rejected when it holds a NUL or a lone UTF-16 surrogate.
268
+ */
269
+ framework_version: wireText(MAX_VERSION_CHARS),
270
+ /**
271
+ * The version of the CS2 server, up to {@link MAX_VERSION_CHARS} characters. Rejected when it holds a NUL or a lone UTF-16 surrogate.
272
+ */
273
+ server_version: wireText(MAX_VERSION_CHARS),
274
+ /** The map that loaded, as the engine names it. */
275
+ map_name: z.string().regex(/^[A-Za-z0-9_/-]{1,128}$/, 'must be a map name'),
276
+ });
277
+ /**
278
+ * Sent when a player connects after the match leaves warmup.
279
+ * {@link ControllerGetServerStatus | get_server_status} lists who is on the server during warmup.
280
+ */
281
+ export const PlayerConnect = z.object({
282
+ type: z.literal('player_connect'),
283
+ ...base,
284
+ /** The player who connected. */
285
+ player: PlayerRef,
286
+ /** Whether the plugin kicked the player because the SteamID is not on any roster. */
287
+ rejected: z.boolean(),
288
+ });
289
+ /** Sent when a player leaves after the match leaves warmup. */
290
+ export const PlayerDisconnect = z.object({
291
+ type: z.literal('player_disconnect'),
292
+ ...base,
293
+ /** The player who left. */
294
+ player: PlayerRef,
295
+ /**
296
+ * The engine's disconnect reason, up to {@link MAX_DISCONNECT_REASON_CHARS} characters. Rejected when it holds a NUL or a lone UTF-16 surrogate.
297
+ */
298
+ reason: wireText(MAX_DISCONNECT_REASON_CHARS),
299
+ });
300
+ /** Sent when the knife round begins. */
301
+ export const KnifeStart = z.object({ type: z.literal('knife_start'), ...base });
302
+ /**
303
+ * Sent when the knife round ends. When both teams survive, the counts in this event decide the
304
+ * winner.
305
+ *
306
+ * Rejected when a team's health is above {@link MAX_HEALTH} times its players alive.
307
+ */
308
+ export const KnifeEnd = z.object({
309
+ type: z.literal('knife_end'),
310
+ ...base,
311
+ /** The team that won the knife round. */
312
+ winner_team: TeamNumber,
313
+ /** The side that the winning team won on. */
314
+ winner_side: PlayingSide,
315
+ /** Players of team 1 alive at the end. */
316
+ team1_alive: z.number().int().nonnegative().max(MAX_SERVER_PLAYERS),
317
+ /** Players of team 2 alive at the end. */
318
+ team2_alive: z.number().int().nonnegative().max(MAX_SERVER_PLAYERS),
319
+ /**
320
+ * Health points left to team 1, summed over its players alive, each at most
321
+ * {@link MAX_HEALTH}.
322
+ */
323
+ team1_health: z.number().int().nonnegative().max(MAX_HEALTH * MAX_SERVER_PLAYERS),
324
+ /** Health points left to team 2, summed like `team1_health`. */
325
+ team2_health: z.number().int().nonnegative().max(MAX_HEALTH * MAX_SERVER_PLAYERS),
326
+ }).refine((knife) => knife.team1_health <= MAX_HEALTH * knife.team1_alive && knife.team2_health <= MAX_HEALTH * knife.team2_alive, 'a team holds at most MAX_HEALTH per player alive');
327
+ /** Sent when the knife round winners choose a side, or run out of time and stay. */
328
+ export const SideChoice = z.object({
329
+ type: z.literal('side_choice'),
330
+ ...base,
331
+ /** The team that chose. */
332
+ team: TeamNumber,
333
+ /**
334
+ * What the team chose.
335
+ *
336
+ * - `stay`: keep the sides of the knife round.
337
+ * - `swap`: exchange the sides.
338
+ */
339
+ choice: z.enum(['stay', 'swap']),
340
+ /** The side of team 1 for the first half, after the choice. */
341
+ team1_side: PlayingSide,
342
+ });
343
+ /**
344
+ * The rules of a match, as a launch asks for them and as {@link GoingLive | going_live} and
345
+ * {@link RoundBackup | round_backup} report the server playing them.
346
+ *
347
+ * The plugin reads the round settings off the server's cvars. `friendly_fire` comes from the
348
+ * launch, and Ankor applies it through the damage reductions instead of `mp_friendlyfire`.
349
+ */
350
+ export const MatchRules = z.object({
351
+ /**
352
+ * Rounds in regulation, as `mp_maxrounds`, up to {@link MAX_ROUNDS}. MR12 is 24 and MR15 is
353
+ * 30.
354
+ */
355
+ max_rounds: z.number().int().positive().max(MAX_ROUNDS),
356
+ /** Whether a tie after regulation goes to overtime, as `mp_overtime_enable`. */
357
+ overtime: z.boolean(),
358
+ /**
359
+ * Rounds in one overtime, both halves together, as `mp_overtime_maxrounds`. The bound is 60, ten
360
+ * times the stock 6.
361
+ */
362
+ overtime_max_rounds: z.number().int().positive().max(60),
363
+ /** Whether bullets and melee hurt teammates. Grenades always do. */
364
+ friendly_fire: z.boolean(),
365
+ });
366
+ /** Sent when the match goes live, with the round settings that it plays under. */
367
+ export const GoingLive = z.object({
368
+ type: z.literal('going_live'),
369
+ ...base,
370
+ /** Team 1's side for the first half. */
371
+ team1_side: PlayingSide,
372
+ ...MatchRules.shape,
373
+ });
374
+ /**
375
+ * Characters in the longest backup file that a {@link RoundBackup | round_backup} carries. A 5v5
376
+ * that runs to 40 rounds writes a file of up to a few hundred KB, and tabs and quotes make it about
377
+ * 1.4 times that as JSON.
378
+ */
379
+ export const MAX_BACKUP_FILE_CHARS = 512 * 1024;
380
+ /**
381
+ * What a server needs to play on from the backup of a live round. That is the engine's backup file,
382
+ * which holds the round, the scores and the money and equipment of every player, and the two things
383
+ * that the file does not hold.
384
+ *
385
+ * A launch carries one as `match.restore`.
386
+ */
387
+ export const RestoreFile = z.object({
388
+ /**
389
+ * Team 1's side in that round. On a launch, the plugin seats players by it through warmup, before
390
+ * the load.
391
+ */
392
+ team1_side: PlayingSide,
393
+ /**
394
+ * The rules that the map was played under. A restore plays on under these rules instead of the
395
+ * rules of its launch.
396
+ */
397
+ rules: MatchRules,
398
+ /**
399
+ * The backup file as the engine wrote it, up to {@link MAX_BACKUP_FILE_CHARS} characters. The
400
+ * file holds the money and equipment of every player, keyed by account, and only
401
+ * `mp_backup_restore_load_file` reads it.
402
+ *
403
+ * The file is text with tabs and line breaks, so a file at the length cap stays within the frame
404
+ * limits once JSON escapes it. Rejected when it holds a lone UTF-16 surrogate.
405
+ */
406
+ file: wireText(MAX_BACKUP_FILE_CHARS).min(1).regex(/^[\t\n\r\P{Cc}]*$/u, 'must be text without control characters other than tabs and line breaks'),
407
+ });
408
+ /**
409
+ * A live round as it started. It holds the {@link RestoreFile} of the round and where the round
410
+ * stands in the match, both read off the engine.
411
+ */
412
+ export const RestorePoint = RestoreFile.extend({
413
+ /** The one-based round that the backup starts. The file numbers rounds from zero, one lower. */
414
+ round: RoundNumber,
415
+ /** Team 1's score before that round. */
416
+ team1_score: Score,
417
+ /** Team 2's score before that round. */
418
+ team2_score: Score,
419
+ });
420
+ /**
421
+ * Sent when the server has loaded a round backup, from the `restore` of its launch, from a
422
+ * {@link ControllerRestoreRound | restore_round}, or from an `mp_backup_restore_load_file` that someone
423
+ * ran by hand.
424
+ *
425
+ * The {@link RoundStart | round_start} that follows plays `round` again. From that
426
+ * {@link RoundStart | round_start} on, the events of the server replace everything that it sent
427
+ * for that round and the rounds after it.
428
+ *
429
+ * The plugin reads the scores and the side from the engine once the engine has loaded the file. The
430
+ * plugin refuses a load during the knife round, the side choice, the halftime break and after the
431
+ * match has ended.
432
+ */
433
+ export const MatchRestored = z.object({
434
+ type: z.literal('match_restored'),
435
+ ...base,
436
+ /** The one-based round that the match plays on from. */
437
+ round: RoundNumber,
438
+ /** Team 1's side in that round. */
439
+ team1_side: PlayingSide,
440
+ /** Team 1's score before that round. */
441
+ team1_score: Score,
442
+ /** Team 2's score before that round. */
443
+ team2_score: Score,
444
+ });
445
+ /**
446
+ * Sent as each live round starts. The newest one of a map, with the launch of that map, holds
447
+ * everything that a restore needs on another node.
448
+ */
449
+ export const RoundBackup = z.object({
450
+ type: z.literal('round_backup'),
451
+ ...base,
452
+ ...RestorePoint.shape,
453
+ /** The teams and spectators that the map is played with. */
454
+ roster: Roster,
455
+ });
456
+ /**
457
+ * A weapon, by the engine's item definition index or, for an item without one, the engine's class
458
+ * name. Rejected when `id` is a number and `fallback` is not null.
459
+ */
460
+ export const Weapon = z.object({
461
+ /** The item definition index, a 16-bit unsigned integer. Null when the item has none. */
462
+ id: z.number().int().positive().max(65535).nullable(),
463
+ /**
464
+ * The engine class name of an item without an index. Null when `id` is set, or when the engine
465
+ * named no weapon either.
466
+ */
467
+ fallback: z.string().regex(/^[A-Za-z0-9_]{1,64}$/, 'must be an engine class name').nullable(),
468
+ }).refine((weapon) => weapon.id === null || weapon.fallback === null, 'A numeric weapon must not also have a fallback name');
469
+ /**
470
+ * One kind of item that a player holds: the engine's item definition index as `weapon`, a quantity,
471
+ * and the unit price in this game build.
472
+ */
473
+ export const InventoryItem = z.object({
474
+ /** The engine's item definition index. */
475
+ weapon: z.number().int().positive().max(65535),
476
+ /** Items of this kind that the player holds, up to ten times the stock limit of 2 flashbangs. */
477
+ quantity: z.number().int().positive().max(20),
478
+ /** Dollars that one item costs, up to ten times the dearest stock item, the M249 at 5,200. */
479
+ unit_value: z.number().int().nonnegative().max(52_000),
480
+ });
481
+ /** What a player carries. A null inventory means that the plugin could not read the pawn. */
482
+ export const PlayerInventory = z.object({
483
+ /** Weapons, grenades and other items held, up to 64. */
484
+ items: z.array(InventoryItem).max(64),
485
+ /** Armor points, up to {@link MAX_HEALTH}, ten times the stock 100. */
486
+ armor: Health,
487
+ /** Whether the armor includes a helmet. */
488
+ helmet: z.boolean(),
489
+ /** Whether the player carries a defuse kit. */
490
+ defuse_kit: z.boolean(),
491
+ });
492
+ /** The state and money of a player when the plugin sampled the round. Money is in dollars. */
493
+ const RoundPlayer = z.object({
494
+ /** The player sampled. */
495
+ player: PlayerRef,
496
+ /** Whether the player was alive when sampled. */
497
+ alive: z.boolean(),
498
+ /** Dollars of equipment that the player carried, the engine's figure. */
499
+ equipment_value: EquipmentValue,
500
+ /** Dollars in hand when sampled. */
501
+ cash: Cash,
502
+ /** Dollars in hand at the start of the round. */
503
+ start_cash: Cash,
504
+ /** Dollars spent this round by the time of the sample. */
505
+ cash_spent: z.number().int().nonnegative().max(2 * MAX_CASH),
506
+ });
507
+ /**
508
+ * Sent when a round begins.
509
+ *
510
+ * The same round can begin again with no {@link RoundEnd | round_end} between. The engine restarts
511
+ * a round that a team leaves before it is decided, as when every player on it disconnects or loses
512
+ * their connection. The later `round_start` replaces the earlier one, and the round is played
513
+ * from it.
514
+ */
515
+ export const RoundStart = z.object({
516
+ type: z.literal('round_start'),
517
+ ...roundScoped,
518
+ /** Team 1's side this round. */
519
+ team1_side: PlayingSide,
520
+ /**
521
+ * The players present when the round started, each once. An empty array is authoritative, so the
522
+ * round had no players. Rejected when two entries share a `player.steam_id`.
523
+ */
524
+ players: z.array(RoundPlayer).max(MAX_SERVER_PLAYERS).refine(uniquePlayers, 'Round players must be unique'),
525
+ /**
526
+ * The players of the previous round as that round left them, after exit kills and pickups. Null
527
+ * across an equipment reset.
528
+ */
529
+ carryover: z.object({
530
+ /** The round after which the plugin took the snapshot. */
531
+ from_round: RoundNumber,
532
+ /** The server tick when the plugin took the snapshot. */
533
+ sampled_tick: base.tick,
534
+ /**
535
+ * The players as that round left them, with what each carried. Rejected when two entries share a
536
+ * `player.steam_id`.
537
+ */
538
+ players: z.array(RoundPlayer.extend({
539
+ /** What the player carried. Null when the plugin could not read the pawn. */
540
+ inventory: PlayerInventory.nullable(),
541
+ })).max(MAX_SERVER_PLAYERS).refine(uniquePlayers, 'Carried-over players must be unique'),
542
+ }).nullable(),
543
+ });
544
+ /**
545
+ * Sent when freeze time ends, as the authoritative list of the players who take part in the round
546
+ * after reconnects during freeze time.
547
+ */
548
+ export const RoundFreezeEnd = z.object({
549
+ type: z.literal('round_freeze_end'),
550
+ ...roundScoped,
551
+ /** Team 1's side this round. */
552
+ team1_side: PlayingSide,
553
+ /** Everyone who takes part in the round, each once. Rejected when two entries share a `player.steam_id`. */
554
+ players: z.array(RoundPlayer).max(MAX_SERVER_PLAYERS).refine(uniquePlayers, 'Round participants must be unique'),
555
+ });
556
+ /**
557
+ * Sent when buy time closes, `mp_buytime` after freeze time ends, with the equipment value that
558
+ * each roster team carried, as the sum of the engine's figure for each pawn.
559
+ *
560
+ * The plugin reads a player at the last buy-zone exit inside the window, or live at the close while
561
+ * the player is still in a buy zone. Snapshots at a death or disconnect keep a buy that was made
562
+ * before the window closes. An earlier buy-zone exit counts for a player who never returned.
563
+ *
564
+ * Rejected unless `team1_equipment_value` and `team2_equipment_value` are each the sum of
565
+ * `equipment_value` over the `players` whose `player.team` is that team.
566
+ */
567
+ export const RoundBuyEnd = z.object({
568
+ type: z.literal('round_buy_end'),
569
+ ...roundScoped,
570
+ /**
571
+ * The samples behind the team totals, each player once. Rejected when two entries share a
572
+ * `player.steam_id`.
573
+ */
574
+ players: z.array(z.object({
575
+ /** The player read. */
576
+ player: PlayerRef,
577
+ /** Dollars of equipment that the player carried. */
578
+ equipment_value: EquipmentValue,
579
+ /** Dollars in hand. */
580
+ cash: Cash,
581
+ /** Dollars in hand at the start of the round. */
582
+ start_cash: Cash,
583
+ /** Dollars spent this round. */
584
+ cash_spent: z.number().int().nonnegative().max(2 * MAX_CASH),
585
+ /** The server tick when the plugin read the player. */
586
+ sampled_tick: base.tick,
587
+ })).max(MAX_SERVER_PLAYERS).refine(uniquePlayers, 'Buy samples must be unique'),
588
+ /**
589
+ * Dollars of equipment that team 1 carried, the sum of `equipment_value` over its `players`, so
590
+ * the event states the economy of the round on its own.
591
+ */
592
+ team1_equipment_value: z.number().int().nonnegative().max(MAX_EQUIPMENT_VALUE * MAX_SERVER_PLAYERS),
593
+ /** Dollars of equipment that team 2 carried, summed like `team1_equipment_value`. */
594
+ team2_equipment_value: z.number().int().nonnegative().max(MAX_EQUIPMENT_VALUE * MAX_SERVER_PLAYERS),
595
+ }).refine((buy) => buy.team1_equipment_value === teamEquipment(buy.players, 1) && buy.team2_equipment_value === teamEquipment(buy.players, 2), 'Team equipment values must be the sums of their players');
596
+ function teamEquipment(players, team) {
597
+ return players.reduce((sum, sample) => (sample.player.team === team ? sum + sample.equipment_value : sum), 0);
598
+ }
599
+ /**
600
+ * Sent when a round is decided. The last gameplay reports of the round may still follow, and
601
+ * {@link RoundComplete | round_complete} closes it.
602
+ */
603
+ export const RoundEnd = z.object({
604
+ type: z.literal('round_end'),
605
+ ...roundScoped,
606
+ /** The side that won the round. */
607
+ winner_side: PlayingSide,
608
+ /** The roster team that won the round. */
609
+ winner_team: TeamNumber,
610
+ /** Why the round ended. */
611
+ reason: RoundEndReason,
612
+ /** Team 1's score after this round. */
613
+ team1_score: Score,
614
+ /** Team 2's score after this round. */
615
+ team2_score: Score,
616
+ /** Seconds that the round lasted, from the end of freeze time to the end of the round. */
617
+ round_length: RoundSeconds,
618
+ });
619
+ /**
620
+ * Sent after every gameplay report of a scored round, its {@link RoundShots | round_shots}
621
+ * included, before the next round or the map end.
622
+ */
623
+ export const RoundComplete = z.object({
624
+ type: z.literal('round_complete'),
625
+ ...roundScoped,
626
+ });
627
+ /**
628
+ * What caused damage or a death.
629
+ *
630
+ * - `player`: another player dealt it, with a weapon or a grenade.
631
+ * - `bomb`: the planted bomb exploded.
632
+ * - `fall`: the victim fell.
633
+ * - `self`: the victim's own grenade or fire.
634
+ * - `disconnect`: the victim left the server, which kills a living player.
635
+ * - `other`: anything else, such as a map's damage trigger or the plugin moving a player to the
636
+ * spectators.
637
+ */
638
+ export const DamageCause = z.enum(['player', 'bomb', 'fall', 'self', 'disconnect', 'other']);
639
+ /**
640
+ * Sent when a player dies. Rejected when the `assister` has the `steam_id` of the `victim` or the
641
+ * `attacker`.
642
+ */
643
+ export const PlayerDeath = z.object({
644
+ type: z.literal('player_death'),
645
+ ...roundScoped,
646
+ /** The player who died. */
647
+ victim: PlacedPlayer,
648
+ /**
649
+ * The player credited with the kill. Null when no player dealt it.
650
+ *
651
+ * For a grenade kill, the position is where the thrower was at the death. The
652
+ * {@link GrenadeThrow | grenade_throw} gives where they threw from.
653
+ */
654
+ attacker: PlayerAt.nullable(),
655
+ /** The player credited with the assist. Null when there is none. */
656
+ assister: PlayerRef.nullable(),
657
+ /** The killing weapon. */
658
+ weapon: Weapon,
659
+ /** What caused the death. */
660
+ cause: DamageCause,
661
+ /** Whether the killing hit was to the head. */
662
+ headshot: z.boolean(),
663
+ /** Surfaces that the bullet went through. The bound is a margin, not a measured engine limit. */
664
+ penetrated: z.number().int().nonnegative().max(10),
665
+ /** Whether the killing shot passed through smoke. */
666
+ thru_smoke: z.boolean(),
667
+ /** Whether the attacker fired a scoped weapon unscoped. */
668
+ no_scope: z.boolean(),
669
+ /** Whether the attacker was flashed. */
670
+ attacker_blind: z.boolean(),
671
+ /** Whether the attacker was airborne. */
672
+ attacker_in_air: z.boolean(),
673
+ /** Whether the assist came from a flashbang. */
674
+ flash_assist: z.boolean(),
675
+ /** Whether the attacker and the victim are on the same roster team, a team kill. */
676
+ friendly_fire: z.boolean(),
677
+ /**
678
+ * The player who last blinded the victim, through a {@link PlayerBlind | player_blind} still
679
+ * running at the death. Null when the victim could see.
680
+ *
681
+ * `victim_blind_remaining` gives the seconds of that blind left. When the attacker is the
682
+ * blinder, the kill is a self-flash kill. The consumer decides how much blind must remain for
683
+ * that to count.
684
+ */
685
+ victim_blind_by: PlayerRef.nullable(),
686
+ /** Seconds left of the blind from `victim_blind_by`. Zero when the victim could see. */
687
+ victim_blind_remaining: BlindSeconds,
688
+ /**
689
+ * Dollars of equipment that the victim carried when the killing blow landed, the engine's
690
+ * figure.
691
+ */
692
+ victim_equipment_value: EquipmentValue,
693
+ /**
694
+ * Grenades still in the victim's inventory when the killing blow landed. The C4 and the knife are
695
+ * not grenades. Two flashbangs show as a `quantity` of 2, and `unit_value` is the price per
696
+ * grenade.
697
+ *
698
+ * An empty array means that the victim held none. Null when the plugin could not read the
699
+ * inventory from before the damage.
700
+ */
701
+ victim_grenades_held: z.array(InventoryItem).max(64).nullable(),
702
+ }).refine((death) => death.assister === null
703
+ || (death.assister.steam_id !== death.victim.steam_id && death.assister.steam_id !== death.attacker?.steam_id), 'The assister is neither the victim nor the attacker');
704
+ /**
705
+ * The zero-based id of a grenade within its server, counted in throw order. A server throws far
706
+ * fewer than a million.
707
+ */
708
+ export const GrenadeId = z.number().int().nonnegative().max(1_000_000);
709
+ /**
710
+ * Sent when a player takes damage. Rejected when `health_before` is a number and `health_after` is
711
+ * above it.
712
+ */
713
+ export const PlayerHurt = z.object({
714
+ type: z.literal('player_hurt'),
715
+ ...roundScoped,
716
+ /** The player hit. */
717
+ victim: PlacedPlayer,
718
+ /**
719
+ * The player who dealt the damage. Null for world damage.
720
+ *
721
+ * For grenade damage, the position is where the thrower was at the hit. The
722
+ * {@link GrenadeThrow | grenade_throw} gives where they threw from.
723
+ */
724
+ attacker: PlayerAt.nullable(),
725
+ /** What dealt the damage. */
726
+ weapon: Weapon,
727
+ /** What caused the damage. */
728
+ cause: DamageCause,
729
+ /**
730
+ * Health damage as the engine reports it, overkill included. `health_before` and `health_after`
731
+ * give the health that the victim actually lost.
732
+ */
733
+ dmg_health: Damage,
734
+ /**
735
+ * The victim's health before the hit, as the engine reports it. Null when the plugin could not
736
+ * read it.
737
+ */
738
+ health_before: Health.nullable(),
739
+ /** The victim's health after the hit, at most `health_before`. */
740
+ health_after: Health,
741
+ /** Armor damage. */
742
+ dmg_armor: Damage,
743
+ /**
744
+ * The engine's hit group, `HitGroup_t` in the Source 2 SDK. The plugin sends the engine's invalid
745
+ * group as `0`, and `9` is unused.
746
+ *
747
+ * - `0`: generic.
748
+ * - `1`: head.
749
+ * - `2`: chest.
750
+ * - `3`: stomach.
751
+ * - `4`: left arm.
752
+ * - `5`: right arm.
753
+ * - `6`: left leg.
754
+ * - `7`: right leg.
755
+ * - `8`: neck.
756
+ * - `10`: gear, the backpack hitbox.
757
+ */
758
+ hitgroup: z.number().int().nonnegative().max(15),
759
+ /** Whether the attacker and the victim are on the same roster team. */
760
+ friendly_fire: z.boolean(),
761
+ /**
762
+ * Whether the grenade itself struck the player before it detonated. Such a hit counts as damage
763
+ * and is stored as a hit, but not as utility damage, which matches the in-game scoreboard.
764
+ */
765
+ projectile_impact: z.boolean(),
766
+ /**
767
+ * The grenade that dealt the damage, as its {@link GrenadeThrow | grenade_throw} identifies it.
768
+ * Null for anything else.
769
+ */
770
+ grenade_id: GrenadeId.nullable(),
771
+ }).refine((hurt) => hurt.health_before === null || hurt.health_after <= hurt.health_before, 'A hit never raises health');
772
+ /** Sent when a flashbang blinds a player. */
773
+ export const PlayerBlind = z.object({
774
+ type: z.literal('player_blind'),
775
+ ...roundScoped,
776
+ /** The player blinded. */
777
+ victim: PlacedPlayer,
778
+ /**
779
+ * The player who threw the flashbang, placed where they were when it went off. The
780
+ * {@link GrenadeThrow | grenade_throw} gives where they threw from.
781
+ */
782
+ attacker: PlayerAt,
783
+ /** Seconds that the blind lasts. */
784
+ duration: BlindSeconds,
785
+ /** Whether the thrower and the victim are on the same roster team. */
786
+ friendly_fire: z.boolean(),
787
+ /**
788
+ * The flashbang, as its {@link GrenadeThrow | grenade_throw} identifies it. Null when the plugin
789
+ * did not see it thrown.
790
+ */
791
+ grenade_id: GrenadeId.nullable(),
792
+ });
793
+ /** The engine class name of a grenade. */
794
+ export const GrenadeKind = z.enum(['flashbang', 'hegrenade', 'smokegrenade', 'molotov', 'incgrenade', 'decoy']);
795
+ /**
796
+ * How a fire ended.
797
+ *
798
+ * - `expired`: the fire burned out, or the round closed on it.
799
+ * - `extinguished`: a smoke put the fire out.
800
+ * - `smothered`: the fire grenade landed inside a smoke.
801
+ */
802
+ export const FireEnd = z.enum(['expired', 'extinguished', 'smothered']);
803
+ /**
804
+ * Sent when a grenade leaves a player's hand. Its `grenade_id` identifies the grenade in the events
805
+ * that follow: its {@link GrenadeDetonate | grenade_detonate} and {@link GrenadeEnd | grenade_end},
806
+ * and the {@link PlayerHurt | player_hurt} and {@link PlayerBlind | player_blind} that it causes.
807
+ */
808
+ export const GrenadeThrow = z.object({
809
+ type: z.literal('grenade_throw'),
810
+ ...roundScoped,
811
+ /** The grenade's id, unique within the server. */
812
+ grenade_id: GrenadeId,
813
+ /** The player who threw the grenade, placed where they stood when the projectile spawned. */
814
+ player: PlacedPlayer,
815
+ /** The kind of grenade thrown. */
816
+ grenade: GrenadeKind,
817
+ });
818
+ /**
819
+ * Sent when a grenade detonates or a fire grenade lights. A grenade that is removed before then
820
+ * sends none.
821
+ */
822
+ export const GrenadeDetonate = z.object({
823
+ type: z.literal('grenade_detonate'),
824
+ ...roundScoped,
825
+ /** The grenade, as its {@link GrenadeThrow | grenade_throw} identifies it. */
826
+ grenade_id: GrenadeId,
827
+ /** The player who threw the grenade, as its {@link GrenadeThrow | grenade_throw} saw them. */
828
+ player: PlayerRef,
829
+ /** Where the engine reported the detonation. Null for a fire that lit without reporting one. */
830
+ detonate_pos: WorldPos.nullable(),
831
+ });
832
+ /**
833
+ * Sent when the effect of a grenade is over, because its last entity was removed or the round
834
+ * closed or reset on it. Every grenade that sent a {@link GrenadeThrow | grenade_throw} sends one
835
+ * before the {@link RoundComplete | round_complete} of its round. A grenade whose round a restore
836
+ * cut off sends none; the {@link MatchRestored | match_restored} replaces that round.
837
+ */
838
+ export const GrenadeEnd = z.object({
839
+ type: z.literal('grenade_end'),
840
+ ...base,
841
+ /** The round that the grenade was thrown in. */
842
+ round: RoundNumber,
843
+ /** The grenade, as its {@link GrenadeThrow | grenade_throw} identifies it. */
844
+ grenade_id: GrenadeId,
845
+ /** The player who threw the grenade, as its {@link GrenadeThrow | grenade_throw} saw them. */
846
+ player: PlayerRef,
847
+ /**
848
+ * Seconds since the round started, at the moment the effect ended. A fire ends when it goes out.
849
+ * When the round closed on the grenade, the value is the round length plus the restart delay,
850
+ * which is when the engine clears the entities.
851
+ */
852
+ end_round_time: RoundSeconds,
853
+ /**
854
+ * How the fire ended. Null for a grenade that was not a fire, and for a fire projectile removed
855
+ * before it lit.
856
+ */
857
+ fire_end: FireEnd.nullable(),
858
+ /**
859
+ * The smoke whose cloud put this fire out, credited to the nearest live smoke. Null when no smoke
860
+ * put the fire out.
861
+ */
862
+ extinguished_by: GrenadeId.nullable(),
863
+ });
864
+ /**
865
+ * The shots that a player fired with one weapon over a round. Rejected unless `shots` equals the
866
+ * number of `ticks`.
867
+ */
868
+ export const WeaponShots = z.object({
869
+ /** The weapon fired. */
870
+ weapon: Weapon,
871
+ /** Shots fired, which equals the number of `ticks`. */
872
+ shots: z.number().int().positive().max(MAX_ROUND_SHOTS),
873
+ /** The server tick of each shot. */
874
+ ticks: z.array(base.tick).min(1).max(MAX_ROUND_SHOTS),
875
+ }).refine((shots) => shots.shots === shots.ticks.length, 'shots must count ticks');
876
+ /** The shots that one player fired over a round. */
877
+ export const PlayerShots = z.object({
878
+ /** The player. */
879
+ player: PlayerRef,
880
+ /** Each weapon that the player fired, once each. */
881
+ weapons: z.array(WeaponShots).min(1).max(MAX_ROUND_SHOTS),
882
+ });
883
+ /**
884
+ * Sent once for each round when it closes, at halftime, the next {@link RoundStart | round_start}
885
+ * or the {@link MapEnd | map_end}, with the shots that each player fired over the round.
886
+ *
887
+ * A round that starts again with no {@link RoundEnd | round_end}, as described on
888
+ * {@link RoundStart | round_start}, sends none for the attempt it abandoned. Neither does a round
889
+ * that a restore cut off, which the {@link MatchRestored | match_restored} replaces.
890
+ *
891
+ * `players` lists each player who fired, and is empty when nobody did. The plugin counts shots
892
+ * from the engine's `weapon_fire`, up to {@link MAX_ROUND_SHOTS} for each player, and counts no
893
+ * more. Knives and grenades are left out, because they fire no rounds. A throw arrives as
894
+ * {@link GrenadeThrow | grenade_throw} instead. Hits are the {@link PlayerHurt | player_hurt}
895
+ * events of the same round, attacker and weapon.
896
+ *
897
+ * Rejected when a player's shots add up to more than {@link MAX_ROUND_SHOTS}, or when a player or a
898
+ * weapon of a player is listed twice.
899
+ */
900
+ export const RoundShots = z.object({
901
+ type: z.literal('round_shots'),
902
+ ...base,
903
+ /** The round that the shots were fired in. */
904
+ round: RoundNumber,
905
+ /** Each player who fired, once each. */
906
+ players: z.array(PlayerShots).max(MAX_SERVER_PLAYERS),
907
+ }).superRefine((round, ctx) => {
908
+ if (!uniquePlayers(round.players)) {
909
+ ctx.addIssue({ code: 'custom', path: ['players'], message: 'each player is listed once' });
910
+ }
911
+ for (const [index, { weapons }] of round.players.entries()) {
912
+ if (weapons.reduce((total, shots) => total + shots.shots, 0) > MAX_ROUND_SHOTS) {
913
+ ctx.addIssue({ code: 'custom', path: ['players', index, 'weapons'], message: 'a player fires at most MAX_ROUND_SHOTS shots' });
914
+ }
915
+ if (new Set(weapons.map(({ weapon }) => JSON.stringify([weapon.id, weapon.fallback]))).size !== weapons.length) {
916
+ ctx.addIssue({ code: 'custom', path: ['players', index, 'weapons'], message: 'each weapon is listed once' });
917
+ }
918
+ }
919
+ });
920
+ /** Sent when a player begins to plant the bomb. */
921
+ export const BombBeginPlant = z.object({
922
+ type: z.literal('bomb_begin_plant'),
923
+ ...roundScoped,
924
+ /** The player planting. */
925
+ player: PlacedPlayer,
926
+ /** The bombsite. Null when the engine's target index did not resolve to a `func_bomb_target`. */
927
+ site: z.enum(['a', 'b']).nullable(),
928
+ });
929
+ /** Sent when a player abandons a plant. */
930
+ export const BombAbortPlant = z.object({
931
+ type: z.literal('bomb_abort_plant'),
932
+ ...roundScoped,
933
+ /** The player who stopped planting. */
934
+ player: PlacedPlayer,
935
+ /** The bombsite. Null when the engine's target index did not resolve to a `func_bomb_target`. */
936
+ site: z.enum(['a', 'b']).nullable(),
937
+ });
938
+ /** Sent when the bomb is planted. */
939
+ export const BombPlanted = z.object({
940
+ type: z.literal('bomb_planted'),
941
+ ...roundScoped,
942
+ /** The player who planted. */
943
+ player: PlacedPlayer,
944
+ /** The bombsite. Null when the engine's target index did not resolve to a `func_bomb_target`. */
945
+ site: z.enum(['a', 'b']).nullable(),
946
+ });
947
+ /** Sent when a player begins to defuse the bomb. */
948
+ export const BombBeginDefuse = z.object({
949
+ type: z.literal('bomb_begin_defuse'),
950
+ ...roundScoped,
951
+ /** The player defusing. */
952
+ player: PlacedPlayer,
953
+ /** Whether the defuser has a kit. */
954
+ has_kit: z.boolean(),
955
+ });
956
+ /** Sent when a player abandons a defuse. */
957
+ export const BombAbortDefuse = z.object({
958
+ type: z.literal('bomb_abort_defuse'),
959
+ ...roundScoped,
960
+ /** The player who stopped defusing. */
961
+ player: PlacedPlayer,
962
+ });
963
+ /** Sent when the bomb is defused. */
964
+ export const BombDefused = z.object({
965
+ type: z.literal('bomb_defused'),
966
+ ...roundScoped,
967
+ /** The player who defused. */
968
+ player: PlacedPlayer,
969
+ /** Seconds left on the bomb, up to 90, the most that `mp_c4timer` allows. */
970
+ time_remaining: z.number().nonnegative().max(90),
971
+ /** Whether the defuser has a kit. */
972
+ has_kit: z.boolean(),
973
+ });
974
+ /** Sent when the bomb explodes. */
975
+ export const BombExploded = z.object({ type: z.literal('bomb_exploded'), ...roundScoped });
976
+ /** Sent when the engine names the MVP of a round. */
977
+ export const RoundMvp = z.object({
978
+ type: z.literal('round_mvp'),
979
+ ...roundScoped,
980
+ /** The MVP of the round. */
981
+ player: PlayerRef,
982
+ /** The engine's MVP reason code, a small enum. */
983
+ reason: z.number().int().nonnegative().max(31),
984
+ });
985
+ /**
986
+ * Why a map ended:
987
+ *
988
+ * - `played`: the map was played out.
989
+ * - `ready_timeout`: `ready_timeout_seconds` passed in warmup, before the match started.
990
+ * - `abandoned`: every rostered player left after the match began, in the knife round, the side
991
+ * choice or live play.
992
+ *
993
+ * Only a `played` map has a winner. The controller decides what any other end means for the match.
994
+ */
995
+ export const MapEndReason = z.enum(['played', 'ready_timeout', 'abandoned']);
996
+ /** A team's players when the ready deadline passed. */
997
+ export const ReadyTeam = z.object({
998
+ /** Rostered players of the team on the server. */
999
+ connected: z.number().int().nonnegative().max(MAX_SERVER_PLAYERS),
1000
+ /** Whether the team counted as ready, its captain's force-ready included. */
1001
+ ready: z.boolean(),
1002
+ });
1003
+ /**
1004
+ * Sent when the map is over. After a `played` map, the scoreboard stays up for 60 seconds, then the
1005
+ * server kicks the players, sends its remaining events and quits. After a `ready_timeout`, the
1006
+ * server kicks the players at once.
1007
+ *
1008
+ * Rejected unless `winner_team` is the team with the higher score of a `played` map, or null when
1009
+ * the scores are equal or the map was not played out. Rejected unless `teams` is set for a
1010
+ * `ready_timeout`, with both scores 0, and null otherwise.
1011
+ */
1012
+ export const MapEnd = z.object({
1013
+ type: z.literal('map_end'),
1014
+ ...base,
1015
+ /** Why the map ended. */
1016
+ reason: MapEndReason,
1017
+ /** The team with the higher score of a `played` map. Null for a draw, and for any other end. */
1018
+ winner_team: TeamNumber.nullable(),
1019
+ /** Team 1's score when the map ended. */
1020
+ team1_score: Score,
1021
+ /** Team 2's score when the map ended. */
1022
+ team2_score: Score,
1023
+ /**
1024
+ * Who was on the server and ready when `ready_timeout_seconds` passed. Null for any other end.
1025
+ */
1026
+ teams: z.object({
1027
+ team1: ReadyTeam,
1028
+ team2: ReadyTeam,
1029
+ /** The rostered spectators, as one group. Null unless the match has them ready up. */
1030
+ spectators: ReadyTeam.nullable(),
1031
+ }).nullable(),
1032
+ }).refine((end) => end.winner_team === (end.reason !== 'played' || end.team1_score === end.team2_score
1033
+ ? null
1034
+ : end.team1_score > end.team2_score ? 1 : 2), 'winner_team must be the score leader of a played map')
1035
+ .refine((end) => (end.reason === 'ready_timeout'
1036
+ ? end.teams !== null && end.team1_score === 0 && end.team2_score === 0
1037
+ : end.teams === null), 'teams must be set, at 0-0, exactly for a ready_timeout');
1038
+ /** Sent when the plugin logs a line worth keeping with the match. */
1039
+ export const PluginLog = z.object({
1040
+ type: z.literal('log'),
1041
+ ...base,
1042
+ /** The severity of the line. */
1043
+ level: z.enum(['debug', 'info', 'warn', 'error']),
1044
+ /**
1045
+ * The log line, up to {@link MAX_LOG_MESSAGE_CHARS} characters. Rejected when it holds a NUL or a lone UTF-16 surrogate.
1046
+ */
1047
+ message: wireText(MAX_LOG_MESSAGE_CHARS),
1048
+ });
1049
+ /** Any gameplay event. */
1050
+ export const PluginToControllerMessage = z.discriminatedUnion('type', [
1051
+ MapStart,
1052
+ PlayerConnect,
1053
+ PlayerDisconnect,
1054
+ KnifeStart,
1055
+ KnifeEnd,
1056
+ SideChoice,
1057
+ GoingLive,
1058
+ RoundBackup,
1059
+ MatchRestored,
1060
+ RoundStart,
1061
+ RoundFreezeEnd,
1062
+ RoundBuyEnd,
1063
+ RoundEnd,
1064
+ RoundComplete,
1065
+ PlayerDeath,
1066
+ PlayerHurt,
1067
+ PlayerBlind,
1068
+ GrenadeThrow,
1069
+ GrenadeDetonate,
1070
+ GrenadeEnd,
1071
+ RoundShots,
1072
+ BombBeginPlant,
1073
+ BombAbortPlant,
1074
+ BombPlanted,
1075
+ BombBeginDefuse,
1076
+ BombAbortDefuse,
1077
+ BombDefused,
1078
+ BombExploded,
1079
+ RoundMvp,
1080
+ MapEnd,
1081
+ PluginLog,
1082
+ ]);