1use std::collections::HashSet;
28
29use uuid::Uuid;
30
31use super::{
32 CombatError, CombatEvent, CombatState, CombatStatus, Combatant, EffectEndReason, LoggedEvent,
33 rules::Emitter,
34};
35
36pub fn undo(
40 log: &[LoggedEvent],
41 target: Option<Uuid>,
42 mine: impl Fn(Uuid) -> bool,
43) -> Result<Vec<CombatEvent>, CombatError> {
44 let target = which(log, target, mine)?;
45 let [before, after, now] = around(log, target);
46
47 let progress = |s: &CombatState| (s.status, s.round, s.current_turn);
48 let rewind = progress(&before) != progress(&after);
49 if rewind && progress(&now) != progress(&after) {
50 return Err(CombatError::CannotUndo(
51 "the combat has moved on since".into(),
52 ));
53 }
54 if now.status == CombatStatus::Ended && !rewind {
55 return Err(CombatError::Ended);
56 }
57
58 let mut out = Emitter {
59 state: now,
60 events: Vec::new(),
61 };
62 out.emit(CombatEvent::Undone { command: target });
63 out.restore_combatants(&before, &after)?;
64 out.end_added_effects(&before, &after);
67 out.restore_effects(&before, &after);
68 out.remove_added_combatants(&before, &after)?;
69 if rewind {
70 out.restore_progress(&before)?;
71 }
72 Ok(out.events)
73}
74
75fn which(
78 log: &[LoggedEvent],
79 target: Option<Uuid>,
80 mine: impl Fn(Uuid) -> bool,
81) -> Result<Uuid, CombatError> {
82 let undone: HashSet<Uuid> = log
83 .iter()
84 .filter_map(|e| match e.event {
85 CombatEvent::Undone { command } => Some(command),
86 _ => None,
87 })
88 .collect();
89 let undos: HashSet<Uuid> = log
91 .iter()
92 .filter(|e| matches!(e.event, CombatEvent::Undone { .. }))
93 .filter_map(|e| e.command_id)
94 .collect();
95
96 let Some(command) = target else {
97 return log
98 .iter()
99 .rev()
100 .filter_map(|e| e.command_id)
101 .find(|c| !undos.contains(c) && !undone.contains(c) && mine(*c))
102 .ok_or(CombatError::NothingToUndo);
103 };
104 if !log.iter().any(|e| e.command_id == Some(command)) {
105 return Err(CombatError::UnknownCommand(command));
106 }
107 if undos.contains(&command) {
108 return Err(CombatError::CannotUndo("an undo can't be undone".into()));
109 }
110 if !mine(command) {
111 return Err(CombatError::NotYourCommand);
112 }
113 if undone.contains(&command) {
114 return Err(CombatError::AlreadyUndone);
115 }
116 Ok(command)
117}
118
119fn around(log: &[LoggedEvent], command: Uuid) -> [CombatState; 3] {
121 let first = log
124 .iter()
125 .position(|e| e.command_id == Some(command))
126 .expect("the command is in the log");
127 let last = log
128 .iter()
129 .rposition(|e| e.command_id == Some(command))
130 .expect("the command is in the log");
131 let replay = |events: &[LoggedEvent]| CombatState::replay(events.iter().map(|e| &e.event));
132 [replay(&log[..first]), replay(&log[..=last]), replay(log)]
133}
134
135impl Emitter {
138 fn restore_combatants(
140 &mut self,
141 before: &CombatState,
142 after: &CombatState,
143 ) -> Result<(), CombatError> {
144 for old in &before.combatants {
145 let current = self.state.combatant(old.id).cloned();
146 match (after.combatant(old.id), current) {
147 (None, None) => self.emit(CombatEvent::CombatantRestored {
148 combatant: old.clone(),
149 }),
150 (Some(new), Some(current)) => {
151 let reverted = revert(old, new, ¤t);
152 if reverted != current {
153 self.emit(CombatEvent::CombatantRestored {
154 combatant: reverted,
155 });
156 }
157 }
158 (Some(new), None) if new != old => {
159 return Err(CombatError::CannotUndo(format!(
160 "{} has left the combat",
161 old.name
162 )));
163 }
164 _ => {}
165 }
166 }
167 Ok(())
168 }
169
170 fn end_added_effects(&mut self, before: &CombatState, after: &CombatState) {
172 for effect in &after.effects {
173 if before.effect(effect.id).is_none() && self.state.effect(effect.id).is_some() {
174 self.emit(CombatEvent::EffectEnded {
175 effect: effect.id,
176 reason: EffectEndReason::Undone,
177 });
178 }
179 }
180 }
181
182 fn restore_effects(&mut self, before: &CombatState, after: &CombatState) {
184 let present = |state: &CombatState, id: &Uuid| state.combatant(*id).is_some();
185 for old in &before.effects {
186 match (after.effect(old.id), self.state.effect(old.id).cloned()) {
187 (None, None) => {
188 let replaced = old.concentration
189 && self
190 .state
191 .effects
192 .iter()
193 .any(|e| e.concentration && e.source == old.source);
194 if !replaced {
195 let mut effect = old.clone();
196 effect.targets.retain(|t| present(&self.state, t));
197 self.emit(CombatEvent::EffectRestored { effect });
198 }
199 }
200 (Some(new), Some(mut current)) => {
201 let lost = old.targets.iter().filter(|t| !new.targets.contains(t));
202 let back: Vec<_> = lost
203 .filter(|t| present(&self.state, t) && !current.targets.contains(t))
204 .copied()
205 .collect();
206 if !back.is_empty() {
207 current.targets.extend(back);
208 self.emit(CombatEvent::EffectRestored { effect: current });
209 }
210 }
211 _ => {}
212 }
213 }
214 }
215
216 fn remove_added_combatants(
218 &mut self,
219 before: &CombatState,
220 after: &CombatState,
221 ) -> Result<(), CombatError> {
222 for new in &after.combatants {
223 if before.combatant(new.id).is_none() && self.state.combatant(new.id).is_some() {
224 self.remove(new.id)?;
225 }
226 }
227 Ok(())
228 }
229
230 fn restore_progress(&mut self, before: &CombatState) -> Result<(), CombatError> {
232 if let Some(turn) = before.current_turn
233 && self.state.combatant(turn).is_none()
234 {
235 return Err(CombatError::CannotUndo(
236 "whose turn it was has left the combat".into(),
237 ));
238 }
239 self.emit(CombatEvent::ProgressRestored {
240 status: before.status,
241 round: before.round,
242 current_turn: before.current_turn,
243 });
244 Ok(())
245 }
246}
247
248fn revert(old: &Combatant, new: &Combatant, current: &Combatant) -> Combatant {
252 let shift = |now: u16, from: u16, to: u16, max: u16| {
253 let value = i32::from(now) - (i32::from(to) - i32::from(from));
254 u16::try_from(value.clamp(0, i32::from(max))).expect("clamped")
255 };
256 let (o, n, c) = (old.hit_points, new.hit_points, current.hit_points);
257 let mut reverted = current.clone();
258 reverted.hit_points.current = shift(c.current, o.current, n.current, c.max);
259 reverted.hit_points.temporary = shift(c.temporary, o.temporary, n.temporary, u16::MAX);
260 if new.initiative != old.initiative && current.initiative == new.initiative {
261 reverted.initiative = old.initiative;
262 }
263 reverted
264}
265
266#[cfg(test)]
267mod tests {
268 use chrono::Utc;
269
270 use super::*;
271 use crate::combat::{CombatCommand, NewEffect, NoDice, decide};
272
273 struct Table {
275 log: Vec<LoggedEvent>,
276 }
277
278 impl Table {
279 fn new() -> Self {
280 let mut table = Self { log: Vec::new() };
281 table.push(
282 None,
283 None,
284 CombatEvent::Created {
285 name: "Ambush".into(),
286 campaign: "Test".into(),
287 },
288 );
289 table
290 }
291
292 fn push(&mut self, command_id: Option<Uuid>, actor: Option<&str>, event: CombatEvent) {
293 self.log.push(LoggedEvent {
294 seq: self.log.len() as u64 + 1,
295 command_id,
296 actor: actor.map(str::to_string),
297 at: Utc::now(),
298 event,
299 });
300 }
301
302 fn state(&self) -> CombatState {
303 CombatState::replay(self.log.iter().map(|e| &e.event))
304 }
305
306 #[expect(
308 clippy::needless_pass_by_value,
309 reason = "tests read better passing commands by value"
310 )]
311 fn run(&mut self, actor: &str, command: CombatCommand) -> Uuid {
312 let events = decide(&self.state(), &command, &mut NoDice).unwrap();
313 let id = Uuid::new_v4();
314 for event in events {
315 self.push(Some(id), Some(actor), event);
316 }
317 id
318 }
319
320 fn try_undo(&mut self, actor: &str, target: Option<Uuid>) -> Result<Uuid, CombatError> {
321 let mine = |command| {
322 self.log
323 .iter()
324 .any(|e| e.command_id == Some(command) && e.actor.as_deref() == Some(actor))
325 };
326 let events = undo(&self.log, target, mine)?;
327 let id = Uuid::new_v4();
328 for event in events {
329 self.push(Some(id), Some(actor), event);
330 }
331 Ok(id)
332 }
333
334 fn undo(&mut self, actor: &str) {
335 self.try_undo(actor, None).unwrap();
336 }
337
338 fn add(&mut self, actor: &str, name: &str, hp: u16) -> Uuid {
339 let combatant = Combatant::new(name, hp);
340 let id = combatant.id;
341 self.run(actor, CombatCommand::AddCombatant { combatant });
342 id
343 }
344
345 fn hp(&self, id: Uuid) -> u16 {
346 self.state().combatant(id).unwrap().hit_points.current
347 }
348
349 fn damage(&mut self, actor: &str, combatant: Uuid, amount: u32) -> Uuid {
350 self.run(actor, CombatCommand::Damage { combatant, amount })
351 }
352
353 fn init(&mut self, actor: &str, combatant: Uuid, total: i16) -> Uuid {
354 self.run(actor, CombatCommand::SetInitiative { combatant, total })
355 }
356
357 fn effect(&mut self, actor: &str, source: Uuid, name: &str, rounds: Option<u32>) -> Uuid {
358 let id = Uuid::new_v4();
359 let effect = NewEffect {
360 id,
361 name: name.into(),
362 source: Some(source),
363 targets: vec![],
364 concentration: true,
365 rounds,
366 };
367 self.run(actor, CombatCommand::AddEffect { effect });
368 id
369 }
370
371 fn current(&self) -> (u32, String) {
372 let state = self.state();
373 let turn = state.combatant(state.current_turn.unwrap()).unwrap();
374 (state.round, turn.name.clone())
375 }
376 }
377
378 #[test]
379 fn undoing_damage_keeps_damage_dealt_since() {
380 let mut t = Table::new();
381 let goblin = t.add("DM", "Goblin", 20);
382 t.damage("Vex", goblin, 7);
383 t.damage("Grog", goblin, 2);
384 t.undo("Vex");
385 assert_eq!(t.hp(goblin), 18, "Grog's 2 damage stays");
386 assert!(matches!(
387 t.log[t.log.len() - 2].event,
388 CombatEvent::Undone { .. }
389 ));
390 }
391
392 #[test]
393 fn undo_steps_back_through_ones_own_commands_only() {
394 let mut t = Table::new();
395 let goblin = t.add("DM", "Goblin", 20);
396 t.damage("Vex", goblin, 1);
397 t.damage("Vex", goblin, 2);
398 t.damage("Grog", goblin, 4);
399 t.undo("Vex");
400 assert_eq!(t.hp(goblin), 15);
401 t.undo("Vex");
402 assert_eq!(t.hp(goblin), 16);
403 assert_eq!(t.try_undo("Vex", None), Err(CombatError::NothingToUndo));
404 assert_eq!(t.state().combatants.len(), 1);
406 }
407
408 #[test]
409 fn a_specific_command_can_be_undone_if_it_is_ones_own() {
410 let mut t = Table::new();
411 let goblin = t.add("DM", "Goblin", 20);
412 let first = t.damage("Vex", goblin, 1);
413 t.damage("Vex", goblin, 2);
414 let groggy = t.damage("Grog", goblin, 4);
415
416 assert_eq!(
417 t.try_undo("Vex", Some(groggy)),
418 Err(CombatError::NotYourCommand)
419 );
420 let unknown = Uuid::new_v4();
421 assert_eq!(
422 t.try_undo("Vex", Some(unknown)),
423 Err(CombatError::UnknownCommand(unknown))
424 );
425 let the_undo = t.try_undo("Vex", Some(first)).unwrap();
426 assert_eq!(t.hp(goblin), 14);
427 assert_eq!(
428 t.try_undo("Vex", Some(first)),
429 Err(CombatError::AlreadyUndone)
430 );
431 assert!(matches!(
432 t.try_undo("Vex", Some(the_undo)),
433 Err(CombatError::CannotUndo(_))
434 ));
435 }
436
437 #[test]
438 fn undoing_a_turn_rewinds_round_turn_and_expiries() {
439 let mut t = Table::new();
440 let cleric = t.add("DM", "Cleric", 10);
441 let orc = t.add("DM", "Orc", 10);
442 t.init("DM", cleric, 10);
443 t.init("DM", orc, 5);
444 t.run("DM", CombatCommand::Start);
445 let bless = t.effect("Cleric", cleric, "Bless", Some(1));
446 t.run("DM", CombatCommand::NextTurn); t.run("DM", CombatCommand::NextTurn); assert!(t.state().effect(bless).is_none());
449
450 t.damage("Orc player", cleric, 3); t.undo("DM");
452 assert_eq!(t.current(), (1, "Orc".to_string()));
453 assert!(t.state().effect(bless).is_some(), "bless back in play");
454 assert_eq!(t.hp(cleric), 7, "the damage since is kept");
455
456 t.run("Orc player", CombatCommand::NextTurn);
459 assert_eq!(
460 t.try_undo("DM", None),
461 Err(CombatError::CannotUndo(
462 "the combat has moved on since".into()
463 ))
464 );
465 }
466
467 #[test]
468 fn undoing_a_removal_brings_back_the_combatant_their_effects_and_turn() {
469 let mut t = Table::new();
470 let keyleth = t.add("DM", "Keyleth", 30);
471 let grog = t.add("DM", "Grog", 50);
472 t.init("DM", keyleth, 15);
473 t.init("DM", grog, 10);
474 t.run("DM", CombatCommand::Start);
475 let id = Uuid::new_v4();
476 let effect = NewEffect {
477 id,
478 name: "Moonbeam".into(),
479 source: Some(keyleth),
480 targets: vec![grog],
481 concentration: true,
482 rounds: None,
483 };
484 t.run("Keyleth", CombatCommand::AddEffect { effect });
485 let goblin = t.add("DM", "Goblin", 7);
486 let faerie = Uuid::new_v4();
487 let effect = NewEffect {
488 id: faerie,
489 name: "Faerie Fire".into(),
490 source: Some(grog),
491 targets: vec![keyleth, goblin],
492 concentration: false,
493 rounds: None,
494 };
495 t.run("Grog", CombatCommand::AddEffect { effect });
496 let before = t.state();
497
498 t.run("DM", CombatCommand::RemoveCombatant { combatant: keyleth });
499 assert_eq!(t.current(), (1, "Grog".to_string()));
500 t.undo("DM");
501 let after = t.state();
502 assert_eq!(after.current_turn, Some(keyleth));
503 assert_eq!(after.combatant(keyleth), before.combatant(keyleth));
504 assert_eq!(after.effect(id), before.effect(id), "concentration back");
505 let targets = &after.effect(faerie).unwrap().targets;
506 assert!(targets.contains(&keyleth) && targets.contains(&goblin));
507 assert_eq!(
508 after.turn_order().map(|c| c.id).collect::<Vec<_>>(),
509 [keyleth, grog]
510 );
511 }
512
513 #[test]
514 fn undoing_an_addition_takes_it_out_again() {
515 let mut t = Table::new();
516 let keyleth = t.add("DM", "Keyleth", 30);
517 let moonbeam = t.effect("Keyleth", keyleth, "Moonbeam", None);
518 let fire = t.effect("Keyleth", keyleth, "Faerie Fire", None);
519 assert!(t.state().effect(moonbeam).is_none(), "replaced");
520 t.undo("Keyleth");
521 let state = t.state();
522 assert!(state.effect(fire).is_none());
523 assert!(state.effect(moonbeam).is_some(), "the replaced one is back");
524
525 t.undo("DM");
526 assert!(t.state().combatants.is_empty());
527 assert!(t.state().effects.is_empty(), "their concentration ends");
528 }
529
530 #[test]
531 fn concentration_broken_by_damage_comes_back_unless_replaced() {
532 let mut t = Table::new();
533 let keyleth = t.add("DM", "Keyleth", 10);
534 let moonbeam = t.effect("Keyleth", keyleth, "Moonbeam", None);
535 t.damage("DM", keyleth, 10);
536 assert!(t.state().effect(moonbeam).is_none());
537 t.undo("DM");
538 assert_eq!(t.hp(keyleth), 10);
539 assert!(t.state().effect(moonbeam).is_some());
540
541 t.damage("DM", keyleth, 10);
542 t.run(
543 "Keyleth",
544 CombatCommand::Heal {
545 combatant: keyleth,
546 amount: 5,
547 },
548 );
549 let bless = t.effect("Keyleth", keyleth, "Bless", None);
550 t.undo("DM");
551 let state = t.state();
552 assert_eq!(t.hp(keyleth), 10, "capped at max");
553 assert!(state.effect(moonbeam).is_none(), "Bless replaced it since");
554 assert!(state.effect(bless).is_some());
555 }
556
557 #[test]
558 fn hit_points_of_someone_who_left_cant_be_put_back() {
559 let mut t = Table::new();
560 let goblin = t.add("DM", "Goblin", 20);
561 t.damage("Vex", goblin, 7);
562 t.run("DM", CombatCommand::RemoveCombatant { combatant: goblin });
563 assert_eq!(
564 t.try_undo("Vex", None),
565 Err(CombatError::CannotUndo("Goblin has left the combat".into()))
566 );
567 }
568
569 #[test]
570 fn initiative_goes_back_unless_changed_since() {
571 let mut t = Table::new();
572 let goblin = t.add("DM", "Goblin", 7);
573 t.init("DM", goblin, 12);
574 t.init("Vex", goblin, 3);
575 t.undo("Vex");
576 assert_eq!(t.state().combatant(goblin).unwrap().initiative, Some(12));
577
578 t.init("Vex", goblin, 3);
579 t.init("DM", goblin, 18);
580 t.undo("Vex");
581 assert_eq!(t.state().combatant(goblin).unwrap().initiative, Some(18));
582 }
583
584 #[test]
585 fn an_ended_combat_only_undoes_its_ending() {
586 let mut t = Table::new();
587 let goblin = t.add("DM", "Goblin", 7);
588 t.init("DM", goblin, 12);
589 t.run("DM", CombatCommand::Start);
590 t.damage("Vex", goblin, 1);
591 t.run("DM", CombatCommand::Pause);
592 t.run("DM", CombatCommand::End);
593 assert_eq!(t.try_undo("Vex", None), Err(CombatError::Ended));
594 t.undo("DM");
595 assert_eq!(t.state().status, CombatStatus::Paused);
596 assert_eq!(t.current(), (1, "Goblin".to_string()));
597 t.undo("Vex");
598 assert_eq!(t.hp(goblin), 7, "undo works while paused");
599 }
600
601 #[test]
602 fn undoing_a_cast_ends_its_effect_and_restores_the_one_it_replaced() {
603 let mut t = Table::new();
604 let keyleth = t.add("DM", "Keyleth", 30);
605 let moonbeam = t.effect("Keyleth", keyleth, "Moonbeam", None);
606 let bless = Uuid::new_v4();
607 t.run(
608 "Keyleth",
609 CombatCommand::CastSpell {
610 caster: keyleth,
611 spell: "Bless".into(),
612 slot: Some(crate::combat::SpellSlot::Level(1)),
613 targets: vec![keyleth],
614 concentration: Some(bless),
615 },
616 );
617 t.undo("Keyleth");
618 let state = t.state();
619 assert!(state.effect(bless).is_none());
620 assert!(state.effect(moonbeam).is_some());
621 }
622
623 #[test]
624 fn decide_leaves_undo_to_the_server() {
625 let t = Table::new();
626 assert_eq!(
627 decide(
628 &t.state(),
629 &CombatCommand::Undo { command: None },
630 &mut NoDice
631 ),
632 Err(CombatError::ServerOnly)
633 );
634 }
635}