essences/
flip.rs

1use crate::prelude::*;
2
3use strum_macros::{Display, EnumString};
4
5/// The currently presented half of every two-sided equipment slot.
6#[derive(
7    Clone,
8    Copy,
9    Debug,
10    Default,
11    Serialize,
12    Deserialize,
13    PartialEq,
14    Eq,
15    Hash,
16    JsonSchema,
17    Tsify,
18    Display,
19    EnumString,
20)]
21#[tsify(from_wasm_abi, into_wasm_abi)]
22pub enum WorldSide {
23    Real,
24    #[default]
25    Fantasy,
26}
27
28impl WorldSide {
29    /// Returns the other side of the global two-sided build.
30    pub fn flipped(self) -> Self {
31        match self {
32            Self::Real => Self::Fantasy,
33            Self::Fantasy => Self::Real,
34        }
35    }
36}
37
38/// Server-owned producer category for normalized global-flip progress.
39///
40/// Future artifact or quest integrations add variants here and feed the same
41/// accumulator instead of reimplementing threshold behavior.
42///
43/// Trigger Stones are the only shipped producer: the gauge fills from stone
44/// fires (weighted per trigger tier via `StonesSettings.gauge_weights`), so a
45/// player with no sockets open does not flip.
46#[derive(
47    Clone,
48    Copy,
49    Debug,
50    Serialize,
51    Deserialize,
52    PartialEq,
53    Eq,
54    Hash,
55    JsonSchema,
56    Tsify,
57    Display,
58    EnumString,
59)]
60#[tsify(from_wasm_abi, into_wasm_abi)]
61pub enum FlipProgressSource {
62    /// One successful Trigger Stone fire. Carries both halves of a fire's gauge
63    /// contribution: the weight of the trigger's own tier, and whatever the
64    /// effect it ran added on top (`EffectStoneAction::GaugeFill`) — the effect
65    /// only runs because the trigger fired, so they share one producer.
66    ///
67    /// New variants append at the tail: the variant order is the postcard tag
68    /// order on the wire.
69    TriggerFired,
70    /// BAL-026: actual HP this combatant removed from a victim, normalized by
71    /// the victim's max HP and its wave share.
72    DamageDealt,
73    /// BAL-026: actual HP this combatant lost, normalized by its own max HP.
74    DamageReceived,
75}
76
77/// Persistent, server-authoritative state shared by every flip equipment slot.
78#[derive(Clone, Copy, Debug, Serialize, Deserialize, JsonSchema, Tsify)]
79#[tsify(from_wasm_abi, into_wasm_abi)]
80pub struct FlipState {
81    /// Side currently presented by all flip slots.
82    pub active_side: WorldSide,
83    /// Normalized accumulated progress in the range `[0, progress_threshold)`.
84    pub progress: f64,
85    /// Monotonic animation key incremented once for every completed flip.
86    pub revision: u64,
87}
88
89impl PartialEq for FlipState {
90    fn eq(&self, other: &Self) -> bool {
91        self.active_side == other.active_side
92            && self.progress.to_bits() == other.progress.to_bits()
93            && self.revision == other.revision
94    }
95}
96
97impl Eq for FlipState {}
98
99impl Default for FlipState {
100    fn default() -> Self {
101        Self {
102            active_side: WorldSide::Fantasy,
103            progress: 0.0,
104            revision: 0,
105        }
106    }
107}
108
109/// One authoritative transition produced by [`FlipState::accumulate`].
110#[derive(Clone, Copy, Debug, PartialEq, Eq)]
111pub struct FlipTransition {
112    pub source: FlipProgressSource,
113    pub from_side: WorldSide,
114    pub to_side: WorldSide,
115    pub revision: u64,
116}
117
118#[derive(Clone, Copy, Debug, PartialEq, Eq, thiserror::Error)]
119pub enum FlipProgressError {
120    #[error("flip progress threshold must be finite and positive")]
121    InvalidThreshold,
122    #[error("flip progress gain must be finite and positive")]
123    InvalidGain,
124}
125
126impl FlipState {
127    /// Adds one normalized gain and returns a transition when it reaches the
128    /// threshold. Overflow is discarded and one call can flip at most once.
129    pub fn accumulate(
130        &mut self,
131        source: FlipProgressSource,
132        amount: f64,
133        progress_threshold: f64,
134    ) -> Result<Option<FlipTransition>, FlipProgressError> {
135        if !progress_threshold.is_finite() || progress_threshold <= 0.0 {
136            return Err(FlipProgressError::InvalidThreshold);
137        }
138        if !amount.is_finite() || amount <= 0.0 {
139            return Err(FlipProgressError::InvalidGain);
140        }
141
142        if !self.progress.is_finite() || self.progress < 0.0 || self.progress >= progress_threshold
143        {
144            self.progress = 0.0;
145        }
146
147        if amount >= progress_threshold - self.progress {
148            let from_side = self.active_side;
149            self.active_side = self.active_side.flipped();
150            self.progress = 0.0;
151            self.revision = self.revision.saturating_add(1);
152            return Ok(Some(FlipTransition {
153                source,
154                from_side,
155                to_side: self.active_side,
156                revision: self.revision,
157            }));
158        }
159
160        self.progress += amount;
161        Ok(None)
162    }
163
164    /// Re-denominates accumulated progress against a threshold that just got
165    /// *smaller* — putting on `ART-03 Halfway Bell` moves the bar under charge
166    /// the player already earned.
167    ///
168    /// The charge is kept, not zeroed: it is parked immediately below the new
169    /// bar so the very next gain of any size flips. Zeroing (what
170    /// [`FlipState::accumulate`] does to an out-of-range accumulator) would
171    /// silently burn up to a full gauge on an equip, and flipping outright here
172    /// would hand out a free transition for putting an artifact on and taking it
173    /// off again.
174    pub fn retarget_progress(&mut self, progress_threshold: f64) {
175        if !progress_threshold.is_finite() || progress_threshold <= 0.0 {
176            return;
177        }
178        if !self.progress.is_finite() || self.progress < 0.0 {
179            self.progress = 0.0;
180            return;
181        }
182        if self.progress >= progress_threshold {
183            // The largest representable value below the bar: still "not there
184            // yet" for `accumulate`, and no charge lost.
185            self.progress = f64::from_bits(progress_threshold.to_bits() - 1);
186        }
187    }
188
189    /// Restores the accumulator invariant after loading durable state.
190    pub fn sanitize_progress(&mut self, progress_threshold: f64) {
191        if !progress_threshold.is_finite()
192            || progress_threshold <= 0.0
193            || !self.progress.is_finite()
194            || self.progress < 0.0
195            || self.progress >= progress_threshold
196        {
197            self.progress = 0.0;
198        }
199    }
200}
201
202#[cfg(test)]
203mod tests {
204    use super::*;
205
206    #[test]
207    fn default_side_is_fantasy() {
208        assert_eq!(FlipState::default().active_side, WorldSide::Fantasy);
209    }
210
211    #[test]
212    fn accumulation_below_threshold_preserves_side_and_revision() {
213        let mut state = FlipState::default();
214
215        let transition = state
216            .accumulate(FlipProgressSource::TriggerFired, 0.4, 1.0)
217            .unwrap();
218
219        assert_eq!(transition, None);
220        assert_eq!(state.active_side, WorldSide::Fantasy);
221        assert_eq!(state.progress, 0.4);
222        assert_eq!(state.revision, 0);
223    }
224
225    #[test]
226    fn threshold_flips_once_and_discards_overflow() {
227        let mut state = FlipState {
228            progress: 0.75,
229            ..Default::default()
230        };
231
232        let transition = state
233            .accumulate(FlipProgressSource::TriggerFired, 2.0, 1.0)
234            .unwrap()
235            .unwrap();
236
237        assert_eq!(transition.from_side, WorldSide::Fantasy);
238        assert_eq!(transition.to_side, WorldSide::Real);
239        assert_eq!(transition.revision, 1);
240        assert_eq!(state.progress, 0.0);
241        assert_eq!(state.revision, 1);
242    }
243
244    #[test]
245    fn invalid_inputs_do_not_mutate_state() {
246        let original = FlipState {
247            active_side: WorldSide::Real,
248            progress: 0.25,
249            revision: 7,
250        };
251
252        for (amount, threshold) in [
253            (0.0, 1.0),
254            (-1.0, 1.0),
255            (f64::NAN, 1.0),
256            (f64::INFINITY, 1.0),
257            (1.0, 0.0),
258            (1.0, f64::NAN),
259            (1.0, f64::INFINITY),
260        ] {
261            let mut state = original;
262            assert!(
263                state
264                    .accumulate(FlipProgressSource::TriggerFired, amount, threshold)
265                    .is_err()
266            );
267            assert_eq!(state, original);
268        }
269    }
270
271    /// Putting the bar under earned charge keeps the charge and flips on the
272    /// next gain, rather than burning it.
273    #[test]
274    fn retargeting_parks_progress_below_a_lowered_bar() {
275        let mut state = FlipState {
276            progress: 90.0,
277            ..Default::default()
278        };
279        state.retarget_progress(60.0);
280        assert!(state.progress < 60.0, "the charge is under the new bar");
281        assert!(state.progress > 59.9, "and none of it was burned");
282        assert_eq!(state.revision, 0, "retargeting is not a flip");
283
284        let transition = state
285            .accumulate(FlipProgressSource::TriggerFired, 0.001, 60.0)
286            .unwrap();
287        assert!(
288            transition.is_some(),
289            "the next gain of any size crosses the lowered bar"
290        );
291    }
292
293    #[test]
294    fn retargeting_leaves_progress_that_still_fits_alone() {
295        let mut state = FlipState {
296            progress: 30.0,
297            ..Default::default()
298        };
299        state.retarget_progress(60.0);
300        assert_eq!(state.progress, 30.0);
301
302        // A nonsense bar is ignored rather than acted on.
303        state.retarget_progress(0.0);
304        state.retarget_progress(f64::NAN);
305        assert_eq!(state.progress, 30.0);
306
307        // A corrupt accumulator is still reset.
308        state.progress = f64::NAN;
309        state.retarget_progress(60.0);
310        assert_eq!(state.progress, 0.0);
311    }
312
313    #[test]
314    fn sanitization_resets_out_of_range_durable_progress() {
315        for progress in [-1.0, 1.0, f64::NAN, f64::INFINITY] {
316            let mut state = FlipState {
317                progress,
318                ..Default::default()
319            };
320            state.sanitize_progress(1.0);
321            assert_eq!(state.progress, 0.0);
322        }
323    }
324}