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, and a break the shell
71    /// released but the host has not ended yet still counts, because the
72    /// events of that one are still to come.
73    Busy,
74    /// The host's interstitial rate limit has not elapsed. `remaining` is how
75    /// much of it is left.
76    TooSoon { remaining: Duration },
77    /// The portal would not take the request at all.
78    Refusal(Refusal),
79}
80
81impl fmt::Display for AdRefused {
82    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
83        match self {
84            Self::Busy => f.write_str("another break is already running"),
85            Self::TooSoon { remaining } => {
86                write!(f, "the host's interstitial delay has {remaining:?} left")
87            }
88            Self::Refusal(reason) => write!(f, "{reason}"),
89        }
90    }
91}
92
93/// Why a cloud write did not go to the host.
94#[derive(Clone, Debug, PartialEq, Eq)]
95pub enum CloudRefused {
96    /// No cloud read has resolved yet, and this host was built with
97    /// [`PortalHost::pull_first`]: the first push of a session must not race
98    /// the read that would have said what the player already has.
99    BeforePull,
100    /// The portal would not take the write at all.
101    Refusal(Refusal),
102}
103
104impl fmt::Display for CloudRefused {
105    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
106        match self {
107            Self::BeforePull => f.write_str("no cloud read has resolved yet"),
108            Self::Refusal(reason) => write!(f, "{reason}"),
109        }
110    }
111}
112
113/// What an ad did, after the shell has matched the host's events to the
114/// request that asked for it.
115#[derive(Clone, Debug, PartialEq, Eq)]
116pub enum AdOutcome {
117    /// The ad reached its end. An interstitial is complete once the host
118    /// reports it closed; a rewarded ad is complete once it reported the
119    /// reward, whatever ends it afterwards. The reward is due.
120    Completed,
121    /// The ad closed before it completed: a rewarded ad was closed by the
122    /// player before the host reported the reward. Nothing is owed.
123    Skipped,
124    /// The host never reported the ad's end before its deadline, so the shell
125    /// released the game. Nothing was owed *then*: the released ad stays
126    /// attributed to its request, so a reward point the host reports after the
127    /// release and the end that follows it still pay. A break cannot be held
128    /// open on a report that may never come.
129    TimedOut { after: Duration },
130    /// The host could not play the ad: it gave up before the video ran.
131    /// Nothing is owed, and asking again is the game's call.
132    Failed,
133}
134
135impl AdOutcome {
136    /// Whether a `kind` ad pays a reward on this outcome.
137    pub fn pays(&self, kind: AdKind) -> bool {
138        kind.is_rewarded() && matches!(self, Self::Completed)
139    }
140
141    /// Whether the ad reached its end, whatever its kind.
142    pub fn completed(&self) -> bool {
143        matches!(self, Self::Completed)
144    }
145}
146
147impl fmt::Display for AdOutcome {
148    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
149        match self {
150            Self::Completed => f.write_str("completed"),
151            Self::Skipped => f.write_str("skipped"),
152            Self::TimedOut { after } => write!(f, "timed out after {after:?}"),
153            Self::Failed => f.write_str("failed"),
154        }
155    }
156}
157
158/// One thing the shell decided, for the game to drain once per frame.
159#[derive(Clone, Debug, PartialEq, Eq)]
160pub enum PortalReport {
161    /// An ad opened and covers the screen. One the game did not ask for is
162    /// reported too: the host can break in on its own.
163    AdOpened { kind: AdKind },
164    /// The ad finished. A rewarded ad pays only when `outcome.pays(kind)`.
165    AdFinished { kind: AdKind, outcome: AdOutcome },
166    /// The host's audio switch moved.
167    AudioEnabled(bool),
168    /// The host paused or resumed the game.
169    HostPaused(bool),
170    /// A cloud read landed: `Ok(None)` when the host had nothing stored under
171    /// the key, which is not a failure.
172    CloudPulled(Result<Option<Vec<u8>>, Refusal>),
173    /// A cloud write landed, or the reason it did not. A refused write leaves
174    /// the game's save dirty, and pushing again is the game's call.
175    CloudStored(Result<(), Refusal>),
176    /// A gameplay notice the host would not take. The host counts impressions
177    /// from these, so a dropped one is reported rather than lost.
178    NoticeDropped { notice: HostNotice, reason: Refusal },
179}
180
181/// One ad in flight, from the moment the host took the request.
182#[derive(Clone, Copy, Debug)]
183struct Pending {
184    kind: AdKind,
185    /// Time left before the shell releases the game: a stall timer, started at
186    /// the request and restarted when the host reports the ad on screen, so the
187    /// wait for the ad does not eat the ad's own time.
188    deadline: Duration,
189    /// The host reported the reward point.
190    rewarded: bool,
191    /// The host reported the ad on screen.
192    visible: bool,
193    /// Which request this ad answers: a released ad's late events carry it.
194    generation: u64,
195}
196
197/// An ad the shell released on its deadline while the host might still be
198/// holding it.
199///
200/// The host's events for it arrive late, if at all, and they belong to this ad
201/// and not to the next request: the tombstone keeps the ad's identity until the
202/// host ends it, which is also what keeps the next request `Busy`. A rewarded
203/// ad whose reward point lands late pays when it ends, because the host's
204/// reward point is the promise and it did arrive.
205#[derive(Clone, Copy, Debug)]
206struct Released {
207    kind: AdKind,
208    rewarded: bool,
209    generation: u64,
210}
211
212/// The shell's half of a portal: requests out, host facts in, one gate.
213pub struct PortalHost {
214    portal: Box<dyn Portal>,
215    reports: Vec<PortalReport>,
216    pending: Option<Pending>,
217    /// The last ad the shell released on its deadline, until the host ends it.
218    released: Option<Released>,
219    /// Hands every ad it sees a distinct generation.
220    generation: u64,
221    audio_enabled: bool,
222    host_paused: bool,
223    backgrounded: bool,
224    /// Time since the game loaded, for the host's interstitial rate limit.
225    since_interstitial: Duration,
226    /// An interstitial has been on screen, so the rate limit between two of
227    /// them now applies instead of the initial one.
228    interstitial_shown: bool,
229    schedule: AdSchedule,
230    /// Whether the host is currently told gameplay is running, so only a change
231    /// sends a notice.
232    announced_gameplay: bool,
233    /// The load finished and `game_ready` is waiting for the first playable
234    /// frame.
235    ready_pending: bool,
236    ready_sent: bool,
237    /// A cloud read has resolved: see [`PortalHost::cloud_ready`] for what
238    /// counts.
239    cloud_read: bool,
240    /// Refuse cloud pushes until a read resolved.
241    pull_first: bool,
242}
243
244impl PortalHost {
245    /// A host over `portal`. A cloud push may be sent as soon as the game has
246    /// something to store.
247    pub fn new(portal: Box<dyn Portal>) -> Self {
248        lifecycle::watch();
249        let status = portal.status();
250        Self {
251            portal,
252            reports: Vec::new(),
253            pending: None,
254            released: None,
255            generation: 0,
256            audio_enabled: status.audio_enabled,
257            host_paused: status.host_paused,
258            backgrounded: lifecycle::backgrounded(),
259            since_interstitial: Duration::ZERO,
260            interstitial_shown: false,
261            schedule: status.interstitial_schedule,
262            announced_gameplay: false,
263            ready_pending: false,
264            ready_sent: false,
265            cloud_read: false,
266            pull_first: false,
267        }
268    }
269
270    /// A host over `portal` that refuses cloud pushes until a read has
271    /// resolved.
272    ///
273    /// A game that loads its save and autosaves on every change wants this: a
274    /// push that lands before the read would store a fresh default over the
275    /// save the player came back for.
276    pub fn pull_first(portal: Box<dyn Portal>) -> Self {
277        Self {
278            pull_first: true,
279            ..Self::new(portal)
280        }
281    }
282
283    /// The host a build without a portal gets: every request is refused with
284    /// [`Refusal::NoPortal`], nothing hangs, and the gate suspends only for what
285    /// the game itself reports.
286    pub fn none() -> Self {
287        Self::new(Box::new(NoPortal))
288    }
289
290    /// The backend's name, for logs.
291    pub fn label(&self) -> &str {
292        self.portal.label()
293    }
294
295    /// What the host can do right now.
296    pub fn status(&self) -> PortalStatus {
297        self.portal.status()
298    }
299
300    /// The portal's half of the gate.
301    pub fn view(&self) -> PortalView {
302        PortalView {
303            ad_pending: self.pending.is_some(),
304            ad_visible: self.ad_visible(),
305            host_paused: self.host_paused,
306            backgrounded: self.backgrounded,
307            audio_enabled: self.audio_enabled,
308        }
309    }
310
311    /// The load finished. The host is told on the first playable frame, see
312    /// [`PortalHost::ready`], because a host that removes its loading overlay
313    /// on `game_ready` must remove it over a frame the player can see.
314    pub fn loaded(&mut self) {
315        self.ready_pending = true;
316    }
317
318    /// The first playable frame is up: send `game_ready`, once.
319    ///
320    /// Returns whether this call sent it. `false` means there was nothing to
321    /// send: a second call, or one made before [`PortalHost::loaded`]. The host
322    /// rejects a second `game_ready`, and nothing in the bridge can report that
323    /// rejection, so this never sends one.
324    pub fn ready(&mut self) -> Result<bool, Refusal> {
325        if !self.ready_pending || self.ready_sent {
326            return Ok(false);
327        }
328        self.send(HostNotice::GameReady)?;
329        self.ready_sent = true;
330        self.ready_pending = false;
331        Ok(true)
332    }
333
334    /// Tell the host something itself. The gate sends the gameplay notices on
335    /// its own; this is for a game that has something else to say.
336    pub fn notify(&mut self, notice: HostNotice) -> Result<(), Refusal> {
337        self.send(notice)
338    }
339
340    /// Advance the portal by one frame: read the lifecycle, hand the backend's
341    /// inbox over, and release an ad that outlived its deadline.
342    ///
343    /// Call once per frame, suspended or not: the ad deadline has to run while
344    /// the game is held by the ad.
345    pub fn update(&mut self, dt: Duration) {
346        self.backgrounded = lifecycle::backgrounded();
347        self.since_interstitial += dt;
348        let events = self.portal.poll();
349        for event in events {
350            self.absorb(event);
351        }
352        let mut timed_out = None;
353        if let Some(pending) = &mut self.pending {
354            pending.deadline = pending.deadline.saturating_sub(dt);
355            if pending.deadline.is_zero() {
356                timed_out = Some(*pending);
357            }
358        }
359        if let Some(pending) = timed_out {
360            log::warn!(
361                "{}: the {} ad (#{}) did not finish within {:?}; releasing the game",
362                self.portal.label(),
363                pending.kind,
364                pending.generation,
365                pending.kind.timeout()
366            );
367            self.release_ad(pending);
368        }
369    }
370
371    /// Settle this frame's gate, and tell the host when the answer changed.
372    pub fn gate(&mut self, game: GameState) -> Suspend {
373        let decision = Suspend::resolve(game, self.view());
374        if decision.host_gameplay() != self.announced_gameplay {
375            let notice = if decision.host_gameplay() {
376                HostNotice::GameplayStarted
377            } else {
378                HostNotice::GameplayStopped
379            };
380            // Only a notice the host took moves the announced state: a refused
381            // one leaves the state stale so the next frame retries it, the way
382            // `ready` retries.
383            if self.send(notice).is_ok() {
384                self.announced_gameplay = decision.host_gameplay();
385            }
386        }
387        decision
388    }
389
390    /// Everything the shell decided since the last call.
391    pub fn take_reports(&mut self) -> Vec<PortalReport> {
392        std::mem::take(&mut self.reports)
393    }
394
395    /// Ask the host for an ad. `placement` names a placement from the game's
396    /// config; `None` asks for the one the config declares.
397    ///
398    /// A taken request holds gameplay the next time the gate is settled, see
399    /// [`PortalHost::ad_pending`], and ends in a
400    /// [`PortalReport::AdFinished`] whichever way the host takes it. A request
401    /// while an ad the shell already released has not ended yet is refused
402    /// [`AdRefused::Busy`], and that released ad's own end, whenever it lands,
403    /// is reported too.
404    ///
405    /// A refused rewarded ad pays nothing. A game that wants to be generous
406    /// where no ad can run (the games' desktop rule: an offline rewarded break
407    /// always pays out) grants the reward from that refusal itself, because the
408    /// policy is the game's and not the portal's.
409    pub fn show_ad(&mut self, kind: AdKind, placement: Option<&str>) -> Result<(), AdRefused> {
410        if self.pending.is_some() || self.released.is_some() {
411            return Err(AdRefused::Busy);
412        }
413        if kind == AdKind::Interstitial {
414            let wait = if self.interstitial_shown {
415                self.schedule.minimum_delay
416            } else {
417                self.schedule.initial_delay
418            };
419            if self.since_interstitial < wait {
420                return Err(AdRefused::TooSoon {
421                    remaining: wait - self.since_interstitial,
422                });
423            }
424        }
425        let placement = placement.map(str::to_string);
426        self.portal
427            .request(PortalRequest::ShowAd { kind, placement })
428            .map_err(AdRefused::Refusal)?;
429        self.generation += 1;
430        self.pending = Some(Pending {
431            kind,
432            deadline: kind.timeout(),
433            rewarded: false,
434            visible: false,
435            generation: self.generation,
436        });
437        Ok(())
438    }
439
440    /// Whether an ad is in flight: taken, and not finished or released yet.
441    pub fn ad_pending(&self) -> bool {
442        self.pending.is_some()
443    }
444
445    /// Whether the host reported an ad on screen.
446    pub fn ad_visible(&self) -> bool {
447        self.pending.is_some_and(|pending| pending.visible)
448    }
449
450    /// The kind of the ad in flight, when there is one.
451    pub fn ad_kind(&self) -> Option<AdKind> {
452        self.pending.map(|pending| pending.kind)
453    }
454
455    /// Ask for the stored save. The answer arrives as
456    /// [`PortalReport::CloudPulled`].
457    pub fn cloud_pull(&mut self) -> Result<(), Refusal> {
458        self.portal.request(PortalRequest::CloudPull)
459    }
460
461    /// Store this payload under the game's cloud key. The answer arrives as
462    /// [`PortalReport::CloudStored`].
463    ///
464    /// The payload is opaque here, and the game's save type stays out of the
465    /// portal: what goes up is what comes back from
466    /// [`PortalReport::CloudPulled`], byte for byte, so a game hands over its
467    /// own serialized save and parses it back itself.
468    pub fn cloud_push(&mut self, payload: &[u8]) -> Result<(), CloudRefused> {
469        if self.pull_first && !self.cloud_read {
470            return Err(CloudRefused::BeforePull);
471        }
472        self.portal
473            .request(PortalRequest::CloudPush {
474                payload: payload.to_vec(),
475            })
476            .map_err(CloudRefused::Refusal)
477    }
478
479    /// Whether a cloud read has resolved: the host answered, nothing stored
480    /// counting as an answer, or it has no cloud at all.
481    ///
482    /// A read the host refused is not an answer: it may have been transient,
483    /// and the save it would have returned may be up there.
484    pub fn cloud_ready(&self) -> bool {
485        self.cloud_read
486    }
487
488    fn send(&mut self, notice: HostNotice) -> Result<(), Refusal> {
489        let outcome = self.portal.request(PortalRequest::Notify(notice));
490        if let Err(reason) = &outcome {
491            self.reports.push(PortalReport::NoticeDropped {
492                notice,
493                reason: reason.clone(),
494            });
495        }
496        outcome
497    }
498
499    fn absorb(&mut self, event: PortalEvent) {
500        match event {
501            PortalEvent::AudioEnabled(enabled) => {
502                self.audio_enabled = enabled;
503                self.reports.push(PortalReport::AudioEnabled(enabled));
504            }
505            PortalEvent::HostPaused(paused) => {
506                self.host_paused = paused;
507                self.reports.push(PortalReport::HostPaused(paused));
508            }
509            PortalEvent::AdOpened(kind) => self.ad_opened(kind),
510            PortalEvent::AdRewarded => self.ad_rewarded(),
511            PortalEvent::AdEnded { kind, ending } => self.ad_ended(kind, ending),
512            PortalEvent::CloudPulled(outcome) => {
513                match &outcome {
514                    Ok(_) => self.cloud_read = true,
515                    // A cloud that does not exist cannot be overwritten, so it
516                    // counts as resolved: a game that pushes anyway gets the
517                    // backend's own answer instead of `BeforePull`.
518                    Err(Refusal::NoPortal | Refusal::NotSupported) => self.cloud_read = true,
519                    // A host refusal may be transient, and a push now could
520                    // store over a save that was there all along.
521                    Err(_) => {}
522                }
523                self.reports.push(PortalReport::CloudPulled(outcome));
524            }
525            PortalEvent::CloudStored(outcome) => {
526                self.reports.push(PortalReport::CloudStored(outcome));
527            }
528        }
529    }
530
531    fn ad_opened(&mut self, kind: AdKind) {
532        if kind == AdKind::Interstitial {
533            // The impression is what the SDK's minimum delay counts from.
534            self.interstitial_shown = true;
535            self.since_interstitial = Duration::ZERO;
536        }
537        if let Some(pending) = &mut self.pending {
538            // The host's report is progress: the deadline restarts once, from
539            // the open, so a slow request no longer eats the ad's own time. A
540            // host repeating the open does not extend the hold.
541            if !pending.visible {
542                pending.deadline = kind.timeout();
543            }
544            pending.visible = true;
545            self.reports.push(PortalReport::AdOpened { kind });
546            return;
547        }
548        let released = self.released.take();
549        let (rewarded, generation) = match released {
550            // The ad the shell released opened after all: it is on screen now,
551            // so the game is held from the open, with the reward point the host
552            // already reported for it.
553            Some(released) if released.kind == kind => {
554                log::info!(
555                    "{}: the released {} ad (#{}) opened late; holding gameplay from the open",
556                    self.portal.label(),
557                    kind,
558                    released.generation
559                );
560                (released.rewarded, released.generation)
561            }
562            Some(released) => {
563                log::warn!(
564                    "{}: a {kind} ad opened while the released {} ad (#{}) had not ended; holding the open one",
565                    self.portal.label(),
566                    released.kind,
567                    released.generation
568                );
569                self.generation += 1;
570                (false, self.generation)
571            }
572            None => {
573                // A break the game did not ask for still covers the frame, so
574                // it is held like an asked-for one, and it is still released by
575                // a deadline rather than held forever.
576                log::info!(
577                    "{}: a {kind} ad opened without a request; holding gameplay until it ends",
578                    self.portal.label()
579                );
580                self.generation += 1;
581                (false, self.generation)
582            }
583        };
584        self.pending = Some(Pending {
585            kind,
586            deadline: kind.timeout(),
587            rewarded,
588            visible: true,
589            generation,
590        });
591        self.reports.push(PortalReport::AdOpened { kind });
592    }
593
594    fn ad_rewarded(&mut self) {
595        match &mut self.pending {
596            Some(pending) if pending.kind.is_rewarded() => pending.rewarded = true,
597            Some(pending) => log::warn!(
598                "{}: a {} ad reported a reward; nothing is owed",
599                self.portal.label(),
600                pending.kind
601            ),
602            None => match &mut self.released {
603                // A reward point reported for a released ad is still the host's
604                // promise: the reward stands when that ad ends.
605                Some(released) if released.kind.is_rewarded() => {
606                    log::info!(
607                        "{}: the released {} ad (#{}) reached its reward point; the reward is due when it ends",
608                        self.portal.label(),
609                        released.kind,
610                        released.generation
611                    );
612                    released.rewarded = true;
613                }
614                Some(released) => log::warn!(
615                    "{}: a {} ad reported a reward; nothing is owed",
616                    self.portal.label(),
617                    released.kind
618                ),
619                None => log::warn!(
620                    "{}: a reward was reported with no ad running; nothing is owed",
621                    self.portal.label()
622                ),
623            },
624        }
625    }
626
627    fn ad_ended(&mut self, kind: AdKind, ending: AdEnding) {
628        if let Some(pending) = self.pending.take() {
629            if pending.kind != kind {
630                log::warn!(
631                    "{}: a {kind} ad ended while a {} ad was running; releasing the running one",
632                    self.portal.label(),
633                    pending.kind
634                );
635            }
636            let outcome = ending_outcome(pending.kind, pending.rewarded, ending);
637            self.finish(pending.kind, outcome);
638            return;
639        }
640        match self.released {
641            Some(released) if released.kind == kind => {
642                self.released = None;
643                let outcome = ending_outcome(released.kind, released.rewarded, ending);
644                log::info!(
645                    "{}: the released {} ad (#{}) ended; reporting {outcome}",
646                    self.portal.label(),
647                    released.kind,
648                    released.generation
649                );
650                self.finish(released.kind, outcome);
651            }
652            Some(released) => log::warn!(
653                "{}: a {kind} ad ended while the released {} ad (#{}) had not; ignored",
654                self.portal.label(),
655                released.kind,
656                released.generation
657            ),
658            None => log::warn!(
659                "{}: an ad ended with none running; ignored",
660                self.portal.label()
661            ),
662        }
663    }
664
665    /// Release the game from an ad that outlived its deadline. The ad stays a
666    /// tombstone until the host ends it, so its late events are attributed to
667    /// it and not to the next request, and a late reward point still pays.
668    fn release_ad(&mut self, pending: Pending) {
669        self.released = Some(Released {
670            kind: pending.kind,
671            rewarded: pending.rewarded,
672            generation: pending.generation,
673        });
674        self.finish(
675            pending.kind,
676            AdOutcome::TimedOut {
677                after: pending.kind.timeout(),
678            },
679        );
680    }
681
682    fn finish(&mut self, kind: AdKind, outcome: AdOutcome) {
683        self.pending = None;
684        self.reports
685            .push(PortalReport::AdFinished { kind, outcome });
686    }
687}
688
689/// What an ending means for an ad, once the reward point is known.
690fn ending_outcome(kind: AdKind, rewarded: bool, ending: AdEnding) -> AdOutcome {
691    match ending {
692        // The reward point is the host's promise, so whatever ends the ad
693        // afterwards the reward stands. Without it, a rewarded ad that just
694        // closed paid nothing.
695        AdEnding::Closed if kind.is_rewarded() && !rewarded => AdOutcome::Skipped,
696        AdEnding::Closed => AdOutcome::Completed,
697        AdEnding::Failed if rewarded => AdOutcome::Completed,
698        AdEnding::Failed => AdOutcome::Failed,
699    }
700}
701
702impl Default for PortalHost {
703    fn default() -> Self {
704        Self::none()
705    }
706}