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}