1use 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#[derive(Debug, Clone)]
17pub struct CombatClient {
18 base_url: String,
19 http: reqwest::Client,
20 token: Option<String>,
21}
22
23impl CombatClient {
24 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 #[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 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 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 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 pub async fn snapshot(&self, id: Uuid) -> Result<CombatSnapshot, ClientError> {
80 send(self.http.get(self.url(&format!("/combats/{id}")))).await
81 }
82
83 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 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 pub async fn encounters(&self) -> Result<Vec<Encounter>, ClientError> {
102 send(self.http.get(self.url("/encounters"))).await
103 }
104
105 pub async fn encounter(&self, id: Uuid) -> Result<Encounter, ClientError> {
107 send(self.http.get(self.url(&format!("/encounters/{id}")))).await
108 }
109
110 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 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 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 #[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#[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 pub struct CombatConnection {
174 sender: CommandSender,
175 receiver: MessageReceiver,
176 }
177
178 pub struct CommandSender(SplitSink<Socket, Message>);
180
181 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 pub async fn send(&mut self, envelope: &CommandEnvelope) -> Result<(), ClientError> {
206 self.sender.send(envelope).await
207 }
208
209 pub async fn recv(&mut self) -> Option<Result<ServerMessage, ClientError>> {
212 self.receiver.recv().await
213 }
214
215 pub fn split(self) -> (CommandSender, MessageReceiver) {
217 (self.sender, self.receiver)
218 }
219 }
220
221 impl CommandSender {
222 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 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#[derive(Debug, Clone)]
265pub struct Replica {
266 confirmed: CombatState,
267 seq: u64,
268 pending: Vec<CommandEnvelope>,
269 predicted: CombatState,
270}
271
272#[derive(Debug, Clone, PartialEq)]
274pub enum Received {
275 Updated,
277 Rejected {
280 envelope: CommandEnvelope,
281 error: String,
282 },
283 NeedsResync,
287}
288
289impl Replica {
290 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 pub fn state(&self) -> &CombatState {
303 &self.predicted
304 }
305
306 pub fn confirmed(&self) -> &CombatState {
308 &self.confirmed
309 }
310
311 pub fn seq(&self) -> u64 {
313 self.seq
314 }
315
316 pub fn pending(&self) -> &[CommandEnvelope] {
318 &self.pending
319 }
320
321 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 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; }
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 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 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 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 let ours = CommandEnvelope::new(CombatCommand::Damage {
462 combatant: goblin,
463 amount: 3,
464 });
465 r.submit(ours.clone()).unwrap();
466 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 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}