Skip to main content

manabrew_server/
replay.rs

1use std::collections::HashMap;
2use std::time::Instant;
3
4use serde_json::Value;
5
6use crate::protocol::{GameOutcomeReport, PlayerDeckInfo, MAX_FATAL_MESSAGE_CHARS};
7const PLAYER_SLOT_PREFIX: &str = "player-";
8
9#[derive(Debug, Clone)]
10pub struct QueuedEngineInput {
11    pub from_player: String,
12    pub state: Value,
13}
14
15#[derive(Debug, Default)]
16pub struct ReportedOutcome {
17    pub reported: bool,
18    pub game_over: bool,
19    pub winner_slot: Option<String>,
20    pub conceded_slots: Vec<String>,
21    pub fatal_message: Option<String>,
22    pub turns: Option<u32>,
23}
24
25#[derive(Debug)]
26pub struct GameReplayCache {
27    pub game_id: String,
28    pub started_at: Instant,
29    pub player_order: Vec<String>,
30    pub player_decks: Vec<PlayerDeckInfo>,
31    pub starting_life: i32,
32    pub last_state: Option<Value>,
33    pub last_state_by_slot: HashMap<String, Value>,
34    pub pending_prompts: HashMap<String, Value>,
35    pub queued_inputs: HashMap<String, Vec<QueuedEngineInput>>,
36    pub outcome: ReportedOutcome,
37}
38
39impl GameReplayCache {
40    pub fn new(
41        game_id: String,
42        player_order: Vec<String>,
43        player_decks: Vec<PlayerDeckInfo>,
44        starting_life: i32,
45    ) -> Self {
46        GameReplayCache {
47            game_id,
48            started_at: Instant::now(),
49            player_order,
50            player_decks,
51            starting_life,
52            last_state: None,
53            last_state_by_slot: HashMap::new(),
54            pending_prompts: HashMap::new(),
55            queued_inputs: HashMap::new(),
56            outcome: ReportedOutcome::default(),
57        }
58    }
59
60    pub fn observe(&mut self, envelope: &Value) {
61        match envelope.get("kind").and_then(Value::as_str) {
62            Some("state") => match envelope.get("forPlayer").and_then(Value::as_str) {
63                Some(slot) => {
64                    self.last_state_by_slot
65                        .insert(slot.to_string(), envelope.clone());
66                }
67                None => self.last_state = Some(envelope.clone()),
68            },
69            // Patches must be folded into the cached state, not stored raw: a
70            // resyncing client needs a whole board.
71            Some("stateDelta") => {
72                let slot = envelope.get("forPlayer").and_then(Value::as_str);
73                let previous = match slot {
74                    Some(slot) => self.last_state_by_slot.remove(slot),
75                    None => self.last_state.take(),
76                };
77                let Some(mut rebuilt) = previous else {
78                    return;
79                };
80                let Some(patch) = envelope.get("patch") else {
81                    self.restore_state(slot, rebuilt);
82                    return;
83                };
84                if let Some(object) = rebuilt.as_object_mut() {
85                    let base = object.remove("state").unwrap_or(Value::Null);
86                    object.insert(
87                        "state".to_string(),
88                        manabrew_relay_protocol::state_delta::apply(&base, patch),
89                    );
90                    if let Some(fingerprint) = envelope.get("fingerprint") {
91                        object.insert("fingerprint".to_string(), fingerprint.clone());
92                    }
93                }
94                self.restore_state(slot, rebuilt);
95            }
96            Some("prompt") => {
97                if let Some(slot) = envelope.get("forPlayer").and_then(Value::as_str) {
98                    self.pending_prompts
99                        .insert(slot.to_string(), envelope.clone());
100                }
101            }
102            Some("response") => {
103                if let Some(slot) = envelope.get("fromPlayer").and_then(Value::as_str) {
104                    self.pending_prompts.remove(slot);
105                }
106            }
107            _ => {}
108        }
109    }
110
111    fn restore_state(&mut self, slot: Option<&str>, state: Value) {
112        match slot {
113            Some(slot) => {
114                self.last_state_by_slot.insert(slot.to_string(), state);
115            }
116            None => self.last_state = Some(state),
117        }
118    }
119
120    pub fn record_outcome(&mut self, report: GameOutcomeReport) {
121        let seats = self.player_order.len();
122        self.outcome = ReportedOutcome {
123            reported: true,
124            game_over: report.game_over,
125            winner_slot: report.winner_slot,
126            conceded_slots: report.conceded_slots.into_iter().take(seats).collect(),
127            fatal_message: report
128                .fatal_message
129                .map(|message| message.chars().take(MAX_FATAL_MESSAGE_CHARS).collect()),
130            turns: report.turns,
131        };
132    }
133
134    /// The whole board this seat should be holding, as folded by [`Self::observe`].
135    /// Used to serve a seat whose client cannot apply the patch that just came
136    /// through, so the relay hands it the state the patch would have produced.
137    pub fn state_after(&self, envelope: &Value) -> Option<&Value> {
138        match envelope.get("forPlayer").and_then(Value::as_str) {
139            Some(slot) => self.last_state_by_slot.get(slot),
140            None => self.last_state.as_ref(),
141        }
142    }
143
144    pub fn slot_for(&self, username: &str) -> Option<String> {
145        self.player_order
146            .iter()
147            .position(|name| name == username)
148            .map(|index| format!("{PLAYER_SLOT_PREFIX}{index}"))
149    }
150
151    pub fn username_for_slot(&self, slot: &str) -> Option<String> {
152        slot.strip_prefix(PLAYER_SLOT_PREFIX)
153            .and_then(|index| index.parse::<usize>().ok())
154            .and_then(|index| self.player_order.get(index).cloned())
155    }
156
157    pub fn queue_input(&mut self, host_username: &str, from_player: &str, state: Value) {
158        self.queued_inputs
159            .entry(host_username.to_string())
160            .or_default()
161            .push(QueuedEngineInput {
162                from_player: from_player.to_string(),
163                state,
164            });
165    }
166
167    pub fn queued_inputs_for(&self, username: &str) -> Vec<QueuedEngineInput> {
168        self.queued_inputs
169            .get(username)
170            .cloned()
171            .unwrap_or_default()
172    }
173
174    pub fn acknowledge_inputs(&mut self, host_username: &str) {
175        self.queued_inputs.remove(host_username);
176    }
177}
178
179#[cfg(test)]
180mod tests {
181    use super::*;
182    use manabrew_relay_protocol::state_delta;
183    use serde_json::json;
184
185    fn cache() -> GameReplayCache {
186        GameReplayCache::new("game-1".into(), vec!["alice".into()], Vec::new(), 40)
187    }
188
189    fn full(slot: &str, state: Value) -> Value {
190        json!({
191            "kind": "state",
192            "forPlayer": slot,
193            "fingerprint": state_delta::fingerprint(&state),
194            "state": state,
195        })
196    }
197
198    /// The envelope a node sends once `state_delta` is on: the real diff
199    /// between the seat's last board and its next one.
200    fn patch(slot: &str, previous: &Value, next: &Value) -> Value {
201        json!({
202            "kind": "stateDelta",
203            "forPlayer": slot,
204            "fingerprint": state_delta::fingerprint(next),
205            "patch": state_delta::diff(previous, next).expect("the boards differ"),
206        })
207    }
208
209    #[test]
210    fn folds_a_patch_into_the_board_it_hands_back() {
211        let before = json!({ "gameView": { "turn": 1, "phase": "MAIN1" } });
212        let after = json!({ "gameView": { "turn": 2, "phase": "MAIN1" } });
213        let mut replay = cache();
214        replay.observe(&full("player0", before.clone()));
215        let patch = patch("player0", &before, &after);
216        replay.observe(&patch);
217
218        let folded = replay.state_after(&patch).expect("a board for that seat");
219        assert_eq!(folded["state"], after);
220        assert_eq!(folded["fingerprint"], patch["fingerprint"]);
221    }
222
223    #[test]
224    fn a_patch_for_a_seat_with_no_board_leaves_nothing_to_hand_back() {
225        let mut replay = cache();
226        let patch = patch(
227            "player0",
228            &json!({ "gameView": { "turn": 1 } }),
229            &json!({ "gameView": { "turn": 2 } }),
230        );
231        replay.observe(&patch);
232        assert!(replay.state_after(&patch).is_none());
233    }
234
235    #[test]
236    fn seats_do_not_share_a_board() {
237        let mut replay = cache();
238        let mine = json!({ "gameView": { "hand": ["Swamp"] } });
239        let theirs = json!({ "gameView": { "hand": [] } });
240        replay.observe(&full("player0", mine.clone()));
241        replay.observe(&full("player1", theirs.clone()));
242
243        let patch = patch("player0", &mine, &json!({ "gameView": { "hand": [] } }));
244        replay.observe(&patch);
245
246        let other = replay
247            .state_after(&json!({ "kind": "stateDelta", "forPlayer": "player1" }))
248            .expect("player1 still has a board");
249        assert_eq!(other["state"], theirs);
250    }
251}