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}