1mod cost;
11mod monsters;
12mod permission;
13mod protocol;
14mod rules;
15pub mod undo;
16pub mod view;
17
18pub use cost::SheetCost;
19pub use monsters::{add_monsters, monster_names};
20pub use permission::player_may;
21pub use protocol::{
22 ClientMessage, CombatSnapshot, CombatSummary, CommandEnvelope, LoggedEvent, ServerMessage,
23};
24pub use rules::{CombatCommand, CombatError, Dice, NewEffect, NoDice, decide};
25pub use view::{Concealer, Vitality};
26
27use serde::{Deserialize, Serialize};
28use uuid::Uuid;
29
30use crate::HitPoints;
31
32#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
34#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
35pub struct CombatState {
36 pub name: String,
37 #[serde(default)]
39 pub campaign: String,
40 pub status: CombatStatus,
41 pub round: u32,
43 pub current_turn: Option<Uuid>,
45 pub combatants: Vec<Combatant>,
47 pub effects: Vec<Effect>,
48}
49
50#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
52#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
53#[serde(rename_all = "snake_case")]
54pub enum CombatStatus {
55 #[default]
57 Preparing,
58 Active,
59 Paused,
62 Ended,
63}
64
65#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
67#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
68pub struct Combatant {
69 pub id: Uuid,
72 pub name: String,
73 #[serde(default, skip_serializing_if = "Option::is_none")]
75 pub character_id: Option<Uuid>,
76 #[serde(default, skip_serializing_if = "Option::is_none")]
79 pub monster_id: Option<Uuid>,
80 #[serde(default)]
81 pub initiative_modifier: i8,
82 #[serde(default)]
84 pub initiative: Option<i16>,
85 pub hit_points: HitPoints,
87 #[serde(default, skip_serializing_if = "Option::is_none")]
88 pub armor_class: Option<u8>,
89 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
92 pub concealed: bool,
93}
94
95impl Combatant {
96 pub fn new(name: impl Into<String>, max_hp: u16) -> Self {
98 Self {
99 id: Uuid::new_v4(),
100 name: name.into(),
101 character_id: None,
102 monster_id: None,
103 initiative_modifier: 0,
104 initiative: None,
105 hit_points: HitPoints {
106 max: max_hp,
107 current: max_hp,
108 temporary: 0,
109 },
110 armor_class: None,
111 concealed: false,
112 }
113 }
114
115 pub fn from_character(character: &crate::Character) -> Self {
118 let sheet = &character.sheet;
119 Self {
120 id: Uuid::new_v4(),
121 name: sheet.name.clone(),
122 character_id: Some(character.id),
123 monster_id: None,
124 initiative_modifier: sheet.ability_scores.modifier(crate::Ability::Dexterity),
125 initiative: None,
126 hit_points: sheet.hit_points,
127 armor_class: Some(sheet.armor_class),
128 concealed: false,
129 }
130 }
131
132 pub fn from_monster(monster: &crate::monster::Monster, name: String, hit_points: u16) -> Self {
135 let sheet = &monster.sheet;
136 Self {
137 id: Uuid::new_v4(),
138 name,
139 character_id: None,
140 monster_id: Some(monster.id),
141 initiative_modifier: sheet.initiative_modifier(),
142 initiative: None,
143 hit_points: HitPoints {
144 max: hit_points,
145 current: hit_points,
146 temporary: 0,
147 },
148 armor_class: Some(sheet.armor_class),
149 concealed: false,
150 }
151 }
152
153 pub fn is_down(&self) -> bool {
155 self.hit_points.current == 0
156 }
157
158 pub fn vitality(&self) -> Vitality {
160 if self.concealed {
161 Vitality::from_concealed(self.hit_points)
162 } else {
163 Vitality::of(self.hit_points)
164 }
165 }
166}
167
168#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
170#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
171pub struct Effect {
172 pub id: Uuid,
173 pub name: String,
174 #[serde(default, skip_serializing_if = "Option::is_none")]
176 pub source: Option<Uuid>,
177 #[serde(default)]
178 pub targets: Vec<Uuid>,
179 #[serde(default)]
182 pub concentration: bool,
183 #[serde(default, skip_serializing_if = "Option::is_none")]
185 pub expires: Option<Expiry>,
186}
187
188#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
193#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
194pub struct Expiry {
195 pub round: u32,
196 #[serde(default, skip_serializing_if = "Option::is_none")]
197 pub at_turn_of: Option<Uuid>,
198}
199
200#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
204#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
205#[serde(tag = "type", rename_all = "snake_case")]
206pub enum CombatEvent {
207 Created {
208 name: String,
209 #[serde(default)]
210 campaign: String,
211 },
212 CombatantAdded {
213 combatant: Combatant,
214 },
215 CombatantRemoved {
216 combatant: Uuid,
217 },
218 InitiativeRolled {
219 combatant: Uuid,
220 roll: u8,
221 modifier: i8,
222 total: i16,
223 },
224 InitiativeSet {
225 combatant: Uuid,
226 total: i16,
227 },
228 Started,
229 RoundStarted {
230 round: u32,
231 },
232 TurnStarted {
233 combatant: Uuid,
234 },
235 Damaged {
236 combatant: Uuid,
237 amount: u32,
238 hit_points: HitPoints,
239 },
240 Healed {
241 combatant: Uuid,
242 amount: u32,
243 hit_points: HitPoints,
244 },
245 TemporaryHitPointsSet {
246 combatant: Uuid,
247 hit_points: HitPoints,
248 },
249 EffectAdded {
250 effect: Effect,
251 },
252 EffectEnded {
253 effect: Uuid,
254 reason: EffectEndReason,
255 },
256 SpellCast {
258 caster: Uuid,
259 spell: String,
260 #[serde(default, skip_serializing_if = "Option::is_none")]
261 slot: Option<SpellSlot>,
262 #[serde(default)]
263 targets: Vec<Uuid>,
264 },
265 FeatureUsed {
268 combatant: Uuid,
269 feature: String,
270 amount: u16,
271 },
272 Paused,
273 Resumed,
274 Ended,
275 Undone {
278 command: Uuid,
279 },
280 CombatantRestored {
283 combatant: Combatant,
284 },
285 EffectRestored {
287 effect: Effect,
288 },
289 ProgressRestored {
291 status: CombatStatus,
292 round: u32,
293 current_turn: Option<Uuid>,
294 },
295}
296
297#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
300#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
301#[serde(rename_all = "snake_case")]
302pub enum SpellSlot {
303 Level(u8),
304 Pact(u8),
305}
306
307impl SpellSlot {
308 pub fn level(self) -> u8 {
310 match self {
311 SpellSlot::Level(level) | SpellSlot::Pact(level) => level,
312 }
313 }
314}
315
316#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
318#[cfg_attr(feature = "openapi", derive(utoipa::ToSchema))]
319#[serde(rename_all = "snake_case")]
320pub enum EffectEndReason {
321 Removed,
322 Expired,
323 ConcentrationReplaced,
325 ConcentrationBroken,
327 SourceRemoved,
329 Undone,
331}
332
333impl CombatState {
334 pub fn replay<'a>(events: impl IntoIterator<Item = &'a CombatEvent>) -> Self {
336 let mut state = Self::default();
337 for event in events {
338 state.apply(event);
339 }
340 state
341 }
342
343 pub fn combatant(&self, id: Uuid) -> Option<&Combatant> {
345 self.combatants.iter().find(|c| c.id == id)
346 }
347
348 pub fn effect(&self, id: Uuid) -> Option<&Effect> {
350 self.effects.iter().find(|e| e.id == id)
351 }
352
353 pub fn turn_order(&self) -> impl Iterator<Item = &Combatant> {
355 self.combatants.iter().filter(|c| c.initiative.is_some())
356 }
357
358 pub fn apply(&mut self, event: &CombatEvent) {
361 match event {
362 CombatEvent::Created { name, campaign } => {
363 self.name.clone_from(name);
364 self.campaign.clone_from(campaign);
365 }
366 CombatEvent::CombatantAdded { combatant } => {
367 self.combatants.push(combatant.clone());
368 self.sort();
369 }
370 CombatEvent::CombatantRemoved { combatant } => {
371 self.combatants.retain(|c| c.id != *combatant);
372 for effect in &mut self.effects {
373 effect.targets.retain(|t| t != combatant);
374 }
375 if self.current_turn == Some(*combatant) {
376 self.current_turn = None;
377 }
378 }
379 CombatEvent::InitiativeRolled {
380 combatant, total, ..
381 }
382 | CombatEvent::InitiativeSet { combatant, total } => {
383 if let Some(c) = self.combatant_mut(*combatant) {
384 c.initiative = Some(*total);
385 }
386 self.sort();
387 }
388 CombatEvent::Started | CombatEvent::Resumed => self.status = CombatStatus::Active,
389 CombatEvent::RoundStarted { round } => self.round = *round,
390 CombatEvent::TurnStarted { combatant } => self.current_turn = Some(*combatant),
391 CombatEvent::Damaged {
392 combatant,
393 hit_points,
394 ..
395 }
396 | CombatEvent::Healed {
397 combatant,
398 hit_points,
399 ..
400 }
401 | CombatEvent::TemporaryHitPointsSet {
402 combatant,
403 hit_points,
404 } => {
405 if let Some(c) = self.combatant_mut(*combatant) {
406 c.hit_points = *hit_points;
407 }
408 }
409 CombatEvent::EffectAdded { effect } => self.effects.push(effect.clone()),
410 CombatEvent::EffectEnded { effect, .. } => self.effects.retain(|e| e.id != *effect),
411 CombatEvent::SpellCast { .. }
412 | CombatEvent::FeatureUsed { .. }
413 | CombatEvent::Undone { .. } => {}
414 CombatEvent::Paused => self.status = CombatStatus::Paused,
415 CombatEvent::Ended => {
416 self.status = CombatStatus::Ended;
417 self.current_turn = None;
418 }
419 CombatEvent::CombatantRestored { combatant } => {
420 match self.combatant_mut(combatant.id) {
421 Some(c) => *c = combatant.clone(),
422 None => self.combatants.push(combatant.clone()),
423 }
424 self.sort();
425 }
426 CombatEvent::EffectRestored { effect } => {
427 match self.effects.iter_mut().find(|e| e.id == effect.id) {
428 Some(e) => *e = effect.clone(),
429 None => self.effects.push(effect.clone()),
430 }
431 }
432 CombatEvent::ProgressRestored {
433 status,
434 round,
435 current_turn,
436 } => {
437 self.status = *status;
438 self.round = *round;
439 self.current_turn = *current_turn;
440 }
441 }
442 }
443
444 fn combatant_mut(&mut self, id: Uuid) -> Option<&mut Combatant> {
445 self.combatants.iter_mut().find(|c| c.id == id)
446 }
447
448 fn sort(&mut self) {
452 self.combatants.sort_by(|a, b| {
453 let key = |c: &Combatant| (c.initiative.is_some(), c.initiative, c.initiative_modifier);
454 key(b).cmp(&key(a))
455 });
456 }
457}