1use crate::prelude::*;
2
3use strum_macros::{Display, EnumString};
4
5#[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 pub fn flipped(self) -> Self {
31 match self {
32 Self::Real => Self::Fantasy,
33 Self::Fantasy => Self::Real,
34 }
35 }
36}
37
38#[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 TriggerFired,
70 DamageDealt,
73 DamageReceived,
75}
76
77#[derive(Clone, Copy, Debug, Serialize, Deserialize, JsonSchema, Tsify)]
79#[tsify(from_wasm_abi, into_wasm_abi)]
80pub struct FlipState {
81 pub active_side: WorldSide,
83 pub progress: f64,
85 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#[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 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 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 self.progress = f64::from_bits(progress_threshold.to_bits() - 1);
186 }
187 }
188
189 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 #[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 state.retarget_progress(0.0);
304 state.retarget_progress(f64::NAN);
305 assert_eq!(state.progress, 30.0);
306
307 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}