essences/
stones.rs

1//! Trigger/Effect Stones — what a player owns, where it sits, how it levels.
2//!
3//! The data half of the feature — nothing here fires anything. Trigger
4//! evaluation, the global cooldown and effect execution live in
5//! `overlord_event_system::logic::stones`, which reads these types plus
6//! `configs::stones`.
7//!
8//! Three facts shape everything below:
9//!
10//! * **One instance per catalog entry, plus a stock of raw copies.** A stone is
11//!   identified by its [`Stone::template_id`] and carries a [`Stone::copies`]
12//!   count, not N interchangeable rows. Template ids are unique across both
13//!   catalogs (`GameConfig::validate_stones`). The consequence: one instance
14//!   means one socket, so a second insert of the same template is refused with
15//!   [`StoneError::AlreadySocketed`].
16//! * **Triggers and Effects are separate collections.** Never socketable into
17//!   each other's sockets and never sharing copies, so two `Vec`s keyed by
18//!   [`StoneKind`] rather than one list with a flag.
19//! * **A socketed stone never leaves the inventory.** Inserting sets
20//!   [`Stone::socket`], removing clears it — there is no second home for a
21//!   stone to be lost in.
22
23use crate::flip::WorldSide;
24use crate::items::ItemType;
25use crate::prelude::*;
26
27use strum::IntoEnumIterator;
28use strum_macros::{Display, EnumCount, EnumIter, EnumString};
29
30use std::collections::HashMap;
31
32#[declare]
33pub type StoneTemplateId = Uuid;
34
35/// Rarity tier of a stone.
36///
37/// For a Trigger Stone the tier is the *shape of the condition* and its weight
38/// in the flip gauge, not how hard it is to farm; for an Effect Stone it is the
39/// shape of the action (instant / short state / schedule change).
40///
41/// The declaration order is the strength order and `Ord` is derived from it:
42/// `Common < Rare < Epic < Legendary`. `QuickEquipStones` ranks candidates by
43/// `(tier, level)` — tier first, because it drives both the gauge contribution
44/// and the grammar of the effect, while level only scales a number.
45#[derive(
46    Clone,
47    Copy,
48    Debug,
49    Default,
50    Serialize,
51    Deserialize,
52    PartialEq,
53    Eq,
54    PartialOrd,
55    Ord,
56    Hash,
57    JsonSchema,
58    Tsify,
59    Display,
60    EnumString,
61    EnumIter,
62    EnumCount,
63)]
64#[tsify(from_wasm_abi, into_wasm_abi)]
65pub enum StoneTier {
66    #[default]
67    Common,
68    Rare,
69    Epic,
70    Legendary,
71}
72
73/// Which of the two collections a stone belongs to.
74#[derive(
75    Clone,
76    Copy,
77    Debug,
78    Default,
79    Serialize,
80    Deserialize,
81    PartialEq,
82    Eq,
83    Hash,
84    JsonSchema,
85    Tsify,
86    Display,
87    EnumString,
88    EnumIter,
89)]
90#[tsify(from_wasm_abi, into_wasm_abi)]
91pub enum StoneKind {
92    /// "When X" — one per two-sided slot, shared by both sides.
93    #[default]
94    Trigger,
95    /// "Do Y" — one per side of a two-sided slot.
96    Effect,
97}
98
99/// One of the three sockets a two-sided equipment slot carries.
100///
101/// The trigger is shared: a slot has exactly one trigger driving both sides,
102/// and only the effect it fires changes when the world flips.
103///
104/// * `Trigger` — the slot's single shared trigger.
105/// * `RealEffect` — the effect fired while the Real side is active.
106/// * `FantasyEffect` — the effect fired while the Fantasy side is active.
107///
108/// Variant docs live here rather than on the variants themselves: a documented
109/// variant makes the admin schema generate a `oneOf` of one-value enums instead
110/// of a plain picker.
111#[derive(
112    Clone,
113    Copy,
114    Debug,
115    Serialize,
116    Deserialize,
117    PartialEq,
118    Eq,
119    Hash,
120    JsonSchema,
121    Tsify,
122    Display,
123    EnumString,
124    EnumIter,
125)]
126#[tsify(from_wasm_abi, into_wasm_abi)]
127pub enum StoneSocketSlot {
128    Trigger,
129    RealEffect,
130    FantasyEffect,
131}
132
133impl StoneSocketSlot {
134    /// The collection a stone must come from to fit this socket. A Trigger
135    /// Stone never goes in an effect socket and vice versa.
136    pub const fn accepts(self) -> StoneKind {
137        match self {
138            Self::Trigger => StoneKind::Trigger,
139            Self::RealEffect | Self::FantasyEffect => StoneKind::Effect,
140        }
141    }
142
143    /// The world side this socket answers for, or `None` for the shared
144    /// trigger.
145    pub const fn world_side(self) -> Option<WorldSide> {
146        match self {
147            Self::Trigger => None,
148            Self::RealEffect => Some(WorldSide::Real),
149            Self::FantasyEffect => Some(WorldSide::Fantasy),
150        }
151    }
152
153    /// The effect socket that is live on `side`.
154    pub const fn effect_for(side: WorldSide) -> Self {
155        match side {
156            WorldSide::Real => Self::RealEffect,
157            WorldSide::Fantasy => Self::FantasyEffect,
158        }
159    }
160}
161
162/// Where one socketed stone sits: which equipment slot, which of its three
163/// sockets.
164///
165/// Only the five two-sided equipment types have sockets — the same five that
166/// answer [`ItemType::supports_world_side`]. Keying off that predicate instead
167/// of a second hand-written list is deliberate: the two lists cannot drift.
168#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq, Hash, JsonSchema, Tsify)]
169#[tsify(from_wasm_abi, into_wasm_abi)]
170pub struct StoneSocketKey {
171    pub item_type: ItemType,
172    pub socket: StoneSocketSlot,
173}
174
175impl StoneSocketKey {
176    /// `None` when `item_type` has no sockets (one of the five ordinary,
177    /// one-sided slots).
178    pub fn new(item_type: ItemType, socket: StoneSocketSlot) -> Option<Self> {
179        item_type
180            .supports_world_side()
181            .then_some(Self { item_type, socket })
182    }
183
184    /// Every socket in the game: 5 two-sided slots × 3 sockets = 15.
185    pub fn all() -> Vec<Self> {
186        ItemType::iter()
187            .filter(|item_type| item_type.supports_world_side())
188            .flat_map(|item_type| {
189                StoneSocketSlot::iter().map(move |socket| Self { item_type, socket })
190            })
191            .collect()
192    }
193}
194
195/// The one instance a player owns of a catalog entry, plus its stock of raw
196/// copies.
197///
198/// `template_id` is the identity: at most one `Stone` per template per
199/// collection, so there is no separate instance id. The database key is
200/// `(character_id, template_id)` for the same reason.
201///
202/// `level` is the upgrade level and `copies` is the currency that buys it. The
203/// first copy becomes the instance at level 1 with zero copies banked; every
204/// later copy increments `copies`. The ladder in
205/// `configs::stones::StonesSettings::upgrade_ladder` says what each level costs.
206/// The first copy already carries the full mechanic — an upgrade only scales the
207/// number it applies.
208#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, JsonSchema, Tsify)]
209#[tsify(from_wasm_abi, into_wasm_abi)]
210pub struct Stone {
211    /// The catalog entry this is the player's instance of, and its identity.
212    pub template_id: StoneTemplateId,
213    pub tier: StoneTier,
214    pub level: i64,
215    /// Raw copies banked against this stone, spendable on the next level. Never
216    /// negative: a refused upgrade spends nothing.
217    pub copies: i64,
218    /// `Some` while socketed. The stone stays in its collection either way —
219    /// this is the whole of "inserted" and "removed". One instance means one
220    /// socket: the same template cannot sit in two slots.
221    pub socket: Option<StoneSocketKey>,
222}
223
224impl Stone {
225    /// The player's first copy of `template_id`: level 1, nothing banked.
226    pub fn new(template_id: StoneTemplateId, tier: StoneTier) -> Self {
227        Self {
228            template_id,
229            tier,
230            level: 1,
231            copies: 0,
232            socket: None,
233        }
234    }
235
236    pub const fn is_socketed(&self) -> bool {
237        self.socket.is_some()
238    }
239
240    /// Ranking key for "the best stone I own": **tier first, then level**.
241    ///
242    /// The two axes are not comparable on their own — a level-3 Common against
243    /// a level-1 Legendary has no arithmetic answer — so the design picks one.
244    /// Tier wins because it decides the stone's gauge contribution and the
245    /// shape of what it does; level only scales a magnitude inside that shape.
246    pub const fn quality(&self) -> (StoneTier, i64) {
247        (self.tier, self.level)
248    }
249}
250
251/// What one `UpgradeAllStones` call actually changed: template id -> (from, to)
252/// level. Modelled on `UpgradedAbilitiesMap` and used the same way — the client
253/// shows the result window from this, and quests read it for progress.
254///
255/// Only stones that actually gained a level appear; a stone whose bank was too
256/// short is simply absent rather than present with an unchanged pair.
257#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq, Tsify, JsonSchema)]
258pub struct UpgradedStonesMap(pub HashMap<StoneTemplateId, (i64, i64)>);
259
260impl UpgradedStonesMap {
261    pub fn insert(&mut self, id: StoneTemplateId, levels: (i64, i64)) {
262        self.0.insert(id, levels);
263    }
264
265    pub fn is_empty(&self) -> bool {
266        self.0.is_empty()
267    }
268}
269
270/// Everything a character owns in the stone system: the two collections, kept
271/// apart, plus the socket assignments carried by the stones themselves.
272#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema, Tsify)]
273#[tsify(from_wasm_abi, into_wasm_abi)]
274pub struct StoneInventory {
275    pub trigger_stones: Vec<Stone>,
276    pub effect_stones: Vec<Stone>,
277}
278
279/// Why an insert/remove/upgrade was refused. Every variant is a rule from the
280/// design doc, so the caller can report the actual reason instead of a generic
281/// failure.
282#[derive(Debug, thiserror::Error, PartialEq, Eq)]
283pub enum StoneError {
284    #[error("the player owns no stone of template {0}")]
285    UnknownStone(StoneTemplateId),
286    #[error("{item_type:?} has no stone sockets — only the five two-sided slots do")]
287    SlotHasNoSockets { item_type: ItemType },
288    #[error("socket {socket} takes a {expected} stone, not a {actual} one")]
289    WrongCollection {
290        socket: StoneSocketSlot,
291        expected: StoneKind,
292        actual: StoneKind,
293    },
294    #[error("socket {socket:?} on {item_type:?} is not unlocked yet")]
295    SocketLocked {
296        item_type: ItemType,
297        socket: StoneSocketSlot,
298    },
299    /// One instance per template means one socket per template: this is also
300    /// how "the same stone in two slots" is refused.
301    #[error("stone {0} is already socketed — a template holds at most one socket")]
302    AlreadySocketed(StoneTemplateId),
303    #[error("stone {0} is not socketed")]
304    NotSocketed(StoneTemplateId),
305    #[error("upgrading stone {stone} to level {level} needs {required} copies, {held} banked")]
306    NotEnoughCopies {
307        stone: StoneTemplateId,
308        level: i64,
309        required: i64,
310        held: i64,
311    },
312    #[error("stone {0} is already at the maximum level {1}")]
313    AlreadyMaxLevel(StoneTemplateId, i64),
314    /// The upgrade ladder has no rung for the level being bought.
315    /// `GameConfig::validate_stones` rules this out at load time, so it means a
316    /// config loaded past validation — refuse rather than guess a cost.
317    #[error("the upgrade ladder has no cost for level {0}")]
318    NoUpgradeStep(i64),
319}
320
321impl StoneInventory {
322    pub fn collection(&self, kind: StoneKind) -> &Vec<Stone> {
323        match kind {
324            StoneKind::Trigger => &self.trigger_stones,
325            StoneKind::Effect => &self.effect_stones,
326        }
327    }
328
329    pub fn collection_mut(&mut self, kind: StoneKind) -> &mut Vec<Stone> {
330        match kind {
331            StoneKind::Trigger => &mut self.trigger_stones,
332            StoneKind::Effect => &mut self.effect_stones,
333        }
334    }
335
336    pub fn all(&self) -> impl Iterator<Item = (StoneKind, &Stone)> {
337        self.trigger_stones
338            .iter()
339            .map(|s| (StoneKind::Trigger, s))
340            .chain(self.effect_stones.iter().map(|s| (StoneKind::Effect, s)))
341    }
342
343    /// Which collection holds `template_id`, if any. The lookup runs across
344    /// both collections precisely so a caller cannot address a Trigger Stone as
345    /// if it were an Effect Stone; the two catalogs share no template id, so
346    /// the answer is unambiguous.
347    pub fn kind_of(&self, template_id: StoneTemplateId) -> Option<StoneKind> {
348        self.all()
349            .find(|(_, stone)| stone.template_id == template_id)
350            .map(|(kind, _)| kind)
351    }
352
353    pub fn get(&self, template_id: StoneTemplateId) -> Option<&Stone> {
354        self.all()
355            .find(|(_, s)| s.template_id == template_id)
356            .map(|(_, s)| s)
357    }
358
359    fn get_mut(&mut self, template_id: StoneTemplateId) -> Option<&mut Stone> {
360        self.trigger_stones
361            .iter_mut()
362            .chain(self.effect_stones.iter_mut())
363            .find(|s| s.template_id == template_id)
364    }
365
366    /// Banks one raw copy of `template_id` in `kind`'s collection.
367    ///
368    /// The first copy becomes the instance (level 1, nothing banked); every
369    /// later one increments [`Stone::copies`]. This is the only way a stone
370    /// enters the inventory, which is what keeps "one instance per template"
371    /// an invariant rather than a convention — a second row for a template the
372    /// player already owns is unreachable.
373    pub fn grant(&mut self, kind: StoneKind, template_id: StoneTemplateId, tier: StoneTier) {
374        if let Some(stone) = self.get_mut(template_id) {
375            stone.copies += 1;
376            return;
377        }
378        self.collection_mut(kind)
379            .push(Stone::new(template_id, tier));
380    }
381
382    /// The stone currently sitting in `key`, if any.
383    pub fn socketed(&self, key: StoneSocketKey) -> Option<&Stone> {
384        self.all()
385            .find(|(_, stone)| stone.socket == Some(key))
386            .map(|(_, stone)| stone)
387    }
388
389    /// Inserts the player's instance of `template_id` into a socket.
390    /// `unlocked` decides whether the socket is available yet — the schedule
391    /// itself lives in config, so this type stays free of chapter arithmetic.
392    ///
393    /// A template has exactly one instance, so socketing it twice is refused
394    /// with [`StoneError::AlreadySocketed`]. An occupied socket is swapped, not
395    /// refused: the sitting stone returns whole to the collection.
396    ///
397    /// Stones may be changed at any time, including mid-fight, so there is no
398    /// combat gate here or in the callers.
399    pub fn insert(
400        &mut self,
401        template_id: StoneTemplateId,
402        key: StoneSocketKey,
403        unlocked: bool,
404    ) -> Result<(), StoneError> {
405        if !key.item_type.supports_world_side() {
406            return Err(StoneError::SlotHasNoSockets {
407                item_type: key.item_type,
408            });
409        }
410        if !unlocked {
411            return Err(StoneError::SocketLocked {
412                item_type: key.item_type,
413                socket: key.socket,
414            });
415        }
416        let kind = self
417            .kind_of(template_id)
418            .ok_or(StoneError::UnknownStone(template_id))?;
419        if kind != key.socket.accepts() {
420            return Err(StoneError::WrongCollection {
421                socket: key.socket,
422                expected: key.socket.accepts(),
423                actual: kind,
424            });
425        }
426        // Validated before anything is displaced: a refusal after the swap
427        // would leave the socket empty and the player worse off than they
428        // started.
429        match self.get(template_id) {
430            None => return Err(StoneError::UnknownStone(template_id)),
431            Some(stone) if stone.is_socketed() => {
432                return Err(StoneError::AlreadySocketed(template_id));
433            }
434            Some(_) => {}
435        }
436
437        // Fitting onto an occupied socket swaps, and the displaced stone
438        // returns whole — nothing is consumed by fitting. Without this there is
439        // no way to upgrade a socket at all: one trigger socket per item means
440        // a Common fitted early would block every better stone forever.
441        if let Some(displaced) = self.socketed(key).map(|stone| stone.template_id)
442            && let Some(stone) = self.get_mut(displaced)
443        {
444            stone.socket = None;
445        }
446
447        let stone = self
448            .get_mut(template_id)
449            .ok_or(StoneError::UnknownStone(template_id))?;
450        stone.socket = Some(key);
451        Ok(())
452    }
453
454    /// Clears a stone's socket. The stone is not moved, copied or re-created —
455    /// it was in its collection all along — so nothing about it can be lost.
456    /// Returns the socket it was freed from.
457    pub fn remove(&mut self, template_id: StoneTemplateId) -> Result<StoneSocketKey, StoneError> {
458        let stone = self
459            .get_mut(template_id)
460            .ok_or(StoneError::UnknownStone(template_id))?;
461        stone
462            .socket
463            .take()
464            .ok_or(StoneError::NotSocketed(template_id))
465    }
466
467    /// Fills every **open and empty** socket of `item_type` with the best stone
468    /// the player is not already wearing, and returns the sockets it filled.
469    ///
470    /// The caller supplies `is_unlocked` because the unlock schedule lives in
471    /// config and this type stays free of chapter arithmetic.
472    ///
473    /// Locked sockets are skipped and occupied ones are left alone — quick
474    /// equip fills gaps, it does not re-shuffle a build. Only unsocketed stones
475    /// are candidates, so quick-equipping one item never strips another, and
476    /// the pool comes from [`StoneSocketSlot::accepts`], which makes a
477    /// wrong-collection pick inexpressible. An empty pool leaves the socket
478    /// empty and is not an error.
479    ///
480    /// Only EMPTY open sockets are filled, each with the BEST usable stone the
481    /// player owns loose: highest [`Stone::quality`] — tier first, then level.
482    /// `catalog_order` is the canonical catalog sequence, pre-filtered to active
483    /// templates by the caller; it decides which templates are eligible at all
484    /// and breaks ties between two stones of identical quality, so the pick is
485    /// deterministic. An instance already socketed elsewhere is never taken.
486    pub fn quick_equip(
487        &mut self,
488        item_type: ItemType,
489        is_unlocked: &dyn Fn(StoneSocketSlot) -> bool,
490        catalog_order: &dyn Fn(StoneKind) -> Vec<StoneTemplateId>,
491    ) -> Vec<(StoneSocketSlot, StoneTemplateId)> {
492        let mut filled = Vec::new();
493        if !item_type.supports_world_side() {
494            return filled;
495        }
496        for socket in StoneSocketSlot::iter() {
497            let Some(key) = StoneSocketKey::new(item_type, socket) else {
498                continue;
499            };
500            if !is_unlocked(socket) {
501                continue;
502            }
503            if self.socketed(key).is_some() {
504                continue;
505            }
506            let kind = socket.accepts();
507            let catalog = catalog_order(kind);
508            // `Reverse(rank)` so an earlier catalog position wins a quality tie;
509            // `template_id` last makes the maximum unique and the pick stable.
510            let Some(template_id) = self
511                .collection(kind)
512                .iter()
513                .filter(|stone| !stone.is_socketed())
514                .filter_map(|stone| {
515                    let rank = catalog.iter().position(|id| *id == stone.template_id)?;
516                    Some((stone.quality(), std::cmp::Reverse(rank), stone.template_id))
517                })
518                .max()
519                .map(|(_, _, template_id)| template_id)
520            else {
521                continue;
522            };
523            // `insert` re-checks every rule; a refusal here would be a bug in
524            // the selection above, so it is reported rather than swallowed.
525            match self.insert(template_id, key, true) {
526                Ok(()) => filled.push((socket, template_id)),
527                Err(err) => tracing::error!("QuickEquipStones picked an illegal stone: {err}"),
528            }
529        }
530        filled
531    }
532
533    /// Raises every stone of `kind` as far as its banked copies reach, and
534    /// returns `(template, level before, level after)` for each stone that moved.
535    ///
536    /// This is the whole of `UpgradeAllTriggerStones` /
537    /// `UpgradeAllEffectStones` (design v0.2 §9). `cost` is the ladder rung for
538    /// a level (`None` past the cap), supplied by the caller for the same reason
539    /// as `quick_equip`'s `is_unlocked`.
540    ///
541    /// One catalog per call: the screen's button raises the tab the player is
542    /// looking at, so a Trigger upgrade must not quietly spend the copies banked
543    /// against Effect stones.
544    ///
545    /// One stone is taken up **as many levels as it can afford in one call** —
546    /// the ladder is cheap at the bottom, so a fresh pile of copies is often
547    /// worth several levels and stopping after one would make the button lie.
548    /// Socketed and loose stones are treated identically: an upgrade never
549    /// touches the socket.
550    pub fn upgrade_all(
551        &mut self,
552        kind: StoneKind,
553        max_level: i64,
554        cost: &dyn Fn(i64) -> Option<i64>,
555    ) -> Vec<(StoneTemplateId, i64, i64)> {
556        let templates: Vec<StoneTemplateId> = self
557            .all()
558            .filter(|(stone_kind, _)| *stone_kind == kind)
559            .map(|(_, s)| s.template_id)
560            .collect();
561        let mut upgraded = Vec::new();
562        for template_id in templates {
563            // The level it started at, so the caller can report "3 -> 5" rather
564            // than just where it landed.
565            let Some(started_at) = self.get(template_id).map(|stone| stone.level) else {
566                continue;
567            };
568            let mut reached = None;
569            // Loop, not one step: level 2 may cost 2 copies and level 3 three,
570            // so a bank of 5 is worth two levels at once.
571            while let Some(stone) = self.get(template_id) {
572                let Some(required) = cost(stone.level + 1) else {
573                    break;
574                };
575                match self.upgrade(template_id, required, max_level) {
576                    Ok(level) => reached = Some(level),
577                    Err(_) => break,
578                }
579            }
580            if let Some(level) = reached {
581                upgraded.push((template_id, started_at, level));
582            }
583        }
584        upgraded
585    }
586
587    /// Spends `required` banked copies to take `template_id` up one level.
588    ///
589    /// `required` is the rung of the config ladder for the level being bought
590    /// (`StonesSettings::upgrade_copies_required`) — the cost is per level and
591    /// this type never guesses it.
592    ///
593    /// Copies are a count, so a copy earned at level 1 pays for level 4 like
594    /// any other. Only `level` and `copies` move, leaving `template_id`, `tier`
595    /// and `socket` untouched, so a socketed stone upgrades in place. Every
596    /// refusal returns before the first mutation, so a short bank is left
597    /// exactly as it was.
598    pub fn upgrade(
599        &mut self,
600        template_id: StoneTemplateId,
601        required: i64,
602        max_level: i64,
603    ) -> Result<i64, StoneError> {
604        let stone = self
605            .get_mut(template_id)
606            .ok_or(StoneError::UnknownStone(template_id))?;
607
608        if stone.level >= max_level {
609            return Err(StoneError::AlreadyMaxLevel(template_id, max_level));
610        }
611        let next_level = stone.level + 1;
612        if stone.copies < required {
613            return Err(StoneError::NotEnoughCopies {
614                stone: template_id,
615                level: next_level,
616                required,
617                held: stone.copies,
618            });
619        }
620
621        stone.copies -= required;
622        stone.level = next_level;
623        Ok(next_level)
624    }
625}
626
627#[cfg(test)]
628mod tests {
629    use super::*;
630
631    const TRIGGER: u128 = 100;
632    const OTHER_TRIGGER: u128 = 101;
633    const EFFECT: u128 = 200;
634
635    fn template(n: u128) -> StoneTemplateId {
636        Uuid::from_u128(n)
637    }
638
639    fn stone(template_id: u128, copies: i64) -> Stone {
640        let mut stone = Stone::new(template(template_id), StoneTier::Common);
641        stone.copies = copies;
642        stone
643    }
644
645    /// One trigger and one effect stone, each with copies banked — the shape
646    /// the design describes: one instance, a warehouse of copies next to it.
647    fn inventory() -> StoneInventory {
648        StoneInventory {
649            trigger_stones: vec![stone(TRIGGER, 2)],
650            effect_stones: vec![stone(EFFECT, 2)],
651        }
652    }
653
654    #[test]
655    fn only_the_five_two_sided_slots_have_sockets() {
656        let sockets = StoneSocketKey::all();
657        assert_eq!(sockets.len(), 15, "5 two-sided slots × 3 sockets");
658        for socket in &sockets {
659            assert!(socket.item_type.supports_world_side());
660        }
661        assert!(StoneSocketKey::new(ItemType::Ring, StoneSocketSlot::Trigger).is_none());
662    }
663
664    #[test]
665    fn the_trigger_is_shared_and_the_two_effect_sides_are_separate() {
666        assert_eq!(StoneSocketSlot::Trigger.world_side(), None);
667        assert_eq!(
668            StoneSocketSlot::effect_for(WorldSide::Real),
669            StoneSocketSlot::RealEffect
670        );
671        assert_eq!(
672            StoneSocketSlot::effect_for(WorldSide::Fantasy),
673            StoneSocketSlot::FantasyEffect
674        );
675        assert_ne!(
676            StoneSocketSlot::RealEffect,
677            StoneSocketSlot::FantasyEffect,
678            "each side keeps its own effect"
679        );
680    }
681
682    #[test]
683    fn a_trigger_stone_does_not_fit_an_effect_socket() {
684        let mut inv = inventory();
685        let key = StoneSocketKey::new(ItemType::Weapon, StoneSocketSlot::RealEffect).unwrap();
686        let err = inv.insert(template(TRIGGER), key, true).unwrap_err();
687        assert!(matches!(err, StoneError::WrongCollection { .. }));
688
689        let key = StoneSocketKey::new(ItemType::Weapon, StoneSocketSlot::Trigger).unwrap();
690        let err = inv.insert(template(EFFECT), key, true).unwrap_err();
691        assert!(matches!(err, StoneError::WrongCollection { .. }));
692    }
693
694    #[test]
695    fn a_locked_socket_refuses_the_stone() {
696        let mut inv = inventory();
697        let key = StoneSocketKey::new(ItemType::Weapon, StoneSocketSlot::Trigger).unwrap();
698        assert!(matches!(
699            inv.insert(template(TRIGGER), key, false),
700            Err(StoneError::SocketLocked { .. })
701        ));
702        assert!(inv.get(template(TRIGGER)).unwrap().socket.is_none());
703    }
704
705    #[test]
706    fn a_removed_stone_returns_to_inventory_intact() {
707        let mut inv = inventory();
708        let before = inv.get(template(TRIGGER)).unwrap().clone();
709        let key = StoneSocketKey::new(ItemType::Weapon, StoneSocketSlot::Trigger).unwrap();
710
711        inv.insert(template(TRIGGER), key, true).unwrap();
712        assert_eq!(
713            inv.socketed(key).map(|s| s.template_id),
714            Some(template(TRIGGER))
715        );
716
717        assert_eq!(inv.remove(template(TRIGGER)).unwrap(), key);
718        assert_eq!(inv.trigger_stones.len(), 1, "nothing was consumed");
719        assert_eq!(
720            inv.get(template(TRIGGER)).unwrap(),
721            &before,
722            "the stone comes back exactly as it went in, copies included"
723        );
724        assert!(inv.socketed(key).is_none());
725    }
726
727    #[test]
728    fn a_socket_holds_one_stone_at_a_time() {
729        let mut inv = inventory();
730        inv.grant(StoneKind::Trigger, template(OTHER_TRIGGER), StoneTier::Rare);
731        let key = StoneSocketKey::new(ItemType::Weapon, StoneSocketSlot::Trigger).unwrap();
732        inv.insert(template(TRIGGER), key, true).unwrap();
733
734        // The socket still holds exactly one stone — the newcomer — and the
735        // one it displaced is back in the collection, unsocketed and whole.
736        inv.insert(template(OTHER_TRIGGER), key, true).unwrap();
737
738        assert_eq!(
739            inv.socketed(key).unwrap().template_id,
740            template(OTHER_TRIGGER)
741        );
742        assert_eq!(inv.get(template(TRIGGER)).unwrap().socket, None);
743        assert_eq!(
744            inv.all().filter(|(_, s)| s.socket == Some(key)).count(),
745            1,
746            "a swap must not leave two stones claiming the same socket"
747        );
748    }
749
750    /// One instance per template means one socket per template. The designer
751    /// accepted this when choosing the copy-count model: the same stone cannot
752    /// be worn on two pieces of gear.
753    #[test]
754    fn one_template_cannot_be_socketed_in_two_slots() {
755        let mut inv = inventory();
756        let weapon = StoneSocketKey::new(ItemType::Weapon, StoneSocketSlot::Trigger).unwrap();
757        let gloves = StoneSocketKey::new(ItemType::Gloves, StoneSocketSlot::Trigger).unwrap();
758
759        inv.insert(template(TRIGGER), weapon, true).unwrap();
760        assert_eq!(
761            inv.insert(template(TRIGGER), gloves, true),
762            Err(StoneError::AlreadySocketed(template(TRIGGER))),
763        );
764        assert_eq!(
765            inv.get(template(TRIGGER)).unwrap().socket,
766            Some(weapon),
767            "the refused second insert left the first socket alone"
768        );
769        assert!(inv.socketed(gloves).is_none());
770    }
771
772    /// A copy of a template the player already owns raises the count instead of
773    /// creating a second instance.
774    #[test]
775    fn a_repeat_drop_banks_a_copy_instead_of_a_second_instance() {
776        let mut inv = StoneInventory::default();
777
778        inv.grant(StoneKind::Trigger, template(TRIGGER), StoneTier::Common);
779        assert_eq!(inv.trigger_stones.len(), 1);
780        assert_eq!(inv.trigger_stones[0].level, 1);
781        assert_eq!(
782            inv.trigger_stones[0].copies, 0,
783            "the first copy IS the instance"
784        );
785
786        inv.grant(StoneKind::Trigger, template(TRIGGER), StoneTier::Common);
787        inv.grant(StoneKind::Trigger, template(TRIGGER), StoneTier::Common);
788        assert_eq!(inv.trigger_stones.len(), 1, "still one instance");
789        assert_eq!(inv.trigger_stones[0].copies, 2);
790
791        // A different template is a different instance, in its own collection.
792        inv.grant(StoneKind::Effect, template(EFFECT), StoneTier::Rare);
793        assert_eq!(inv.trigger_stones.len(), 1);
794        assert_eq!(inv.effect_stones.len(), 1);
795    }
796
797    /// A drop of a socketed stone banks a copy without touching the socket.
798    #[test]
799    fn a_repeat_drop_leaves_the_socket_alone() {
800        let mut inv = inventory();
801        let key = StoneSocketKey::new(ItemType::Weapon, StoneSocketSlot::Trigger).unwrap();
802        inv.insert(template(TRIGGER), key, true).unwrap();
803
804        inv.grant(StoneKind::Trigger, template(TRIGGER), StoneTier::Common);
805
806        let stone = inv.get(template(TRIGGER)).unwrap();
807        assert_eq!(stone.socket, Some(key));
808        assert_eq!(stone.copies, 3);
809    }
810
811    /// The whole point of the raw-copies rule: the cost is per level and comes
812    /// from the caller's ladder, and the stone keeps everything but its level.
813    #[test]
814    fn an_upgrade_spends_copies_and_keeps_the_stone() {
815        let mut inv = inventory();
816        inv.trigger_stones[0].copies = 5;
817
818        assert_eq!(inv.upgrade(template(TRIGGER), 2, 10), Ok(2));
819        let stone = inv.get(template(TRIGGER)).unwrap();
820        assert_eq!(stone.level, 2);
821        assert_eq!(stone.copies, 3, "exactly the rung's cost was spent");
822        assert_eq!(stone.template_id, template(TRIGGER));
823        assert_eq!(stone.tier, StoneTier::Common);
824
825        // The next rung costs something different — the ladder, not a constant.
826        assert_eq!(inv.upgrade(template(TRIGGER), 3, 10), Ok(3));
827        assert_eq!(inv.get(template(TRIGGER)).unwrap().copies, 0);
828        assert_eq!(
829            inv.effect_stones[0].copies, 2,
830            "the other collection is untouched"
831        );
832    }
833
834    /// A socketed stone upgrades in place — the socket is not disturbed and the
835    /// player does not have to unequip first.
836    #[test]
837    fn a_socketed_stone_upgrades_in_place() {
838        let mut inv = inventory();
839        let key = StoneSocketKey::new(ItemType::Weapon, StoneSocketSlot::Trigger).unwrap();
840        inv.insert(template(TRIGGER), key, true).unwrap();
841
842        assert_eq!(inv.upgrade(template(TRIGGER), 2, 10), Ok(2));
843
844        let stone = inv.get(template(TRIGGER)).unwrap();
845        assert_eq!(stone.level, 2);
846        assert_eq!(stone.socket, Some(key), "the socket survived the upgrade");
847        assert_eq!(inv.socketed(key).map(|s| s.level), Some(2));
848    }
849
850    /// A short bank is refused and spends nothing — no partial deduction.
851    #[test]
852    fn a_short_bank_is_refused_without_spending_anything() {
853        let mut inv = inventory();
854        assert_eq!(
855            inv.upgrade(template(TRIGGER), 3, 10),
856            Err(StoneError::NotEnoughCopies {
857                stone: template(TRIGGER),
858                level: 2,
859                required: 3,
860                held: 2,
861            })
862        );
863
864        let stone = inv.get(template(TRIGGER)).unwrap();
865        assert_eq!(stone.copies, 2, "nothing was spent");
866        assert_eq!(stone.level, 1);
867    }
868
869    #[test]
870    fn an_upgrade_stops_at_the_maximum_level() {
871        let mut inv = inventory();
872        inv.trigger_stones[0].level = 10;
873        inv.trigger_stones[0].copies = 99;
874
875        assert_eq!(
876            inv.upgrade(template(TRIGGER), 2, 10),
877            Err(StoneError::AlreadyMaxLevel(template(TRIGGER), 10))
878        );
879        assert_eq!(inv.trigger_stones[0].copies, 99, "nothing was spent");
880    }
881
882    #[test]
883    fn upgrading_a_stone_the_player_does_not_own_is_refused() {
884        let mut inv = inventory();
885        assert_eq!(
886            inv.upgrade(template(OTHER_TRIGGER), 2, 10),
887            Err(StoneError::UnknownStone(template(OTHER_TRIGGER)))
888        );
889    }
890
891    /// Copies belong to one stone of one template, so the two collections can
892    /// never fund each other's upgrades.
893    #[test]
894    fn copies_never_cross_between_the_two_collections() {
895        let mut inv = StoneInventory::default();
896        inv.grant(StoneKind::Trigger, template(TRIGGER), StoneTier::Common);
897        for _ in 0..5 {
898            inv.grant(StoneKind::Effect, template(EFFECT), StoneTier::Rare);
899        }
900
901        // The effect stone's bank cannot pay for a trigger level.
902        assert!(matches!(
903            inv.upgrade(template(TRIGGER), 2, 10),
904            Err(StoneError::NotEnoughCopies { held: 0, .. })
905        ));
906        assert_eq!(inv.effect_stones[0].copies, 4, "the effect bank is intact");
907
908        // And the effect stone spends only its own.
909        assert_eq!(inv.upgrade(template(EFFECT), 2, 10), Ok(2));
910        assert_eq!(inv.effect_stones[0].copies, 2);
911        assert_eq!(inv.trigger_stones[0].level, 1);
912    }
913
914    /// Design v0.2 §8: "best" is tier first, then level. A level-10 Common
915    /// loses to a level-1 Legendary.
916    #[test]
917    fn quality_ranks_tier_above_level() {
918        let mut common = Stone::new(template(1), StoneTier::Common);
919        common.level = 10;
920        let legendary = Stone::new(template(2), StoneTier::Legendary);
921        assert!(legendary.quality() > common.quality());
922
923        // Within one tier, level decides.
924        let mut rare_low = Stone::new(template(3), StoneTier::Rare);
925        rare_low.level = 2;
926        let mut rare_high = Stone::new(template(4), StoneTier::Rare);
927        rare_high.level = 3;
928        assert!(rare_high.quality() > rare_low.quality());
929    }
930
931    fn tiered(template_id: u128, kind: StoneKind, tier: StoneTier) -> Stone {
932        let _ = kind;
933        Stone::new(template(template_id), tier)
934    }
935
936    /// Acceptance criterion #10: only the open sockets of the named item are
937    /// filled, and a stone worn on other gear is never taken.
938    #[test]
939    fn quick_equip_fills_open_sockets_of_one_item_only() {
940        let mut inv = StoneInventory {
941            trigger_stones: vec![
942                tiered(1, StoneKind::Trigger, StoneTier::Common),
943                tiered(2, StoneKind::Trigger, StoneTier::Legendary),
944            ],
945            effect_stones: vec![
946                tiered(10, StoneKind::Effect, StoneTier::Rare),
947                tiered(11, StoneKind::Effect, StoneTier::Epic),
948                tiered(12, StoneKind::Effect, StoneTier::Common),
949            ],
950        };
951        // The epic effect is already worn on another item and must stay there.
952        let gloves = StoneSocketKey::new(ItemType::Gloves, StoneSocketSlot::RealEffect).unwrap();
953        inv.insert(template(11), gloves, true).unwrap();
954
955        // Only the trigger and the Real effect socket of the weapon are open.
956        // The pick is the BEST usable stone: the Legendary trigger wins even
957        // though the Common one comes first in the catalog, and the Epic effect
958        // is unavailable, so the Rare beats the Common.
959        let order = |kind: StoneKind| -> Vec<StoneTemplateId> {
960            match kind {
961                StoneKind::Trigger => vec![template(1), template(2)],
962                StoneKind::Effect => vec![template(10), template(11), template(12)],
963            }
964        };
965        let filled = inv.quick_equip(
966            ItemType::Weapon,
967            &|socket| socket != StoneSocketSlot::FantasyEffect,
968            &order,
969        );
970
971        assert_eq!(
972            filled,
973            vec![
974                (StoneSocketSlot::Trigger, template(2)),
975                (StoneSocketSlot::RealEffect, template(10)),
976            ],
977            "highest-tier trigger, highest-tier FREE effect"
978        );
979        assert!(
980            inv.socketed(
981                StoneSocketKey::new(ItemType::Weapon, StoneSocketSlot::FantasyEffect).unwrap()
982            )
983            .is_none(),
984            "a locked socket is never filled"
985        );
986        assert_eq!(
987            inv.get(template(11)).unwrap().socket,
988            Some(gloves),
989            "a stone worn on other gear is not moved"
990        );
991    }
992
993    /// Acceptance criterion #11: nothing to equip is a quiet no-op, and an
994    /// occupied socket is left as the player arranged it.
995    #[test]
996    fn quick_equip_leaves_a_socket_empty_when_nothing_fits() {
997        let mut inv = StoneInventory {
998            trigger_stones: vec![tiered(1, StoneKind::Trigger, StoneTier::Common)],
999            effect_stones: Vec::new(),
1000        };
1001        let trigger_key = StoneSocketKey::new(ItemType::Weapon, StoneSocketSlot::Trigger).unwrap();
1002        inv.insert(template(1), trigger_key, true).unwrap();
1003
1004        // Every socket open, but the only trigger is already in place and there
1005        // are no effect stones at all.
1006        let order = |kind: StoneKind| -> Vec<StoneTemplateId> {
1007            match kind {
1008                StoneKind::Trigger => vec![template(1)],
1009                StoneKind::Effect => Vec::new(),
1010            }
1011        };
1012        let filled = inv.quick_equip(ItemType::Weapon, &|_| true, &order);
1013
1014        assert!(filled.is_empty(), "nothing to do, and nothing broke");
1015        assert_eq!(inv.get(template(1)).unwrap().socket, Some(trigger_key));
1016
1017        // An item type with no sockets at all is also a no-op.
1018        assert!(
1019            inv.quick_equip(ItemType::Ring, &|_| true, &order)
1020                .is_empty()
1021        );
1022    }
1023
1024    /// Two fitted stones trading sockets. The client sends remove/remove then
1025    /// insert/insert, because an insert onto a stone that is still socketed
1026    /// anywhere is refused — this pins that the sequence really does exchange
1027    /// them rather than emptying one side.
1028    #[test]
1029    fn two_socketed_stones_can_trade_places() {
1030        let mut inv = StoneInventory {
1031            trigger_stones: Vec::new(),
1032            effect_stones: vec![
1033                tiered(1, StoneKind::Effect, StoneTier::Rare),
1034                tiered(2, StoneKind::Effect, StoneTier::Epic),
1035            ],
1036        };
1037        let left = StoneSocketKey::new(ItemType::Weapon, StoneSocketSlot::RealEffect).unwrap();
1038        let right = StoneSocketKey::new(ItemType::Weapon, StoneSocketSlot::FantasyEffect).unwrap();
1039        inv.insert(template(1), left, true).unwrap();
1040        inv.insert(template(2), right, true).unwrap();
1041
1042        // exactly what dragging left onto right sends
1043        inv.remove(template(1)).unwrap();
1044        inv.remove(template(2)).unwrap();
1045        inv.insert(template(1), right, true).unwrap();
1046        inv.insert(template(2), left, true).unwrap();
1047
1048        assert_eq!(inv.socketed(right).unwrap().template_id, template(1));
1049        assert_eq!(
1050            inv.socketed(left).unwrap().template_id,
1051            template(2),
1052            "the displaced stone takes the vacated socket, it does not fall out"
1053        );
1054    }
1055
1056    /// BAL-018: Quick Equip never touches an occupied socket — the player's
1057    /// arrangement stands, and no tier ranking exists to "upgrade" against.
1058    #[test]
1059    fn quick_equip_leaves_an_occupied_socket_alone() {
1060        let mut inv = StoneInventory {
1061            trigger_stones: vec![
1062                tiered(1, StoneKind::Trigger, StoneTier::Common),
1063                tiered(2, StoneKind::Trigger, StoneTier::Legendary),
1064            ],
1065            effect_stones: Vec::new(),
1066        };
1067        let key = StoneSocketKey::new(ItemType::Weapon, StoneSocketSlot::Trigger).unwrap();
1068        inv.insert(template(1), key, true).unwrap();
1069
1070        let order = |kind: StoneKind| -> Vec<StoneTemplateId> {
1071            match kind {
1072                StoneKind::Trigger => vec![template(2), template(1)],
1073                StoneKind::Effect => Vec::new(),
1074            }
1075        };
1076        let filled = inv.quick_equip(
1077            ItemType::Weapon,
1078            &|socket| socket == StoneSocketSlot::Trigger,
1079            &order,
1080        );
1081
1082        assert!(
1083            filled.is_empty(),
1084            "an occupied socket is left as the player arranged it"
1085        );
1086        assert_eq!(inv.socketed(key).unwrap().template_id, template(1));
1087        assert_eq!(
1088            inv.get(template(2)).unwrap().socket,
1089            None,
1090            "the Legendary spare stays in the collection"
1091        );
1092    }
1093
1094    /// Acceptance criterion #12: one call climbs several rungs when the bank
1095    /// allows, and stops exactly where it runs out.
1096    #[test]
1097    fn upgrade_all_climbs_several_levels_in_one_call() {
1098        let ladder = |level: i64| match level {
1099            2 => Some(2),
1100            3 => Some(3),
1101            4 => Some(5),
1102            _ => None,
1103        };
1104        let mut inv = StoneInventory {
1105            // 5 copies pay for level 2 (2) and level 3 (3), then stop.
1106            trigger_stones: vec![stone(TRIGGER, 5)],
1107            // 1 copy is not enough for level 2 at all.
1108            effect_stones: vec![stone(EFFECT, 1)],
1109        };
1110
1111        let upgraded = inv.upgrade_all(StoneKind::Trigger, 4, &ladder);
1112
1113        assert_eq!(
1114            upgraded,
1115            vec![(template(TRIGGER), 1, 3)],
1116            "reported as from-level 1 to level 3, not just where it landed"
1117        );
1118        let trigger = inv.get(template(TRIGGER)).unwrap();
1119        assert_eq!(trigger.level, 3);
1120        assert_eq!(trigger.copies, 0);
1121        let effect = inv.get(template(EFFECT)).unwrap();
1122        assert_eq!(effect.level, 1, "a short bank buys nothing");
1123        assert_eq!(effect.copies, 1, "and spends nothing");
1124    }
1125
1126    /// One call raises one catalog: the screen's button acts on the open tab, so
1127    /// a Trigger upgrade must not spend what is banked against Effect stones.
1128    #[test]
1129    fn upgrade_all_touches_only_the_requested_kind() {
1130        let mut inv = inventory();
1131        inv.trigger_stones[0].copies = 1_000;
1132        inv.effect_stones[0].copies = 1_000;
1133
1134        let upgraded = inv.upgrade_all(StoneKind::Trigger, 3, &|_| Some(1));
1135
1136        assert_eq!(
1137            upgraded,
1138            vec![(template(TRIGGER), 1, 3)],
1139            "only the Trigger catalog is reported"
1140        );
1141        let effect = inv.get(template(EFFECT)).unwrap();
1142        assert_eq!(effect.level, 1, "the Effect stone did not move");
1143        assert_eq!(effect.copies, 1_000, "and its bank is untouched");
1144
1145        let upgraded = inv.upgrade_all(StoneKind::Effect, 3, &|_| Some(1));
1146
1147        assert_eq!(upgraded, vec![(template(EFFECT), 1, 3)]);
1148        assert_eq!(
1149            inv.get(template(TRIGGER)).unwrap().level,
1150            3,
1151            "the Trigger stone kept the level the first call gave it"
1152        );
1153    }
1154
1155    /// The cap and the socket both survive an upgrade-all.
1156    #[test]
1157    fn upgrade_all_respects_the_cap_and_keeps_sockets() {
1158        let mut inv = inventory();
1159        inv.trigger_stones[0].copies = 1_000;
1160        let key = StoneSocketKey::new(ItemType::Weapon, StoneSocketSlot::Trigger).unwrap();
1161        inv.insert(template(TRIGGER), key, true).unwrap();
1162
1163        let upgraded = inv.upgrade_all(StoneKind::Trigger, 3, &|_| Some(1));
1164
1165        assert_eq!(upgraded, vec![(template(TRIGGER), 1, 3)]);
1166        let trigger = inv.get(template(TRIGGER)).unwrap();
1167        assert_eq!(trigger.level, 3, "stops at the cap, not at the bank");
1168        assert_eq!(trigger.socket, Some(key), "the socket survived");
1169    }
1170}