repame_shell/portal/host.rs
1//! The shell's portal state machine: the one object a game drives each frame.
2//!
3//! A game never talks to a host directly. It hands [`PortalHost`] a backend
4//! (Playgama on the web, [`NoPortal`] on the desktop), drives it once per
5//! frame, and reads two things back: the frame's [`Suspend`] gate and the
6//! [`PortalReport`]s the host decided since the last call.
7//!
8//! ```ignore
9//! // Once, at boot.
10//! let mut portal = PortalHost::pull_first(backend); // or PortalHost::none()
11//! portal.loaded(); // the assets are up
12//!
13//! // Once per frame, before the sim steps.
14//! portal.update(dt); // one clock for ad deadlines
15//! let gate = portal.gate(GameState {
16//! playing: run.playing(),
17//! transitional: run.transitional(),
18//! modal: false,
19//! muted: save.data.muted,
20//! });
21//! gate.apply(&mut sim); // the one writer of time_scale
22//! if gate.live() {
23//! schedule.run(&mut sim.world);
24//! }
25//! portal.ready(); // first playable frame: game_ready
26//! for report in portal.take_reports() {
27//! match report {
28//! PortalReport::AdFinished { kind: AdKind::Rewarded, outcome } if outcome.pays(kind) => revive(),
29//! PortalReport::CloudPulled(Ok(Some(bytes))) => save.apply_cloud(&bytes),
30//! _ => {}
31//! }
32//! }
33//! ```
34//!
35//! What the host owns and the game does not:
36//!
37//! - one ad at a time, and the deadline that releases the game when the host
38//! never ends it ([`INTERSTITIAL_TIMEOUT`], [`REWARDED_TIMEOUT`]);
39//! - the reward rule (a rewarded ad pays only on [`AdOutcome::Completed`],
40//! which needs the host's reward point, never the ask);
41//! - the interstitial rate limit, mirrored from the game's bridge config so a
42//! refused ad is a typed [`AdRefused::TooSoon`] instead of a request the host
43//! ignores;
44//! - the lifecycle read and the host-facing gameplay notices.
45//!
46//! The shell never holds the player: a break the host never ends is released on
47//! its deadline and reported as [`AdOutcome::TimedOut`], and a request the host
48//! cannot serve is refused on the spot. Both are visible to the game: nothing
49//! here can wait on a callback that will not come, and nothing reports a
50//! success it did not get.
51//!
52//! [`INTERSTITIAL_TIMEOUT`]: super::INTERSTITIAL_TIMEOUT
53//! [`REWARDED_TIMEOUT`]: super::REWARDED_TIMEOUT
54
55use std::fmt;
56
57use web_time::Duration;
58
59use super::gate::{GameState, PortalView, Suspend};
60use super::lifecycle;
61use super::none::NoPortal;
62use super::protocol::{
63 AdEnding, AdKind, AdSchedule, HostNotice, Portal, PortalEvent, PortalRequest, PortalStatus,
64 Refusal,
65};
66
67/// Why an ad the game asked for is not on its way.
68#[derive(Clone, Debug, PartialEq, Eq)]
69pub enum AdRefused {
70 /// Another break is already running: one at a time.
71 Busy,
72 /// The host's interstitial rate limit has not elapsed. `remaining` is how
73 /// much of it is left.
74 TooSoon { remaining: Duration },
75 /// The portal would not take the request at all.
76 Refusal(Refusal),
77}
78
79impl fmt::Display for AdRefused {
80 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
81 match self {
82 Self::Busy => f.write_str("another break is already running"),
83 Self::TooSoon { remaining } => {
84 write!(f, "the host's interstitial delay has {remaining:?} left")
85 }
86 Self::Refusal(reason) => write!(f, "{reason}"),
87 }
88 }
89}
90
91/// Why a cloud write did not go to the host.
92#[derive(Clone, Debug, PartialEq, Eq)]
93pub enum CloudRefused {
94 /// No cloud read has resolved yet, and this host was built with
95 /// [`PortalHost::pull_first`]: the first push of a session must not race
96 /// the read that would have said what the player already has.
97 BeforePull,
98 /// The portal would not take the write at all.
99 Refusal(Refusal),
100}
101
102impl fmt::Display for CloudRefused {
103 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
104 match self {
105 Self::BeforePull => f.write_str("no cloud read has resolved yet"),
106 Self::Refusal(reason) => write!(f, "{reason}"),
107 }
108 }
109}
110
111/// What an ad did, after the shell has matched the host's events to the
112/// request that asked for it.
113#[derive(Clone, Debug, PartialEq, Eq)]
114pub enum AdOutcome {
115 /// The ad reached its end. An interstitial is complete once the host
116 /// reports it closed; a rewarded ad is complete once it reported the
117 /// reward, whatever ends it afterwards. The reward is due.
118 Completed,
119 /// The ad closed before it completed: a rewarded ad was closed by the
120 /// player before the host reported the reward. Nothing is owed.
121 Skipped,
122 /// The host never reported the ad's end before its deadline, so the shell
123 /// released the game. Nothing is owed, and the host's later word on that ad
124 /// is ignored: a break cannot be held open on a report that may never come.
125 TimedOut { after: Duration },
126 /// The host could not play the ad: it gave up before the video ran.
127 /// Nothing is owed, and asking again is the game's call.
128 Failed,
129}
130
131impl AdOutcome {
132 /// Whether a `kind` ad pays a reward on this outcome.
133 pub fn pays(&self, kind: AdKind) -> bool {
134 kind.is_rewarded() && matches!(self, Self::Completed)
135 }
136
137 /// Whether the ad reached its end, whatever its kind.
138 pub fn completed(&self) -> bool {
139 matches!(self, Self::Completed)
140 }
141}
142
143impl fmt::Display for AdOutcome {
144 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
145 match self {
146 Self::Completed => f.write_str("completed"),
147 Self::Skipped => f.write_str("skipped"),
148 Self::TimedOut { after } => write!(f, "timed out after {after:?}"),
149 Self::Failed => f.write_str("failed"),
150 }
151 }
152}
153
154/// One thing the shell decided, for the game to drain once per frame.
155#[derive(Clone, Debug, PartialEq, Eq)]
156pub enum PortalReport {
157 /// An ad opened and covers the screen. One the game did not ask for is
158 /// reported too: the host can break in on its own.
159 AdOpened { kind: AdKind },
160 /// The ad finished. A rewarded ad pays only when `outcome.pays(kind)`.
161 AdFinished { kind: AdKind, outcome: AdOutcome },
162 /// The host's audio switch moved.
163 AudioEnabled(bool),
164 /// The host paused or resumed the game.
165 HostPaused(bool),
166 /// A cloud read landed: `Ok(None)` when the host had nothing stored under
167 /// the key, which is not a failure.
168 CloudPulled(Result<Option<Vec<u8>>, Refusal>),
169 /// A cloud write landed, or the reason it did not. A refused write leaves
170 /// the game's save dirty, and pushing again is the game's call.
171 CloudStored(Result<(), Refusal>),
172 /// A gameplay notice the host would not take. The host counts impressions
173 /// from these, so a dropped one is reported rather than lost.
174 NoticeDropped { notice: HostNotice, reason: Refusal },
175}
176
177/// One ad in flight, from the moment the host took the request.
178#[derive(Clone, Copy, Debug)]
179struct Pending {
180 kind: AdKind,
181 /// Time left before the shell releases the game.
182 deadline: Duration,
183 /// The host reported the reward point.
184 rewarded: bool,
185 /// The host reported the ad on screen.
186 visible: bool,
187}
188
189/// The shell's half of a portal: requests out, host facts in, one gate.
190pub struct PortalHost {
191 portal: Box<dyn Portal>,
192 reports: Vec<PortalReport>,
193 pending: Option<Pending>,
194 audio_enabled: bool,
195 host_paused: bool,
196 backgrounded: bool,
197 /// Time since the game loaded, for the host's interstitial rate limit.
198 since_interstitial: Duration,
199 /// An interstitial has been on screen, so the rate limit between two of
200 /// them now applies instead of the initial one.
201 interstitial_shown: bool,
202 schedule: AdSchedule,
203 /// Whether the host is currently told gameplay is running, so only a change
204 /// sends a notice.
205 announced_gameplay: bool,
206 /// The load finished and `game_ready` is waiting for the first playable
207 /// frame.
208 ready_pending: bool,
209 ready_sent: bool,
210 /// A cloud read has resolved: see [`PortalHost::cloud_ready`] for what
211 /// counts.
212 cloud_read: bool,
213 /// Refuse cloud pushes until a read resolved.
214 pull_first: bool,
215}
216
217impl PortalHost {
218 /// A host over `portal`. A cloud push may be sent as soon as the game has
219 /// something to store.
220 pub fn new(portal: Box<dyn Portal>) -> Self {
221 lifecycle::watch();
222 let status = portal.status();
223 Self {
224 portal,
225 reports: Vec::new(),
226 pending: None,
227 audio_enabled: status.audio_enabled,
228 host_paused: status.host_paused,
229 backgrounded: lifecycle::backgrounded(),
230 since_interstitial: Duration::ZERO,
231 interstitial_shown: false,
232 schedule: status.interstitial_schedule,
233 announced_gameplay: false,
234 ready_pending: false,
235 ready_sent: false,
236 cloud_read: false,
237 pull_first: false,
238 }
239 }
240
241 /// A host over `portal` that refuses cloud pushes until a read has
242 /// resolved.
243 ///
244 /// A game that loads its save and autosaves on every change wants this: a
245 /// push that lands before the read would store a fresh default over the
246 /// save the player came back for.
247 pub fn pull_first(portal: Box<dyn Portal>) -> Self {
248 Self {
249 pull_first: true,
250 ..Self::new(portal)
251 }
252 }
253
254 /// The host a build without a portal gets: every request is refused with
255 /// [`Refusal::NoPortal`], nothing hangs, and the gate suspends only for what
256 /// the game itself reports.
257 pub fn none() -> Self {
258 Self::new(Box::new(NoPortal))
259 }
260
261 /// The backend's name, for logs.
262 pub fn label(&self) -> &str {
263 self.portal.label()
264 }
265
266 /// What the host can do right now.
267 pub fn status(&self) -> PortalStatus {
268 self.portal.status()
269 }
270
271 /// The portal's half of the gate.
272 pub fn view(&self) -> PortalView {
273 PortalView {
274 ad_pending: self.pending.is_some(),
275 ad_visible: self.ad_visible(),
276 host_paused: self.host_paused,
277 backgrounded: self.backgrounded,
278 audio_enabled: self.audio_enabled,
279 }
280 }
281
282 /// The load finished. The host is told on the first playable frame, see
283 /// [`PortalHost::ready`], because a host that removes its loading overlay
284 /// on `game_ready` must remove it over a frame the player can see.
285 pub fn loaded(&mut self) {
286 self.ready_pending = true;
287 }
288
289 /// The first playable frame is up: send `game_ready`, once.
290 ///
291 /// Returns whether this call sent it. `false` means there was nothing to
292 /// send: a second call, or one made before [`PortalHost::loaded`]. The host
293 /// rejects a second `game_ready`, and nothing in the bridge can report that
294 /// rejection, so this never sends one.
295 pub fn ready(&mut self) -> Result<bool, Refusal> {
296 if !self.ready_pending || self.ready_sent {
297 return Ok(false);
298 }
299 self.send(HostNotice::GameReady)?;
300 self.ready_sent = true;
301 self.ready_pending = false;
302 Ok(true)
303 }
304
305 /// Tell the host something itself. The gate sends the gameplay notices on
306 /// its own; this is for a game that has something else to say.
307 pub fn notify(&mut self, notice: HostNotice) -> Result<(), Refusal> {
308 self.send(notice)
309 }
310
311 /// Advance the portal by one frame: read the lifecycle, hand the backend's
312 /// inbox over, and release an ad that outlived its deadline.
313 ///
314 /// Call once per frame, suspended or not: the ad deadline has to run while
315 /// the game is held by the ad.
316 pub fn update(&mut self, dt: Duration) {
317 self.backgrounded = lifecycle::backgrounded();
318 self.since_interstitial += dt;
319 let events = self.portal.poll();
320 for event in events {
321 self.absorb(event);
322 }
323 let mut released = None;
324 if let Some(pending) = &mut self.pending {
325 pending.deadline = pending.deadline.saturating_sub(dt);
326 if pending.deadline.is_zero() {
327 released = Some(pending.kind);
328 }
329 }
330 if let Some(kind) = released {
331 log::warn!(
332 "{}: a {kind} ad did not finish within {:?}; releasing the game",
333 self.portal.label(),
334 kind.timeout()
335 );
336 self.finish(
337 kind,
338 AdOutcome::TimedOut {
339 after: kind.timeout(),
340 },
341 );
342 }
343 }
344
345 /// Settle this frame's gate, and tell the host when the answer changed.
346 pub fn gate(&mut self, game: GameState) -> Suspend {
347 let decision = Suspend::resolve(game, self.view());
348 if decision.host_gameplay() != self.announced_gameplay {
349 self.announced_gameplay = decision.host_gameplay();
350 let notice = if decision.host_gameplay() {
351 HostNotice::GameplayStarted
352 } else {
353 HostNotice::GameplayStopped
354 };
355 let _ = self.send(notice);
356 }
357 decision
358 }
359
360 /// Everything the shell decided since the last call.
361 pub fn take_reports(&mut self) -> Vec<PortalReport> {
362 std::mem::take(&mut self.reports)
363 }
364
365 /// Ask the host for an ad. `placement` names a placement from the game's
366 /// config; `None` asks for the one the config declares.
367 ///
368 /// A taken request holds gameplay the next time the gate is settled, see
369 /// [`PortalHost::ad_pending`], and ends in a
370 /// [`PortalReport::AdFinished`] whichever way the host takes it.
371 ///
372 /// A refused rewarded ad pays nothing. A game that wants to be generous
373 /// where no ad can run (the games' desktop rule: an offline rewarded break
374 /// always pays out) grants the reward from that refusal itself, because the
375 /// policy is the game's and not the portal's.
376 pub fn show_ad(&mut self, kind: AdKind, placement: Option<&str>) -> Result<(), AdRefused> {
377 if self.pending.is_some() {
378 return Err(AdRefused::Busy);
379 }
380 if kind == AdKind::Interstitial {
381 let wait = if self.interstitial_shown {
382 self.schedule.minimum_delay
383 } else {
384 self.schedule.initial_delay
385 };
386 if self.since_interstitial < wait {
387 return Err(AdRefused::TooSoon {
388 remaining: wait - self.since_interstitial,
389 });
390 }
391 }
392 let placement = placement.map(str::to_string);
393 self.portal
394 .request(PortalRequest::ShowAd { kind, placement })
395 .map_err(AdRefused::Refusal)?;
396 self.pending = Some(Pending {
397 kind,
398 deadline: kind.timeout(),
399 rewarded: false,
400 visible: false,
401 });
402 Ok(())
403 }
404
405 /// Whether an ad is in flight: taken, and not finished or released yet.
406 pub fn ad_pending(&self) -> bool {
407 self.pending.is_some()
408 }
409
410 /// Whether the host reported an ad on screen.
411 pub fn ad_visible(&self) -> bool {
412 self.pending.is_some_and(|pending| pending.visible)
413 }
414
415 /// The kind of the ad in flight, when there is one.
416 pub fn ad_kind(&self) -> Option<AdKind> {
417 self.pending.map(|pending| pending.kind)
418 }
419
420 /// Ask for the stored save. The answer arrives as
421 /// [`PortalReport::CloudPulled`].
422 pub fn cloud_pull(&mut self) -> Result<(), Refusal> {
423 self.portal.request(PortalRequest::CloudPull)
424 }
425
426 /// Store this payload under the game's cloud key. The answer arrives as
427 /// [`PortalReport::CloudStored`].
428 ///
429 /// The payload is opaque here, and the game's save type stays out of the
430 /// portal: what goes up is what comes back from
431 /// [`PortalReport::CloudPulled`], byte for byte, so a game hands over its
432 /// own serialized save and parses it back itself.
433 pub fn cloud_push(&mut self, payload: &[u8]) -> Result<(), CloudRefused> {
434 if self.pull_first && !self.cloud_read {
435 return Err(CloudRefused::BeforePull);
436 }
437 self.portal
438 .request(PortalRequest::CloudPush {
439 payload: payload.to_vec(),
440 })
441 .map_err(CloudRefused::Refusal)
442 }
443
444 /// Whether a cloud read has resolved: the host answered, nothing stored
445 /// counting as an answer, or it has no cloud at all.
446 ///
447 /// A read the host refused is not an answer: it may have been transient,
448 /// and the save it would have returned may be up there.
449 pub fn cloud_ready(&self) -> bool {
450 self.cloud_read
451 }
452
453 fn send(&mut self, notice: HostNotice) -> Result<(), Refusal> {
454 let outcome = self.portal.request(PortalRequest::Notify(notice));
455 if let Err(reason) = &outcome {
456 self.reports.push(PortalReport::NoticeDropped {
457 notice,
458 reason: reason.clone(),
459 });
460 }
461 outcome
462 }
463
464 fn absorb(&mut self, event: PortalEvent) {
465 match event {
466 PortalEvent::AudioEnabled(enabled) => {
467 self.audio_enabled = enabled;
468 self.reports.push(PortalReport::AudioEnabled(enabled));
469 }
470 PortalEvent::HostPaused(paused) => {
471 self.host_paused = paused;
472 self.reports.push(PortalReport::HostPaused(paused));
473 }
474 PortalEvent::AdOpened(kind) => self.ad_opened(kind),
475 PortalEvent::AdRewarded => self.ad_rewarded(),
476 PortalEvent::AdEnded { kind, ending } => self.ad_ended(kind, ending),
477 PortalEvent::CloudPulled(outcome) => {
478 match &outcome {
479 Ok(_) => self.cloud_read = true,
480 // A cloud that does not exist cannot be overwritten, so it
481 // counts as resolved: a game that pushes anyway gets the
482 // backend's own answer instead of `BeforePull`.
483 Err(Refusal::NoPortal | Refusal::NotSupported) => self.cloud_read = true,
484 // A host refusal may be transient, and a push now could
485 // store over a save that was there all along.
486 Err(_) => {}
487 }
488 self.reports.push(PortalReport::CloudPulled(outcome));
489 }
490 PortalEvent::CloudStored(outcome) => {
491 self.reports.push(PortalReport::CloudStored(outcome));
492 }
493 }
494 }
495
496 fn ad_opened(&mut self, kind: AdKind) {
497 if kind == AdKind::Interstitial {
498 // The impression is what the SDK's minimum delay counts from.
499 self.interstitial_shown = true;
500 self.since_interstitial = Duration::ZERO;
501 }
502 match &mut self.pending {
503 Some(pending) => pending.visible = true,
504 None => {
505 // A break the game did not ask for still covers the frame, so
506 // it is held like an asked-for one, and it is still released by
507 // a deadline rather than held forever.
508 log::info!(
509 "{}: a {kind} ad opened without a request; holding gameplay until it ends",
510 self.portal.label()
511 );
512 self.pending = Some(Pending {
513 kind,
514 deadline: kind.timeout(),
515 rewarded: false,
516 visible: true,
517 });
518 }
519 }
520 self.reports.push(PortalReport::AdOpened { kind });
521 }
522
523 fn ad_rewarded(&mut self) {
524 match &mut self.pending {
525 Some(pending) if pending.kind.is_rewarded() => pending.rewarded = true,
526 Some(pending) => log::warn!(
527 "{}: a {} ad reported a reward; nothing is owed",
528 self.portal.label(),
529 pending.kind
530 ),
531 None => log::warn!(
532 "{}: a reward was reported with no ad running; nothing is owed",
533 self.portal.label()
534 ),
535 }
536 }
537
538 fn ad_ended(&mut self, kind: AdKind, ending: AdEnding) {
539 let Some(pending) = self.pending.take() else {
540 log::warn!(
541 "{}: an ad ended with none running; ignored",
542 self.portal.label()
543 );
544 return;
545 };
546 if pending.kind != kind {
547 log::warn!(
548 "{}: a {kind} ad ended while a {} ad was running; releasing the running one",
549 self.portal.label(),
550 pending.kind
551 );
552 }
553 let outcome = match ending {
554 // The reward point is the host's promise, so whatever ends the ad
555 // afterwards the reward stands. Without it, a rewarded ad that just
556 // closed paid nothing.
557 AdEnding::Closed if pending.kind.is_rewarded() && !pending.rewarded => {
558 AdOutcome::Skipped
559 }
560 AdEnding::Closed => AdOutcome::Completed,
561 AdEnding::Failed if pending.rewarded => AdOutcome::Completed,
562 AdEnding::Failed => AdOutcome::Failed,
563 };
564 self.finish(pending.kind, outcome);
565 }
566
567 fn finish(&mut self, kind: AdKind, outcome: AdOutcome) {
568 self.pending = None;
569 self.reports
570 .push(PortalReport::AdFinished { kind, outcome });
571 }
572}
573
574impl Default for PortalHost {
575 fn default() -> Self {
576 Self::none()
577 }
578}