Skip to main content

edda_types/combat/
permission.rs

1//! What a player's device may do in a combat. The DM's devices may do
2//! anything. A player changes only their own combatants (those playing a
3//! character the device owns); whatever happens to anyone else, the DM or
4//! that combatant's player enters themselves. So a player may:
5//!
6//! - damage, heal and give temporary hit points to their own combatants,
7//!   and use their features;
8//! - cast spells and put down effects that name only their own combatants,
9//!   and end effects that involve only them;
10//! - concentrate on a spell or effect of their own on anyone (Bless on the
11//!   party, Hunter's Mark on a goblin), and end it: it only marks whom it's
12//!   on, and ends with their concentration;
13//! - end their own turn;
14//! - undo their own commands.
15//!
16//! Everything else (who's in the fight, initiative, starting, pausing and
17//! ending it) is the DM's. The combat service enforces this; clients can
18//! check it first to say so before sending.
19
20use uuid::Uuid;
21
22use super::{CombatCommand, CombatError, CombatState, Combatant};
23
24/// Whether a player may send `command`, given which combatants are their
25/// own. Combatants or effects the combat doesn't have are let through, for
26/// [`super::decide`] to refuse.
27pub fn player_may(
28    state: &CombatState,
29    command: &CombatCommand,
30    own: impl Fn(&Combatant) -> bool,
31) -> Result<(), CombatError> {
32    let mine = |ids: &mut dyn Iterator<Item = &Uuid>| -> Result<(), CombatError> {
33        for id in ids {
34            if let Some(combatant) = state.combatant(*id)
35                && !own(combatant)
36            {
37                return Err(CombatError::NotYours(combatant.name.clone()));
38            }
39        }
40        Ok(())
41    };
42    match command {
43        CombatCommand::Damage { combatant, .. }
44        | CombatCommand::Heal { combatant, .. }
45        | CombatCommand::SetTemporaryHitPoints { combatant, .. }
46        | CombatCommand::UseFeature { combatant, .. } => mine(&mut std::iter::once(combatant)),
47        // Whom a concentration spell or effect of one's own is on is only
48        // marked.
49        CombatCommand::CastSpell {
50            caster,
51            concentration: Some(_),
52            ..
53        } => mine(&mut std::iter::once(caster)),
54        CombatCommand::CastSpell {
55            caster, targets, ..
56        } => mine(&mut std::iter::once(caster).chain(targets)),
57        CombatCommand::AddEffect { effect } => match effect.source {
58            Some(source) if effect.concentration => mine(&mut std::iter::once(&source)),
59            _ => mine(&mut effect.source.iter().chain(&effect.targets)),
60        },
61        CombatCommand::RemoveEffect { effect } => match state.effect(*effect) {
62            Some(effect) => match effect.source {
63                Some(source) if effect.concentration => mine(&mut std::iter::once(&source)),
64                _ => mine(&mut effect.source.iter().chain(&effect.targets)),
65            },
66            None => Ok(()),
67        },
68        CombatCommand::NextTurn => match state.current_turn.and_then(|id| state.combatant(id)) {
69            Some(current) if own(current) => Ok(()),
70            _ => Err(CombatError::NotYourTurn),
71        },
72        // Only their own, which the server checks against the log.
73        CombatCommand::Undo { .. } => Ok(()),
74        CombatCommand::AddCombatant { .. }
75        | CombatCommand::AddMonsters { .. }
76        | CombatCommand::RemoveCombatant { .. }
77        | CombatCommand::RollInitiative { .. }
78        | CombatCommand::RollInitiativeForAll
79        | CombatCommand::SetInitiative { .. }
80        | CombatCommand::Start
81        | CombatCommand::Pause
82        | CombatCommand::Resume
83        | CombatCommand::End => Err(CombatError::DmOnly),
84    }
85}
86
87#[cfg(test)]
88mod tests {
89    use super::*;
90    use crate::combat::{CombatEvent, NewEffect};
91
92    /// Vex (the player's) has the turn; Grog (another player's) and a
93    /// goblin are in the fight too.
94    fn fight() -> (CombatState, [Uuid; 3]) {
95        let mut state = CombatState::default();
96        let mut ids = [Uuid::nil(); 3];
97        for (i, name) in ["Vex", "Grog", "Goblin"].into_iter().enumerate() {
98            let mut combatant = Combatant::new(name, 10);
99            combatant.initiative = Some(20 - i16::try_from(i).unwrap());
100            ids[i] = combatant.id;
101            state.apply(&CombatEvent::CombatantAdded { combatant });
102        }
103        state.apply(&CombatEvent::Started);
104        state.apply(&CombatEvent::TurnStarted { combatant: ids[0] });
105        (state, ids)
106    }
107
108    #[expect(
109        clippy::needless_pass_by_value,
110        reason = "tests read better passing commands by value"
111    )]
112    fn vex_may(state: &CombatState, command: CombatCommand) -> Result<(), CombatError> {
113        player_may(state, &command, |c| c.name == "Vex")
114    }
115
116    #[test]
117    fn players_change_only_their_own_combatants() {
118        let (state, [vex, grog, goblin]) = fight();
119        let damage = |combatant| CombatCommand::Damage {
120            combatant,
121            amount: 3,
122        };
123        assert_eq!(vex_may(&state, damage(vex)), Ok(()));
124        assert_eq!(
125            vex_may(&state, damage(goblin)),
126            Err(CombatError::NotYours("Goblin".into()))
127        );
128        let cast = |targets| CombatCommand::CastSpell {
129            caster: vex,
130            spell: "Cure Wounds".into(),
131            slot: None,
132            targets,
133            concentration: None,
134        };
135        assert_eq!(vex_may(&state, cast(vec![vex])), Ok(()));
136        assert_eq!(
137            vex_may(&state, cast(vec![vex, grog])),
138            Err(CombatError::NotYours("Grog".into()))
139        );
140        let effect = |source, targets| CombatCommand::AddEffect {
141            effect: NewEffect {
142                id: Uuid::new_v4(),
143                name: "Blessed".into(),
144                source,
145                targets,
146                concentration: false,
147                rounds: None,
148            },
149        };
150        assert_eq!(vex_may(&state, effect(None, vec![vex])), Ok(()));
151        assert!(vex_may(&state, effect(Some(vex), vec![goblin])).is_err());
152        assert!(vex_may(&state, effect(Some(grog), vec![vex])).is_err());
153    }
154
155    #[test]
156    fn players_concentrate_on_anyone_but_only_for_themselves() {
157        let (mut state, [vex, grog, goblin]) = fight();
158        let concentrate = |source, targets| CombatCommand::AddEffect {
159            effect: NewEffect {
160                id: Uuid::new_v4(),
161                name: "Hunter's Mark".into(),
162                source,
163                targets,
164                concentration: true,
165                rounds: None,
166            },
167        };
168        assert_eq!(
169            vex_may(&state, concentrate(Some(vex), vec![goblin])),
170            Ok(())
171        );
172        assert!(vex_may(&state, concentrate(Some(grog), vec![goblin])).is_err());
173        assert!(vex_may(&state, concentrate(None, vec![goblin])).is_err());
174        let bless = |concentration| CombatCommand::CastSpell {
175            caster: vex,
176            spell: "Bless".into(),
177            slot: None,
178            targets: vec![vex, grog],
179            concentration,
180        };
181        assert_eq!(vex_may(&state, bless(Some(Uuid::new_v4()))), Ok(()));
182        assert_eq!(
183            vex_may(&state, bless(None)),
184            Err(CombatError::NotYours("Grog".into()))
185        );
186
187        // Ending it is hers too; ending Grog's isn't.
188        let mark = |id, source| CombatEvent::EffectAdded {
189            effect: crate::combat::Effect {
190                id,
191                name: "Mark".into(),
192                source: Some(source),
193                targets: vec![goblin],
194                concentration: true,
195                expires: None,
196            },
197        };
198        let (hers, his) = (Uuid::new_v4(), Uuid::new_v4());
199        state.apply(&mark(hers, vex));
200        state.apply(&mark(his, grog));
201        let drop = |effect| CombatCommand::RemoveEffect { effect };
202        assert_eq!(vex_may(&state, drop(hers)), Ok(()));
203        assert!(vex_may(&state, drop(his)).is_err());
204    }
205
206    #[test]
207    fn players_end_only_their_own_turn_and_the_dm_runs_the_rest() {
208        let (state, [vex, ..]) = fight();
209        assert_eq!(vex_may(&state, CombatCommand::NextTurn), Ok(()));
210        let grogs_turn = |c: &Combatant| c.name == "Grog";
211        assert_eq!(
212            player_may(&state, &CombatCommand::NextTurn, grogs_turn),
213            Err(CombatError::NotYourTurn)
214        );
215        for command in [
216            CombatCommand::Start,
217            CombatCommand::End,
218            CombatCommand::RemoveCombatant { combatant: vex },
219            CombatCommand::RollInitiative {
220                combatant: vex,
221                roll: None,
222            },
223        ] {
224            assert_eq!(vex_may(&state, command), Err(CombatError::DmOnly));
225        }
226        assert_eq!(
227            vex_may(&state, CombatCommand::Undo { command: None }),
228            Ok(())
229        );
230    }
231}