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}