@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.
- package/CHANGELOG.md +9 -0
- package/LICENSE +202 -0
- package/README.md +72 -2
- package/dist/agent-config.d.ts +101 -0
- package/dist/agent-config.js +144 -0
- package/dist/agent-image.d.ts +12 -0
- package/dist/agent-image.js +12 -0
- package/dist/agent-socket.d.ts +3927 -0
- package/dist/agent-socket.js +1764 -0
- package/dist/agent-yml.d.ts +21 -0
- package/dist/agent-yml.js +71 -0
- package/dist/agent-yml.schema.json +160 -0
- package/dist/asyncapi.yaml +10569 -0
- package/dist/checks.d.ts +19 -0
- package/dist/checks.js +40 -0
- package/dist/container.d.ts +190 -0
- package/dist/container.js +144 -0
- package/dist/event-batch.d.ts +857 -0
- package/dist/event-batch.js +90 -0
- package/dist/game-events.d.ts +2137 -0
- package/dist/game-events.js +1082 -0
- package/dist/helpers.d.ts +176 -0
- package/dist/helpers.js +362 -0
- package/dist/index.d.ts +26 -0
- package/dist/index.js +26 -0
- package/dist/launch.d.ts +316 -0
- package/dist/launch.js +287 -0
- package/dist/limits.d.ts +381 -0
- package/dist/limits.js +277 -0
- package/dist/paths.d.ts +73 -0
- package/dist/paths.js +73 -0
- package/dist/plugin-socket.d.ts +1946 -0
- package/dist/plugin-socket.js +160 -0
- package/dist/session.d.ts +127 -0
- package/dist/session.js +114 -0
- package/dist/values.d.ts +186 -0
- package/dist/values.js +230 -0
- package/node-setup.sh +1061 -0
- package/package.json +57 -4
|
@@ -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
|
+
]);
|