essences/
artifacts.rs

1//! Artifacts — the container a player wears, its six sockets, and the stones
2//! that sit in them.
3//!
4//! This is the data half of the feature (plan part A). Nothing here fires
5//! anything: the ownership stat bonus, the World Law and the Aspect rules read
6//! exactly these types plus [`configs::artifacts`], and live in
7//! `overlord_event_system`.
8//!
9//! An artifact is its own collection: it is never an [`crate::items::Item`],
10//! never occupies a gear slot, and never rolls attributes.
11//!
12//! Four facts shape everything below:
13//!
14//! * **One instance per catalog entry, plus a stock of raw copies.** Like
15//!   Trigger/Effect Stones, an artifact is identified by its template. A
16//!   duplicate drop banks a copy, including after the artifact reaches its
17//!   maximum level. An explicit upgrade spends the configured rung; grants
18//!   never change a level by themselves.
19//! * **Exactly one artifact is worn; every owned artifact pays.** The ownership
20//!   stat bonus sums over the whole collection ([`ArtifactCollection::artifacts`]),
21//!   while the World Law is read from [`ArtifactCollection::equipped`] only.
22//! * **The sockets belong to the player, not to the artifact.** The six
23//!   [`ArtifactSocketSlot`]s and the stones in them live on the collection, so
24//!   "stones transfer when the artifact is swapped" is true *by construction*:
25//!   swapping only rewrites [`ArtifactCollection::equipped`] and cannot touch a
26//!   stone.
27//! * **Sockets are typed and non-interchangeable.** Which socket a stone fits is
28//!   a property of its catalog entry, so a Law stone can never enter an Aspect
29//!   socket and vice versa. The right-hand (Law) column exists structurally here
30//!   even though its catalog ships empty — its stones need the cores/laws
31//!   vertical, which is a separate branch.
32
33use crate::cores::LawTemplateId;
34use crate::prelude::*;
35
36use strum::IntoEnumIterator;
37use strum_macros::{Display, EnumIter, EnumString};
38
39#[declare]
40pub type ArtifactTemplateId = Uuid;
41
42#[declare]
43pub type ArtifactStoneTemplateId = Uuid;
44
45/// Which half of the artifact a socket belongs to.
46///
47/// The two columns never overlap: `Aspect` sockets retune the Trigger/Effect
48/// stones worn in gear, `Law` sockets retune the laws and bridges worn in cores.
49/// This is a rule, not a coincidence — a stone declares one socket and fits
50/// nothing else.
51#[derive(
52    Clone,
53    Copy,
54    Debug,
55    Default,
56    Serialize,
57    Deserialize,
58    PartialEq,
59    Eq,
60    Hash,
61    JsonSchema,
62    Tsify,
63    Display,
64    EnumString,
65    EnumIter,
66)]
67#[tsify(from_wasm_abi, into_wasm_abi)]
68pub enum ArtifactSocketColumn {
69    /// Left column — acts on gear Effects.
70    #[default]
71    Aspect,
72    /// Right column — acts on Core Laws. Structural in this branch: its stones
73    /// need the cores/laws vertical, so its catalog ships empty and its sockets
74    /// are scheduled past the end of the campaign.
75    Law,
76}
77
78/// One of the artifact's six sockets.
79///
80/// Order is fixed and load-bearing: the whole left column opens before the right
81/// one, because a Law stone has nothing to retune until the player owns cores.
82/// [`ArtifactSocketSlot::unlock_order`] is that order, and
83/// `GameConfig::validate_artifacts` holds the unlock schedule to it.
84///
85/// Variant docs live here rather than on the variants themselves: a documented
86/// variant makes the admin schema generate a `oneOf` of one-value enums instead
87/// of a plain picker.
88///
89/// * `VisibleAspect` — how active gear effects behave.
90/// * `HiddenAspect` — what hidden gear effects do.
91/// * `FlipAspect` — what happens on the flip itself.
92/// * `VisibleLaw` — how active core laws behave.
93/// * `HiddenLaw` — what hidden core laws do.
94/// * `BridgeLaw` — how bridges work.
95#[derive(
96    Clone,
97    Copy,
98    Debug,
99    Default,
100    Serialize,
101    Deserialize,
102    PartialEq,
103    Eq,
104    Hash,
105    JsonSchema,
106    Tsify,
107    Display,
108    EnumString,
109    EnumIter,
110)]
111#[tsify(from_wasm_abi, into_wasm_abi)]
112pub enum ArtifactSocketSlot {
113    #[default]
114    VisibleAspect,
115    HiddenAspect,
116    FlipAspect,
117    VisibleLaw,
118    HiddenLaw,
119    BridgeLaw,
120}
121
122impl ArtifactSocketSlot {
123    /// The column this socket belongs to. A stone of one column can never enter
124    /// a socket of the other.
125    pub const fn column(self) -> ArtifactSocketColumn {
126        match self {
127            Self::VisibleAspect | Self::HiddenAspect | Self::FlipAspect => {
128                ArtifactSocketColumn::Aspect
129            }
130            Self::VisibleLaw | Self::HiddenLaw | Self::BridgeLaw => ArtifactSocketColumn::Law,
131        }
132    }
133
134    /// Position in the fixed opening order — left column first, then right.
135    /// Config may space the chapters however it likes, but never out of this
136    /// order.
137    pub const fn unlock_order(self) -> u8 {
138        match self {
139            Self::VisibleAspect => 0,
140            Self::HiddenAspect => 1,
141            Self::FlipAspect => 2,
142            Self::VisibleLaw => 3,
143            Self::HiddenLaw => 4,
144            Self::BridgeLaw => 5,
145        }
146    }
147
148    /// All six sockets, in opening order.
149    pub fn all() -> Vec<Self> {
150        let mut slots: Vec<Self> = Self::iter().collect();
151        slots.sort_by_key(|slot| slot.unlock_order());
152        slots
153    }
154}
155
156/// The player's one instance of an artifact template, plus the copies banked
157/// against it.
158///
159/// `level` is 1..=`max_artifact_level`. Copies are banked by
160/// [`ArtifactCollection::grant_artifact`] and spent only by an explicit
161/// [`ArtifactCollection::upgrade_artifact`] call.
162#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, JsonSchema, Tsify)]
163#[tsify(from_wasm_abi, into_wasm_abi)]
164pub struct Artifact {
165    pub template_id: ArtifactTemplateId,
166    pub level: i64,
167    /// Raw copies banked for explicit upgrades. Copies continue accumulating at
168    /// the cap so duplicate grants are never silently discarded.
169    pub copies: i64,
170}
171
172impl Artifact {
173    pub const fn new(template_id: ArtifactTemplateId) -> Self {
174        Self {
175            template_id,
176            level: 1,
177            copies: 0,
178        }
179    }
180}
181
182/// One artifact stone the player owns, and the socket it currently sits in.
183///
184/// The socket lives on the stone, exactly like [`crate::stones::Stone`], so a
185/// socketed stone never leaves the collection and cannot be lost by any
186/// operation — including swapping the worn artifact, which does not read this
187/// list at all.
188#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, JsonSchema, Tsify)]
189#[tsify(from_wasm_abi, into_wasm_abi)]
190pub struct ArtifactStone {
191    pub template_id: ArtifactStoneTemplateId,
192    pub level: i64,
193    /// Raw copies banked against this stone, spendable on the next level.
194    pub copies: i64,
195    /// `Some` while socketed. One instance means one socket.
196    pub socket: Option<ArtifactSocketSlot>,
197    /// The law a right-column (Law) stone acts on. `None` for every Aspect
198    /// stone, and for a Law stone the player has not pointed anywhere yet — such
199    /// a stone is inert, exactly like an empty socket.
200    ///
201    /// **The reference is to the LAW, never to a bridge or a link.** A bridge
202    /// has no id of its own (`character_law_bridges` is keyed by the law pair)
203    /// and v0.2 gives a law exactly one partner
204    /// (`mechanics::cores::MAX_BRIDGES_PER_LAW`, `validate_new_bridge`), so "the
205    /// bridge that law X is in" is an unambiguous address built out of one law
206    /// id. The consequence is deliberate and load-bearing rather than an
207    /// accident of the encoding: **if the player tears down the chosen law's
208    /// bridge and builds it a new one, the stone follows onto the new bridge by
209    /// itself.** Nothing points at the old link, so nothing has to be re-pointed.
210    ///
211    /// Cleared when the chosen law leaves its slot (the monolith's `unslot`),
212    /// so a stone can never hold a reference to a law that is not on a core.
213    pub law_target: Option<LawTemplateId>,
214}
215
216impl ArtifactStone {
217    pub const fn new(template_id: ArtifactStoneTemplateId) -> Self {
218        Self {
219            template_id,
220            level: 1,
221            copies: 0,
222            socket: None,
223            law_target: None,
224        }
225    }
226
227    pub const fn is_socketed(&self) -> bool {
228        self.socket.is_some()
229    }
230}
231
232/// Everything a character owns in the artifact system.
233#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema, Tsify)]
234#[tsify(from_wasm_abi, into_wasm_abi)]
235pub struct ArtifactCollection {
236    pub artifacts: Vec<Artifact>,
237    /// The single worn artifact. `None` before the vertical opens — that player
238    /// plays exactly as before, because every artifact read starts here or at an
239    /// empty `artifacts`.
240    pub equipped: Option<ArtifactTemplateId>,
241    /// Owned artifact stones, each carrying its own socket assignment. Not owned
242    /// by any artifact: this is what makes the stones survive a swap.
243    pub stones: Vec<ArtifactStone>,
244}
245
246/// Why an artifact operation was refused. Every variant is a rule from the
247/// design doc.
248#[derive(Debug, thiserror::Error, PartialEq, Eq)]
249pub enum ArtifactError {
250    #[error("the player owns no artifact of template {0}")]
251    UnknownArtifact(ArtifactTemplateId),
252    #[error("the player owns no artifact stone of template {0}")]
253    UnknownStone(ArtifactStoneTemplateId),
254    #[error("socket {socket} takes a {expected} stone")]
255    WrongSocket {
256        socket: ArtifactSocketSlot,
257        expected: ArtifactSocketSlot,
258    },
259    #[error("socket {0} is not unlocked yet")]
260    SocketLocked(ArtifactSocketSlot),
261    #[error("socket {0} already holds a stone")]
262    SocketOccupied(ArtifactSocketSlot),
263    #[error("artifact stone {0} is already socketed — a template holds at most one socket")]
264    AlreadySocketed(ArtifactStoneTemplateId),
265    #[error("artifact stone {0} is not socketed")]
266    NotSocketed(ArtifactStoneTemplateId),
267    #[error("artifact stone {0} sits in an Aspect socket and has no law to choose")]
268    NotALawStone(ArtifactStoneTemplateId),
269    #[error("law {0} is not slotted on a core, so no artifact stone may point at it")]
270    LawNotSlotted(LawTemplateId),
271    #[error(
272        "upgrading artifact stone {stone} to level {level} needs {required} copies, {held} banked"
273    )]
274    NotEnoughCopies {
275        stone: ArtifactStoneTemplateId,
276        level: i64,
277        required: i64,
278        held: i64,
279    },
280    #[error("artifact stone {0} is already at the maximum level {1}")]
281    AlreadyMaxLevel(ArtifactStoneTemplateId, i64),
282    #[error(
283        "upgrading artifact {artifact} to level {level} needs {required} copies, {held} banked"
284    )]
285    NotEnoughArtifactCopies {
286        artifact: ArtifactTemplateId,
287        level: i64,
288        required: i64,
289        held: i64,
290    },
291    #[error("artifact {0} is already at the maximum level {1}")]
292    ArtifactAlreadyMaxLevel(ArtifactTemplateId, i64),
293    /// The upgrade ladder has no rung for the level being bought.
294    /// `GameConfig::validate_artifacts` rules this out at load time.
295    #[error("the artifact stone upgrade ladder has no cost for level {0}")]
296    NoUpgradeStep(i64),
297    #[error("artifacts and their stones may only be changed out of combat")]
298    InCombat,
299}
300
301impl ArtifactCollection {
302    pub fn get(&self, template_id: ArtifactTemplateId) -> Option<&Artifact> {
303        self.artifacts
304            .iter()
305            .find(|artifact| artifact.template_id == template_id)
306    }
307
308    fn get_mut(&mut self, template_id: ArtifactTemplateId) -> Option<&mut Artifact> {
309        self.artifacts
310            .iter_mut()
311            .find(|artifact| artifact.template_id == template_id)
312    }
313
314    pub fn stone(&self, template_id: ArtifactStoneTemplateId) -> Option<&ArtifactStone> {
315        self.stones
316            .iter()
317            .find(|stone| stone.template_id == template_id)
318    }
319
320    fn stone_mut(&mut self, template_id: ArtifactStoneTemplateId) -> Option<&mut ArtifactStone> {
321        self.stones
322            .iter_mut()
323            .find(|stone| stone.template_id == template_id)
324    }
325
326    /// The stone sitting in `socket`, if any.
327    pub fn socketed(&self, socket: ArtifactSocketSlot) -> Option<&ArtifactStone> {
328        self.stones
329            .iter()
330            .find(|stone| stone.socket == Some(socket))
331    }
332
333    /// Grants one artifact copy without performing an upgrade.
334    ///
335    /// The first grant creates the level-1 instance. Every duplicate only
336    /// increments its raw-copy bank, including when the artifact is already at
337    /// the level cap.
338    ///
339    /// The very first artifact a player owns is also WORN. Exactly one artifact
340    /// is worn at a time, so a collection that owns something and wears nothing
341    /// is not a state the player can produce — [`Self::equip`] has no "take off"
342    /// counterpart. Leaving the slot empty read as a bug on the artifact screen,
343    /// which then had no worn artifact to open on. This never overrides a
344    /// choice: it fires only while [`Self::equipped`] is `None`.
345    pub fn grant_artifact(&mut self, template_id: ArtifactTemplateId) {
346        if self.equipped.is_none() {
347            self.equipped = Some(template_id);
348        }
349
350        let Some(artifact) = self.get_mut(template_id) else {
351            self.artifacts.push(Artifact::new(template_id));
352            return;
353        };
354        artifact.copies += 1;
355    }
356
357    /// Spends exactly one configured rung and raises an artifact one level.
358    ///
359    /// All refusal checks happen before either field changes, so an unknown
360    /// artifact, a capped artifact or a short copy bank is atomic.
361    pub fn upgrade_artifact(
362        &mut self,
363        template_id: ArtifactTemplateId,
364        required: i64,
365        max_level: i64,
366    ) -> Result<i64, ArtifactError> {
367        let artifact = self
368            .get_mut(template_id)
369            .ok_or(ArtifactError::UnknownArtifact(template_id))?;
370
371        if artifact.level >= max_level {
372            return Err(ArtifactError::ArtifactAlreadyMaxLevel(
373                template_id,
374                max_level,
375            ));
376        }
377        let next_level = artifact.level + 1;
378        if artifact.copies < required {
379            return Err(ArtifactError::NotEnoughArtifactCopies {
380                artifact: template_id,
381                level: next_level,
382                required,
383                held: artifact.copies,
384            });
385        }
386
387        artifact.copies -= required;
388        artifact.level = next_level;
389        Ok(next_level)
390    }
391
392    /// Wears `template_id`. Exactly one artifact is worn at a time, so this
393    /// replaces whatever was worn before; the previous one keeps paying its
394    /// ownership bonus and loses only its World Law.
395    ///
396    /// Socketed stones are deliberately not read here — that is the whole of
397    /// "stones transfer to the new artifact".
398    pub fn equip(&mut self, template_id: ArtifactTemplateId) -> Result<(), ArtifactError> {
399        if self.get(template_id).is_none() {
400            return Err(ArtifactError::UnknownArtifact(template_id));
401        }
402        self.equipped = Some(template_id);
403        Ok(())
404    }
405
406    /// Banks one raw copy of an artifact stone. The first copy becomes the
407    /// instance at level 1; later copies raise [`ArtifactStone::copies`], which
408    /// the player spends through an explicit upgrade — same shape as
409    /// [`crate::stones::StoneInventory::grant`].
410    pub fn grant_stone(&mut self, template_id: ArtifactStoneTemplateId) {
411        if let Some(stone) = self.stone_mut(template_id) {
412            stone.copies += 1;
413            return;
414        }
415        self.stones.push(ArtifactStone::new(template_id));
416        // Keep the collection in template-id order — the order the storage
417        // fetch loads it back in — so the in-memory state and a fresh load are
418        // byte-identical. Sockets reference stones by template id, so the
419        // position of a row is not semantic.
420        self.stones.sort_by_key(|stone| stone.template_id);
421    }
422
423    /// Puts the player's instance of `template_id` into `socket`.
424    ///
425    /// `expected` is the socket the stone's catalog entry declares — a stone
426    /// fits one socket and no other, which is how the two columns are kept
427    /// apart. `unlocked` is the chapter gate; the schedule itself is config, so
428    /// this type stays free of chapter arithmetic.
429    pub fn insert_stone(
430        &mut self,
431        template_id: ArtifactStoneTemplateId,
432        socket: ArtifactSocketSlot,
433        expected: ArtifactSocketSlot,
434        unlocked: bool,
435    ) -> Result<(), ArtifactError> {
436        if socket != expected {
437            return Err(ArtifactError::WrongSocket { socket, expected });
438        }
439        if !unlocked {
440            return Err(ArtifactError::SocketLocked(socket));
441        }
442        if self.stone(template_id).is_none() {
443            return Err(ArtifactError::UnknownStone(template_id));
444        }
445        if self.socketed(socket).is_some() {
446            return Err(ArtifactError::SocketOccupied(socket));
447        }
448        let stone = self
449            .stone_mut(template_id)
450            .ok_or(ArtifactError::UnknownStone(template_id))?;
451        if stone.is_socketed() {
452            return Err(ArtifactError::AlreadySocketed(template_id));
453        }
454        stone.socket = Some(socket);
455        Ok(())
456    }
457
458    /// Points a right-column stone at one law, or clears its choice (`None`).
459    ///
460    /// `slotted` is "this law is owned and currently sitting in a core slot",
461    /// decided by the caller: the law lives in `CoresState`, which this type
462    /// deliberately knows nothing about. `is_law_socket` is the stone's own
463    /// column, read from its catalog entry.
464    ///
465    /// The one check that is NOT made here is a Real/Fantasy match against the
466    /// socket. The three Law sockets are Visible / Hidden / Bridge, and
467    /// "visible" is a *phase*, not a side: the same law is the visible one while
468    /// its side is up and the hidden one while it is down (post-merge plan §8).
469    /// So every slotted law is a legal target for every Law socket, and which
470    /// half of the fight the stone speaks in is decided by the flip, not by
471    /// validation.
472    pub fn set_law_target(
473        &mut self,
474        template_id: ArtifactStoneTemplateId,
475        law: Option<LawTemplateId>,
476        is_law_socket: bool,
477        slotted: bool,
478    ) -> Result<(), ArtifactError> {
479        if !is_law_socket {
480            return Err(ArtifactError::NotALawStone(template_id));
481        }
482        if let Some(law) = law
483            && !slotted
484        {
485            return Err(ArtifactError::LawNotSlotted(law));
486        }
487        let stone = self
488            .stone_mut(template_id)
489            .ok_or(ArtifactError::UnknownStone(template_id))?;
490        stone.law_target = law;
491        Ok(())
492    }
493
494    /// Drops every stone's choice of `law`. Called when the law leaves its slot,
495    /// so no stone is ever left pointing at a law that is not on a core.
496    ///
497    /// Returns whether anything changed, so the caller can skip a write.
498    pub fn forget_law_target(&mut self, law: LawTemplateId) -> bool {
499        let mut changed = false;
500        for stone in &mut self.stones {
501            if stone.law_target == Some(law) {
502                stone.law_target = None;
503                changed = true;
504            }
505        }
506        changed
507    }
508
509    /// Clears a stone's socket. The stone stays in the collection with its level
510    /// and copies intact.
511    pub fn remove_stone(
512        &mut self,
513        template_id: ArtifactStoneTemplateId,
514    ) -> Result<ArtifactSocketSlot, ArtifactError> {
515        let stone = self
516            .stone_mut(template_id)
517            .ok_or(ArtifactError::UnknownStone(template_id))?;
518        stone
519            .socket
520            .take()
521            .ok_or(ArtifactError::NotSocketed(template_id))
522    }
523
524    /// Spends `required` banked copies to take an artifact stone up one level.
525    ///
526    /// An upgrade only scales the rule's magnitude — the rule itself came whole
527    /// with the first copy (design §3), so nothing here touches the socket, the
528    /// template or anything a rule reads besides `level`.
529    pub fn upgrade_stone(
530        &mut self,
531        template_id: ArtifactStoneTemplateId,
532        required: i64,
533        max_level: i64,
534    ) -> Result<i64, ArtifactError> {
535        let stone = self
536            .stone_mut(template_id)
537            .ok_or(ArtifactError::UnknownStone(template_id))?;
538
539        if stone.level >= max_level {
540            return Err(ArtifactError::AlreadyMaxLevel(template_id, max_level));
541        }
542        let next_level = stone.level + 1;
543        if stone.copies < required {
544            return Err(ArtifactError::NotEnoughCopies {
545                stone: template_id,
546                level: next_level,
547                required,
548                held: stone.copies,
549            });
550        }
551
552        stone.copies -= required;
553        stone.level = next_level;
554        Ok(next_level)
555    }
556}
557
558#[cfg(test)]
559mod tests {
560    use super::*;
561
562    const EMPTY_FRAME: u128 = 900;
563    const SECOND_ARTIFACT: u128 = 901;
564    const ASPECT_STONE: u128 = 910;
565    const OTHER_ASPECT_STONE: u128 = 911;
566    const LAW_STONE: u128 = 920;
567    const OTHER_LAW_STONE: u128 = 921;
568    const CHOSEN_LAW: u128 = 930;
569    const OTHER_LAW: u128 = 931;
570
571    fn id(n: u128) -> Uuid {
572        Uuid::from_u128(n)
573    }
574
575    /// The shipped ladder: level 2 costs one copy, level 3 costs two.
576    fn ladder(level: i64) -> Option<i64> {
577        match level {
578            2 => Some(1),
579            3 => Some(2),
580            _ => None,
581        }
582    }
583
584    #[test]
585    fn the_six_sockets_open_left_column_first() {
586        let all = ArtifactSocketSlot::all();
587        assert_eq!(all.len(), 6);
588        let columns: Vec<_> = all.iter().map(|slot| slot.column()).collect();
589        assert_eq!(
590            columns,
591            vec![
592                ArtifactSocketColumn::Aspect,
593                ArtifactSocketColumn::Aspect,
594                ArtifactSocketColumn::Aspect,
595                ArtifactSocketColumn::Law,
596                ArtifactSocketColumn::Law,
597                ArtifactSocketColumn::Law,
598            ],
599            "the whole Aspect column comes before the Law column"
600        );
601    }
602
603    /// Acceptance criterion 1: many owned, exactly one worn.
604    #[test]
605    fn exactly_one_artifact_is_worn_while_many_are_owned() {
606        let mut collection = ArtifactCollection::default();
607        collection.grant_artifact(id(EMPTY_FRAME));
608        collection.grant_artifact(id(SECOND_ARTIFACT));
609        assert_eq!(collection.artifacts.len(), 2);
610        assert_eq!(
611            collection.equipped,
612            Some(id(EMPTY_FRAME)),
613            "the first artifact owned is worn; the second does not take over"
614        );
615
616        collection.equip(id(EMPTY_FRAME)).unwrap();
617        assert_eq!(collection.equipped, Some(id(EMPTY_FRAME)));
618
619        collection.equip(id(SECOND_ARTIFACT)).unwrap();
620        assert_eq!(
621            collection.equipped,
622            Some(id(SECOND_ARTIFACT)),
623            "wearing a second artifact replaces the first, never adds to it"
624        );
625        assert_eq!(collection.artifacts.len(), 2, "both are still owned");
626    }
627
628    /// The worn slot is never empty once the player owns something: the first
629    /// grant wears itself, later grants leave the choice alone, and a duplicate
630    /// of the worn artifact does not disturb it.
631    #[test]
632    fn the_first_granted_artifact_is_worn_and_later_grants_do_not_take_over() {
633        let mut collection = ArtifactCollection::default();
634
635        collection.grant_artifact(id(SECOND_ARTIFACT));
636        assert_eq!(collection.equipped, Some(id(SECOND_ARTIFACT)));
637
638        collection.grant_artifact(id(EMPTY_FRAME));
639        assert_eq!(
640            collection.equipped,
641            Some(id(SECOND_ARTIFACT)),
642            "a later grant does not silently swap what the player wears"
643        );
644
645        collection.equip(id(EMPTY_FRAME)).unwrap();
646        collection.grant_artifact(id(SECOND_ARTIFACT));
647        assert_eq!(
648            collection.equipped,
649            Some(id(EMPTY_FRAME)),
650            "a duplicate never overrides an explicit choice"
651        );
652    }
653
654    #[test]
655    fn an_unowned_artifact_cannot_be_worn() {
656        let mut collection = ArtifactCollection::default();
657        assert_eq!(
658            collection.equip(id(SECOND_ARTIFACT)),
659            Err(ArtifactError::UnknownArtifact(id(SECOND_ARTIFACT)))
660        );
661        assert_eq!(collection.equipped, None);
662    }
663
664    /// Artifact grants only bank copies. Levels move through the explicit
665    /// upgrade path, and a duplicate at the cap is retained.
666    #[test]
667    fn duplicates_bank_until_explicit_upgrade_including_at_the_cap() {
668        let mut collection = ArtifactCollection::default();
669
670        collection.grant_artifact(id(SECOND_ARTIFACT));
671        let artifact = collection.get(id(SECOND_ARTIFACT)).unwrap();
672        assert_eq!((artifact.level, artifact.copies), (1, 0));
673
674        collection.grant_artifact(id(SECOND_ARTIFACT));
675        let artifact = collection.get(id(SECOND_ARTIFACT)).unwrap();
676        assert_eq!((artifact.level, artifact.copies), (1, 1));
677        assert_eq!(
678            collection.upgrade_artifact(id(SECOND_ARTIFACT), ladder(2).unwrap(), 3),
679            Ok(2)
680        );
681        assert_eq!(
682            (
683                collection.get(id(SECOND_ARTIFACT)).unwrap().level,
684                collection.get(id(SECOND_ARTIFACT)).unwrap().copies
685            ),
686            (2, 0)
687        );
688
689        collection.grant_artifact(id(SECOND_ARTIFACT));
690        collection.grant_artifact(id(SECOND_ARTIFACT));
691        assert_eq!(
692            collection.upgrade_artifact(id(SECOND_ARTIFACT), ladder(3).unwrap(), 3),
693            Ok(3)
694        );
695
696        collection.grant_artifact(id(SECOND_ARTIFACT));
697        let artifact = collection.get(id(SECOND_ARTIFACT)).unwrap();
698        assert_eq!((artifact.level, artifact.copies), (3, 1));
699        assert_eq!(collection.artifacts.len(), 1, "still one instance");
700    }
701
702    #[test]
703    fn refused_artifact_upgrades_are_atomic() {
704        let mut collection = ArtifactCollection::default();
705        assert_eq!(
706            collection.upgrade_artifact(id(SECOND_ARTIFACT), 1, 3),
707            Err(ArtifactError::UnknownArtifact(id(SECOND_ARTIFACT)))
708        );
709
710        collection.grant_artifact(id(SECOND_ARTIFACT));
711        let before = collection.clone();
712        assert_eq!(
713            collection.upgrade_artifact(id(SECOND_ARTIFACT), 1, 3),
714            Err(ArtifactError::NotEnoughArtifactCopies {
715                artifact: id(SECOND_ARTIFACT),
716                level: 2,
717                required: 1,
718                held: 0,
719            })
720        );
721        assert_eq!(collection, before);
722
723        let artifact = collection.get_mut(id(SECOND_ARTIFACT)).unwrap();
724        artifact.level = 3;
725        artifact.copies = 4;
726        let before = collection.clone();
727        assert_eq!(
728            collection.upgrade_artifact(id(SECOND_ARTIFACT), 2, 3),
729            Err(ArtifactError::ArtifactAlreadyMaxLevel(
730                id(SECOND_ARTIFACT),
731                3
732            ))
733        );
734        assert_eq!(collection, before);
735    }
736
737    /// Acceptance criterion 5: a stone fits its own socket and no other.
738    #[test]
739    fn a_stone_only_fits_the_socket_its_catalog_entry_names() {
740        let mut collection = ArtifactCollection::default();
741        collection.grant_stone(id(ASPECT_STONE));
742
743        let err = collection
744            .insert_stone(
745                id(ASPECT_STONE),
746                ArtifactSocketSlot::HiddenAspect,
747                ArtifactSocketSlot::VisibleAspect,
748                true,
749            )
750            .unwrap_err();
751        assert_eq!(
752            err,
753            ArtifactError::WrongSocket {
754                socket: ArtifactSocketSlot::HiddenAspect,
755                expected: ArtifactSocketSlot::VisibleAspect,
756            }
757        );
758
759        // The whole right column is refused the same way, by the same rule.
760        let err = collection
761            .insert_stone(
762                id(ASPECT_STONE),
763                ArtifactSocketSlot::VisibleLaw,
764                ArtifactSocketSlot::VisibleAspect,
765                true,
766            )
767            .unwrap_err();
768        assert!(matches!(err, ArtifactError::WrongSocket { .. }));
769        assert!(collection.stone(id(ASPECT_STONE)).unwrap().socket.is_none());
770
771        collection
772            .insert_stone(
773                id(ASPECT_STONE),
774                ArtifactSocketSlot::VisibleAspect,
775                ArtifactSocketSlot::VisibleAspect,
776                true,
777            )
778            .unwrap();
779        assert_eq!(
780            collection
781                .socketed(ArtifactSocketSlot::VisibleAspect)
782                .map(|stone| stone.template_id),
783            Some(id(ASPECT_STONE))
784        );
785    }
786
787    /// Acceptance criterion 7: a socket the chapter gate has not opened refuses
788    /// the stone and changes nothing.
789    #[test]
790    fn a_locked_socket_refuses_the_stone() {
791        let mut collection = ArtifactCollection::default();
792        collection.grant_stone(id(ASPECT_STONE));
793
794        assert_eq!(
795            collection.insert_stone(
796                id(ASPECT_STONE),
797                ArtifactSocketSlot::VisibleAspect,
798                ArtifactSocketSlot::VisibleAspect,
799                false,
800            ),
801            Err(ArtifactError::SocketLocked(
802                ArtifactSocketSlot::VisibleAspect
803            ))
804        );
805        assert!(collection.stone(id(ASPECT_STONE)).unwrap().socket.is_none());
806    }
807
808    #[test]
809    fn a_socket_holds_one_stone_at_a_time() {
810        let mut collection = ArtifactCollection::default();
811        collection.grant_stone(id(ASPECT_STONE));
812        collection.grant_stone(id(OTHER_ASPECT_STONE));
813
814        collection
815            .insert_stone(
816                id(ASPECT_STONE),
817                ArtifactSocketSlot::VisibleAspect,
818                ArtifactSocketSlot::VisibleAspect,
819                true,
820            )
821            .unwrap();
822        assert_eq!(
823            collection.insert_stone(
824                id(OTHER_ASPECT_STONE),
825                ArtifactSocketSlot::VisibleAspect,
826                ArtifactSocketSlot::VisibleAspect,
827                true,
828            ),
829            Err(ArtifactError::SocketOccupied(
830                ArtifactSocketSlot::VisibleAspect
831            ))
832        );
833    }
834
835    /// Acceptance criterion 6: swapping the worn artifact leaves every socketed
836    /// stone exactly where it was — the swap cannot reach them.
837    #[test]
838    fn stones_survive_an_artifact_swap() {
839        let mut collection = ArtifactCollection::default();
840        collection.grant_artifact(id(EMPTY_FRAME));
841        collection.grant_artifact(id(SECOND_ARTIFACT));
842        collection.grant_stone(id(ASPECT_STONE));
843        collection.grant_stone(id(ASPECT_STONE)); // one banked copy
844        collection.equip(id(EMPTY_FRAME)).unwrap();
845        collection
846            .insert_stone(
847                id(ASPECT_STONE),
848                ArtifactSocketSlot::FlipAspect,
849                ArtifactSocketSlot::FlipAspect,
850                true,
851            )
852            .unwrap();
853        let before = collection.stone(id(ASPECT_STONE)).unwrap().clone();
854
855        collection.equip(id(SECOND_ARTIFACT)).unwrap();
856
857        assert_eq!(collection.stones.len(), 1);
858        assert_eq!(
859            collection.stone(id(ASPECT_STONE)).unwrap(),
860            &before,
861            "level, copies and socket all carried over to the new artifact"
862        );
863        assert_eq!(
864            collection
865                .socketed(ArtifactSocketSlot::FlipAspect)
866                .map(|stone| stone.template_id),
867            Some(id(ASPECT_STONE))
868        );
869    }
870
871    #[test]
872    fn a_removed_stone_keeps_its_level_and_copies() {
873        let mut collection = ArtifactCollection::default();
874        collection.grant_stone(id(ASPECT_STONE));
875        collection.grant_stone(id(ASPECT_STONE));
876        collection
877            .insert_stone(
878                id(ASPECT_STONE),
879                ArtifactSocketSlot::HiddenAspect,
880                ArtifactSocketSlot::HiddenAspect,
881                true,
882            )
883            .unwrap();
884
885        assert_eq!(
886            collection.remove_stone(id(ASPECT_STONE)).unwrap(),
887            ArtifactSocketSlot::HiddenAspect
888        );
889        let stone = collection.stone(id(ASPECT_STONE)).unwrap();
890        assert_eq!((stone.level, stone.copies, stone.socket), (1, 1, None));
891    }
892
893    /// Acceptance criterion 9, data half: an upgrade moves the level and
894    /// nothing else — the rule and the socket are untouched.
895    #[test]
896    fn upgrading_a_stone_only_moves_its_level() {
897        let mut collection = ArtifactCollection::default();
898        collection.grant_stone(id(ASPECT_STONE));
899        for _ in 0..3 {
900            collection.grant_stone(id(ASPECT_STONE));
901        }
902        collection
903            .insert_stone(
904                id(ASPECT_STONE),
905                ArtifactSocketSlot::VisibleAspect,
906                ArtifactSocketSlot::VisibleAspect,
907                true,
908            )
909            .unwrap();
910
911        assert_eq!(collection.upgrade_stone(id(ASPECT_STONE), 2, 5), Ok(2));
912        let stone = collection.stone(id(ASPECT_STONE)).unwrap();
913        assert_eq!(stone.level, 2);
914        assert_eq!(stone.copies, 1);
915        assert_eq!(
916            stone.socket,
917            Some(ArtifactSocketSlot::VisibleAspect),
918            "an upgrade never disturbs the socket"
919        );
920    }
921
922    #[test]
923    fn a_short_bank_or_a_capped_stone_spends_nothing() {
924        let mut collection = ArtifactCollection::default();
925        collection.grant_stone(id(ASPECT_STONE));
926
927        assert_eq!(
928            collection.upgrade_stone(id(ASPECT_STONE), 2, 5),
929            Err(ArtifactError::NotEnoughCopies {
930                stone: id(ASPECT_STONE),
931                level: 2,
932                required: 2,
933                held: 0,
934            })
935        );
936        assert_eq!(collection.stone(id(ASPECT_STONE)).unwrap().copies, 0);
937
938        collection.stone_mut(id(ASPECT_STONE)).unwrap().level = 5;
939        collection.stone_mut(id(ASPECT_STONE)).unwrap().copies = 9;
940        assert_eq!(
941            collection.upgrade_stone(id(ASPECT_STONE), 2, 5),
942            Err(ArtifactError::AlreadyMaxLevel(id(ASPECT_STONE), 5))
943        );
944        assert_eq!(collection.stone(id(ASPECT_STONE)).unwrap().copies, 9);
945    }
946
947    /// Right column, data half: the choice is one law id, it only exists on a
948    /// Law-column stone, and it must name a law that is actually on a core.
949    #[test]
950    fn only_a_law_stone_carries_a_chosen_law_and_only_a_slotted_one() {
951        let mut collection = ArtifactCollection::default();
952        collection.grant_stone(id(ASPECT_STONE));
953        collection.grant_stone(id(LAW_STONE));
954        let law = id(CHOSEN_LAW);
955
956        assert_eq!(
957            collection.set_law_target(id(ASPECT_STONE), Some(law), false, true),
958            Err(ArtifactError::NotALawStone(id(ASPECT_STONE))),
959            "an Aspect stone has no law to choose"
960        );
961        assert_eq!(
962            collection.set_law_target(id(LAW_STONE), Some(law), true, false),
963            Err(ArtifactError::LawNotSlotted(law)),
964            "a law off the core is not a legal target"
965        );
966        assert_eq!(collection.stone(id(LAW_STONE)).unwrap().law_target, None);
967
968        collection
969            .set_law_target(id(LAW_STONE), Some(law), true, true)
970            .unwrap();
971        assert_eq!(
972            collection.stone(id(LAW_STONE)).unwrap().law_target,
973            Some(law)
974        );
975
976        // Clearing needs no slotted law: the point is to have none.
977        collection
978            .set_law_target(id(LAW_STONE), None, true, false)
979            .unwrap();
980        assert_eq!(collection.stone(id(LAW_STONE)).unwrap().law_target, None);
981    }
982
983    /// Taking the chosen law off its core leaves no stone pointing at it — the
984    /// "no dangling reference" half of plan §6.1.
985    #[test]
986    fn forgetting_a_law_clears_every_stone_that_chose_it() {
987        let mut collection = ArtifactCollection::default();
988        collection.grant_stone(id(LAW_STONE));
989        collection.grant_stone(id(OTHER_LAW_STONE));
990        let law = id(CHOSEN_LAW);
991        let other = id(OTHER_LAW);
992
993        collection
994            .set_law_target(id(LAW_STONE), Some(law), true, true)
995            .unwrap();
996        collection
997            .set_law_target(id(OTHER_LAW_STONE), Some(other), true, true)
998            .unwrap();
999
1000        assert!(collection.forget_law_target(law));
1001        assert_eq!(collection.stone(id(LAW_STONE)).unwrap().law_target, None);
1002        assert_eq!(
1003            collection.stone(id(OTHER_LAW_STONE)).unwrap().law_target,
1004            Some(other),
1005            "only the unslotted law is forgotten"
1006        );
1007        assert!(
1008            !collection.forget_law_target(law),
1009            "a second pass has nothing to clear and reports so"
1010        );
1011    }
1012
1013    /// The socket is not part of the choice: unsocketing a stone keeps its law,
1014    /// exactly as it keeps its level and copies.
1015    #[test]
1016    fn unsocketing_a_law_stone_keeps_its_chosen_law() {
1017        let mut collection = ArtifactCollection::default();
1018        collection.grant_stone(id(LAW_STONE));
1019        collection
1020            .insert_stone(
1021                id(LAW_STONE),
1022                ArtifactSocketSlot::VisibleLaw,
1023                ArtifactSocketSlot::VisibleLaw,
1024                true,
1025            )
1026            .unwrap();
1027        collection
1028            .set_law_target(id(LAW_STONE), Some(id(CHOSEN_LAW)), true, true)
1029            .unwrap();
1030
1031        collection.remove_stone(id(LAW_STONE)).unwrap();
1032        let stone = collection.stone(id(LAW_STONE)).unwrap();
1033        assert_eq!(stone.socket, None);
1034        assert_eq!(stone.law_target, Some(id(CHOSEN_LAW)));
1035    }
1036
1037    /// Acceptance criterion 10, data half: a player who owns nothing has an
1038    /// empty collection with no worn artifact — every read below starts there
1039    /// and returns nothing.
1040    #[test]
1041    fn a_player_without_artifacts_has_an_inert_collection() {
1042        let collection = ArtifactCollection::default();
1043        assert!(collection.artifacts.is_empty());
1044        assert!(collection.stones.is_empty());
1045        assert_eq!(collection.equipped, None);
1046        for socket in ArtifactSocketSlot::all() {
1047            assert!(collection.socketed(socket).is_none());
1048        }
1049    }
1050}