essences/
pet_facets.rs

1//! Pet Facets / Team Die v0 — the shapes.
2//!
3//! Every Pet carries exactly TWO facets: one Real and one Fantasy. They are part
4//! of the Pet's identity ([`crate::pets::PetTemplate`]), not separately
5//! equippable stones and not rolled on drop, so swapping one Pet swaps one Real
6//! and one Fantasy outcome at once.
7//!
8//! The Team Die is rolled automatically on entering the starting world and after
9//! every global Flip. It rolls among the three facets of the INCOMING side only,
10//! uniformly (`1/3` each) — the d6 of the design doc is presentation, each facet
11//! painted on two sides of one physical die.
12//!
13//! A facet outcome is **Derived**: it never ignites a Trigger Stone and never
14//! creates Resonance, Mastery, Bridge charge or Gauge. [`PetFacet::RisingGate`]
15//! and [`PetFacet::AdvanceNotice`] are the doc's one stated exception — they are
16//! the Flip-Gauge pair and exist to hand out Gauge.
17
18use crate::flip::WorldSide;
19use crate::prelude::*;
20
21use strum_macros::{Display, EnumIter, EnumString};
22
23/// One facet rule. Twenty of them: ten Real, ten Fantasy, in the pairs the
24/// design doc authors them in.
25///
26/// `PET-07 Bridge Fox` and `PET-12 Encore Wisp` of the doc are deliberately
27/// absent: they are written against "Surplus" and "Flip Echo", neither of which
28/// exists in this codebase, and stubbing them would mean inventing two systems.
29#[derive(
30    Clone,
31    Copy,
32    Debug,
33    Default,
34    Serialize,
35    Deserialize,
36    PartialEq,
37    Eq,
38    Hash,
39    JsonSchema,
40    Tsify,
41    Display,
42    EnumString,
43    EnumIter,
44)]
45#[tsify(from_wasm_abi, into_wasm_abi)]
46pub enum PetFacet {
47    // ---- PET-01 Zip — Tempo ----
48    /// Real. Every remaining Skill cooldown loses `head_start_ticks`.
49    #[default]
50    HeadStart,
51    /// Fantasy. The next original Skill gets one derived repeat.
52    SecondSpark,
53
54    // ---- PET-02 Ledger Mimic — Mana ----
55    /// Real. The next N original casts pay a reduced Mana Cost.
56    BudgetPlan,
57    /// Fantasy. The next original cast overpays Mana it can afford and turns
58    /// the surcharge into payload.
59    OpenTab,
60
61    // ---- PET-03 Springpaw — Basic Attack ----
62    /// Real. A timed Attack Speed window.
63    Overtime,
64    /// Fantasy. An immediate volley of derived bolts.
65    MagicVolley,
66
67    // ---- PET-04 Bulwark Slime — Guard ----
68    /// Real. A timed incoming-damage reduction.
69    SafetyNet,
70    /// Fantasy. The next Core hit on the hero answers with a derived area burst.
71    Retaliation,
72
73    // ---- PET-05 Lucky Bat — Crit ----
74    /// Real. The next N Core attacks carry extra Crit Chance.
75    Calibration,
76    /// Fantasy. The next Core critical hit adds a derived burst.
77    LuckyStar,
78
79    // ---- PET-06 Owl Auditor — Laws ----
80    /// Real. The next N activations of the chosen source Law bank extra
81    /// Resonance into ITS bridge.
82    LeadReading,
83    /// Fantasy. The chosen active Law's Effect is stronger for a window; its
84    /// Resonance is not.
85    WildReading,
86
87    // ---- PET-08 Clock Crow — Flip Gauge ----
88    /// Real. The new phase opens with a share of the Flip Gauge already filled.
89    AdvanceNotice,
90    /// Fantasy. The first N gear Trigger procs of the phase each add Gauge.
91    RisingGate,
92
93    // ---- PET-09 Toolbox Goblin — Gear Effects ----
94    /// Real. The first ordinary proc of EACH gear slot in the phase runs
95    /// stronger.
96    PreparedSlots,
97    /// Fantasy. The first ordinary gear Effect of the phase runs a second time.
98    FirstSpell,
99
100    // ---- PET-10 Mirror Moth — Hidden side ----
101    /// Real. The next N Trigger procs each also run the paired hidden Effect.
102    Souvenir,
103    /// Fantasy. The next suitable Core event wakes the chosen hidden Law.
104    DreamReader,
105
106    // ---- PET-11 Lunchbox Boar — Sustain ----
107    /// Real. An instant heal; overheal is discarded.
108    PackedLunch,
109    /// Fantasy. A timed share of Core damage healed back.
110    LifeBloom,
111}
112
113impl PetFacet {
114    /// The world side this facet is painted on. A facet is only ever eligible
115    /// for a die roll whose incoming side matches.
116    pub fn side(self) -> WorldSide {
117        match self {
118            Self::HeadStart
119            | Self::BudgetPlan
120            | Self::Overtime
121            | Self::SafetyNet
122            | Self::Calibration
123            | Self::LeadReading
124            | Self::AdvanceNotice
125            | Self::PreparedSlots
126            | Self::Souvenir
127            | Self::PackedLunch => WorldSide::Real,
128            Self::SecondSpark
129            | Self::OpenTab
130            | Self::MagicVolley
131            | Self::Retaliation
132            | Self::LuckyStar
133            | Self::WildReading
134            | Self::RisingGate
135            | Self::FirstSpell
136            | Self::DreamReader
137            | Self::LifeBloom => WorldSide::Fantasy,
138        }
139    }
140
141    /// Whether this facet needs a player-chosen Law to act on.
142    pub fn needs_law_choice(self) -> Option<PetFacetLawRole> {
143        match self {
144            Self::LeadReading => Some(PetFacetLawRole::LeadReading),
145            Self::WildReading => Some(PetFacetLawRole::WildReading),
146            Self::DreamReader => Some(PetFacetLawRole::DreamReader),
147            _ => None,
148        }
149    }
150}
151
152/// Which of the three "one chosen Law" slots a selection fills.
153///
154/// The three are separate because they draw from three different populations: a
155/// source Law of either side (`LeadReading`), a law of the side that is UP
156/// (`WildReading`), and a law of the side that is DOWN (`DreamReader`).
157#[derive(
158    Clone,
159    Copy,
160    Debug,
161    Serialize,
162    Deserialize,
163    PartialEq,
164    Eq,
165    Hash,
166    JsonSchema,
167    Tsify,
168    Display,
169    EnumString,
170    EnumIter,
171)]
172#[tsify(from_wasm_abi, into_wasm_abi)]
173pub enum PetFacetLawRole {
174    LeadReading,
175    WildReading,
176    DreamReader,
177}
178
179/// The player's out-of-combat Law pre-selections for the three facets that need
180/// one. `None` means "not chosen yet"; the runtime then falls back to the
181/// lowest-slot eligible Law rather than picking at random or doing nothing.
182#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema, Tsify)]
183#[tsify(from_wasm_abi, into_wasm_abi)]
184pub struct PetFacetLawChoices {
185    pub lead_reading: Option<crate::cores::LawTemplateId>,
186    pub wild_reading: Option<crate::cores::LawTemplateId>,
187    pub dream_reader: Option<crate::cores::LawTemplateId>,
188}
189
190impl PetFacetLawChoices {
191    pub fn get(&self, role: PetFacetLawRole) -> Option<crate::cores::LawTemplateId> {
192        match role {
193            PetFacetLawRole::LeadReading => self.lead_reading,
194            PetFacetLawRole::WildReading => self.wild_reading,
195            PetFacetLawRole::DreamReader => self.dream_reader,
196        }
197    }
198
199    pub fn set(&mut self, role: PetFacetLawRole, law: Option<crate::cores::LawTemplateId>) {
200        match role {
201            PetFacetLawRole::LeadReading => self.lead_reading = law,
202            PetFacetLawRole::WildReading => self.wild_reading = law,
203            PetFacetLawRole::DreamReader => self.dream_reader = law,
204        }
205    }
206}
207
208#[cfg(test)]
209mod tests {
210    use super::*;
211    use strum::IntoEnumIterator;
212
213    /// Twenty facets, ten per side — the whole shipped scope, and the property
214    /// the die rests on: three equipped pets give exactly three eligible facets
215    /// per side.
216    #[test]
217    fn the_catalog_is_twenty_facets_split_evenly_by_side() {
218        let all: Vec<PetFacet> = PetFacet::iter().collect();
219        assert_eq!(all.len(), 20);
220        assert_eq!(
221            all.iter().filter(|f| f.side() == WorldSide::Real).count(),
222            10
223        );
224        assert_eq!(
225            all.iter()
226                .filter(|f| f.side() == WorldSide::Fantasy)
227                .count(),
228            10
229        );
230    }
231
232    #[test]
233    fn only_the_three_law_facets_need_a_choice() {
234        let needing: Vec<PetFacet> = PetFacet::iter()
235            .filter(|f| f.needs_law_choice().is_some())
236            .collect();
237        assert_eq!(
238            needing,
239            vec![
240                PetFacet::LeadReading,
241                PetFacet::WildReading,
242                PetFacet::DreamReader
243            ]
244        );
245    }
246
247    #[test]
248    fn law_choices_round_trip_by_role() {
249        let mut choices = PetFacetLawChoices::default();
250        let law = uuid::Uuid::from_u128(7);
251        for role in PetFacetLawRole::iter() {
252            assert_eq!(choices.get(role), None);
253            choices.set(role, Some(law));
254            assert_eq!(choices.get(role), Some(law));
255        }
256        assert_eq!(choices.lead_reading, Some(law));
257        assert_eq!(choices.wild_reading, Some(law));
258        assert_eq!(choices.dream_reader, Some(law));
259
260        choices.set(PetFacetLawRole::WildReading, None);
261        assert_eq!(choices.wild_reading, None);
262        assert_eq!(choices.lead_reading, Some(law));
263    }
264
265    #[test]
266    fn facets_round_trip_through_string_and_json() {
267        for facet in PetFacet::iter() {
268            assert_eq!(facet.to_string().parse::<PetFacet>().unwrap(), facet);
269            let json = serde_json::to_string(&facet).unwrap();
270            assert_eq!(serde_json::from_str::<PetFacet>(&json).unwrap(), facet);
271        }
272    }
273}