1use std::{
17 collections::{HashMap, HashSet},
18 sync::Arc,
19 time::Duration,
20};
21
22use edda_client::{Client, ClientError};
23use edda_types::{
24 HitPoints,
25 combat::{CombatEvent, CombatState, LoggedEvent, SheetCost},
26};
27use uuid::Uuid;
28
29use crate::{error::ApiError, services::ServiceLogin, store::Store};
30
31const RETRY_INTERVAL: Duration = Duration::from_secs(30);
33
34pub struct SheetSync {
37 store: Store,
38 login: Arc<ServiceLogin>,
39 character_service: String,
40}
41
42fn give_back_key(command: Uuid, why: &str) -> Uuid {
45 Uuid::new_v5(&command, why.as_bytes())
46}
47
48pub fn refused_key(command: Uuid) -> Uuid {
50 give_back_key(command, "refused")
51}
52
53fn undone_key(command: Uuid) -> Uuid {
54 give_back_key(command, "undone")
55}
56
57#[derive(Debug, thiserror::Error)]
58enum SyncError {
59 #[error(transparent)]
60 Store(#[from] ApiError),
61 #[error("character service: {0}")]
62 Characters(#[from] ClientError),
63}
64
65impl SheetSync {
66 pub fn new(store: Store, login: Arc<ServiceLogin>, character_service: String) -> Self {
69 Self {
70 store,
71 login,
72 character_service,
73 }
74 }
75
76 async fn characters(&self, campaign: Uuid) -> Result<Client, ClientError> {
78 let token = self.login.token(campaign).await?;
79 Ok(Client::new(&self.character_service).with_token(&token))
80 }
81
82 pub async fn played_by(
85 &self,
86 campaign: Uuid,
87 device: &str,
88 ) -> Result<HashSet<Uuid>, ClientError> {
89 let characters = self.characters(campaign).await?.list().await?;
90 Ok(characters
91 .into_iter()
92 .filter(|c| c.owners.iter().any(|owner| owner == device))
93 .map(|c| c.id)
94 .collect())
95 }
96
97 pub async fn spend(
100 &self,
101 campaign: Uuid,
102 character: Uuid,
103 cost: &SheetCost,
104 command: Uuid,
105 ) -> Result<(), ClientError> {
106 let characters = self.characters(campaign).await?;
107 characters
108 .act_once(character, &cost.spend(), command)
109 .await
110 .map(drop)
111 }
112
113 pub async fn give_back(
115 &self,
116 campaign: Uuid,
117 character: Uuid,
118 cost: &SheetCost,
119 key: Uuid,
120 ) -> Result<(), ClientError> {
121 let characters = self.characters(campaign).await?;
122 match characters.act_once(character, &cost.give_back(), key).await {
123 Ok(_) | Err(ClientError::NotFound) => Ok(()),
124 Err(e) => Err(e),
125 }
126 }
127
128 pub async fn run(self: Arc<Self>, combat: Uuid) {
131 loop {
132 match self.sync_once(combat).await {
133 Ok(true) => {}
134 Ok(false) => break,
135 Err(e) => {
136 tracing::warn!(%combat, error = %e, "hit point write-back failed; will retry");
137 break;
138 }
139 }
140 }
141 }
142
143 pub async fn retry_forever(self: Arc<Self>) {
146 let mut interval = tokio::time::interval(RETRY_INTERVAL);
147 loop {
148 interval.tick().await;
149 let behind: Result<Vec<(Uuid,)>, _> = sqlx::query_as(
150 "SELECT s.combat_id FROM combat_hp_sync s JOIN combats c ON c.id = s.combat_id \
151 WHERE s.synced_seq < c.seq",
152 )
153 .fetch_all(self.store.pool())
154 .await;
155 match behind {
156 Ok(combats) => {
157 metrics::gauge!("edda_combat_hp_sync_behind")
158 .set(edda_server::measure::as_f64(combats.len()));
159 for (combat,) in combats {
160 self.clone().run(combat).await;
161 }
162 }
163 Err(e) => tracing::warn!(error = %e, "finding combats to sync failed"),
164 }
165 }
166 }
167
168 async fn sync_once(&self, combat: Uuid) -> Result<bool, SyncError> {
172 let mut tx = self.store.begin().await?;
173 let row: Option<(i64,)> = sqlx::query_as(
174 "SELECT synced_seq FROM combat_hp_sync WHERE combat_id = $1 FOR UPDATE SKIP LOCKED",
175 )
176 .bind(combat)
177 .fetch_optional(&mut *tx)
178 .await
179 .map_err(ApiError::from)?;
180 let Some((synced,)) = row else {
181 return Ok(false);
182 };
183 let synced = u64::try_from(synced).unwrap_or(0);
184 let log = self.store.events_in(&mut tx, combat, 0).await?;
185 let Some(latest) = log.last().map(|e| e.seq).filter(|&seq| seq > synced) else {
186 return Ok(false);
187 };
188
189 let changes = hp_changes(&log, synced);
190 let refunds = undone_costs(&log, synced);
191 let campaign = CombatState::replay(log.iter().map(|e| &e.event)).campaign;
192 if !changes.is_empty() || !refunds.is_empty() {
193 match campaign.parse() {
194 Ok(campaign) => {
195 self.write(combat, campaign, &changes).await?;
196 for (character, cost, command) in &refunds {
197 self.give_back(campaign, *character, cost, undone_key(*command))
198 .await?;
199 }
200 }
201 Err(_) => {
202 tracing::warn!(%combat, %campaign, "combat has no valid campaign; not syncing");
203 }
204 }
205 }
206
207 sqlx::query("UPDATE combat_hp_sync SET synced_seq = $1 WHERE combat_id = $2")
208 .bind(i64::try_from(latest).unwrap_or(i64::MAX))
209 .bind(combat)
210 .execute(&mut *tx)
211 .await
212 .map_err(ApiError::from)?;
213 tx.commit().await.map_err(ApiError::from)?;
214 Ok(true)
215 }
216
217 async fn write(
218 &self,
219 combat: Uuid,
220 campaign: Uuid,
221 changes: &HashMap<Uuid, HitPoints>,
222 ) -> Result<(), SyncError> {
223 let characters = self.characters(campaign).await?;
224 for (&character, &hp) in changes {
225 let written = characters
226 .modify(character, |sheet| {
227 let sheet_hp = &mut sheet.hit_points;
228 sheet_hp.current = hp.current.min(sheet_hp.max);
231 sheet_hp.temporary = hp.temporary;
232 Ok::<_, ClientError>(())
233 })
234 .await;
235 match written {
236 Ok(_) => {}
237 Err(ClientError::NotFound) => {
238 tracing::info!(%combat, %character, "character no longer exists; skipping");
239 }
240 Err(e) => return Err(e.into()),
241 }
242 }
243 Ok(())
244 }
245}
246
247fn hp_changes(log: &[LoggedEvent], after: u64) -> HashMap<Uuid, HitPoints> {
251 let mut state = CombatState::default();
252 let mut known: HashMap<Uuid, HitPoints> = HashMap::new();
253 let mut changes = HashMap::new();
254 for event in log {
255 state.apply(&event.event);
256 for combatant in &state.combatants {
257 let Some(character) = combatant.character_id else {
258 continue;
259 };
260 let previous = known.insert(combatant.id, combatant.hit_points);
261 if event.seq > after && previous.is_some_and(|hp| hp != combatant.hit_points) {
262 changes.insert(character, combatant.hit_points);
263 }
264 }
265 }
266 changes
267}
268
269fn undone_costs(log: &[LoggedEvent], after: u64) -> Vec<(Uuid, SheetCost, Uuid)> {
272 let mut characters: HashMap<Uuid, Uuid> = HashMap::new();
274 for event in log {
275 if let CombatEvent::CombatantAdded { combatant }
276 | CombatEvent::CombatantRestored { combatant } = &event.event
277 && let Some(character) = combatant.character_id
278 {
279 characters.insert(combatant.id, character);
280 }
281 }
282 log.iter()
283 .filter(|e| e.seq > after)
284 .filter_map(|e| match e.event {
285 CombatEvent::Undone { command } => Some(command),
286 _ => None,
287 })
288 .flat_map(|command| {
289 log.iter()
290 .filter(move |e| e.command_id == Some(command))
291 .filter_map(|e| e.event.sheet_cost())
292 .filter_map(|(combatant, cost)| {
293 characters.get(&combatant).map(|c| (*c, cost, command))
294 })
295 .collect::<Vec<_>>()
296 })
297 .collect()
298}
299
300#[cfg(test)]
301mod tests {
302 use chrono::Utc;
303 use edda_types::combat::{CombatEvent, Combatant};
304
305 use super::*;
306
307 fn logged(seq: u64, event: CombatEvent) -> LoggedEvent {
308 LoggedEvent {
309 seq,
310 command_id: None,
311 actor: None,
312 at: Utc::now(),
313 event,
314 }
315 }
316
317 fn hp(current: u16, temporary: u16) -> HitPoints {
318 HitPoints {
319 max: 30,
320 current,
321 temporary,
322 }
323 }
324
325 #[test]
326 fn only_changes_after_the_sync_point_count_and_the_latest_wins() {
327 let character = Uuid::new_v4();
328 let mut vex = Combatant::new("Vex", 30);
329 vex.character_id = Some(character);
330 let goblin = Combatant::new("Goblin", 7);
331 let damaged = |seq, combatant, current| {
332 logged(
333 seq,
334 CombatEvent::Damaged {
335 combatant,
336 amount: 1,
337 hit_points: hp(current, 0),
338 },
339 )
340 };
341 let log = vec![
342 logged(
343 1,
344 CombatEvent::Created {
345 name: "A".into(),
346 campaign: "C".into(),
347 },
348 ),
349 logged(
350 2,
351 CombatEvent::CombatantAdded {
352 combatant: vex.clone(),
353 },
354 ),
355 logged(
356 3,
357 CombatEvent::CombatantAdded {
358 combatant: goblin.clone(),
359 },
360 ),
361 damaged(4, vex.id, 25),
362 damaged(5, goblin.id, 2),
363 damaged(6, vex.id, 20),
364 ];
365 assert_eq!(hp_changes(&log, 3).len(), 1);
366 assert_eq!(hp_changes(&log, 3)[&character], hp(20, 0));
367 assert_eq!(hp_changes(&log, 5)[&character], hp(20, 0));
368 assert!(hp_changes(&log, 6).is_empty());
369 assert!(
370 hp_changes(&log[..3], 0).is_empty(),
371 "joining isn't a change"
372 );
373
374 let mut log = log;
376 log.push(damaged(7, vex.id, 10));
377 log.push(logged(
378 8,
379 CombatEvent::CombatantRemoved { combatant: vex.id },
380 ));
381 assert_eq!(hp_changes(&log, 6)[&character], hp(10, 0));
382 }
383}