use std::fmt;
use web_time::Duration;
use super::gate::{GameState, PortalView, Suspend};
use super::lifecycle;
use super::none::NoPortal;
use super::protocol::{
AdEnding, AdKind, AdSchedule, HostNotice, Portal, PortalEvent, PortalRequest, PortalStatus,
Refusal,
};
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum AdRefused {
Busy,
TooSoon { remaining: Duration },
Refusal(Refusal),
}
impl fmt::Display for AdRefused {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Busy => f.write_str("another break is already running"),
Self::TooSoon { remaining } => {
write!(f, "the host's interstitial delay has {remaining:?} left")
}
Self::Refusal(reason) => write!(f, "{reason}"),
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum CloudRefused {
BeforePull,
Refusal(Refusal),
}
impl fmt::Display for CloudRefused {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::BeforePull => f.write_str("no cloud read has resolved yet"),
Self::Refusal(reason) => write!(f, "{reason}"),
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum AdOutcome {
Completed,
Skipped,
TimedOut { after: Duration },
Failed,
}
impl AdOutcome {
pub fn pays(&self, kind: AdKind) -> bool {
kind.is_rewarded() && matches!(self, Self::Completed)
}
pub fn completed(&self) -> bool {
matches!(self, Self::Completed)
}
}
impl fmt::Display for AdOutcome {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Completed => f.write_str("completed"),
Self::Skipped => f.write_str("skipped"),
Self::TimedOut { after } => write!(f, "timed out after {after:?}"),
Self::Failed => f.write_str("failed"),
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum PortalReport {
AdOpened { kind: AdKind },
AdFinished { kind: AdKind, outcome: AdOutcome },
AudioEnabled(bool),
HostPaused(bool),
CloudPulled(Result<Option<Vec<u8>>, Refusal>),
CloudStored(Result<(), Refusal>),
NoticeDropped { notice: HostNotice, reason: Refusal },
}
#[derive(Clone, Copy, Debug)]
struct Pending {
kind: AdKind,
deadline: Duration,
rewarded: bool,
visible: bool,
}
pub struct PortalHost {
portal: Box<dyn Portal>,
reports: Vec<PortalReport>,
pending: Option<Pending>,
audio_enabled: bool,
host_paused: bool,
backgrounded: bool,
since_interstitial: Duration,
interstitial_shown: bool,
schedule: AdSchedule,
announced_gameplay: bool,
ready_pending: bool,
ready_sent: bool,
cloud_read: bool,
pull_first: bool,
}
impl PortalHost {
pub fn new(portal: Box<dyn Portal>) -> Self {
lifecycle::watch();
let status = portal.status();
Self {
portal,
reports: Vec::new(),
pending: None,
audio_enabled: status.audio_enabled,
host_paused: status.host_paused,
backgrounded: lifecycle::backgrounded(),
since_interstitial: Duration::ZERO,
interstitial_shown: false,
schedule: status.interstitial_schedule,
announced_gameplay: false,
ready_pending: false,
ready_sent: false,
cloud_read: false,
pull_first: false,
}
}
pub fn pull_first(portal: Box<dyn Portal>) -> Self {
Self {
pull_first: true,
..Self::new(portal)
}
}
pub fn none() -> Self {
Self::new(Box::new(NoPortal))
}
pub fn label(&self) -> &str {
self.portal.label()
}
pub fn status(&self) -> PortalStatus {
self.portal.status()
}
pub fn view(&self) -> PortalView {
PortalView {
ad_pending: self.pending.is_some(),
ad_visible: self.ad_visible(),
host_paused: self.host_paused,
backgrounded: self.backgrounded,
audio_enabled: self.audio_enabled,
}
}
pub fn loaded(&mut self) {
self.ready_pending = true;
}
pub fn ready(&mut self) -> Result<bool, Refusal> {
if !self.ready_pending || self.ready_sent {
return Ok(false);
}
self.send(HostNotice::GameReady)?;
self.ready_sent = true;
self.ready_pending = false;
Ok(true)
}
pub fn notify(&mut self, notice: HostNotice) -> Result<(), Refusal> {
self.send(notice)
}
pub fn update(&mut self, dt: Duration) {
self.backgrounded = lifecycle::backgrounded();
self.since_interstitial += dt;
let events = self.portal.poll();
for event in events {
self.absorb(event);
}
let mut released = None;
if let Some(pending) = &mut self.pending {
pending.deadline = pending.deadline.saturating_sub(dt);
if pending.deadline.is_zero() {
released = Some(pending.kind);
}
}
if let Some(kind) = released {
log::warn!(
"{}: a {kind} ad did not finish within {:?}; releasing the game",
self.portal.label(),
kind.timeout()
);
self.finish(
kind,
AdOutcome::TimedOut {
after: kind.timeout(),
},
);
}
}
pub fn gate(&mut self, game: GameState) -> Suspend {
let decision = Suspend::resolve(game, self.view());
if decision.host_gameplay() != self.announced_gameplay {
self.announced_gameplay = decision.host_gameplay();
let notice = if decision.host_gameplay() {
HostNotice::GameplayStarted
} else {
HostNotice::GameplayStopped
};
let _ = self.send(notice);
}
decision
}
pub fn take_reports(&mut self) -> Vec<PortalReport> {
std::mem::take(&mut self.reports)
}
pub fn show_ad(&mut self, kind: AdKind, placement: Option<&str>) -> Result<(), AdRefused> {
if self.pending.is_some() {
return Err(AdRefused::Busy);
}
if kind == AdKind::Interstitial {
let wait = if self.interstitial_shown {
self.schedule.minimum_delay
} else {
self.schedule.initial_delay
};
if self.since_interstitial < wait {
return Err(AdRefused::TooSoon {
remaining: wait - self.since_interstitial,
});
}
}
let placement = placement.map(str::to_string);
self.portal
.request(PortalRequest::ShowAd { kind, placement })
.map_err(AdRefused::Refusal)?;
self.pending = Some(Pending {
kind,
deadline: kind.timeout(),
rewarded: false,
visible: false,
});
Ok(())
}
pub fn ad_pending(&self) -> bool {
self.pending.is_some()
}
pub fn ad_visible(&self) -> bool {
self.pending.is_some_and(|pending| pending.visible)
}
pub fn ad_kind(&self) -> Option<AdKind> {
self.pending.map(|pending| pending.kind)
}
pub fn cloud_pull(&mut self) -> Result<(), Refusal> {
self.portal.request(PortalRequest::CloudPull)
}
pub fn cloud_push(&mut self, payload: &[u8]) -> Result<(), CloudRefused> {
if self.pull_first && !self.cloud_read {
return Err(CloudRefused::BeforePull);
}
self.portal
.request(PortalRequest::CloudPush {
payload: payload.to_vec(),
})
.map_err(CloudRefused::Refusal)
}
pub fn cloud_ready(&self) -> bool {
self.cloud_read
}
fn send(&mut self, notice: HostNotice) -> Result<(), Refusal> {
let outcome = self.portal.request(PortalRequest::Notify(notice));
if let Err(reason) = &outcome {
self.reports.push(PortalReport::NoticeDropped {
notice,
reason: reason.clone(),
});
}
outcome
}
fn absorb(&mut self, event: PortalEvent) {
match event {
PortalEvent::AudioEnabled(enabled) => {
self.audio_enabled = enabled;
self.reports.push(PortalReport::AudioEnabled(enabled));
}
PortalEvent::HostPaused(paused) => {
self.host_paused = paused;
self.reports.push(PortalReport::HostPaused(paused));
}
PortalEvent::AdOpened(kind) => self.ad_opened(kind),
PortalEvent::AdRewarded => self.ad_rewarded(),
PortalEvent::AdEnded { kind, ending } => self.ad_ended(kind, ending),
PortalEvent::CloudPulled(outcome) => {
match &outcome {
Ok(_) => self.cloud_read = true,
Err(Refusal::NoPortal | Refusal::NotSupported) => self.cloud_read = true,
Err(_) => {}
}
self.reports.push(PortalReport::CloudPulled(outcome));
}
PortalEvent::CloudStored(outcome) => {
self.reports.push(PortalReport::CloudStored(outcome));
}
}
}
fn ad_opened(&mut self, kind: AdKind) {
if kind == AdKind::Interstitial {
self.interstitial_shown = true;
self.since_interstitial = Duration::ZERO;
}
match &mut self.pending {
Some(pending) => pending.visible = true,
None => {
log::info!(
"{}: a {kind} ad opened without a request; holding gameplay until it ends",
self.portal.label()
);
self.pending = Some(Pending {
kind,
deadline: kind.timeout(),
rewarded: false,
visible: true,
});
}
}
self.reports.push(PortalReport::AdOpened { kind });
}
fn ad_rewarded(&mut self) {
match &mut self.pending {
Some(pending) if pending.kind.is_rewarded() => pending.rewarded = true,
Some(pending) => log::warn!(
"{}: a {} ad reported a reward; nothing is owed",
self.portal.label(),
pending.kind
),
None => log::warn!(
"{}: a reward was reported with no ad running; nothing is owed",
self.portal.label()
),
}
}
fn ad_ended(&mut self, kind: AdKind, ending: AdEnding) {
let Some(pending) = self.pending.take() else {
log::warn!(
"{}: an ad ended with none running; ignored",
self.portal.label()
);
return;
};
if pending.kind != kind {
log::warn!(
"{}: a {kind} ad ended while a {} ad was running; releasing the running one",
self.portal.label(),
pending.kind
);
}
let outcome = match ending {
AdEnding::Closed if pending.kind.is_rewarded() && !pending.rewarded => {
AdOutcome::Skipped
}
AdEnding::Closed => AdOutcome::Completed,
AdEnding::Failed if pending.rewarded => AdOutcome::Completed,
AdEnding::Failed => AdOutcome::Failed,
};
self.finish(pending.kind, outcome);
}
fn finish(&mut self, kind: AdKind, outcome: AdOutcome) {
self.pending = None;
self.reports
.push(PortalReport::AdFinished { kind, outcome });
}
}
impl Default for PortalHost {
fn default() -> Self {
Self::none()
}
}