Skip to main content

repame_shell/portal/
gate.rs

1//! The suspend gate: one decision per frame, out of the run state the game
2//! knows and the facts the portal knows.
3//!
4//! The games that ship a portal integration hand-roll this at every call site
5//! that touches the clock:
6//!
7//! ```ignore
8//! let suspended = self.platform.suspends_gameplay() && !run.transitional();
9//! let live = run.playing() && !run.transitional() && !suspended;
10//! self.sim.time_scale = if suspended { 0.0 } else { 1.0 };
11//! if live { schedule.run(&mut world); }
12//! ```
13//!
14//! [`Suspend`] is those three lines once, as a pure function of both halves, so
15//! the truth table is testable without a backend and every call site reads the
16//! same way. [`Suspend::apply`] is the only place `Sim::time_scale` is written.
17//!
18//! `repame-fx`'s `SimDriver::frame(step_fx, blocked, tick_sim, clear_edges)`
19//! encodes the other half of the same split: it sequences an ungated fx step
20//! against a gated gameplay tick and takes the block as an argument.
21//! [`Suspend::suspended`] is that argument (the gate decides, the driver
22//! sequences), so the two compose instead of overlapping. `SimDriver` is
23//! stateless and knows nothing about run state, ads, modals, mute or lifecycle,
24//! which is exactly what this module is.
25
26use std::fmt;
27
28use repame_sim::Sim;
29
30/// What only the game knows, settled per frame.
31#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
32pub struct GameState {
33    /// The run is playing: not on a menu, not in a break, not paused. The host
34    /// is told gameplay is running while this holds and nothing else does:
35    /// see [`Suspend::host_gameplay`].
36    pub playing: bool,
37    /// A transition is in flight: a run transition, or the game's own break
38    /// screen while it waits for an ad.
39    ///
40    /// The clock runs on through it whichever way the rest of the gate falls,
41    /// because a transition that cannot tick can never unblock itself; the
42    /// transition is what ends it. Gameplay systems stay off.
43    pub transitional: bool,
44    /// A game-side modal owns the screen and nothing animates under it (a pause
45    /// menu, a settings panel). A modal that animates is a transition instead.
46    pub modal: bool,
47    /// The player muted the game in its own settings. Audio only: a mute is not
48    /// a reason to stop the world.
49    pub muted: bool,
50}
51
52/// What the portal knows, as a plain value so the truth table is testable
53/// without a backend.
54#[derive(Clone, Copy, Debug, PartialEq, Eq)]
55pub struct PortalView {
56    /// An ad was accepted and has not finished or timed out: hold gameplay even
57    /// before the host opens it, or the game plays under the ad's own load.
58    pub ad_pending: bool,
59    /// An ad is on screen.
60    pub ad_visible: bool,
61    /// The host paused the game (a platform pause, not the game's own menu).
62    pub host_paused: bool,
63    /// The page or app is in the background.
64    pub backgrounded: bool,
65    /// The host's audio switch is on. `false` means the host's own mute, which
66    /// is not the player's, see [`GameState::muted`].
67    pub audio_enabled: bool,
68}
69
70impl Default for PortalView {
71    /// Nothing known, audio on: the view of a game with no host, which is also
72    /// the one that suspends nothing.
73    fn default() -> Self {
74        Self {
75            ad_pending: false,
76            ad_visible: false,
77            host_paused: false,
78            backgrounded: false,
79            audio_enabled: true,
80        }
81    }
82}
83
84/// Why the clock is stopped, outermost first.
85///
86/// Several can hold at once (an ad opens in a backgrounded tab) and the first
87/// one that applies is the reported cause: the outermost reason is the one
88/// that explains why the game is neither running nor visible.
89#[derive(Clone, Copy, Debug, PartialEq, Eq)]
90pub enum Cause {
91    /// The page or app is in the background.
92    Backgrounded,
93    /// The host paused the game.
94    HostPaused,
95    /// An ad is on screen, or is on its way.
96    Ad,
97    /// A game-side modal owns the screen.
98    Modal,
99}
100
101impl fmt::Display for Cause {
102    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
103        f.write_str(match self {
104            Self::Backgrounded => "the app is in the background",
105            Self::HostPaused => "the host paused the game",
106            Self::Ad => "an ad is on screen",
107            Self::Modal => "a menu owns the screen",
108        })
109    }
110}
111
112/// One frame's answer to "does gameplay run, does the world tick, does sound
113/// play".
114#[derive(Clone, Copy, Debug, PartialEq, Eq)]
115pub struct Suspend {
116    suspended: bool,
117    live: bool,
118    silenced: bool,
119    host_gameplay: bool,
120    cause: Option<Cause>,
121}
122
123impl Suspend {
124    /// Settle one frame from both halves of the gate.
125    pub fn resolve(game: GameState, portal: PortalView) -> Self {
126        // An ad holds the game from the moment the host accepts it: from then
127        // on the frame the game draws is about to be covered.
128        let ad = portal.ad_pending || portal.ad_visible;
129        // The host took the screen away: it paused us, or the app is not on
130        // screen at all.
131        let away = portal.host_paused || portal.backgrounded;
132        let cause = if portal.backgrounded {
133            Some(Cause::Backgrounded)
134        } else if portal.host_paused {
135            Some(Cause::HostPaused)
136        } else if ad {
137            Some(Cause::Ad)
138        } else if game.modal {
139            Some(Cause::Modal)
140        } else {
141            None
142        };
143        // Gameplay systems run only when a run is playing and nothing holds it
144        // back: a transition, a game-side modal, an ad or a host-side pause
145        // covers the screen the same way, and running spawners, timers and
146        // input edges under any of them is a bug the games kept shipping.
147        let live = game.playing && !game.transitional && !game.modal && !ad && !away;
148        Self {
149            // A transition runs on the raw clock whichever way this falls: the
150            // transition itself is what ends it.
151            suspended: cause.is_some() && !game.transitional,
152            live,
153            // Sound stops for the player's own mute and whenever the game is
154            // not the thing on screen.
155            silenced: game.muted || !portal.audio_enabled || ad || away,
156            // The host counts impressions from time it believes is gameplay. A
157            // transition or a modal is the game's own screen and is not the
158            // host's business, so neither is reported: what is reported is
159            // exactly when gameplay systems run.
160            host_gameplay: live,
161            cause: if game.transitional { None } else { cause },
162        }
163    }
164
165    /// The clock is stopped this frame, so the sim must not tick. This is the
166    /// `blocked` argument of `repame-fx`'s `SimDriver::frame`.
167    pub fn suspended(&self) -> bool {
168        self.suspended
169    }
170
171    /// Gameplay systems run this frame (the games' `live` flag): a run is
172    /// playing and nothing covers it, not a transition, a modal, an ad or the
173    /// host. The world still ticks under a transition and the clock still runs,
174    /// so this is not the negation of [`Suspend::suspended`].
175    pub fn live(&self) -> bool {
176        self.live
177    }
178
179    /// Audio must be silent this frame.
180    pub fn silenced(&self) -> bool {
181        self.silenced
182    }
183
184    /// Whether the host should count gameplay as running this frame: the same
185    /// answer as [`Suspend::live`], because a transition or a modal is the
186    /// game's own screen and impression counts are contractual.
187    pub fn host_gameplay(&self) -> bool {
188        self.host_gameplay
189    }
190
191    /// The outermost reason the clock is stopped, `None` when it runs.
192    pub fn cause(&self) -> Option<Cause> {
193        self.cause
194    }
195
196    /// The clock this decision means: `0.0` while suspended, `1.0` otherwise.
197    ///
198    /// A stopped clock is not a banked one: `Sim::step` drops the wall time it
199    /// would have accumulated, so resuming after a long ad does not run a burst
200    /// of catch-up ticks.
201    pub fn time_scale(&self) -> f32 {
202        if self.suspended { 0.0 } else { 1.0 }
203    }
204
205    /// Write the decision to the sim clock.
206    pub fn apply(&self, sim: &mut Sim) {
207        sim.time_scale = self.time_scale();
208    }
209}
210
211#[cfg(test)]
212mod tests {
213    use super::*;
214
215    fn game(playing: bool, transitional: bool, modal: bool, muted: bool) -> GameState {
216        GameState {
217            playing,
218            transitional,
219            modal,
220            muted,
221        }
222    }
223
224    fn portal(
225        ad_pending: bool,
226        ad_visible: bool,
227        host_paused: bool,
228        backgrounded: bool,
229    ) -> PortalView {
230        PortalView {
231            ad_pending,
232            ad_visible,
233            host_paused,
234            backgrounded,
235            ..Default::default()
236        }
237    }
238
239    /// Every combination of the five gate inputs and the four game flags, held
240    /// to the rules the field docs state.
241    #[test]
242    fn the_truth_table_is_the_same_rule_everywhere() {
243        for bits in 0..512u32 {
244            let game_state = game(bits & 1 != 0, bits & 2 != 0, bits & 4 != 0, bits & 8 != 0);
245            let view = PortalView {
246                ad_pending: bits & 16 != 0,
247                ad_visible: bits & 32 != 0,
248                host_paused: bits & 64 != 0,
249                backgrounded: bits & 128 != 0,
250                audio_enabled: bits & 256 != 0,
251            };
252            let decision = Suspend::resolve(game_state, view);
253
254            let ad = view.ad_pending || view.ad_visible;
255            let away = view.host_paused || view.backgrounded;
256            let live =
257                game_state.playing && !game_state.transitional && !game_state.modal && !ad && !away;
258            let held = ad || away || game_state.modal;
259            let reported = {
260                if view.backgrounded {
261                    Some(Cause::Backgrounded)
262                } else if view.host_paused {
263                    Some(Cause::HostPaused)
264                } else if ad {
265                    Some(Cause::Ad)
266                } else if game_state.modal {
267                    Some(Cause::Modal)
268                } else {
269                    None
270                }
271            };
272
273            assert_eq!(
274                decision.suspended(),
275                held && !game_state.transitional,
276                "clock at {bits:#011b}"
277            );
278            assert_eq!(
279                decision.cause(),
280                if game_state.transitional {
281                    None
282                } else {
283                    reported
284                },
285                "cause at {bits:#011b}"
286            );
287            assert_eq!(decision.live(), live, "gameplay at {bits:#011b}");
288            assert_eq!(
289                decision.silenced(),
290                game_state.muted || !view.audio_enabled || ad || away,
291                "sound at {bits:#011b}"
292            );
293            assert_eq!(
294                decision.host_gameplay(),
295                live,
296                "host gameplay at {bits:#011b}"
297            );
298            assert_eq!(
299                decision.time_scale(),
300                if decision.suspended() { 0.0 } else { 1.0 },
301                "clock scale at {bits:#011b}"
302            );
303        }
304    }
305
306    #[test]
307    fn a_transition_keeps_the_clock_for_every_holder() {
308        for view in [
309            portal(true, false, false, false),
310            portal(false, true, false, false),
311            portal(false, false, true, false),
312            portal(false, false, false, true),
313        ] {
314            let decision = Suspend::resolve(game(true, true, false, false), view);
315            assert!(
316                !decision.suspended(),
317                "a transition must be able to finish: {view:?}"
318            );
319            assert_eq!(decision.time_scale(), 1.0);
320            assert!(!decision.live(), "gameplay stays off under a transition");
321        }
322    }
323
324    #[test]
325    fn a_modal_keeps_the_clock_and_the_transition_rule_is_not_a_modal() {
326        let menu = Suspend::resolve(game(false, false, true, false), PortalView::default());
327        assert!(menu.suspended());
328        assert_eq!(menu.cause(), Some(Cause::Modal));
329        assert_eq!(menu.time_scale(), 0.0);
330        assert!(!menu.live());
331    }
332
333    #[test]
334    fn the_default_view_suspends_nothing_and_silences_nothing() {
335        let decision = Suspend::resolve(GameState::default(), PortalView::default());
336        assert!(!decision.suspended() && !decision.silenced());
337        assert_eq!(decision.time_scale(), 1.0);
338        assert!(!decision.live(), "a default state is not a playing run");
339        assert!(!decision.host_gameplay());
340    }
341
342    #[test]
343    fn apply_writes_the_clock_and_a_stopped_clock_is_dropped_not_banked() {
344        let mut sim = Sim::with_default_step();
345        Suspend::resolve(
346            game(true, false, false, false),
347            portal(true, false, false, false),
348        )
349        .apply(&mut sim);
350        assert_eq!(sim.time_scale, 0.0);
351        Suspend::resolve(game(true, false, false, false), PortalView::default()).apply(&mut sim);
352        assert_eq!(sim.time_scale, 1.0);
353    }
354}