Skip to main content

edda_types/combat/
undo.rs

1//! Undoing a command.
2//!
3//! The log is append-only, so an undo never removes anything: it appends an
4//! [`CombatEvent::Undone`] marker followed by events that put back what the
5//! undone command changed. Everyone sees the correction like any other
6//! change, and the log still shows both the mistake and its undo.
7//!
8//! Later commands by other people are kept. Only what the undone command
9//! itself changed is reverted:
10//!
11//! - Hit points move back by the amount the command changed them, so 7
12//!   damage undone after someone else's 2 leaves the 2.
13//! - Initiative goes back unless someone has changed it since.
14//! - Combatants and effects the command added are taken out again; ones it
15//!   removed or ended come back. An effect isn't brought back if its source
16//!   has started concentrating on something else since.
17//! - Round, turn and status (from `start`, `next_turn`, `pause`, `end`, a
18//!   removal passing the turn) go back only if nothing has moved them
19//!   since; otherwise the undo is refused, as the turn has moved on.
20//!
21//! An ended combat accepts only the undo of the command that ended it.
22//!
23//! Spells and features are spent on character sheets, outside the combat:
24//! undoing a cast ends the effect it put into play, and the combat service
25//! gives the slot back to the sheet.
26
27use std::collections::HashSet;
28
29use uuid::Uuid;
30
31use super::{
32    CombatError, CombatEvent, CombatState, CombatStatus, Combatant, EffectEndReason, LoggedEvent,
33    rules::Emitter,
34};
35
36/// The events that undo `target` (or, if `None`, the latest command for
37/// which `mine` holds that isn't undone yet), given the combat's full log.
38/// `mine` says whether a command was sent by whoever is undoing.
39pub 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    // Ended before any are restored, so a concentration effect the command
65    // replaced can come back.
66    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
75/// The command to undo: `target`, if it can be undone by whoever `mine`
76/// says sent it, or their latest one not undone yet.
77fn 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    // The undos themselves, which can't be undone in turn.
90    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
119/// The combat just before `command`, just after it, and now.
120fn around(log: &[LoggedEvent], command: Uuid) -> [CombatState; 3] {
121    // A command's events are contiguous: commands to a combat are applied
122    // one at a time.
123    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
135/// Taking back what the command changed, from the combat as it was
136/// `before` the command and `after` it.
137impl Emitter {
138    /// Combatants it removed or changed.
139    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, &current);
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    /// Effects it added.
171    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    /// Effects it ended, or whose targets it removed.
183    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    /// Combatants it added.
217    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    /// The round, turn and status from before it.
231    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
248/// `current` with the change from `old` to `new` taken back: hit points
249/// move back by the same amount; initiative goes back unless it has been
250/// changed again since.
251fn 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    /// A combat run by several people, recording who sent what.
274    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        /// Runs `command` as `actor`; returns its command id.
307        #[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        // The DM's add is still there to undo, but it's the DM's.
405        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); // round 1, orc
447        t.run("DM", CombatCommand::NextTurn); // round 2, cleric: bless expires
448        assert!(t.state().effect(bless).is_none());
449
450        t.damage("Orc player", cleric, 3); // someone else acts meanwhile
451        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        // Once someone else moves the turn on, the DM's earlier turn can't
457        // be taken back.
458        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}