manabrew-protocol 5.13.0

Engine-free game DTOs shared by manabrew clients
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
//! How fast an engine answered, for one finished game.
//!
//! The hosted node publishes its own decision timings to Prometheus. Nothing
//! measured the engines that run on a player's machine, and the browser Forge
//! build made that gap matter: the whole point of it is latency, and the only
//! numbers we had came from a laptop under a test harness.
//!
//! [`EnginePlayStats`] is one report per game, aggregates only: no deck, no
//! cards, no opponent, nothing that identifies a player. It travels two ways —
//! over the relay for a game the relay already knows about, and to the hub for
//! offline play, where no server is in the loop at all.
//!
//! [`OfflinePlayGame`] is the other half, and it is not anonymous: it names
//! players, because the hosted node recorded exactly these fields for exactly
//! these games until Play vs AI moved into the browser. Account erasure has to
//! reach it, which `Storage::delete_account` does by handle.

use serde::{Deserialize, Serialize};
use ts_rs::TS;

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "telemetry.ts")]
pub struct EnginePlayStats {
    /// Generated by the client, so a retry cannot double-count the game.
    pub report_id: String,
    /// Which game these timings belong to: the relay's `game_id` for a relay
    /// game, and [`OfflinePlayGame::report_id`] for an offline one. Without it
    /// a report is an orphan: the timings are there, but nothing says what was
    /// played, who won, or how it ended.
    ///
    /// It points at a record that names players. The report itself stays
    /// anonymous, and erasure reaches the record it points at by handle, so
    /// the id survives a scrub while what it leads to no longer names anyone.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    #[ts(optional)]
    pub game_id: Option<String>,
    /// Which engine actually ran, which is not always the one the room asked
    /// for: a browser with the Forge engine on hosts a "Manabrew" room on it.
    pub engine: String,
    pub client_version: String,
    pub platform: String,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    #[ts(optional)]
    pub format: Option<String>,
    pub seats: u32,
    pub multiplayer: bool,
    pub duration_s: u32,
    pub end_reason: String,
    /// Client-side turnaround: answer sent to next prompt landing. This is the
    /// interval the player feels, and the one comparable across engines.
    pub turnaround: EngineTurnaround,
    /// `turnaround` cut at the first reply frame reaching the client.
    /// `reply_wait` is everything outside the player's machine: the server,
    /// the wire, and the transfer of the reply. `client_work` is everything on
    /// it: parsing, applying the state, rendering, until the prompt is
    /// handled. Absent from clients that predate the cut and from engines with
    /// no frame boundary to stamp.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    #[ts(optional)]
    pub reply_wait: Option<EngineTurnaround>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    #[ts(optional)]
    pub client_work: Option<EngineTurnaround>,
    /// The engine's own think time, when it reports one.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    #[ts(optional)]
    pub engine_think: Option<EngineTurnaround>,
    /// `engine_think` split by whether an opponent turn happened inside the
    /// window. A window is answer-received to next-prompt-ready, so in a game
    /// against the AI the cross-turn half carries whole opponent turns and is
    /// not a measure of one decision.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    #[ts(optional)]
    pub engine_think_same_turn: Option<EngineTurnaround>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    #[ts(optional)]
    pub engine_think_cross_turn: Option<EngineTurnaround>,
    /// `engine_think` split by who owned the time: the part of each window
    /// spent on bot prompts, and the rest, which is the rules engine resolving
    /// what the table did. Absent from an engine that does not tag its
    /// windows.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    #[ts(optional)]
    pub engine_think_bot: Option<EngineTurnaround>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    #[ts(optional)]
    pub engine_think_rules: Option<EngineTurnaround>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    #[ts(optional)]
    pub checkpoints: Option<EngineCheckpointStats>,
    /// Windows dropped because the tab was backgrounded for part of them: the
    /// engine times itself in wall clock, which keeps running while the worker
    /// is descheduled.
    #[serde(default)]
    pub think_samples_hidden: u32,
    #[serde(default)]
    pub by_type: Vec<EngineTypeTurnaround>,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "telemetry.ts")]
pub struct EngineTurnaround {
    pub n: u32,
    pub p50: u32,
    pub p90: u32,
    pub max: u32,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "telemetry.ts")]
pub struct EngineTypeTurnaround {
    #[serde(rename = "type")]
    pub prompt_type: String,
    pub n: u32,
    pub p50: u32,
    pub max: u32,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "telemetry.ts")]
pub struct EngineCheckpointTiming {
    pub n: u32,
    #[ts(type = "number")]
    pub sum_us: u64,
    #[ts(type = "number")]
    pub p50_us: u64,
    #[ts(type = "number")]
    pub p95_us: u64,
    #[ts(type = "number")]
    pub p99_us: u64,
    #[ts(type = "number")]
    pub max_us: u64,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "telemetry.ts")]
