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