Skip to main content

edda_client/
combat.rs

1//! Client for the combat service: HTTP access, a live WebSocket connection
2//! and [`Replica`], which gives frontends instant (predicted) feedback.
3
4use edda_types::combat::{
5    CombatError, CombatSnapshot, CombatState, CombatStatus, CombatSummary, CommandEnvelope,
6    LoggedEvent, NoDice, ServerMessage, decide,
7};
8use edda_types::encounter::{Encounter, EncounterPlan};
9use serde::Serialize;
10use serde_json::json;
11use uuid::Uuid;
12
13use crate::{ClientError, send};
14
15/// The combat service's client: combats and planned encounters.
16#[derive(Debug, Clone)]
17pub struct CombatClient {
18    base_url: String,
19    http: reqwest::Client,
20    token: Option<String>,
21}
22
23impl CombatClient {
24    /// A client for the combat service at `base_url`, without a token.
25    pub fn new(base_url: impl Into<String>) -> Self {
26        Self {
27            base_url: base_url.into().trim_end_matches('/').to_string(),
28            http: crate::http_client(None),
29            token: None,
30        }
31    }
32
33    /// Sends `token` (from [`crate::auth::AuthClient::login`]) with every
34    /// request, and when connecting.
35    #[must_use]
36    pub fn with_token(mut self, token: &str) -> Self {
37        self.http = crate::http_client(Some(token));
38        self.token = Some(token.to_string());
39        self
40    }
41
42    /// The logged-in campaign's combats, most recently active first,
43    /// optionally only those in one status (e.g. paused combats waiting to
44    /// be resumed).
45    pub async fn list(
46        &self,
47        status: Option<CombatStatus>,
48    ) -> Result<Vec<CombatSummary>, ClientError> {
49        #[derive(Serialize)]
50        struct Filter {
51            #[serde(skip_serializing_if = "Option::is_none")]
52            status: Option<CombatStatus>,
53        }
54        let request = self
55            .http
56            .get(self.url("/combats"))
57            .query(&Filter { status });
58        send(request).await
59    }
60
61    /// Creates a combat in the logged-in campaign.
62    pub async fn create(&self, name: &str) -> Result<CombatSnapshot, ClientError> {
63        let body = json!({ "name": name });
64        send(self.http.post(self.url("/combats")).json(&body)).await
65    }
66
67    /// Creates a combat from a planned encounter, with the plan's monsters
68    /// in it, called `name` or after the plan. All or nothing.
69    pub async fn create_from_plan(
70        &self,
71        encounter: Uuid,
72        name: Option<&str>,
73    ) -> Result<CombatSnapshot, ClientError> {
74        let body = json!({ "encounter": encounter, "name": name });
75        send(self.http.post(self.url("/combats")).json(&body)).await
76    }
77
78    /// Round, whose turn it is, initiative order and effects in play.
79    pub async fn snapshot(&self, id: Uuid) -> Result<CombatSnapshot, ClientError> {
80        send(self.http.get(self.url(&format!("/combats/{id}")))).await
81    }
82
83    /// The log after position `after` (0 for all of it).
84    pub async fn log(&self, id: Uuid, after: u64) -> Result<Vec<LoggedEvent>, ClientError> {
85        let url = self.url(&format!("/combats/{id}/log?after={after}"));
86        send(self.http.get(url)).await
87    }
88
89    /// Runs a command over plain HTTP and returns the events it produced.
90    /// Safe to retry: a repeated `command_id` returns the original events.
91    pub async fn command(
92        &self,
93        id: Uuid,
94        envelope: &CommandEnvelope,
95    ) -> Result<Vec<LoggedEvent>, ClientError> {
96        let url = self.url(&format!("/combats/{id}/commands"));
97        send(self.http.post(url).json(envelope)).await
98    }
99
100    /// The campaign's planned encounters, by name.
101    pub async fn encounters(&self) -> Result<Vec<Encounter>, ClientError> {
102        send(self.http.get(self.url("/encounters"))).await
103    }
104
105    /// One planned encounter.
106    pub async fn encounter(&self, id: Uuid) -> Result<Encounter, ClientError> {
107        send(self.http.get(self.url(&format!("/encounters/{id}")))).await
108    }
109
110    /// Keeps a new plan.
111    pub async fn create_encounter(&self, plan: &EncounterPlan) -> Result<Encounter, ClientError> {
112        send(self.http.post(self.url("/encounters")).json(plan)).await
113    }
114
115    /// Replaces a plan; the last save wins.
116    pub async fn replace_encounter(
117        &self,
118        id: Uuid,
119        plan: &EncounterPlan,
120    ) -> Result<Encounter, ClientError> {
121        send(
122            self.http
123                .put(self.url(&format!("/encounters/{id}")))
124                .json(plan),
125        )
126        .await
127    }
128
129    /// Deletes a plan.
130    pub async fn delete_encounter(&self, id: Uuid) -> Result<(), ClientError> {
131        let response = self
132            .http
133            .delete(self.url(&format!("/encounters/{id}")))
134            .send()
135            .await?;
136        crate::check(response).await.map(drop)
137    }
138
139    /// Opens a live connection. The first message is always a snapshot.
140    #[cfg(feature = "websocket")]
141    pub async fn connect(&self, id: Uuid) -> Result<live::CombatConnection, ClientError> {
142        let url = self
143            .url(&format!("/combats/{id}/ws"))
144            .replacen("http", "ws", 1);
145        live::CombatConnection::open(&url, self.token.as_deref()).await
146    }
147
148    fn url(&self, path: &str) -> String {
149        format!("{}{path}", self.base_url)
150    }
151}
152
153/// Following a combat live, over its WebSocket.
154#[cfg(feature = "websocket")]
155pub mod live {
156    use edda_types::combat::{ClientMessage, CommandEnvelope, ServerMessage};
157    use futures_util::{
158        SinkExt, StreamExt,
159        stream::{SplitSink, SplitStream},
160    };
161    use tokio::net::TcpStream;
162    use tokio_tungstenite::{
163        MaybeTlsStream, WebSocketStream,
164        tungstenite::{self, Message, client::IntoClientRequest},
165    };
166
167    use crate::ClientError;
168
169    type Socket = WebSocketStream<MaybeTlsStream<TcpStream>>;
170
171    /// A live combat connection. Use [`CombatConnection::split`] to send and
172    /// receive from different tasks.
173    pub struct CombatConnection {
174        sender: CommandSender,
175        receiver: MessageReceiver,
176    }
177
178    /// The half of a [`CombatConnection`] that sends commands.
179    pub struct CommandSender(SplitSink<Socket, Message>);
180
181    /// The half of a [`CombatConnection`] that receives what the server sends.
182    pub struct MessageReceiver(SplitStream<Socket>);
183
184    impl CombatConnection {
185        pub(super) async fn open(url: &str, token: Option<&str>) -> Result<Self, ClientError> {
186            let ws_error = |e: tungstenite::Error| ClientError::WebSocket(e.to_string());
187            let mut request = url.into_client_request().map_err(ws_error)?;
188            if let Some(token) = token {
189                let value = format!("Bearer {token}")
190                    .parse()
191                    .expect("tokens are plain ASCII");
192                request.headers_mut().insert("authorization", value);
193            }
194            let (socket, _) = tokio_tungstenite::connect_async(request)
195                .await
196                .map_err(ws_error)?;
197            let (sink, stream) = socket.split();
198            Ok(Self {
199                sender: CommandSender(sink),
200                receiver: MessageReceiver(stream),
201            })
202        }
203
204        /// Sends a command; its events come back like everyone else's.
205        pub async fn send(&mut self, envelope: &CommandEnvelope) -> Result<(), ClientError> {
206            self.sender.send(envelope).await
207        }
208
209        /// The next message from the server; `None` once the connection has
210        /// closed.
211        pub async fn recv(&mut self) -> Option<Result<ServerMessage, ClientError>> {
212            self.receiver.recv().await
213        }
214
215        /// Splits the connection, to send and receive from different tasks.
216        pub fn split(self) -> (CommandSender, MessageReceiver) {
217            (self.sender, self.receiver)
218        }
219    }
220
221    impl CommandSender {
222        /// Sends a command; its events come back like everyone else's.
223        pub async fn send(&mut self, envelope: &CommandEnvelope) -> Result<(), ClientError> {
224            let message = ClientMessage::Command(envelope.clone());
225            let text = serde_json::to_string(&message).expect("commands always serialize");
226            self.0
227                .send(Message::Text(text.into()))
228                .await
229                .map_err(|e| ClientError::WebSocket(e.to_string()))
230        }
231    }
232
233    impl MessageReceiver {
234        /// The next message from the server; `None` once the connection is
235        /// closed.
236        pub async fn recv(&mut self) -> Option<Result<ServerMessage, ClientError>> {
237            loop {
238                return match self.0.next().await? {
239                    Ok(Message::Text(text)) => Some(
240                        serde_json::from_str(&text)
241                            .map_err(|e| ClientError::WebSocket(format!("bad message: {e}"))),
242                    ),
243                    Ok(Message::Close(_)) => None,
244                    Ok(_) => continue,
245                    Err(e) => Some(Err(ClientError::WebSocket(e.to_string()))),
246                };
247            }
248        }
249    }
250}
251
252/// A client's local copy of a combat, for optimistic UIs.
253///
254/// Holds the state the server has confirmed plus this client's commands
255/// that are still in flight. [`Replica::state`] is the confirmed state with
256/// the pending commands applied on top, so a user sees the result of their
257/// action immediately. When the server's events arrive (in its order, which
258/// may put other players' actions first) the pending commands are replayed
259/// on top of the new confirmed state; if the server rejects one, it simply
260/// disappears from the prediction.
261///
262/// Contains no IO, so any frontend (native or WASM) can drive it from
263/// whatever transport it has.
264#[derive(Debug, Clone)]
265pub struct Replica {
266    confirmed: CombatState,
267    seq: u64,
268    pending: Vec<CommandEnvelope>,
269    predicted: CombatState,
270}
271
272/// What a message from the server did to a [`Replica`].
273#[derive(Debug, Clone, PartialEq)]
274pub enum Received {
275    /// The state may have changed; re-render.
276    Updated,
277    /// One of this client's commands was refused. The prediction has
278    /// already been rolled back.
279    Rejected {
280        envelope: CommandEnvelope,
281        error: String,
282    },
283    /// Events were missed (e.g. after a network hiccup). Reconnect, or
284    /// fetch a snapshot and pass it to [`Replica::receive`], then resend
285    /// [`Replica::pending`].
286    NeedsResync,
287}
288
289impl Replica {
290    /// A replica starting from `snapshot`, with nothing pending.
291    pub fn new(snapshot: CombatSnapshot) -> Self {
292        Self {
293            predicted: snapshot.state.clone(),
294            confirmed: snapshot.state,
295            seq: snapshot.seq,
296            pending: Vec::new(),
297        }
298    }
299
300    /// What to show: the confirmed state plus this client's pending
301    /// commands.
302    pub fn state(&self) -> &CombatState {
303        &self.predicted
304    }
305
306    /// What the server has confirmed.
307    pub fn confirmed(&self) -> &CombatState {
308        &self.confirmed
309    }
310
311    /// The last event the confirmed state includes.
312    pub fn seq(&self) -> u64 {
313        self.seq
314    }
315
316    /// Commands sent but not yet confirmed or rejected.
317    pub fn pending(&self) -> &[CommandEnvelope] {
318        &self.pending
319    }
320
321    /// Records a command about to be sent and applies its predicted outcome.
322    /// Commands that are certainly invalid fail here, instantly, and should
323    /// not be sent. Commands whose outcome needs a server dice roll, and
324    /// undos, are accepted but only show up once the server answers.
325    pub fn submit(&mut self, envelope: CommandEnvelope) -> Result<(), CombatError> {
326        match decide(&self.predicted, &envelope.command, &mut NoDice) {
327            Ok(_) | Err(CombatError::DiceUnavailable | CombatError::ServerOnly) => {}
328            Err(e) => return Err(e),
329        }
330        self.pending.push(envelope);
331        self.rebuild();
332        Ok(())
333    }
334
335    /// Takes in a message from the server, replaying pending commands on top
336    /// of what it confirms.
337    pub fn receive(&mut self, message: ServerMessage) -> Received {
338        let outcome = match message {
339            ServerMessage::Snapshot(snapshot) => {
340                self.confirmed = snapshot.state;
341                self.seq = snapshot.seq;
342                Received::Updated
343            }
344            ServerMessage::Event(event) => {
345                if event.seq <= self.seq {
346                    return Received::Updated; // Already have it.
347                }
348                if event.seq != self.seq + 1 {
349                    return Received::NeedsResync;
350                }
351                self.confirmed.apply(&event.event);
352                self.seq = event.seq;
353                if let Some(id) = event.command_id {
354                    self.pending.retain(|p| p.command_id != id);
355                }
356                Received::Updated
357            }
358            ServerMessage::Rejected { command_id, error } => {
359                match self.pending.iter().position(|p| p.command_id == command_id) {
360                    Some(i) => Received::Rejected {
361                        envelope: self.pending.remove(i),
362                        error,
363                    },
364                    None => Received::Updated,
365                }
366            }
367            ServerMessage::AlreadyApplied { command_id } => {
368                self.pending.retain(|p| p.command_id != command_id);
369                Received::Updated
370            }
371        };
372        self.rebuild();
373        outcome
374    }
375
376    fn rebuild(&mut self) {
377        let mut state = self.confirmed.clone();
378        for envelope in &self.pending {
379            // A pending command that no longer applies (someone else got
380            // there first) or needs dice just isn't shown; the server will
381            // have the final word.
382            if let Ok(events) = decide(&state, &envelope.command, &mut NoDice) {
383                for event in &events {
384                    state.apply(event);
385                }
386            }
387        }
388        self.predicted = state;
389    }
390}
391
392#[cfg(test)]
393mod tests {
394    use edda_types::combat::{CombatCommand, CombatEvent, Combatant, LoggedEvent};
395
396    use super::*;
397
398    fn event(seq: u64, command_id: Option<Uuid>, event: CombatEvent) -> ServerMessage {
399        ServerMessage::Event(LoggedEvent {
400            seq,
401            command_id,
402            actor: None,
403            at: chrono::DateTime::default(),
404            event,
405        })
406    }
407
408    /// A replica of a combat containing one goblin, plus the goblin's id.
409    fn replica() -> (Replica, Uuid) {
410        let goblin = Combatant::new("Goblin", 7);
411        let id = goblin.id;
412        let mut state = CombatState::default();
413        state.apply(&CombatEvent::Created {
414            campaign: "Test".into(),
415            name: "Ambush".into(),
416        });
417        state.apply(&CombatEvent::CombatantAdded { combatant: goblin });
418        let snapshot = CombatSnapshot {
419            id: Uuid::new_v4(),
420            seq: 2,
421            state,
422        };
423        (Replica::new(snapshot), id)
424    }
425
426    fn hp(r: &CombatState, id: Uuid) -> u16 {
427        r.combatant(id).unwrap().hit_points.current
428    }
429
430    /// The events the server would produce for `command` against the
431    /// replica's confirmed state.
432    fn server_events(r: &Replica, command: &CombatCommand) -> Vec<CombatEvent> {
433        decide(r.confirmed(), command, &mut NoDice).unwrap()
434    }
435
436    #[test]
437    fn own_commands_show_immediately_and_are_confirmed() {
438        let (mut r, goblin) = replica();
439        let hit = CommandEnvelope::new(CombatCommand::Damage {
440            combatant: goblin,
441            amount: 3,
442        });
443        r.submit(hit.clone()).unwrap();
444        assert_eq!(hp(r.state(), goblin), 4, "predicted");
445        assert_eq!(hp(r.confirmed(), goblin), 7, "not yet confirmed");
446
447        let [e] = server_events(&r, &hit.command).try_into().unwrap();
448        assert_eq!(
449            r.receive(event(3, Some(hit.command_id), e)),
450            Received::Updated
451        );
452        assert!(r.pending().is_empty());
453        assert_eq!(hp(r.state(), goblin), 4);
454        assert_eq!(r.state(), r.confirmed());
455    }
456
457    #[test]
458    fn pending_commands_are_replayed_after_other_players_actions() {
459        let (mut r, goblin) = replica();
460        // We hit for 3...
461        let ours = CommandEnvelope::new(CombatCommand::Damage {
462            combatant: goblin,
463            amount: 3,
464        });
465        r.submit(ours.clone()).unwrap();
466        // ...but the server processes another player's 2 damage first.
467        let theirs = CombatCommand::Damage {
468            combatant: goblin,
469            amount: 2,
470        };
471        let [e] = server_events(&r, &theirs).try_into().unwrap();
472        r.receive(event(3, Some(Uuid::new_v4()), e));
473        assert_eq!(hp(r.confirmed(), goblin), 5);
474        assert_eq!(hp(r.state(), goblin), 2, "ours rebased on top of theirs");
475    }
476
477    #[test]
478    fn rejected_commands_roll_back() {
479        let (mut r, goblin) = replica();
480        let remove = CommandEnvelope::new(CombatCommand::RemoveCombatant { combatant: goblin });
481        r.submit(remove.clone()).unwrap();
482        assert!(r.state().combatants.is_empty());
483
484        let outcome = r.receive(ServerMessage::Rejected {
485            command_id: remove.command_id,
486            error: "nope".into(),
487        });
488        assert_eq!(
489            outcome,
490            Received::Rejected {
491                envelope: remove,
492                error: "nope".into()
493            }
494        );
495        assert_eq!(r.state().combatants.len(), 1);
496    }
497
498    #[test]
499    fn certainly_invalid_commands_fail_locally() {
500        let (mut r, _) = replica();
501        let bad = CombatCommand::Heal {
502            combatant: Uuid::new_v4(),
503            amount: 1,
504        };
505        assert!(matches!(
506            r.submit(CommandEnvelope::new(bad)),
507            Err(CombatError::UnknownCombatant(_))
508        ));
509        assert!(r.pending().is_empty());
510    }
511
512    #[test]
513    fn dice_rolls_wait_for_the_server() {
514        let (mut r, goblin) = replica();
515        let roll = CommandEnvelope::new(CombatCommand::RollInitiative {
516            combatant: goblin,
517            roll: None,
518        });
519        r.submit(roll.clone()).unwrap();
520        assert_eq!(r.pending().len(), 1);
521        assert_eq!(r.state().combatant(goblin).unwrap().initiative, None);
522
523        let rolled = CombatEvent::InitiativeRolled {
524            combatant: goblin,
525            roll: 14,
526            modifier: 0,
527            total: 14,
528        };
529        r.receive(event(3, Some(roll.command_id), rolled));
530        assert_eq!(r.state().combatant(goblin).unwrap().initiative, Some(14));
531        assert!(r.pending().is_empty());
532    }
533
534    #[test]
535    fn gaps_and_duplicates_are_detected() {
536        let (mut r, _) = replica();
537        assert_eq!(
538            r.receive(event(2, None, CombatEvent::Started)),
539            Received::Updated,
540            "old"
541        );
542        assert_eq!(
543            r.state().status,
544            CombatStatus::default(),
545            "old event ignored"
546        );
547        assert_eq!(
548            r.receive(event(5, None, CombatEvent::Started)),
549            Received::NeedsResync
550        );
551    }
552
553    #[test]
554    fn already_applied_commands_are_dropped() {
555        let (mut r, goblin) = replica();
556        let hit = CommandEnvelope::new(CombatCommand::Damage {
557            combatant: goblin,
558            amount: 3,
559        });
560        r.submit(hit.clone()).unwrap();
561        // After a reconnect the snapshot already includes the hit...
562        let mut state = r.confirmed().clone();
563        for e in server_events(&r, &hit.command) {
564            state.apply(&e);
565        }
566        r.receive(ServerMessage::Snapshot(CombatSnapshot {
567            id: Uuid::nil(),
568            seq: 3,
569            state,
570        }));
571        assert_eq!(
572            hp(r.state(), goblin),
573            1,
574            "briefly double-counted until the server says so"
575        );
576        r.receive(ServerMessage::AlreadyApplied {
577            command_id: hit.command_id,
578        });
579        assert_eq!(hp(r.state(), goblin), 4);
580    }
581}