pub struct EngineCheckpointStats {
    pub copy: EngineCheckpointTiming,
    pub bookkeeping: EngineCheckpointTiming,
    pub total: EngineCheckpointTiming,
    pub decision: EngineCheckpointTiming,
    pub hidden: u32,
    pub dropped: u32,
}

impl EngineCheckpointTiming {
    fn is_plausible(&self) -> bool {
        self.n <= 4000
            && self.p50_us <= self.p95_us
            && self.p95_us <= self.p99_us
            && self.p99_us <= self.max_us
            && self.max_us <= self.sum_us
            && self.sum_us <= 86_400_000_000
            && (self.n > 0 || self.sum_us == 0)
    }
}

impl EngineCheckpointStats {
    fn is_plausible(&self) -> bool {
        [&self.copy, &self.bookkeeping, &self.total, &self.decision]
            .iter()
            .all(|timing| timing.is_plausible())
            && self.copy.n == self.bookkeeping.n
            && self.copy.n == self.total.n
            && self.total.sum_us == self.copy.sum_us + self.bookkeeping.sum_us
    }
}

impl EnginePlayStats {
    /// Whether this is worth storing. Rejects the shapes a mistake or a hostile
    /// client produces: an unparseable id, empty or oversized text, a table
    /// with impossible seats, a report of no decisions at all.
    pub fn is_plausible(&self) -> bool {
        let text = |value: &str, max: usize| !value.is_empty() && value.len() <= max;
        uuid_shaped(&self.report_id)
            && text(&self.engine, 40)
            && text(&self.client_version, 40)
            && text(&self.platform, 20)
            && text(&self.end_reason, 20)
            && self.format.as_ref().is_none_or(|f| f.len() <= 40)
            && (1..=8).contains(&self.seats)
            && self.by_type.len() <= 32
            && self.turnaround.n > 0
            && self
                .checkpoints
                .as_ref()
                .is_none_or(EngineCheckpointStats::is_plausible)
    }

    /// The game this report belongs to, when the id is one a store can key on.
    /// A bad id costs the link, never the report: the timings are the point,
    /// and dropping a whole game over a malformed field is how telemetry ends
    /// up measuring nothing.
    pub fn linked_game_id(&self) -> Option<&str> {
        self.game_id.as_deref().filter(|id| uuid_shaped(id))
    }
}

/// The relay's `game_started` + `game_ended` + `deck_selected` collapsed into
/// one record: offline play has no connection to stream them over.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "telemetry.ts")]
pub struct OfflinePlayGame {
    /// Client-generated, so a retry cannot double-count the game. Stands in for
    /// the relay's `game_id` downstream.
    pub report_id: String,
    pub started_at: String,
    pub ended_at: String,
    pub duration_s: u32,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    #[ts(optional)]
    pub format: Option<String>,
    pub engine: String,
    pub starting_life: i32,
    pub end_reason: String,
    pub game_over: bool,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    #[ts(optional)]
    pub engine_error: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    #[ts(optional)]
    pub winner: Option<String>,
    #[serde(default)]
    pub conceded: Vec<String>,
    pub client_version: String,
    pub platform: String,
    pub players: Vec<OfflinePlaySeat>,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "telemetry.ts")]
pub struct OfflinePlaySeat {
    /// The account handle when there is one, the local display name otherwise.
    pub username: String,
    pub is_bot: bool,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    #[ts(optional)]
    pub deck_name: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    #[ts(optional)]
    pub commander: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    #[ts(optional)]
    pub published_deck_id: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    #[ts(optional)]
    pub deck_fingerprint: Option<String>,
    #[serde(default)]
    pub sideboard_count: u32,
    #[serde(default)]
    pub cards: Vec<OfflinePlayCard>,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export, export_to = "telemetry.ts")]
pub struct OfflinePlayCard {
    pub name: String,
    pub set_code: String,
    pub count: u32,
}

const MAX_CARDS_PER_SEAT: usize = 400;

impl OfflinePlayGame {
    /// Whether this is worth storing. Same job as
    /// [`EnginePlayStats::is_plausible`], over a wider record.
    pub fn is_plausible(&self) -> bool {
        let text = |value: &str, max: usize| !value.is_empty() && value.len() <= max;
        uuid_shaped(&self.report_id)
            && text(&self.started_at, 40)
            && text(&self.ended_at, 40)
            && text(&self.engine, 40)
            && text(&self.client_version, 40)
            && text(&self.platform, 20)
            && text(&self.end_reason, 30)
            && self.format.as_ref().is_none_or(|f| f.len() <= 40)
            && self.winner.as_ref().is_none_or(|w| w.len() <= 80)
            && self.conceded.len() <= 8
            && self.conceded.iter().all(|name| text(name, 80))
            && (1..=8).contains(&self.players.len())
            && self.players.iter().all(OfflinePlaySeat::is_plausible)
    }
}

