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}