1use std::{
18 collections::{HashMap, HashSet},
19 sync::{Arc, Mutex as StdMutex},
20 time::{Duration, Instant},
21};
22
23use edda_auth::{
24 protocol::{Claims, Principal, Quotas},
25 server::READ_ONLY,
26};
27use edda_server::Limits;
28use edda_types::{
29 combat::{
30 CombatCommand, CombatEvent, CombatSnapshot, CombatState, CommandEnvelope, Dice,
31 LoggedEvent, SheetCost, add_monsters, decide, player_may, undo,
32 },
33 monster::Monster,
34};
35use sqlx::postgres::PgListener;
36use tokio::sync::{Mutex, broadcast};
37use uuid::Uuid;
38
39use crate::{
40 encounters,
41 error::ApiError,
42 services::Monsters,
43 store::{self, Notice, Store},
44 sync::SheetSync,
45};
46
47const SUBSCRIBER_BUFFER: usize = 256;
50
51const IDLE_TIMEOUT: Duration = Duration::from_mins(10);
53
54pub struct Hub {
57 store: Store,
58 live: Mutex<HashMap<Uuid, Arc<LiveCombat>>>,
59 sheet_sync: Option<Arc<SheetSync>>,
60 monsters: Option<Monsters>,
61 limits: Limits,
62}
63
64pub struct LiveCombat {
66 id: Uuid,
67 campaign: String,
69 core: Mutex<View>,
71 feed: Mutex<View>,
74 events: broadcast::Sender<LoggedEvent>,
75 last_used: StdMutex<Instant>,
76}
77
78#[derive(Clone)]
79struct View {
80 state: CombatState,
81 seq: u64,
82}
83
84impl View {
85 fn apply(&mut self, event: &LoggedEvent) {
86 self.state.apply(&event.event);
87 self.seq = event.seq;
88 }
89}
90
91pub enum Outcome {
93 Applied(Vec<LoggedEvent>),
94 AlreadyApplied,
95}
96
97fn room_for_events(limits: &Limits, seq: u64, more: usize) -> Result<(), ApiError> {
100 let seq = i64::try_from(seq).unwrap_or(i64::MAX);
101 let more = u32::try_from(more).unwrap_or(u32::MAX);
102 limits
103 .room_for(
104 seq,
105 more,
106 limits.events_per_combat,
107 "events in a combat; start a new one",
108 )
109 .map_err(ApiError::Forbidden)
110}
111
112struct ServerDice;
113
114impl Dice for ServerDice {
115 fn roll(&mut self, sides: u16) -> Option<u16> {
116 Some(rand::random_range(1..=sides.max(1)))
117 }
118}
119
120impl Hub {
121 pub async fn start(
125 store: Store,
126 mut listener: PgListener,
127 sheet_sync: Option<SheetSync>,
128 monsters: Option<Monsters>,
129 limits: Limits,
130 ) -> Result<Arc<Self>, sqlx::Error> {
131 listener.listen(store::CHANNEL).await?;
132 let hub = Arc::new(Self {
133 store,
134 live: Mutex::new(HashMap::new()),
135 sheet_sync: sheet_sync.map(Arc::new),
136 monsters,
137 limits,
138 });
139 tokio::spawn(hub.clone().listen(listener));
140 tokio::spawn(hub.clone().evict_idle());
141 if let Some(sync) = &hub.sheet_sync {
142 tokio::spawn(sync.clone().retry_forever());
143 }
144 Ok(hub)
145 }
146
147 pub fn store(&self) -> &Store {
149 &self.store
150 }
151
152 pub fn limits(&self) -> &Limits {
154 &self.limits
155 }
156
157 async fn room_for_a_combat(&self, campaign: &str, limits: &Limits) -> Result<(), ApiError> {
159 let kept = self.store.count(campaign).await?;
160 limits
161 .room_for(kept, 1, limits.combats, "combats")
162 .map_err(ApiError::Forbidden)
163 }
164
165 pub async fn create(
167 &self,
168 campaign: &str,
169 name: &str,
170 quotas: &Quotas,
171 ) -> Result<CombatSnapshot, ApiError> {
172 self.room_for_a_combat(campaign, &self.limits.with(quotas))
173 .await?;
174 let id = self.store.create(campaign, name, Vec::new()).await?;
175 self.combat(id).await?.snapshot(&self.store).await
176 }
177
178 pub async fn create_from_plan(
182 &self,
183 campaign: Uuid,
184 encounter: Uuid,
185 name: Option<&str>,
186 quotas: &Quotas,
187 ) -> Result<CombatSnapshot, ApiError> {
188 let limits = self.limits.with(quotas);
189 self.room_for_a_combat(&campaign.to_string(), &limits)
190 .await?;
191 let plan = encounters::plan(self, campaign, encounter).await?;
192 let name = name.filter(|n| !n.trim().is_empty()).unwrap_or(&plan.name);
193 let mut state = CombatState::default();
194 state.apply(&CombatEvent::Created {
195 name: name.to_string(),
196 campaign: campaign.to_string(),
197 });
198 let mut setup = Vec::new();
199 for entry in &plan.monsters {
200 let monster = match self.stat_block(campaign, entry.monster).await {
201 Err(ApiError::BadRequest(_)) => {
202 return Err(ApiError::BadRequest(format!(
203 "{} from the plan isn't among the campaign's monsters any more",
204 entry.name
205 )));
206 }
207 other => other?,
208 };
209 for event in add_monsters(&state, &monster, entry.count, entry.roll, &mut ServerDice)? {
210 state.apply(&event);
211 setup.push(event);
212 }
213 }
214 room_for_events(&limits, 1, setup.len())?;
215 let id = self
216 .store
217 .create(&campaign.to_string(), name, setup)
218 .await?;
219 self.combat(id).await?.snapshot(&self.store).await
220 }
221
222 async fn played_by(
224 &self,
225 combat: &LiveCombat,
226 device: &str,
227 ) -> Result<HashSet<Uuid>, ApiError> {
228 let sync = self.sheet_sync.as_ref().ok_or_else(|| {
229 ApiError::Forbidden(
230 "players can't act here: this combat service can't reach the character sheets \
231 to know whose they are"
232 .into(),
233 )
234 })?;
235 sync.played_by(combat.campaign_id()?, device)
236 .await
237 .map_err(|e| ApiError::SheetUnavailable(e.to_string()))
238 }
239
240 async fn stat_block(&self, campaign: Uuid, monster: Uuid) -> Result<Monster, ApiError> {
242 let monsters = self.monsters.as_ref().ok_or_else(|| {
243 ApiError::BadRequest(
244 "this combat service can't add monsters (no monster service)".into(),
245 )
246 })?;
247 monsters
248 .get(campaign, monster)
249 .await
250 .map_err(ApiError::from_monsters)
251 }
252
253 pub async fn combat(&self, id: Uuid) -> Result<Arc<LiveCombat>, ApiError> {
255 let mut live = self.live.lock().await;
258 if let Some(combat) = live.get(&id) {
259 combat.touch();
260 return Ok(combat.clone());
261 }
262 let log = self.store.events(id, 0).await?.ok_or(ApiError::NotFound)?;
263 let combat = Arc::new(LiveCombat::new(id, &log));
264 live.insert(id, combat.clone());
265 Ok(combat)
266 }
267
268 pub async fn combat_in(&self, id: Uuid, campaign: Uuid) -> Result<Arc<LiveCombat>, ApiError> {
271 let combat = self.combat(id).await?;
272 if combat.campaign != campaign.to_string() {
273 return Err(ApiError::NotFound);
274 }
275 Ok(combat)
276 }
277
278 pub async fn execute(
283 &self,
284 id: Uuid,
285 envelope: CommandEnvelope,
286 claims: &Claims,
287 ) -> Result<Outcome, ApiError> {
288 if claims.settings.read_only {
291 return Err(ApiError::Forbidden(READ_ONLY.into()));
292 }
293 let combat = self.combat(id).await?;
294 let command_id = envelope.command_id;
295 let actor_key = claims.key.to_string();
296 let limits = self.limits.with(&claims.settings.quotas);
297 let played = match claims.role {
300 Principal::Player => {
301 let played = self.played_by(&combat, &actor_key).await?;
302 let state = combat.snapshot(&self.store).await?.state;
303 permit(&state, &envelope.command, Some(&played))?;
304 Some(played)
305 }
306 Principal::Dm | Principal::Service => None,
307 };
308 let stat_block = match &envelope.command {
309 CombatCommand::AddMonsters { monster, .. } => {
310 Some(self.stat_block(combat.campaign_id()?, *monster).await?)
311 }
312 _ => None,
313 };
314 let spent = self.spend(&combat, &envelope).await?;
315 let outcome = combat
316 .execute(
317 &self.store,
318 envelope,
319 &actor_key,
320 played.as_ref(),
321 stat_block.as_ref(),
322 |seq, more| room_for_events(&limits, seq, more),
323 )
324 .await;
325 if let (Err(_), Some((sync, campaign, character, cost))) = (&outcome, &spent) {
326 let key = crate::sync::refused_key(command_id);
327 if let Err(e) = sync.give_back(*campaign, *character, cost, key).await {
328 tracing::error!(%id, %character, error = %e, "giving back what a refused command spent failed");
329 }
330 }
331 let outcome = outcome?;
332 if let (Outcome::Applied(events), Some(sync)) = (&outcome, &self.sheet_sync) {
333 let undo = events
334 .iter()
335 .any(|e| matches!(e.event, CombatEvent::Undone { .. }));
336 if undo {
337 sync.clone().run(id).await;
340 } else {
341 tokio::spawn(sync.clone().run(id));
342 }
343 }
344 Ok(outcome)
345 }
346
347 async fn spend(
350 &self,
351 combat: &LiveCombat,
352 envelope: &CommandEnvelope,
353 ) -> Result<Option<(Arc<SheetSync>, Uuid, Uuid, SheetCost)>, ApiError> {
354 let (Some(sync), Some((combatant, cost))) =
355 (&self.sheet_sync, envelope.command.sheet_cost())
356 else {
357 return Ok(None);
358 };
359 let snapshot = combat.snapshot(&self.store).await?;
360 let Some(character) = snapshot
361 .state
362 .combatant(combatant)
363 .and_then(|c| c.character_id)
364 else {
365 return Ok(None);
366 };
367 let campaign = combat.campaign_id()?;
368 sync.spend(campaign, character, &cost, envelope.command_id)
369 .await
370 .map_err(ApiError::from_sheet)?;
371 Ok(Some((sync.clone(), campaign, character, cost)))
372 }
373
374 async fn listen(self: Arc<Self>, mut listener: PgListener) {
375 loop {
376 match listener.try_recv().await {
377 Ok(Some(notification)) => {
378 match serde_json::from_str::<Notice>(notification.payload()) {
379 Ok(notice) => {
380 let combat = self.live.lock().await.get(¬ice.combat).cloned();
381 if let Some(combat) = combat {
382 let store = self.store.clone();
383 tokio::spawn(async move {
384 if let Err(e) = combat.catch_up(&store, Some(notice.seq)).await
385 {
386 tracing::error!(error = %e, "broadcasting events failed");
387 }
388 });
389 }
390 }
391 Err(e) => tracing::warn!(error = %e, "ignoring malformed notification"),
392 }
393 }
394 Ok(None) => {
397 tracing::warn!("lost connection for notifications; catching up");
398 let combats: Vec<_> = self.live.lock().await.values().cloned().collect();
399 for combat in combats {
400 if let Err(e) = combat.catch_up(&self.store, None).await {
401 tracing::error!(error = %e, "catching up failed");
402 }
403 }
404 }
405 Err(e) => {
406 tracing::error!(error = %e, "listening for notifications failed");
407 tokio::time::sleep(Duration::from_secs(1)).await;
408 }
409 }
410 }
411 }
412
413 async fn evict_idle(self: Arc<Self>) {
414 let mut interval = tokio::time::interval(Duration::from_mins(1));
415 loop {
416 interval.tick().await;
417 self.live
418 .lock()
419 .await
420 .retain(|_, c| c.events.receiver_count() > 0 || c.idle_for() < IDLE_TIMEOUT);
421 }
422 }
423}
424
425impl LiveCombat {
426 fn new(id: Uuid, log: &[LoggedEvent]) -> Self {
427 let view = View {
428 state: CombatState::replay(log.iter().map(|e| &e.event)),
429 seq: log.last().map_or(0, |e| e.seq),
430 };
431 Self {
432 id,
433 campaign: view.state.campaign.clone(),
434 core: Mutex::new(view.clone()),
435 feed: Mutex::new(view),
436 events: broadcast::channel(SUBSCRIBER_BUFFER).0,
437 last_used: StdMutex::new(Instant::now()),
438 }
439 }
440
441 fn campaign_id(&self) -> Result<Uuid, ApiError> {
442 self.campaign
443 .parse()
444 .map_err(|e: uuid::Error| ApiError::Corrupt(e.to_string()))
445 }
446
447 pub fn watchers(&self) -> usize {
449 self.events.receiver_count()
450 }
451
452 fn touch(&self) {
453 *self.last_used.lock().expect("not poisoned") = Instant::now();
454 }
455
456 fn idle_for(&self) -> Duration {
457 self.last_used.lock().expect("not poisoned").elapsed()
458 }
459
460 pub async fn snapshot(&self, store: &Store) -> Result<CombatSnapshot, ApiError> {
462 self.catch_up(store, None).await?;
463 let feed = self.feed.lock().await;
464 Ok(self.snapshot_of(&feed))
465 }
466
467 pub async fn subscribe(
469 &self,
470 store: &Store,
471 ) -> Result<(CombatSnapshot, broadcast::Receiver<LoggedEvent>), ApiError> {
472 self.catch_up(store, None).await?;
473 let feed = self.feed.lock().await;
474 Ok((self.snapshot_of(&feed), self.events.subscribe()))
475 }
476
477 fn snapshot_of(&self, view: &View) -> CombatSnapshot {
478 CombatSnapshot {
479 id: self.id,
480 seq: view.seq,
481 state: view.state.clone(),
482 }
483 }
484
485 async fn catch_up(&self, store: &Store, seq: Option<u64>) -> Result<(), ApiError> {
488 let mut feed = self.feed.lock().await;
489 if seq.is_some_and(|seq| seq <= feed.seq) {
490 return Ok(());
491 }
492 let events = store
495 .events(self.id, feed.seq)
496 .await?
497 .ok_or(ApiError::NotFound)?;
498 for event in events {
499 feed.apply(&event);
500 let _ = self.events.send(event);
502 }
503 Ok(())
504 }
505
506 async fn execute(
509 &self,
510 store: &Store,
511 envelope: CommandEnvelope,
512 actor_key: &str,
513 played: Option<&HashSet<Uuid>>,
514 stat_block: Option<&Monster>,
515 room_for_events: impl Fn(u64, usize) -> Result<(), ApiError>,
516 ) -> Result<Outcome, ApiError> {
517 let mut core = self.core.lock().await;
518 let mut tx = store.begin().await?;
519 let latest = store
520 .lock(&mut tx, self.id)
521 .await?
522 .ok_or(ApiError::NotFound)?;
523 if latest > core.seq {
524 for event in store.events_in(&mut tx, self.id, core.seq).await? {
527 core.apply(&event);
528 }
529 }
530 permit(&core.state, &envelope.command, played)?;
531 if !store
532 .record_command(&mut tx, self.id, envelope.command_id, actor_key)
533 .await?
534 {
535 return Ok(Outcome::AlreadyApplied);
536 }
537 let events = match envelope.command {
538 CombatCommand::Undo { command } => {
539 let log = store.events_in(&mut tx, self.id, 0).await?;
540 let mine = store.commands_by(&mut tx, self.id, actor_key).await?;
541 undo::undo(&log, command, |c| mine.contains(&c))?
542 }
543 CombatCommand::AddMonsters { count, roll, .. } => {
544 let monster = stat_block.expect("fetched by Hub::execute");
545 add_monsters(&core.state, monster, count, roll, &mut ServerDice)?
546 }
547 ref command => decide(&core.state, command, &mut ServerDice)?,
548 };
549
550 room_for_events(core.seq, events.len())?;
551 let at = store::now();
552 let logged: Vec<_> = events
553 .into_iter()
554 .zip(core.seq + 1..)
555 .map(|(event, seq)| LoggedEvent {
556 seq,
557 command_id: Some(envelope.command_id),
558 actor: envelope.actor.clone(),
559 at,
560 event,
561 })
562 .collect();
563 let mut next = core.clone();
564 for event in &logged {
565 next.apply(event);
566 }
567 store.append(&mut tx, self.id, &logged, &next.state).await?;
570 tx.commit().await?;
571 *core = next;
572 Ok(Outcome::Applied(logged))
573 }
574}
575
576fn permit(
580 state: &CombatState,
581 command: &CombatCommand,
582 played: Option<&HashSet<Uuid>>,
583) -> Result<(), ApiError> {
584 let Some(played) = played else {
585 return Ok(());
586 };
587 player_may(state, command, |c| {
588 c.character_id.is_some_and(|id| played.contains(&id))
589 })
590 .map_err(|e| ApiError::Forbidden(e.to_string()))
591}