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