Skip to main content

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}