Skip to main content

repame_shell/portal/
protocol.rs

1//! The wire between the shell and a portal: what the shell asks for, what a
2//! portal reports back, and the typed answers for the cases where the host
3//! cannot deliver.
4//!
5//! The split is deliberate. A backend reports what the **host** said (an ad
6//! opened, the audio switch moved, a cloud read landed) and never what it
7//! means; [`PortalHost`](super::PortalHost) decides what it means (whether an
8//! interrupted ad pays, whether the clock runs, what the game is told). A new
9//! backend is then a translation layer, and a test backend only has to produce
10//! host facts.
11//!
12//! Nothing here is fire-and-forget on the way in: a request is either taken
13//! (`Ok`) or refused **now**, with a [`Refusal`] that says why, and everything
14//! taken ends in a [`PortalEvent`]. A portal that cannot serve a call says so
15//! instead of letting the game wait for an answer that never comes.
16
17use std::fmt;
18
19use web_time::Duration;
20
21/// How long the shell waits for an interstitial before releasing the game.
22///
23/// The games' `BREAK_TIMEOUT`: a host that never reports the ad's end must not
24/// freeze the game, so the deadline ends the break whichever way it went.
25pub const INTERSTITIAL_TIMEOUT: Duration = Duration::from_secs(10);
26
27/// How long the shell waits for a rewarded video before releasing the game.
28///
29/// The games' `REWARD_TIMEOUT`, longer than [`INTERSTITIAL_TIMEOUT`] because a
30/// rewarded video is watched to the end on purpose.
31pub const REWARDED_TIMEOUT: Duration = Duration::from_secs(30);
32
33/// The two ads a portal serves.
34#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
35pub enum AdKind {
36    /// A full-screen ad between rounds. Nothing is owed either way, and the
37    /// host may also refuse it by its own rate limit.
38    Interstitial,
39    /// A video the player chose to watch for a reward. The reward is due only
40    /// once the host reports the reward point, never on the strength of having
41    /// asked for one.
42    Rewarded,
43}
44
45impl AdKind {
46    /// The deadline for this kind, see [`INTERSTITIAL_TIMEOUT`] and
47    /// [`REWARDED_TIMEOUT`].
48    pub fn timeout(self) -> Duration {
49        match self {
50            Self::Interstitial => INTERSTITIAL_TIMEOUT,
51            Self::Rewarded => REWARDED_TIMEOUT,
52        }
53    }
54
55    /// Whether this kind pays a reward. Only [`AdKind::Rewarded`] does.
56    pub fn is_rewarded(self) -> bool {
57        matches!(self, Self::Rewarded)
58    }
59}
60
61impl fmt::Display for AdKind {
62    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
63        f.write_str(match self {
64            Self::Interstitial => "interstitial",
65            Self::Rewarded => "rewarded",
66        })
67    }
68}
69
70/// Why a portal would not take a request, or cannot hand back what it has.
71///
72/// Every one of these is an answer the caller gets on the frame it asked: a
73/// host without ads or without a cloud produces one of these rather than a
74/// callback that never fires.
75#[derive(Clone, Debug, PartialEq, Eq)]
76pub enum Refusal {
77    /// Nothing is listening: no portal backend is installed (a plain desktop
78    /// build), or the bridge that was handed over is not talking to a live
79    /// host.
80    NoPortal,
81    /// The host has the feature but reports it unsupported here, which is the
82    /// SDK's `is_interstitial_supported` / `is_rewarded_supported` being false
83    /// and its bare `Promise.reject()` for the rest.
84    NotSupported,
85    /// A request of the same kind is already in flight and the SDK drops a
86    /// second one instead of queueing it, so a request nobody would answer is
87    /// refused instead of sent.
88    InFlight,
89    /// The host refused the call on its own terms. `code` is the code text the
90    /// platform sent, known to the bridge or not; it is `None` when the
91    /// platform rejected without naming one.
92    Host {
93        code: Option<String>,
94        message: String,
95    },
96    /// The bytes are not text the host can keep, or the value stored under the
97    /// key is not text. A platform's cloud keeps text, and a payload it cannot
98    /// hold is refused rather than mangled.
99    NotText,
100    /// The payload is empty. The platform's cloud reads an empty value as a
101    /// **delete**, so storing nothing would drop the player's save.
102    EmptyPayload,
103}
104
105impl Refusal {
106    /// The host's own code, when it gave one.
107    pub fn code(&self) -> Option<&str> {
108        match self {
109            Self::Host { code, .. } => code.as_deref(),
110            _ => None,
111        }
112    }
113}
114
115impl fmt::Display for Refusal {
116    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
117        match self {
118            Self::NoPortal => f.write_str("no portal is installed"),
119            Self::NotSupported => f.write_str("the host does not support this"),
120            Self::InFlight => f.write_str("a request of the same kind is already in flight"),
121            Self::Host {
122                code: Some(code),
123                message,
124            } => write!(f, "{code}: {message}"),
125            Self::Host {
126                code: None,
127                message,
128            } => f.write_str(message),
129            Self::NotText => f.write_str("the cloud keeps text, and these bytes are not text"),
130            Self::EmptyPayload => {
131                f.write_str("an empty payload is a delete for the platform's cloud")
132            }
133        }
134    }
135}
136
137/// Whether a portal can serve one feature, with the host's own reason when it
138/// cannot.
139#[derive(Clone, Debug, PartialEq, Eq)]
140pub enum Availability {
141    Available,
142    Unavailable(Refusal),
143}
144
145impl Availability {
146    pub fn is_available(&self) -> bool {
147        matches!(self, Self::Available)
148    }
149
150    /// Why it is unavailable, or `None` when it is available.
151    pub fn reason(&self) -> Option<&Refusal> {
152        match self {
153            Self::Available => None,
154            Self::Unavailable(reason) => Some(reason),
155        }
156    }
157}
158
159impl fmt::Display for Availability {
160    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
161        match self {
162            Self::Available => f.write_str("available"),
163            Self::Unavailable(reason) => write!(f, "unavailable: {reason}"),
164        }
165    }
166}
167
168/// The interstitial rate limit the host enforces, as the game's bridge config
169/// declares it (`minimumDelayBetweenInterstitial`, `initialInterstitialDelay`).
170///
171/// The shell mirrors the host's rule so a game gets [`AdRefused::TooSoon`] and
172/// the time left, instead of a request the host ignores while the shell waits
173/// out the ad deadline for an ad that was never going to play.
174///
175/// [`AdRefused::TooSoon`]: super::AdRefused::TooSoon
176#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
177pub struct AdSchedule {
178    /// No interstitial before this much time has passed since the game loaded.
179    pub initial_delay: Duration,
180    /// No two interstitials closer together than this.
181    pub minimum_delay: Duration,
182}
183
184/// A notice the host tracks gameplay by.
185#[derive(Clone, Copy, Debug, PartialEq, Eq)]
186pub enum HostNotice {
187    /// The first playable frame is up. The SDK removes its loading overlay on
188    /// this one and rejects a second, so it is sent exactly once.
189    GameReady,
190    /// Gameplay is live; the host counts impressions from here.
191    GameplayStarted,
192    /// Gameplay is not live: the run ended, or an ad, a host pause or the
193    /// background stopped it.
194    GameplayStopped,
195}
196
197impl fmt::Display for HostNotice {
198    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
199        f.write_str(match self {
200            Self::GameReady => "game_ready",
201            Self::GameplayStarted => "gameplay_started",
202            Self::GameplayStopped => "gameplay_stopped",
203        })
204    }
205}
206
207/// One request the shell hands a portal.
208#[derive(Clone, Debug, PartialEq, Eq)]
209pub enum PortalRequest {
210    /// Tell the host where gameplay stands.
211    Notify(HostNotice),
212    /// Show an ad. The outcome arrives as [`PortalEvent::AdEnded`], or as the
213    /// shell's deadline when the host never ends it.
214    ShowAd {
215        kind: AdKind,
216        /// `None` asks for the placement the game's config declares.
217        placement: Option<String>,
218    },
219    /// Read the stored save.
220    CloudPull,
221    /// Write the stored save.
222    CloudPush { payload: Vec<u8> },
223}
224
225/// The host's last word on an ad, before the shell turns it into an outcome.
226#[derive(Clone, Copy, Debug, PartialEq, Eq)]
227pub enum AdEnding {
228    /// The ad ended (the SDK's `closed`).
229    Closed,
230    /// The host gave up on the ad (the SDK's `failed`).
231    Failed,
232}
233
234/// One thing a portal learned from the host.
235///
236/// This is the backend's side of the wire: host facts, in the host's own
237/// terms. The shell owns the translation into what the game is told, see
238/// [`PortalReport`](super::PortalReport).
239#[derive(Clone, Debug, PartialEq, Eq)]
240pub enum PortalEvent {
241    /// The host's audio switch moved. The value at start is in
242    /// [`PortalStatus`], because a host only reports changes.
243    AudioEnabled(bool),
244    /// The host paused or resumed the game.
245    HostPaused(bool),
246    /// An ad opened. One the game did not ask for still covers the frame, so
247    /// it still holds gameplay.
248    AdOpened(AdKind),
249    /// A rewarded ad reached its reward point. The host owes the reward from
250    /// here whatever ends the ad.
251    AdRewarded,
252    /// The ad ended, in the host's own words.
253    AdEnded { kind: AdKind, ending: AdEnding },
254    /// A cloud read landed. `Ok(None)` is "the host has nothing stored under
255    /// the key", which is not a refusal.
256    CloudPulled(Result<Option<Vec<u8>>, Refusal>),
257    /// A cloud write landed, or the reason it did not: the game should keep
258    /// the save dirty and push again.
259    CloudStored(Result<(), Refusal>),
260}
261
262/// What a portal can do, and the host facts it knows without being asked.
263#[derive(Clone, Debug, PartialEq, Eq)]
264pub struct PortalStatus {
265    /// `false` when nothing is listening: a desktop build, or a bridge that
266    /// never came up. Everything else in here is then a refusal for the same
267    /// reason, so a game can hide a rewarded button without probing.
268    pub hosted: bool,
269    /// The host's audio switch. Read at start as well as subscribed to: the
270    /// hosts only report changes.
271    pub audio_enabled: bool,
272    /// The host paused the game. Read at start for the same reason.
273    pub host_paused: bool,
274    pub interstitial: Availability,
275    pub rewarded: Availability,
276    pub cloud: Availability,
277    /// The interstitial rate limit the host enforces.
278    pub interstitial_schedule: AdSchedule,
279}
280
281impl Default for PortalStatus {
282    /// Nothing is listening and every feature refuses for that reason, which
283    /// is what a build without a portal reports.
284    fn default() -> Self {
285        Self {
286            hosted: false,
287            audio_enabled: true,
288            host_paused: false,
289            interstitial: Availability::Unavailable(Refusal::NoPortal),
290            rewarded: Availability::Unavailable(Refusal::NoPortal),
291            cloud: Availability::Unavailable(Refusal::NoPortal),
292            interstitial_schedule: AdSchedule::default(),
293        }
294    }
295}
296
297/// One portal backend: the adapter between the shell and one host.
298///
299/// A backend translates host callbacks into [`PortalEvent`]s and queues them
300/// for [`Portal::poll`]; it decides nothing. Requests are taken or refused on
301/// the spot, so a caller never waits on a host that has nothing to answer.
302///
303/// The listeners a backend subscribes to belong to the host, so they outlive
304/// the call that installed them: a backend must own what its listeners touch
305/// (`Rc`), and must be the only writer. A backend is installed for the life of
306/// the app, see [`PortalHost::new`](super::PortalHost::new).
307pub trait Portal {
308    /// Hand one request to the host.
309    fn request(&mut self, request: PortalRequest) -> Result<(), Refusal>;
310
311    /// Everything the host said since the last call. Answers may have arrived
312    /// on this frame, or on any frame since the previous poll.
313    fn poll(&mut self) -> Vec<PortalEvent>;
314
315    /// What the host can do and what it says without being asked. Read this at
316    /// start: a host reports *changes*, so a value only ever read from events
317    /// would be the wrong one until the host happens to change it.
318    fn status(&self) -> PortalStatus;
319
320    /// A name for logs: the platform and language behind the bridge, or why
321    /// there is no host at all.
322    fn label(&self) -> &str;
323}