all_is_cubes/space/
space_txn.rs

1use alloc::collections::BTreeMap;
2use alloc::collections::btree_map::Entry::*;
3use alloc::string::ToString;
4use alloc::sync::Arc;
5use alloc::vec::Vec;
6use core::{fmt, mem};
7
8use bevy_ecs::prelude as ecs;
9use hashbrown::HashMap as HbHashMap;
10
11use crate::behavior::{self, BehaviorSetTransaction};
12use crate::block::Block;
13use crate::drawing::DrawingPlane;
14use crate::fluff::Fluff;
15use crate::math::{Cube, GridCoordinate, GridPoint, Gridgid, Vol};
16use crate::space::{
17    self, ActivatableRegion, GridAab, Mutation, PendingEvaluation, SetCubeError, Space,
18};
19use crate::transaction::{
20    CommitError, Equal, ExecuteError, Merge, NoOutput, Transaction, Transactional, no_outputs,
21};
22use crate::universe::{self, ReadTicket};
23use crate::util::{ConciseDebug, Refmt as _};
24
25#[cfg(doc)]
26use crate::behavior::BehaviorSet;
27
28impl Transactional for Space {
29    type Transaction = SpaceTransaction;
30}
31
32/// A [`Transaction`] that modifies a [`Space`].
33#[derive(Clone, Default, Eq, PartialEq)]
34#[must_use]
35pub struct SpaceTransaction {
36    cubes: BTreeMap<[GridCoordinate; 3], CubeTransaction>,
37    behaviors: BehaviorSetTransaction<Space>,
38}
39
40impl SpaceTransaction {
41    /// Allows modifying the part of this transaction which is a [`CubeTransaction`] at the given
42    /// cube, creating it if necessary (as [`CubeTransaction::default()`]).
43    ///
44    /// You can replace the transaction or use [`CubeTransaction::merge_from()`] to merge in
45    /// another transaction.
46    ///
47    /// This is for incremental construction of a complex transaction;
48    /// to create a transaction affecting a single cube, [`CubeTransaction::at()`] will be more
49    /// convenient.
50    pub fn at(&mut self, cube: Cube) -> &mut CubeTransaction {
51        let cube: GridPoint = cube.into();
52        self.cubes.entry(cube.into()).or_default()
53    }
54
55    /// Construct a [`SpaceTransaction`] which modifies a volume by applying a [`CubeTransaction`]
56    /// computed by `function` to each cube.
57    pub fn filling<F>(region: GridAab, mut function: F) -> Self
58    where
59        F: FnMut(Cube) -> CubeTransaction,
60    {
61        // TODO: Try having a compact `Vol<Box<[CubeTransaction]>>` representation for this kind of
62        // transaction with uniformly shaped contents.
63        let mut txn = SpaceTransaction::default();
64        for cube in region.interior_iter() {
65            *txn.at(cube) = function(cube);
66        }
67        txn
68    }
69
70    /// Construct a [`SpaceTransaction`] for a single cube.
71    ///
72    /// If `old` is not [`None`], requires that the existing block is that block or the
73    /// transaction will fail.
74    /// If `new` is not [`None`], replaces the existing block with `new`.
75    ///
76    /// TODO: Consider replacing all uses of this with `CubeTransaction::replacing()`.
77    pub fn set_cube(cube: impl Into<Cube>, old: Option<Block>, new: Option<Block>) -> Self {
78        CubeTransaction::replacing(old, new).at(cube.into())
79    }
80
81    /// Provides an [`DrawTarget`](embedded_graphics::prelude::DrawTarget)
82    /// adapter for 2.5D drawing.
83    ///
84    /// For more information on how to use this, see
85    /// [`all_is_cubes::drawing`](crate::drawing).
86    pub fn draw_target<C>(&mut self, transform: Gridgid) -> DrawingPlane<'_, Self, C> {
87        DrawingPlane::new(self, transform)
88    }
89
90    /// Marks all cube modifications in this transaction as [non-conservative].
91    ///
92    /// This means that two transactions which both place the same block in a given cube
93    /// may be merged, whereas the default state is that they will conflict (on the
94    /// principle that such a merge could cause there to be fewer total occurrences of
95    /// that block than intended).
96    ///
97    /// Also, the transaction will not fail if some of its cubes are outside the bounds of
98    /// the [`Space`].
99    ///
100    /// [non-conservative]: https://en.wikipedia.org/wiki/Conserved_quantity
101    pub fn nonconserved(mut self) -> Self {
102        for (_, cube_txn) in self.cubes.iter_mut() {
103            cube_txn.conserved = false;
104        }
105        self
106    }
107
108    /// Modify the space's [`BehaviorSet`].
109    pub fn behaviors(t: BehaviorSetTransaction<Space>) -> Self {
110        Self {
111            behaviors: t,
112            ..Default::default()
113        }
114    }
115
116    /// Add a behavior to the [`Space`].
117    /// This is a shortcut for creating a [`BehaviorSetTransaction`].
118    pub fn add_behavior<B>(bounds: GridAab, behavior: B) -> Self
119    where
120        B: behavior::Behavior<Space> + 'static,
121    {
122        Self::behaviors(BehaviorSetTransaction::insert(
123            super::SpaceBehaviorAttachment::new(bounds),
124            Arc::new(behavior),
125        ))
126    }
127
128    /// Computes the region of cubes directly affected by this transaction.
129    /// Ignores behaviors.
130    ///
131    /// Returns [`None`] if no cubes are affected.
132    ///
133    /// TODO: Handle the case where the total volume is too large.
134    /// (Maybe `GridAab` should lose that restriction.)
135    pub fn bounds_only_cubes(&self) -> Option<GridAab> {
136        // Destructuring to statically check that we consider all fields.
137        let Self {
138            cubes,
139            behaviors: _,
140        } = self;
141        let mut bounds: Option<GridAab> = None;
142
143        for &cube_array in cubes.keys() {
144            let cube = Cube::from(cube_array);
145            if let Some(bounds) = &mut bounds {
146                *bounds = (*bounds).union_cube(cube);
147            } else {
148                bounds = Some(GridAab::single_cube(cube));
149            }
150        }
151
152        bounds
153    }
154
155    /// Computes the region affected by this transaction.
156    ///
157    /// Returns [`None`] if no specific regions of the space are affected.
158    pub fn bounds(&self) -> Option<GridAab> {
159        // Destructuring to statically check that we consider all fields.
160        let Self {
161            cubes: _,
162            behaviors,
163        } = self;
164        let mut bounds: Option<GridAab> = self.bounds_only_cubes();
165
166        for attachment in behaviors.attachments_affected() {
167            if let Some(bounds) = &mut bounds {
168                *bounds = (*bounds).union_box(attachment.bounds);
169            } else {
170                bounds = Some(attachment.bounds);
171            }
172        }
173
174        bounds
175    }
176
177    /// Shared implementation of transaction traits.
178    fn check_common(
179        &self,
180        read_ticket: ReadTicket<'_>,
181        palette: &space::Palette,
182        contents: Vol<&[space::BlockIndex]>,
183        behaviors: &behavior::BehaviorSet<Space>,
184    ) -> Result<CommitCheck, SpaceTransactionMismatch> {
185        let mut new_block_evaluations: HbHashMap<Block, PendingEvaluation> = HbHashMap::new();
186
187        for (
188            &cube,
189            CubeTransaction {
190                old,
191                new,
192                conserved,
193                activate_behavior: _,
194                fluff: _,
195            },
196        ) in &self.cubes
197        {
198            // TODO: This is a lot of possibly redundant hash lookups.
199            // `SpaceTransaction`s should use their own palettes to aovid this.
200            if let Equal(Some(new_block)) = new
201                && let hashbrown::hash_map::Entry::Vacant(ve) =
202                    new_block_evaluations.entry(new_block.clone())
203            {
204                ve.insert(PendingEvaluation::new(
205                    read_ticket,
206                    palette,
207                    new_block.clone(),
208                ));
209            }
210
211            let cube = Cube::from(cube);
212            if let Some(cube_index) = contents.index(cube) {
213                if let Equal(Some(old)) = old {
214                    // Raw lookup because we already computed the index for a bounds check
215                    // (TODO: Put this in a function, like get_block_index)
216                    if palette.entry(contents.as_linear()[cube_index]).block() != old {
217                        return Err(SpaceTransactionMismatch::Cube(cube));
218                    }
219                }
220            } else {
221                // Out of bounds.
222                if *conserved || old.0.is_some() {
223                    // It is an error for conserved cube txns to be out of bounds,
224                    // or for a precondition to be not meetable because it is out of bounds.
225                    // TODO: Should we allow `old: Some(AIR), new: None`, since we treat
226                    // outside-space as being AIR? Let's wait until a use case appears rather than
227                    // making AIR more special.
228                    return Err(SpaceTransactionMismatch::OutOfBounds {
229                        transaction: cube.grid_aab(),
230                        space: contents.bounds(),
231                    });
232                }
233            }
234        }
235        Ok(CommitCheck {
236            behaviors: self
237                .behaviors
238                .check(behaviors, ())
239                .map_err(SpaceTransactionMismatch::Behaviors)?,
240            new_block_evaluations,
241        })
242    }
243
244    fn commit_common(
245        self,
246        m: &mut Mutation<'_, '_>,
247        mut check: CommitCheck,
248    ) -> Result<(), CommitError> {
249        let mut to_activate = Vec::new();
250
251        for (
252            cube,
253            CubeTransaction {
254                old: _,
255                new,
256                conserved,
257                activate_behavior: activate,
258                fluff,
259            },
260        ) in self.cubes
261        {
262            let cube = Cube::from(cube);
263
264            if let Equal(Some(new)) = new {
265                match Space::set_impl(m, cube, &new, check.new_block_evaluations.remove(&new)) {
266                    Ok(_) => Ok(()),
267                    Err(SetCubeError::OutOfBounds { .. }) if !conserved => {
268                        // ignore
269                        Ok(())
270                    }
271                    Err(other) => Err(CommitError::catch::<Self, _>(other)),
272                }?;
273            }
274
275            if activate {
276                // Deferred for slightly more consistency
277                to_activate.push(cube);
278            }
279
280            #[expect(
281                clippy::shadow_unrelated,
282                reason = "https://github.com/rust-lang/rust-clippy/issues/11827"
283            )]
284            for fluff in fluff.iter().cloned() {
285                m.fluff_buffer.push(super::SpaceFluff {
286                    position: cube,
287                    fluff,
288                });
289            }
290        }
291
292        self.behaviors
293            .commit(m.behaviors, check.behaviors, &mut no_outputs)
294            .map_err(|e| e.context("behaviors".into()))?;
295
296        if !to_activate.is_empty() {
297            'b: for query_item in m.behaviors.query::<ActivatableRegion>() {
298                // TODO: error return from the function? error report for nonexistence?
299                for cube in to_activate.iter().copied() {
300                    // TODO: this should be part of the query instead, to allow efficient search
301                    if query_item.attachment.bounds.contains_cube(cube) {
302                        query_item.behavior.activate();
303                        continue 'b;
304                    }
305                }
306            }
307        }
308
309        Ok(())
310    }
311
312    /// As [`Transaction::execute()`], but taking a [`Mutation`] instead of a [`Space`].
313    // TODO: better name
314    pub fn execute_m(self, target: &mut Mutation<'_, '_>) -> Result<(), ExecuteError<Self>> {
315        let check = self
316            .check_common(
317                target.read_ticket,
318                target.palette,
319                target.contents.as_ref(),
320                target.behaviors,
321            )
322            .map_err(ExecuteError::Check)?;
323        self.commit_common(target, check).map_err(ExecuteError::Commit)
324    }
325}
326
327#[doc(hidden)] // would be impl Trait if we could
328#[derive(Debug)]
329pub struct CommitCheck {
330    behaviors: <BehaviorSetTransaction<Space> as Transaction>::CommitCheck,
331    new_block_evaluations: hashbrown::HashMap<Block, PendingEvaluation>,
332}
333
334impl Transaction for SpaceTransaction {
335    type Target = Space;
336    type Context<'a> = ReadTicket<'a>;
337    type CommitCheck = CommitCheck;
338    type Output = NoOutput;
339    type Mismatch = SpaceTransactionMismatch;
340
341    fn check(
342        &self,
343        space: &Space,
344        read_ticket: Self::Context<'_>,
345    ) -> Result<Self::CommitCheck, Self::Mismatch> {
346        self.check_common(
347            read_ticket,
348            &space.palette,
349            space.contents.as_ref(),
350            &space.behaviors,
351        )
352    }
353
354    fn commit(
355        self,
356        space: &mut Space,
357        check: Self::CommitCheck,
358        _outputs: &mut dyn FnMut(Self::Output),
359    ) -> Result<(), CommitError> {
360        // Passing a stub read ticket here is OK because all of our block evaluations will have been
361        // performed by check(). TODO: Can we make that statically checked?
362        space.mutate(ReadTicket::stub(), |m| self.commit_common(m, check))
363    }
364}
365
366impl universe::TransactionOnEcs for SpaceTransaction {
367    type WriteQueryData = (
368        &'static mut space::Palette,
369        &'static mut space::Contents,
370        &'static mut space::LightStorage,
371        &'static mut space::BehaviorSet<Space>,
372        &'static mut space::DefaultSpawn,
373        &'static space::Notifiers,
374        &'static mut space::Ticks,
375    );
376
377    fn check(
378        &self,
379        target: space::Read<'_>,
380        read_ticket: ReadTicket<'_>,
381    ) -> Result<Self::CommitCheck, Self::Mismatch> {
382        self.check_common(
383            read_ticket,
384            target.palette,
385            target.contents,
386            target.behaviors,
387        )
388    }
389
390    fn commit(
391        self,
392        target: (
393            ecs::Mut<'_, space::Palette>,
394            ecs::Mut<'_, space::Contents>,
395            ecs::Mut<'_, space::LightStorage>,
396            ecs::Mut<'_, space::BehaviorSet<Space>>,
397            ecs::Mut<'_, space::DefaultSpawn>,
398            &space::Notifiers,
399            ecs::Mut<'_, space::Ticks>,
400        ),
401        check: Self::CommitCheck,
402    ) -> Result<(), CommitError> {
403        // Passing a stub read ticket here is OK because all of our block evaluations will have been
404        // performed by check(). TODO: Can we make that statically checked?
405        Mutation::with_write_query(ReadTicket::stub(), target, |m| self.commit_common(m, check))
406    }
407}
408impl Merge for SpaceTransaction {
409    type MergeCheck = <BehaviorSetTransaction<Space> as Merge>::MergeCheck;
410    type Conflict = SpaceTransactionConflict;
411
412    fn check_merge(&self, other: &Self) -> Result<Self::MergeCheck, Self::Conflict> {
413        let mut cubes1 = &self.cubes;
414        let mut cubes2 = &other.cubes;
415        if cubes1.len() > cubes2.len() {
416            // The cost of the check is the cost of iterating over keys, so iterate over
417            // the smaller map rather than the larger.
418            // TODO: We can improve further by taking advantage of sortedness, using the
419            // first and last of one set to iterate over a range of the other.
420            // alloc::collections::btree_set::Intersection implements something like this,
421            // but unfortunately, does not have an analogue for BTreeMap.
422            mem::swap(&mut cubes1, &mut cubes2);
423        }
424        for (&cube, t1) in cubes1 {
425            if let Some(t2) = cubes2.get(&cube) {
426                let CubeMergeCheck {} =
427                    t1.check_merge(t2).map_err(|conflict| SpaceTransactionConflict::Cube {
428                        cube: cube.into(),
429                        conflict,
430                    })?;
431            }
432        }
433        self.behaviors
434            .check_merge(&other.behaviors)
435            .map_err(SpaceTransactionConflict::Behaviors)
436    }
437
438    fn commit_merge(&mut self, mut other: Self, check: Self::MergeCheck) {
439        let Self { cubes, behaviors } = self;
440
441        if other.cubes.len() > cubes.len() {
442            // Whichever cube set is shorter, iterate that one
443            mem::swap(cubes, &mut other.cubes);
444        }
445        for (cube, t2) in other.cubes {
446            match cubes.entry(cube) {
447                Occupied(mut entry) => {
448                    entry.get_mut().commit_merge(t2, CubeMergeCheck {});
449                }
450                Vacant(entry) => {
451                    entry.insert(t2);
452                }
453            }
454        }
455
456        behaviors.commit_merge(other.behaviors, check);
457    }
458}
459
460impl fmt::Debug for SpaceTransaction {
461    fn fmt(&self, fmt: &mut fmt::Formatter<'_>) -> fmt::Result {
462        let Self { cubes, behaviors } = self;
463        let mut ds = fmt.debug_struct("SpaceTransaction");
464        for (cube, txn) in cubes {
465            ds.field(&Cube::from(*cube).refmt(&ConciseDebug).to_string(), txn);
466        }
467        if !behaviors.is_empty() {
468            ds.field("behaviors", &behaviors);
469        }
470        ds.finish()
471    }
472}
473
474impl universe::VisitHandles for SpaceTransaction {
475    fn visit_handles(&self, visitor: &mut dyn universe::HandleVisitor) {
476        let Self { cubes, behaviors } = self;
477        for cube_txn in cubes.values() {
478            cube_txn.visit_handles(visitor);
479        }
480        behaviors.visit_handles(visitor);
481    }
482}
483
484/// Transaction precondition error type for a [`SpaceTransaction`].
485#[derive(Clone, Debug, Eq, PartialEq)]
486#[non_exhaustive]
487pub enum SpaceTransactionMismatch {
488    #[allow(missing_docs)]
489    Cube(Cube),
490
491    /// The transaction tried to modify something outside of the space bounds.
492    OutOfBounds {
493        /// Bounds within which the transaction attempted to make a change.
494        /// (This is not necessarily equal to [`SpaceTransaction::bounds()`])
495        transaction: GridAab,
496
497        /// Bounds of the space.
498        space: GridAab,
499    },
500
501    #[allow(missing_docs)]
502    Behaviors(behavior::BehaviorTransactionMismatch),
503}
504
505/// Transaction conflict error type for a [`SpaceTransaction`].
506#[derive(Clone, Debug, Eq, PartialEq)]
507#[non_exhaustive]
508pub enum SpaceTransactionConflict {
509    #[allow(missing_docs)]
510    Cube {
511        cube: Cube, // TODO: GridAab instead?
512        conflict: CubeConflict,
513    },
514    #[allow(missing_docs)]
515    Behaviors(behavior::BehaviorTransactionConflict),
516}
517
518impl core::error::Error for SpaceTransactionMismatch {
519    fn source(&self) -> Option<&(dyn core::error::Error + 'static)> {
520        match self {
521            SpaceTransactionMismatch::Cube(_) => None,
522            SpaceTransactionMismatch::OutOfBounds { .. } => None,
523            SpaceTransactionMismatch::Behaviors(mismatch) => Some(mismatch),
524        }
525    }
526}
527impl core::error::Error for SpaceTransactionConflict {
528    fn source(&self) -> Option<&(dyn core::error::Error + 'static)> {
529        match self {
530            SpaceTransactionConflict::Cube { conflict, .. } => Some(conflict),
531            SpaceTransactionConflict::Behaviors(conflict) => Some(conflict),
532        }
533    }
534}
535
536impl fmt::Display for SpaceTransactionMismatch {
537    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
538        match self {
539            SpaceTransactionMismatch::Cube(cube) => {
540                write!(f, "mismatch at cube {c}", c = cube.refmt(&ConciseDebug))
541            }
542
543            SpaceTransactionMismatch::OutOfBounds { transaction, space } => {
544                // TODO: don't use Debug formatting here — we'll need to decide what Display formatting for an AAB is
545                write!(
546                    f,
547                    "transaction bounds {transaction:?} exceed space bounds {space:?}"
548                )
549            }
550            SpaceTransactionMismatch::Behaviors(_) => write!(f, "in behaviors"),
551        }
552    }
553}
554impl fmt::Display for SpaceTransactionConflict {
555    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
556        match self {
557            SpaceTransactionConflict::Cube { cube, conflict: _ } => {
558                write!(f, "conflict at cube {c}", c = cube.refmt(&ConciseDebug))
559            }
560            SpaceTransactionConflict::Behaviors(_) => write!(f, "conflict in behaviors"),
561        }
562    }
563}
564
565/// A modification to the contents of single cube of a [`Space`].
566///
567/// To make use of this, insert it into a [`SpaceTransaction`] to specify _which_ cube is
568/// modified. This type does not function directly as a [`Transaction`] (though it does
569/// implement [`Merge`]).
570#[derive(Clone, Default, Eq, PartialEq)]
571pub struct CubeTransaction {
572    /// Previous block which must occupy this cube.
573    /// If `None`, no precondition.
574    old: Equal<Block>,
575
576    /// Block to be put in this cube.
577    /// If `None`, this is only a precondition for modifying another block.
578    new: Equal<Block>,
579
580    /// If true, two transactions with the same `new` block may not be merged.
581    conserved: bool,
582
583    /// The cube was “activated” (clicked on, more or less) and behaviors attached to
584    /// that region of space should respond to that.
585    activate_behavior: bool,
586
587    /// [`Fluff`] to emit at this location when the transaction is committed.
588    ///
589    /// TODO: eventually will need rotation and possibly intra-cube positioning.
590    ///
591    /// TODO: define a merge ordering. should this be a multi-BTreeSet?
592    ///
593    /// TODO: Allow having a single entry with no allocation?
594    fluff: Vec<Fluff>,
595}
596
597impl fmt::Debug for CubeTransaction {
598    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
599        let Self {
600            old: Equal(old),
601            new: Equal(new),
602            conserved,
603            activate_behavior,
604            fluff,
605        } = self;
606        let mut ds = f.debug_struct("CubeTransaction");
607        if old.is_some() || new.is_some() {
608            ds.field("old", &old);
609            ds.field("new", &new);
610            ds.field("conserved", &conserved);
611        }
612        if *activate_behavior {
613            ds.field("activate_behavior", &activate_behavior);
614        }
615        if !fluff.is_empty() {
616            ds.field("fluff", &fluff);
617        }
618        ds.finish()
619    }
620}
621
622impl CubeTransaction {
623    /// Creates a [`SpaceTransaction`] that applies `self` to the given cube of the space.
624    pub fn at(self, cube: Cube) -> SpaceTransaction {
625        SpaceTransaction {
626            cubes: BTreeMap::from([(<[i32; 3]>::from(cube), self)]),
627            ..Default::default()
628        }
629    }
630
631    pub(crate) const ACTIVATE_BEHAVIOR: Self = Self {
632        old: Equal(None),
633        new: Equal(None),
634        conserved: false,
635        activate_behavior: true,
636        fluff: Vec::new(),
637    };
638
639    /// Construct a [`CubeTransaction`] that may check and may replace the block in the cube.
640    ///
641    /// If `old` is not [`None`], requires that the existing block is that block or the
642    /// transaction will fail.
643    /// If `new` is not [`None`], replaces the existing block with `new`.
644    pub fn replacing(old: Option<Block>, new: Option<Block>) -> Self {
645        CubeTransaction {
646            old: Equal(old),
647            new: Equal(new),
648            conserved: true,
649            ..Default::default()
650        }
651    }
652
653    /// Sets the block to be placed at this cube, replacing any existing modification instruction
654    /// This does not affect a precondition on the existing block, or the conservative option.
655    ///
656    /// This is thus comparable to the effect of a direct [`Mutation::set()`] after the rest of the
657    /// transaction.
658    //---
659    // TODO: no tests
660    pub fn overwrite(&mut self, block: Block) {
661        self.new = Equal(Some(block));
662    }
663
664    #[doc(hidden)] // TODO: good public API?
665    pub fn new_mut(&mut self) -> Option<&mut Block> {
666        self.new.0.as_mut()
667    }
668
669    /// Emit [`Fluff`] (sound/particle effects) at this cube when the transaction is committed.
670    pub fn fluff(fluff: Fluff) -> Self {
671        let mut this = Self::default();
672        this.add_fluff(fluff);
673        this
674    }
675
676    /// Emit [`Fluff`] (sound/particle effects) at this cube when the transaction is committed,
677    /// in addition to its other effects.
678    pub fn add_fluff(&mut self, fluff: Fluff) {
679        self.fluff.push(fluff)
680    }
681}
682
683impl Merge for CubeTransaction {
684    type MergeCheck = CubeMergeCheck;
685    type Conflict = CubeConflict;
686
687    fn check_merge(&self, other: &Self) -> Result<Self::MergeCheck, Self::Conflict> {
688        let conflict = CubeConflict {
689            // Incompatible preconditions will always fail.
690            old: self.old.check_merge(&other.old).is_err(),
691            new: if self.conserved {
692                // Replacing the same cube twice is not allowed -- even if they're
693                // equal, doing so could violate an intended conservation law.
694                self.new.0.is_some() && other.new.0.is_some()
695            } else {
696                // If nonconservative, then we simply require equal outcomes.
697                self.new.check_merge(&other.new).is_err()
698            },
699        };
700
701        if (conflict
702            != CubeConflict {
703                old: false,
704                new: false,
705            })
706        {
707            Err(conflict)
708        } else {
709            Ok(CubeMergeCheck {})
710        }
711    }
712
713    fn commit_merge(&mut self, other: Self, CubeMergeCheck {}: Self::MergeCheck) {
714        let Self {
715            old,
716            new,
717            conserved,
718            activate_behavior,
719            fluff,
720        } = self;
721
722        // This would be more elegant if `conserved` was within the `self.new` Option.
723        *conserved = (*conserved && new.0.is_some()) || (other.conserved && other.new.0.is_some());
724
725        old.commit_merge(other.old, ());
726        new.commit_merge(other.new, ());
727
728        *activate_behavior |= other.activate_behavior;
729
730        fluff.extend(other.fluff);
731    }
732}
733
734impl universe::VisitHandles for CubeTransaction {
735    fn visit_handles(&self, visitor: &mut dyn universe::HandleVisitor) {
736        let Self {
737            old,
738            new,
739            conserved: _,
740            activate_behavior: _,
741            fluff,
742        } = self;
743
744        old.visit_handles(visitor);
745        new.visit_handles(visitor);
746        fluff.visit_handles(visitor);
747    }
748}
749
750#[doc(hidden)]
751#[derive(Debug)]
752#[non_exhaustive]
753pub struct CubeMergeCheck {
754    // This might end up having some data later.
755    // For now, it's a placeholder to avoid passing () around
756    // and getting clippy::let_unit_value warnings
757}
758
759/// Transaction conflict error type for a single [`CubeTransaction`] within a
760/// [`SpaceTransaction`].
761#[derive(Clone, Copy, Debug, Eq, PartialEq)]
762#[non_exhaustive]
763pub struct CubeConflict {
764    /// The transactions have conflicting preconditions (`old` blocks).
765    pub(crate) old: bool,
766    /// The transactions are attempting to modify the same cube.
767    pub(crate) new: bool,
768}
769
770impl core::error::Error for CubeConflict {}
771
772impl fmt::Display for CubeConflict {
773    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
774        match *self {
775            CubeConflict {
776                old: true,
777                new: false,
778            } => write!(f, "different preconditions"),
779            CubeConflict {
780                old: false,
781                new: true,
782            } => write!(f, "cannot write the same cube twice"),
783            CubeConflict {
784                old: true,
785                new: true,
786            } => write!(f, "different preconditions (with write)"),
787            CubeConflict {
788                old: false,
789                new: false,
790            } => unreachable!(),
791        }
792    }
793}
794
795#[cfg(test)]
796mod tests {
797    use super::*;
798    use crate::behavior::NoopBehavior;
799    use crate::block::{self, AIR};
800    use crate::content::make_some_blocks;
801    use crate::inv::EphemeralOpaque;
802    use crate::transaction::TransactionTester;
803    use core::sync::atomic::{AtomicU32, Ordering};
804    use pretty_assertions::assert_eq;
805
806    #[test]
807    fn set_out_of_bounds_conserved_fails() {
808        let [block] = make_some_blocks();
809        // Note: by using .check() we validate that it doesn't fail in the commit phase
810        SpaceTransaction::set_cube([1, 0, 0], None, Some(block))
811            .check(&Space::empty_positive(1, 1, 1), ReadTicket::stub())
812            .unwrap_err();
813    }
814
815    #[test]
816    fn set_out_of_bounds_nonconserved_succeeds() {
817        let [block] = make_some_blocks();
818        SpaceTransaction::set_cube([1, 0, 0], None, Some(block))
819            .nonconserved()
820            .execute(
821                &mut Space::empty_positive(1, 1, 1),
822                ReadTicket::stub(),
823                &mut no_outputs,
824            )
825            .unwrap();
826    }
827
828    #[test]
829    fn compare_out_of_bounds_conserved_fails() {
830        let [block] = make_some_blocks();
831        SpaceTransaction::set_cube([1, 0, 0], Some(block), None)
832            .check(&Space::empty_positive(1, 1, 1), ReadTicket::stub())
833            .unwrap_err();
834    }
835
836    #[test]
837    fn compare_out_of_bounds_nonconserved_fails() {
838        let [block] = make_some_blocks();
839        SpaceTransaction::set_cube([1, 0, 0], Some(block), None)
840            .nonconserved()
841            .check(&Space::empty_positive(1, 1, 1), ReadTicket::stub())
842            .unwrap_err();
843    }
844
845    /// Test that when a block is modified during the check phase of the transaction and the
846    /// commit phase — as might happen if the transaction also modifies a block definition —
847    /// the space knows that it must reevaluate the block, even though it was evaluated during
848    /// the check phase.
849    ///
850    /// Also test the outcome when there is no such change.
851    #[rstest::rstest]
852    fn block_changed_between_check_and_commit(#[values(false, true)] actually_change: bool) {
853        let mut u = universe::Universe::new();
854        let [initial_block_value, final_block_value] = make_some_blocks();
855        let block_handle = u.insert_anonymous(block::BlockDef::new(
856            u.read_ticket(),
857            initial_block_value.clone(),
858        ));
859        let indirect_block = Block::from(block_handle.clone());
860        let mut space = Space::builder(GridAab::ORIGIN_CUBE).build();
861
862        let space_txn =
863            SpaceTransaction::set_cube([0, 0, 0], Some(AIR), Some(indirect_block.clone()));
864        let check = space_txn.check(&space, u.read_ticket()).unwrap();
865        if actually_change {
866            u.execute_1(
867                &block_handle,
868                block::BlockDefTransaction::overwrite(final_block_value.clone()),
869            )
870            .unwrap();
871        }
872        space_txn.commit(&mut space, check, &mut no_outputs).unwrap();
873
874        assert_eq!(
875            space.get_evaluated([0, 0, 0]).color(),
876            initial_block_value.color(),
877            "evaluation should always be from check time, regardless of change"
878        );
879        assert_eq!(
880            space.palette.block_is_to_be_reevaluated(&indirect_block),
881            actually_change,
882            "space todo ≠ actual change"
883        );
884    }
885
886    #[test]
887    fn merge_allows_independent() {
888        let [b1, b2, b3] = make_some_blocks();
889        let t1 = SpaceTransaction::set_cube([0, 0, 0], Some(b1.clone()), Some(b2.clone()));
890        let t2 = SpaceTransaction::set_cube([1, 0, 0], Some(b1.clone()), Some(b3.clone()));
891        let t3 = t1.clone().merge(t2.clone()).unwrap();
892        assert_eq!(
893            t3.cubes.into_iter().collect::<Vec<_>>(),
894            vec![
895                (
896                    [0, 0, 0],
897                    CubeTransaction {
898                        old: Equal(Some(b1.clone())),
899                        new: Equal(Some(b2.clone())),
900                        conserved: true,
901                        activate_behavior: false,
902                        fluff: vec![],
903                    }
904                ),
905                (
906                    [1, 0, 0],
907                    CubeTransaction {
908                        old: Equal(Some(b1.clone())),
909                        new: Equal(Some(b3.clone())),
910                        conserved: true,
911                        activate_behavior: false,
912                        fluff: vec![],
913                    }
914                ),
915            ]
916        );
917    }
918
919    #[test]
920    fn merge_rejects_same_new_conserved() {
921        let [block] = make_some_blocks();
922        let t1 = SpaceTransaction::set_cube([0, 0, 0], None, Some(block.clone()));
923        let t2 = SpaceTransaction::set_cube([0, 0, 0], None, Some(block.clone()));
924        t1.merge(t2).unwrap_err();
925    }
926
927    #[test]
928    fn merge_allows_same_new_nonconserved() {
929        let [old, new] = make_some_blocks();
930        let t1 = SpaceTransaction::set_cube([0, 0, 0], Some(old), Some(new.clone())).nonconserved();
931        let t2 = SpaceTransaction::set_cube([0, 0, 0], None, Some(new.clone())).nonconserved();
932        assert_eq!(t1.clone().merge(t2).unwrap(), t1);
933    }
934
935    #[test]
936    fn merge_rejects_different_new_conserved() {
937        let [b1, b2] = make_some_blocks();
938        let t1 = SpaceTransaction::set_cube([0, 0, 0], None, Some(b1.clone()));
939        let t2 = SpaceTransaction::set_cube([0, 0, 0], None, Some(b2.clone()));
940        t1.merge(t2).unwrap_err();
941    }
942
943    #[test]
944    fn merge_rejects_different_new_nonconserved() {
945        let [b1, b2] = make_some_blocks();
946        let t1 = SpaceTransaction::set_cube([0, 0, 0], None, Some(b1.clone())).nonconserved();
947        let t2 = SpaceTransaction::set_cube([0, 0, 0], None, Some(b2.clone())).nonconserved();
948        t1.merge(t2).unwrap_err();
949    }
950
951    #[test]
952    fn merge_rejects_different_old() {
953        let [b1, b2] = make_some_blocks();
954        let t1 = SpaceTransaction::set_cube([0, 0, 0], Some(b1.clone()), None);
955        let t2 = SpaceTransaction::set_cube([0, 0, 0], Some(b2.clone()), None);
956        t1.merge(t2).unwrap_err();
957    }
958
959    #[test]
960    fn merge_allows_same_old() {
961        let [b1, b2] = make_some_blocks();
962        let t1 = SpaceTransaction::set_cube([0, 0, 0], Some(b1.clone()), Some(b2.clone()));
963        let t2 = SpaceTransaction::set_cube([0, 0, 0], Some(b1.clone()), None);
964        assert_eq!(t1.clone(), t1.clone().merge(t2).unwrap());
965    }
966
967    #[test]
968    fn activate() {
969        let mut space = Space::empty_positive(1, 1, 1);
970        let cube = Cube::new(0, 0, 0);
971
972        let signal = Arc::new(AtomicU32::new(0));
973        SpaceTransaction::add_behavior(
974            GridAab::single_cube(cube),
975            ActivatableRegion {
976                // TODO: This sure is clunky
977                effect: EphemeralOpaque::new(Arc::new({
978                    let signal = signal.clone();
979                    move || {
980                        signal.fetch_add(1, Ordering::Relaxed);
981                    }
982                })),
983            },
984        )
985        .execute(&mut space, ReadTicket::stub(), &mut no_outputs)
986        .unwrap();
987
988        CubeTransaction::ACTIVATE_BEHAVIOR
989            .at(cube)
990            .execute(&mut space, ReadTicket::stub(), &mut drop)
991            .unwrap();
992        assert_eq!(signal.load(Ordering::Relaxed), 1);
993    }
994
995    #[test]
996    fn systematic() {
997        let [b1, b2, b3] = make_some_blocks();
998        TransactionTester::new()
999            .transaction(SpaceTransaction::default(), |_, _| Ok(()))
1000            .transaction(
1001                SpaceTransaction::set_cube([0, 0, 0], Some(b1.clone()), Some(b2.clone())),
1002                |_, after| {
1003                    if after[[0, 0, 0]] != b2 {
1004                        return Err("did not set b2".into());
1005                    }
1006                    Ok(())
1007                },
1008            )
1009            .transaction(
1010                SpaceTransaction::set_cube([0, 0, 0], Some(b1.clone()), Some(b3.clone())),
1011                |_, after| {
1012                    if after[[0, 0, 0]] != b3 {
1013                        return Err("did not set b3".into());
1014                    }
1015                    Ok(())
1016                },
1017            )
1018            .transaction(
1019                SpaceTransaction::set_cube([0, 0, 0], None, Some(b2.clone())),
1020                |_, after| {
1021                    if after[[0, 0, 0]] != b2 {
1022                        return Err("did not set b2".into());
1023                    }
1024                    Ok(())
1025                },
1026            )
1027            .transaction(
1028                SpaceTransaction::set_cube([0, 0, 0], Some(b2.clone()), None),
1029                |_, _| Ok(()),
1030            )
1031            .transaction(
1032                SpaceTransaction::set_cube([0, 0, 0], Some(b1.clone()), None),
1033                |_, _| Ok(()),
1034            )
1035            .transaction(
1036                CubeTransaction::ACTIVATE_BEHAVIOR.at(Cube::new(0, 0, 0)),
1037                // TODO: Add a test that activation happened once that's possible
1038                |_, _| Ok(()),
1039            )
1040            .transaction(
1041                CubeTransaction::ACTIVATE_BEHAVIOR.at(Cube::new(1, 0, 0)),
1042                // TODO: Add a test that activation happened once that's possible
1043                |_, _| Ok(()),
1044            )
1045            .target(|| Space::empty_positive(2, 1, 1))
1046            .target(|| {
1047                Space::builder(GridAab::from_lower_size([0, 0, 0], [2, 1, 1]))
1048                    .build_and_mutate(|m| {
1049                        m.set([0, 0, 0], &b1)?;
1050                        Ok(())
1051                    })
1052                    .unwrap()
1053            })
1054            .target(|| {
1055                Space::builder(GridAab::from_lower_size([0, 0, 0], [2, 1, 1]))
1056                    .build_and_mutate(|m| {
1057                        m.set([0, 0, 0], &b2)?;
1058                        Ok(())
1059                    })
1060                    .unwrap()
1061            })
1062            .target(|| {
1063                // This space makes the test transactions at [0, 0, 0] out of bounds
1064                Space::empty(GridAab::from_lower_size([1, 0, 0], [1, 1, 1]))
1065            })
1066            // TODO: more spaces
1067            .test(ReadTicket::stub());
1068    }
1069
1070    #[test]
1071    fn bounds_empty() {
1072        assert_eq!(SpaceTransaction::default().bounds(), None);
1073    }
1074
1075    #[test]
1076    fn bounds_single_cube() {
1077        assert_eq!(
1078            SpaceTransaction::set_cube([-7, 3, 5], None, Some(AIR)).bounds(),
1079            Some(GridAab::single_cube(Cube::new(-7, 3, 5)))
1080        );
1081    }
1082
1083    #[test]
1084    fn bounds_multi_cube() {
1085        let t1 = SpaceTransaction::set_cube([-7, 3, 5], None, Some(AIR));
1086        let t2 = SpaceTransaction::set_cube([10, 3, 5], None, Some(AIR));
1087        assert_eq!(
1088            t1.merge(t2).unwrap().bounds(),
1089            Some(GridAab::from_lower_upper([-7, 3, 5], [11, 4, 6]))
1090        );
1091    }
1092
1093    #[test]
1094    fn bounds_behavior() {
1095        let bounds = GridAab::from_lower_size([1, 2, 3], [4, 5, 6]);
1096        let txn = SpaceTransaction::add_behavior(bounds, NoopBehavior(1));
1097        assert_eq!(txn.bounds(), Some(bounds));
1098    }
1099}