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}