impl OfflinePlaySeat {
    fn is_plausible(&self) -> bool {
        let opt =
            |value: &Option<String>, max: usize| value.as_ref().is_none_or(|v| v.len() <= max);
        !self.username.is_empty()
            && self.username.len() <= 80
            && opt(&self.deck_name, 120)
            && opt(&self.commander, 120)
            && opt(&self.published_deck_id, 200)
            // The same 64 lowercase hex the deck-play endpoint takes.
            && self
                .deck_fingerprint
                .as_ref()
                .is_none_or(|value| value.len() == 64 && value.bytes().all(is_lower_hex))
            && self.cards.len() <= MAX_CARDS_PER_SEAT
            && self.cards.iter().all(OfflinePlayCard::is_plausible)
    }
}

impl OfflinePlayCard {
    fn is_plausible(&self) -> bool {
        !self.name.is_empty()
            && self.name.len() <= 200
            && self.set_code.len() <= 20
            && (1..=1000).contains(&self.count)
    }
}

fn is_lower_hex(byte: u8) -> bool {
    byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)
}

fn uuid_shaped(value: &str) -> bool {
    value.len() == 36
        && value.bytes().enumerate().all(|(index, byte)| match index {
            8 | 13 | 18 | 23 => byte == b'-',
            _ => byte.is_ascii_hexdigit(),
        })
}

#[cfg(test)]
mod tests {
    use super::*;

    fn sample() -> EnginePlayStats {
        EnginePlayStats {
            report_id: "11111111-2222-3333-4444-555555555555".to_string(),
            game_id: Some("66666666-7777-8888-9999-aaaaaaaaaaaa".to_string()),
            engine: "forge-wasm".to_string(),
            client_version: "3.18.5".to_string(),
            platform: "web".to_string(),
            format: Some("standard".to_string()),
            seats: 2,
            multiplayer: false,
            duration_s: 400,
            end_reason: "gameOver".to_string(),
            turnaround: EngineTurnaround {
                n: 180,
                p50: 46,
                p90: 78,
                max: 320,
            },
            reply_wait: None,
            client_work: None,
            engine_think: None,
            engine_think_same_turn: None,
            engine_think_cross_turn: None,
            engine_think_bot: None,
            engine_think_rules: None,
            checkpoints: None,
            think_samples_hidden: 0,
            by_type: vec![],
        }
    }

    #[test]
    fn accepts_a_real_report() {
        assert!(sample().is_plausible());
        assert_eq!(
            sample().linked_game_id(),
            Some("66666666-7777-8888-9999-aaaaaaaaaaaa")
        );
    }

    /// The timings are the point. A game id that cannot be keyed on costs the
    /// link to the game and nothing else, because dropping the report over it
    /// would lose the measurement this whole path exists to take.
    #[test]
    fn a_bad_game_id_costs_the_link_not_the_report() {
        let mut report = sample();
        report.game_id = Some("not-a-uuid".to_string());
        assert!(report.is_plausible());
        assert_eq!(report.linked_game_id(), None);

        report.game_id = None;
        assert!(report.is_plausible());
        assert_eq!(report.linked_game_id(), None);
    }

    #[test]
    fn rejects_the_shapes_a_hostile_client_sends() {
        let cases: Vec<(&str, Box<dyn Fn(&mut EnginePlayStats)>)> = vec![
            (
                "not a uuid",
                Box::new(|s: &mut EnginePlayStats| s.report_id = "nope".to_string()),
            ),
            (
                "empty engine",
                Box::new(|s: &mut EnginePlayStats| s.engine = String::new()),
            ),
            (
                "huge engine",
                Box::new(|s: &mut EnginePlayStats| s.engine = "x".repeat(41)),
            ),
            ("no seats", Box::new(|s: &mut EnginePlayStats| s.seats = 0)),
            (
                "too many seats",
                Box::new(|s: &mut EnginePlayStats| s.seats = 9),
            ),
            (
                "no decisions",
                Box::new(|s: &mut EnginePlayStats| s.turnaround.n = 0),
            ),
        ];
        for (name, break_it) in cases {
            let mut report = sample();
            break_it(&mut report);
            assert!(!report.is_plausible(), "{name} should have been rejected");
        }
    }
}