all_is_cubes/
character.rs

1//! Player-character stuff.
2
3use alloc::boxed::Box;
4use core::fmt;
5
6use bevy_ecs::prelude as ecs;
7use manyfmt::Fmt;
8use ordered_float::NotNan;
9
10/// Acts as polyfill for float methods
11#[cfg(not(feature = "std"))]
12#[allow(unused_imports)]
13use num_traits::float::Float as _;
14
15use crate::camera::ViewTransform;
16use crate::inv::{self, Inventory, InventoryComponent, InventoryTransaction, Slot, Tool};
17use crate::listen::{self, IntoListener};
18use crate::math::{Aab, Face6, FreePoint, FreeVector};
19use crate::physics::{self, Body, BodyTransaction, step::PhysicsOutputs};
20use crate::rerun_glue as rg;
21#[cfg(feature = "save")]
22use crate::save::schema;
23use crate::sound;
24use crate::space::Space;
25use crate::transaction::{self, Equal, Merge, Transaction, Transactional};
26use crate::universe::{
27    self, Handle, HandleError, HandleVisitor, ReadTicket, UniverseTransaction, VisitHandles,
28};
29use crate::util::{ConciseDebug, Refmt as _, StatusText};
30
31// -------------------------------------------------------------------------------------------------
32
33mod ambient_sound;
34
35mod cursor;
36pub use cursor::*;
37
38mod exposure;
39
40mod eye;
41pub(crate) use eye::add_eye_systems;
42
43mod spawn;
44pub use spawn::*;
45
46mod step;
47pub(crate) use step::add_main_systems;
48
49#[cfg(test)]
50mod tests;
51
52// -------------------------------------------------------------------------------------------------
53
54/// A `Character`:
55///
56/// * knows what [`Space`] it is looking at, by [`Handle`],
57/// * knows where it is located and how it collides via a `Body` which it owns and
58///   steps, and
59/// * handles the parts of input management that are associated with universe state
60///   (controlling velocity, holding tools).
61///
62#[doc = include_str!("save/serde-warning.md")]
63// TODO: derive(ecs::Bundle) eventually?
64pub struct Character {
65    core: CharacterCore,
66
67    /// Position, collision, and look direction.
68    pub body: Body,
69
70    /// Refers to the [`Space`] to be viewed and collided with.
71    pub space: Handle<Space>,
72
73    inventory: InventoryComponent,
74}
75
76/// Every piece of data in a [`Character`] that is not (yet) split into its own separate
77/// ECS component. TODO(ecs): get rid of this?
78#[derive(Debug, ecs::Component)]
79#[require(eye::CharacterEye, Input, rg::Destination)]
80pub(crate) struct CharacterCore {
81    /// Indices into the [`Inventory`] slots of this character, which identify the tools currently
82    /// in use / “in hand”.
83    ///
84    /// If the indices are out of range, this is considered equivalent to selecting an empty slot.
85    selected_slots: [inv::Ix; inv::TOOL_SELECTIONS],
86
87    /// Notifier for modifications.
88    ///
89    /// Note: `InventoryComponent` has its own notifier.
90    notifier: listen::Notifier<CharacterChange>,
91}
92
93/// Component defining what [`Space`] a [`Body`] exists in.
94// TODO(ecs): Should this be optional? It's already fallible anyway via invalid handles.
95// TODO(ecs): This should probably be in the body module.
96#[derive(Clone, Debug, ecs::Component)]
97pub(crate) struct ParentSpace(pub Handle<Space>);
98
99/// Commands produced by the player’s input and executed by the character.
100///
101/// This data is not persisted.
102#[derive(Clone, Debug, Default, ecs::Component)]
103#[non_exhaustive]
104pub struct Input {
105    /// Velocity specified by user input, which the actual velocity is smoothly adjusted
106    /// towards.
107    ///
108    /// Maximum range for normal keyboard input should be -1 to 1
109    pub velocity_input: FreeVector,
110
111    /// Set this to true to jump during the next step.
112    pub jump: bool,
113
114    /// Indices into the [`Inventory`] slots of this character, which identify the tools currently
115    /// in use / “in hand”.
116    ///
117    /// Set elements to [`Some`] to change which slots are selected.
118    /// [`None`] means no change from the current value.
119    pub set_selected_slots: [Option<inv::Ix>; inv::TOOL_SELECTIONS],
120}
121
122// -------------------------------------------------------------------------------------------------
123
124impl fmt::Debug for Character {
125    #[mutants::skip]
126    fn fmt(&self, fmt: &mut fmt::Formatter<'_>) -> fmt::Result {
127        let Self {
128            core:
129                CharacterCore {
130                    selected_slots,
131                    notifier: _,
132                },
133            body,
134            space: _,
135            inventory,
136        } = self;
137        fmt.debug_struct("Character")
138            .field("body", &body)
139            .field("inventory", &inventory)
140            .field("selected_slots", selected_slots)
141            .finish_non_exhaustive()
142    }
143}
144
145impl Character {
146    /// Constructs a [`Character`] within/looking at the given `space`
147    /// with the initial state specified by `spawn`.
148    pub fn spawn(spawn: &Spawn, space: Handle<Space>) -> Self {
149        // TODO: special inventory slots should be set up some other way.
150        // * The knowledge "toolbar has 10 items" shouldn't be needed exactly here.
151        // * And we shouldn't have special slots identified solely by number.
152        // * And not every character should have a CopyFromSpace.
153        const SLOT_COUNT: inv::Ix = 11;
154        const INVISIBLE_SLOT: inv::Ix = SLOT_COUNT - 1;
155        let mut inventory = vec![Slot::Empty; usize::from(SLOT_COUNT)].into_boxed_slice();
156        inventory[usize::from(INVISIBLE_SLOT)] = Tool::CopyFromSpace.into();
157        let mut free: usize = 0;
158        let mut ordinary_tool_selection: inv::Ix = 0;
159        'fill: for item in spawn.inventory.iter() {
160            while inventory[free] != Slot::Empty {
161                free += 1;
162                if free >= inventory.len() {
163                    break 'fill;
164                }
165            }
166            inventory[free] = item.clone();
167
168            // Pick the first empty slot or tool that's not one of these as the button-2 tool
169            if matches!(
170                item,
171                Slot::Stack(_, Tool::RemoveBlock { .. } | Tool::Jetpack { .. })
172            ) && usize::from(ordinary_tool_selection) == free
173            {
174                ordinary_tool_selection += 1;
175            }
176        }
177        let selected_slots = [
178            0,
179            ordinary_tool_selection.min(INVISIBLE_SLOT - 1),
180            INVISIBLE_SLOT,
181        ];
182
183        let look_direction = spawn.look_direction.map(|c| c.into_inner());
184        let yaw = f64::atan2(look_direction.x, -look_direction.z).to_degrees();
185        let pitch =
186            f64::atan2(-look_direction.y, look_direction.z.hypot(look_direction.x)).to_degrees();
187
188        // TODO: This should be configurable, possibly in some more 'template' way
189        // than per-spawn?
190        let collision_box = Aab::new(-0.35, 0.35, -1.75, 0.15, -0.35, 0.35);
191
192        // Choose position.
193        // TODO: Should also check if the chosen position is intersecting with the contents
194        // of the Space, and avoid that.
195        let position: FreePoint = match spawn.eye_position {
196            Some(pos) => pos.map(NotNan::into_inner),
197            None => {
198                // Stand on the floor of the spawn bounds.
199                // TODO: Account for different gravity.
200                let mut pos: FreePoint = spawn.bounds.center();
201                pos.y = collision_box.face_coordinate(Face6::NY)
202                    - spawn.bounds.to_free().face_coordinate(Face6::NY);
203                pos
204            }
205        };
206
207        Self {
208            body: {
209                let mut body = Body::new_minimal(position, collision_box);
210                body.flying = false; // will be overriden anyway
211                body.yaw = yaw;
212                body.pitch = pitch;
213                body
214            },
215            core: CharacterCore {
216                selected_slots,
217                notifier: listen::Notifier::new(),
218            },
219            space,
220            inventory: InventoryComponent::new(Inventory::from_slots(inventory)),
221        }
222    }
223
224    /// Constructs a [`Character`] within/looking at the given `space`
225    /// with the initial state specified by [`Space::spawn`].
226    pub fn spawn_default(
227        read_ticket: ReadTicket<'_>,
228        space: Handle<Space>,
229    ) -> Result<Self, HandleError> {
230        Ok(Self::spawn(space.read(read_ticket)?.spawn(), space))
231    }
232
233    /// Computes the view transform for this character's eye; translation and rotation from
234    /// the camera coordinate system (whose look direction is the -Z axis) to the [`Space`]'s
235    /// coordinate system.
236    ///
237    /// See the documentation for [`ViewTransform`] for the interpretation of this transform.
238    ///
239    /// In addition to the transform it also returns the [`Space`] to be viewed and the
240    /// automatic exposure value.
241    ///
242    /// TODO: This return value should really be a struct, but we are somewhat in the middle of
243    /// refactoring how [`Character`] is built and this particular tuple is an interim measure.
244    //---
245    // TODO(ecs): this needs a better signature. figure out how to do that without exposing `CharacterEye` publicly unless we want to do that on purpose.
246    // TODO: documentation needs updating, and return value should be a struct
247    pub fn view<'t>(
248        handle: &Handle<Self>,
249        read_ticket: ReadTicket<'t>,
250    ) -> Result<(&'t Handle<Space>, ViewTransform, f32), HandleError> {
251        let body = handle.query::<Body>(read_ticket)?;
252        let space = &handle.query::<ParentSpace>(read_ticket)?.0;
253        let eye = handle.query::<eye::CharacterEye>(read_ticket)?; // TODO(ecs): need to distinguish "missing component"
254        let transform = eye.view_transform.unwrap_or_else(|| {
255            // Handle initial frame where the possibly-displaced view hasn't been computed yet.
256            eye::compute_view_transform(body, FreeVector::zero())
257        });
258        Ok((
259            space,
260            transform,
261            handle.query::<exposure::State>(read_ticket)?.exposure(),
262        ))
263    }
264
265    /// Returns the character's current inventory.
266    pub fn inventory(&self) -> &Inventory {
267        self.inventory.inventory()
268    }
269
270    /// Returns the character's currently selected inventory slots.
271    ///
272    /// The indices of this array are buttons (e.g. mouse buttons), and the values are
273    /// inventory slot indices.
274    pub fn selected_slots(&self) -> [inv::Ix; inv::TOOL_SELECTIONS] {
275        self.core.selected_slots
276    }
277
278    /// Changes which inventory slot is currently selected.
279    pub fn set_selected_slot(&mut self, which_selection: usize, slot: inv::Ix) {
280        let s = &mut self.core.selected_slots;
281        if which_selection < s.len() && slot != s[which_selection] {
282            s[which_selection] = slot;
283            self.core.notifier.notify(&CharacterChange::Selections);
284        }
285    }
286
287    /// Use this character's selected tool on the given cursor.
288    ///
289    /// Return an error if:
290    /// * The tool is not usable.
291    /// * The cursor does not refer to the same space as this character occupies.
292    pub fn click(
293        read_ticket: ReadTicket<'_>,
294        this: Handle<Character>,
295        cursor: Option<&Cursor>,
296        button: usize,
297    ) -> Result<UniverseTransaction, inv::ToolError> {
298        let tb = this.read(read_ticket).unwrap();
299
300        // Check that this is not a cursor into some other space.
301        // This shouldn't happen according to game rules but it might due to a UI/session
302        // update glitch, and if it does, we do
303        if let Some(cursor_space) = cursor.map(Cursor::space) {
304            let our_space = tb.space();
305            if cursor_space != our_space {
306                return Err(inv::ToolError::Internal(format!(
307                    "space mismatch: cursor {cursor_space:?} != character {our_space:?}"
308                )));
309            }
310        }
311
312        let slot_index = tb.selected_slots().get(button).copied().unwrap_or(tb.selected_slots()[0]);
313        tb.inventory().use_tool(read_ticket, cursor, this, slot_index)
314    }
315}
316
317impl universe::SealedMember for Character {
318    type Bundle = (CharacterCore, Body, ParentSpace, InventoryComponent);
319    type ReadQueryData = (
320        &'static CharacterCore,
321        &'static Body,
322        &'static ParentSpace,
323        &'static InventoryComponent,
324        &'static PhysicsOutputs,
325        &'static ambient_sound::State,
326    );
327
328    fn register_all_member_components(world: &mut ecs::World) {
329        universe::VisitableComponents::register::<CharacterCore>(world);
330        universe::VisitableComponents::register::<ParentSpace>(world);
331        universe::VisitableComponents::register::<InventoryComponent>(world);
332        // skipping other components which have nothing visitable
333    }
334
335    fn read_from_standalone(value: &Self) -> <Self as universe::UniverseMember>::Read<'_> {
336        Read {
337            core: &value.core,
338            body: &value.body,
339            space: &value.space,
340            inventory: &value.inventory,
341            physics: None,
342            ambient_sound: &sound::SpatialAmbient::SILENT,
343        }
344    }
345    fn read_from_query(
346        data: <Self::ReadQueryData as ::bevy_ecs::query::QueryData>::Item<'_>,
347    ) -> <Self as universe::UniverseMember>::Read<'_> {
348        let (core, body, ParentSpace(space), inventory, physics, sound_state) = data;
349        Read {
350            core,
351            body,
352            space,
353            inventory,
354            physics: Some(physics),
355            ambient_sound: sound_state.sound_average(),
356        }
357    }
358    fn read_from_entity_ref(
359        entity: ::bevy_ecs::world::EntityRef<'_>,
360    ) -> Option<<Self as universe::UniverseMember>::Read<'_>> {
361        Some(Read {
362            core: entity.get()?,
363            body: entity.get()?,
364            space: &entity.get::<ParentSpace>()?.0,
365            inventory: entity.get::<InventoryComponent>()?,
366            physics: entity.get::<PhysicsOutputs>(),
367            ambient_sound: entity.get::<ambient_sound::State>()?.sound_average(),
368        })
369    }
370    fn into_bundle(value: Box<Self>) -> Self::Bundle {
371        let Self {
372            core,
373            body,
374            space,
375            inventory,
376        } = *value;
377        (core, body, ParentSpace(space), inventory)
378    }
379}
380impl universe::UniverseMember for Character {
381    type Read<'ticket> = Read<'ticket>;
382}
383
384// TODO: stop making Body directly mutable
385impl universe::PubliclyMutableComponent<Character> for Body {}
386impl universe::PubliclyMutableComponent<Character> for Input {}
387
388impl VisitHandles for Character {
389    fn visit_handles(&self, visitor: &mut dyn HandleVisitor) {
390        // Use pattern matching so that if we add a new field that might contain handles,
391        // we are reminded to traverse it here.
392        let Self {
393            core,
394            body: _,
395            space,
396            inventory,
397        } = self;
398        core.visit_handles(visitor);
399        visitor.visit(space);
400        inventory.visit_handles(visitor);
401    }
402}
403impl VisitHandles for CharacterCore {
404    fn visit_handles(&self, _visitor: &mut dyn HandleVisitor) {
405        let Self {
406            selected_slots: _,
407            notifier: _,
408        } = self;
409    }
410}
411impl VisitHandles for ParentSpace {
412    fn visit_handles(&self, visitor: &mut dyn HandleVisitor) {
413        let Self(handle) = self;
414        visitor.visit(handle);
415    }
416}
417/// Registers a listener for mutations of this character.
418// TODO(ecs): keep this? or only allow listening once in the Universe
419impl listen::Listen for Character {
420    type Msg = CharacterChange;
421    type Listener = <listen::Notifier<Self::Msg> as listen::Listen>::Listener;
422    fn listen_raw(&self, listener: Self::Listener) {
423        universe::SealedMember::read_from_standalone(self).listen_raw(listener);
424    }
425}
426
427impl Transactional for Character {
428    type Transaction = CharacterTransaction;
429}
430
431#[cfg(feature = "save")]
432impl serde::Serialize for Read<'_> {
433    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
434    where
435        S: serde::Serializer,
436    {
437        use alloc::borrow::Cow::Borrowed;
438
439        let &Read {
440            core:
441                &CharacterCore {
442                    selected_slots,
443                    // Not persisted - run-time connections to other things
444                    notifier: _,
445                },
446            body,
447            space,
448            inventory,
449            physics: _,
450            ambient_sound: _,
451        } = self;
452        schema::CharacterSer::CharacterV1 {
453            space: space.clone(),
454            body: Borrowed(body),
455            inventory: Borrowed(inventory.inventory()),
456            selected_slots,
457        }
458        .serialize(serializer)
459    }
460}
461
462#[cfg(feature = "save")]
463impl serde::Serialize for Character {
464    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
465    where
466        S: serde::Serializer,
467    {
468        universe::SealedMember::read_from_standalone(self).serialize(serializer)
469    }
470}
471
472#[cfg(feature = "save")]
473impl<'de> serde::Deserialize<'de> for Character {
474    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
475    where
476        D: serde::Deserializer<'de>,
477    {
478        match schema::CharacterSer::deserialize(deserializer)? {
479            schema::CharacterSer::CharacterV1 {
480                space,
481                body,
482                inventory,
483                selected_slots,
484            } => Ok(Character {
485                core: CharacterCore {
486                    selected_slots,
487                    notifier: listen::Notifier::new(),
488                },
489                body: body.into_owned(),
490                space,
491                inventory: InventoryComponent::new(inventory.into_owned()),
492            }),
493        }
494    }
495}
496
497// -------------------------------------------------------------------------------------------------
498
499/// Read access to a [`Character`] that is currently in a [`Universe`][crate::universe::Universe].
500#[derive(Clone, Copy, Debug)]
501pub struct Read<'ticket> {
502    core: &'ticket CharacterCore,
503    body: &'ticket Body,
504    space: &'ticket Handle<Space>,
505    inventory: &'ticket InventoryComponent,
506    physics: Option<&'ticket PhysicsOutputs>,
507    ambient_sound: &'ticket sound::SpatialAmbient,
508}
509
510impl<'t> Read<'t> {
511    /// Position, collision, and look direction.
512    pub fn body(&self) -> &'t Body {
513        self.body
514    }
515
516    /// Returns the character's current inventory.
517    pub fn inventory(&self) -> &'t Inventory {
518        self.inventory.inventory()
519    }
520
521    /// Refers to the [`Space`] to be viewed and collided with.
522    pub fn space(&self) -> &'t Handle<Space> {
523        self.space
524    }
525
526    /// Indices into [`Self::inventory()`] slots which identify the tools selected for use by
527    /// clicking.
528    pub fn selected_slots(&self) -> [inv::Ix; inv::TOOL_SELECTIONS] {
529        self.core.selected_slots
530    }
531
532    #[doc(hidden)] // pub to be used by all-is-cubes-gpu and fuzz_physics
533    pub fn physics(&self) -> Option<&PhysicsOutputs> {
534        self.physics
535    }
536
537    /// Returns the character’s current ambient (continuous, spatial) sound environment.
538    pub fn ambient_sound(&self) -> &sound::SpatialAmbient {
539        self.ambient_sound
540    }
541}
542
543/// Registers a listener for mutations of this character.
544impl listen::Listen for Read<'_> {
545    type Msg = CharacterChange;
546    type Listener = <listen::Notifier<Self::Msg> as listen::Listen>::Listener;
547    fn listen_raw(&self, listener: Self::Listener) {
548        use listen::Listener; // trait must be in scope to get the non-trait-object impl
549
550        self.core.notifier.listen_raw(listener.clone());
551        self.inventory.listen_raw(
552            listener
553                .filter(|change: &inv::InventoryChange| {
554                    Some(CharacterChange::Inventory(change.clone()))
555                })
556                .into_listener(),
557        );
558    }
559}
560
561impl Fmt<StatusText> for Read<'_> {
562    #[mutants::skip] // technically user visible but really debugging
563    fn fmt(&self, fmt: &mut fmt::Formatter<'_>, fopt: &StatusText) -> fmt::Result {
564        write!(fmt, "{}", self.body().refmt(fopt))?;
565        if let Some(physics) = self.physics {
566            writeln!(fmt, "\n")?;
567            if let Some(info) = &physics.last_step_info {
568                writeln!(fmt, "Last step: {:#?}", info.refmt(&ConciseDebug))?;
569            }
570            write!(fmt, "Colliding: {:?}", physics.colliding_cubes.len())?;
571        }
572        Ok(())
573    }
574}
575
576// -------------------------------------------------------------------------------------------------
577
578/// A [`Transaction`] that modifies a [`Character`].
579#[derive(Clone, Debug, Default, PartialEq)]
580#[must_use]
581#[expect(clippy::module_name_repetitions)] // TODO: reconsider
582pub struct CharacterTransaction {
583    set_space: Equal<Handle<Space>>,
584    body: BodyTransaction,
585    inventory: InventoryTransaction,
586}
587
588impl CharacterTransaction {
589    /// Move the character to a different [`Space`].
590    ///
591    /// Note that this leaves the position within the spaces unchanged; use a
592    /// [`body()`](Self::body) transaction to also change that. TODO: Better API?
593    pub fn move_to_space(space: Handle<Space>) -> Self {
594        CharacterTransaction {
595            set_space: Equal(Some(space)),
596            ..Default::default()
597        }
598    }
599
600    /// Modify the character's [`Body`].
601    pub fn body(t: BodyTransaction) -> Self {
602        CharacterTransaction {
603            body: t,
604            ..Default::default()
605        }
606    }
607
608    /// Modify the character's [`Inventory`].
609    pub fn inventory(t: InventoryTransaction) -> Self {
610        CharacterTransaction {
611            inventory: t,
612            ..Default::default()
613        }
614    }
615}
616
617impl Transaction for CharacterTransaction {
618    type Target = Character;
619    // This ReadTicket is not currently used, but at least for now, *all* universe member transactions are to have ReadTicket as their context type.
620    type Context<'a> = ReadTicket<'a>;
621    type CommitCheck = (
622        <BodyTransaction as Transaction>::CommitCheck,
623        <InventoryTransaction as Transaction>::CommitCheck,
624    );
625    type Output = transaction::NoOutput;
626    type Mismatch = CharacterTransactionMismatch;
627
628    fn check(
629        &self,
630        target: &Character,
631        _: Self::Context<'_>,
632    ) -> Result<Self::CommitCheck, Self::Mismatch> {
633        let Self {
634            set_space: _, // no check needed
635            body,
636            inventory,
637        } = self;
638        Ok((
639            body.check(&target.body, ()).map_err(CharacterTransactionMismatch::Body)?,
640            inventory
641                .check(target.inventory.inventory(), ())
642                .map_err(CharacterTransactionMismatch::Inventory)?,
643        ))
644    }
645
646    fn commit(
647        self,
648        target: &mut Character,
649        (body_check, inventory_check): Self::CommitCheck,
650        outputs: &mut dyn FnMut(Self::Output),
651    ) -> Result<(), transaction::CommitError> {
652        self.set_space.commit(&mut target.space);
653
654        self.body
655            .commit(&mut target.body, body_check, outputs)
656            .map_err(|e| e.context("body".into()))?;
657
658        target
659            .inventory
660            .commit_inventory_transaction(self.inventory, inventory_check)
661            .map_err(|e| e.context("inventory".into()))?;
662
663        Ok(())
664    }
665}
666
667impl universe::TransactionOnEcs for CharacterTransaction {
668    type WriteQueryData = (
669        &'static mut Body,
670        &'static mut InventoryComponent,
671        &'static mut ParentSpace,
672    );
673
674    fn check(
675        &self,
676        target: Read<'_>,
677        _: ReadTicket<'_>,
678    ) -> Result<Self::CommitCheck, Self::Mismatch> {
679        let Self {
680            set_space: _, // no check needed
681            body,
682            inventory,
683        } = self;
684        Ok((
685            body.check(target.body, ()).map_err(CharacterTransactionMismatch::Body)?,
686            inventory
687                .check(target.inventory(), ())
688                .map_err(CharacterTransactionMismatch::Inventory)?,
689        ))
690    }
691
692    fn commit(
693        self,
694        (mut body, mut inventory, mut space): (
695            ecs::Mut<'_, Body>,
696            ecs::Mut<'_, InventoryComponent>,
697            ecs::Mut<'_, ParentSpace>,
698        ),
699        (body_check, inventory_check): Self::CommitCheck,
700    ) -> Result<(), transaction::CommitError> {
701        self.set_space.commit(&mut space.0);
702
703        self.body
704            .commit(&mut *body, body_check, &mut transaction::no_outputs)
705            .map_err(|e| e.context("body".into()))?;
706
707        inventory
708            .commit_inventory_transaction(self.inventory, inventory_check)
709            .map_err(|e| e.context("inventory".into()))?;
710
711        Ok(())
712    }
713}
714
715impl Merge for CharacterTransaction {
716    type MergeCheck = (
717        <BodyTransaction as Merge>::MergeCheck,
718        <InventoryTransaction as Merge>::MergeCheck,
719    );
720    type Conflict = CharacterTransactionConflict;
721
722    fn check_merge(&self, other: &Self) -> Result<Self::MergeCheck, Self::Conflict> {
723        use CharacterTransactionConflict as C;
724        if self.set_space.check_merge(&other.set_space).is_err() {
725            return Err(CharacterTransactionConflict::SetSpace);
726        }
727        Ok((
728            self.body.check_merge(&other.body).map_err(C::Body)?,
729            self.inventory.check_merge(&other.inventory).map_err(C::Inventory)?,
730        ))
731    }
732
733    fn commit_merge(&mut self, other: Self, (body_check, inventory_check): Self::MergeCheck) {
734        let Self {
735            set_space,
736            body,
737            inventory,
738        } = self;
739        set_space.commit_merge(other.set_space, ());
740        body.commit_merge(other.body, body_check);
741        inventory.commit_merge(other.inventory, inventory_check);
742    }
743}
744
745/// Transaction precondition error type for a [`CharacterTransaction`].
746#[derive(Clone, Debug, Eq, PartialEq, displaydoc::Display)]
747#[non_exhaustive]
748#[expect(clippy::module_name_repetitions)]
749pub enum CharacterTransactionMismatch {
750    /// in character body
751    Body(<BodyTransaction as Transaction>::Mismatch),
752    /// in character inventory
753    Inventory(<InventoryTransaction as Transaction>::Mismatch),
754}
755
756/// Transaction conflict error type for a [`CharacterTransaction`].
757#[derive(Clone, Debug, Eq, PartialEq, displaydoc::Display)]
758#[non_exhaustive]
759#[expect(clippy::module_name_repetitions)]
760pub enum CharacterTransactionConflict {
761    /// conflict in space to move character into
762    SetSpace,
763    /// conflict in character body
764    Body(physics::BodyConflict),
765    /// conflict in character inventory
766    Inventory(inv::InventoryConflict),
767}
768
769impl core::error::Error for CharacterTransactionMismatch {
770    fn source(&self) -> Option<&(dyn core::error::Error + 'static)> {
771        match self {
772            CharacterTransactionMismatch::Body(e) => Some(e),
773            CharacterTransactionMismatch::Inventory(e) => Some(e),
774        }
775    }
776}
777
778impl core::error::Error for CharacterTransactionConflict {
779    fn source(&self) -> Option<&(dyn core::error::Error + 'static)> {
780        match self {
781            CharacterTransactionConflict::SetSpace => None,
782            CharacterTransactionConflict::Body(_) => None,
783            CharacterTransactionConflict::Inventory(e) => Some(e),
784        }
785    }
786}
787
788// -------------------------------------------------------------------------------------------------
789
790/// Description of a change to a [`Character`] for use in listeners.
791#[derive(Clone, Debug, Eq, Hash, PartialEq)]
792#[expect(clippy::exhaustive_enums)] // any change will probably be breaking anyway
793#[expect(clippy::module_name_repetitions)] // TODO: reconsider together with other Change types
794pub enum CharacterChange {
795    /// Inventory contents.
796    Inventory(inv::InventoryChange),
797    /// Which inventory slots are selected.
798    Selections,
799}