mod config;
pub use config::{ConfigError, PlacementList, PlaygamaConfig};
use std::cell::{Cell, RefCell};
use std::rc::Rc;
use playgama_bridge::{
Bridge, BridgeError, BridgeResult, InterstitialState, PlatformMessage, RewardedState,
StorageValue,
};
use super::protocol::{
AdEnding, AdKind, Availability, HostNotice, Portal, PortalEvent, PortalRequest, PortalStatus,
Refusal,
};
pub struct Playgama {
bridge: Rc<Bridge>,
inbox: Rc<RefCell<Vec<PortalEvent>>>,
pulling: Rc<Cell<bool>>,
pushing: Rc<Cell<bool>>,
config: PlaygamaConfig,
cloud_key: String,
label: String,
}
impl Playgama {
pub fn attach(
bridge: Bridge,
config: PlaygamaConfig,
cloud_key: impl Into<String>,
) -> Result<Self, Refusal> {
if !bridge.is_web() {
return Err(Refusal::NoPortal);
}
let cloud_key = cloud_key.into();
let label = label(&bridge);
let inbox = Rc::new(RefCell::new(Vec::new()));
let bridge = Rc::new(bridge);
listen(&bridge, &inbox);
Ok(Self {
bridge,
inbox,
pulling: Rc::new(Cell::new(false)),
pushing: Rc::new(Cell::new(false)),
config,
cloud_key,
label,
})
}
pub fn config(&self) -> &PlaygamaConfig {
&self.config
}
fn show_interstitial(&self, placement: Option<String>) -> Result<(), Refusal> {
if !self.bridge.advertisement.is_interstitial_supported() {
return Err(Refusal::NotSupported);
}
let placement = self.resolve(AdKind::Interstitial, placement);
self.bridge
.advertisement
.show_interstitial(placement.as_deref());
Ok(())
}
fn show_rewarded(&self, placement: Option<String>) -> Result<(), Refusal> {
if !self.bridge.advertisement.is_rewarded_supported() {
return Err(Refusal::NotSupported);
}
let placement = self.resolve(AdKind::Rewarded, placement);
self.bridge
.advertisement
.show_rewarded(placement.as_deref());
Ok(())
}
fn resolve(&self, kind: AdKind, placement: Option<String>) -> Option<String> {
let Some(placement) = placement else {
return self.config.placement(kind).map(str::to_string);
};
if !self.config.is_declared(kind, &placement) {
log::warn!(
"{}: the {kind} config does not declare the placement {placement:?}; the host will use {:?}",
self.label,
self.config.placement(kind)
);
}
Some(placement)
}
fn cloud_pull(&mut self) -> Result<(), Refusal> {
if self.pulling.replace(true) {
return Err(Refusal::InFlight);
}
let key = self.cloud_key.clone();
let inbox = Rc::clone(&self.inbox);
let pulling = Rc::clone(&self.pulling);
self.bridge.storage.get(
&[&key],
false,
Box::new(move |outcome| {
let event = PortalEvent::CloudPulled(read(outcome));
inbox.borrow_mut().push(event);
pulling.set(false);
}),
);
Ok(())
}
fn cloud_push(&mut self, payload: Vec<u8>) -> Result<(), Refusal> {
let Ok(text) = String::from_utf8(payload) else {
return Err(Refusal::NotText);
};
if text.is_empty() {
return Err(Refusal::EmptyPayload);
}
if self.pushing.replace(true) {
return Err(Refusal::InFlight);
}
let key = self.cloud_key.clone();
let inbox = Rc::clone(&self.inbox);
let pushing = Rc::clone(&self.pushing);
self.bridge.storage.set(
&[(&key, StorageValue::Str(text))],
Box::new(move |outcome| {
let event = PortalEvent::CloudStored(outcome.map_err(refusal));
inbox.borrow_mut().push(event);
pushing.set(false);
}),
);
Ok(())
}
}
impl Portal for Playgama {
fn request(&mut self, request: PortalRequest) -> Result<(), Refusal> {
match request {
PortalRequest::Notify(notice) => {
self.bridge.platform.send_message(wire(notice));
Ok(())
}
PortalRequest::ShowAd { kind, placement } => match kind {
AdKind::Interstitial => self.show_interstitial(placement),
AdKind::Rewarded => self.show_rewarded(placement),
},
PortalRequest::CloudPull => self.cloud_pull(),
PortalRequest::CloudPush { payload } => self.cloud_push(payload),
}
}
fn poll(&mut self) -> Vec<PortalEvent> {
std::mem::take(&mut self.inbox.borrow_mut())
}
fn status(&self) -> PortalStatus {
let advertisement = &self.bridge.advertisement;
PortalStatus {
hosted: true,
audio_enabled: self.bridge.platform.is_audio_enabled(),
host_paused: self.bridge.platform.is_paused(),
interstitial: available(advertisement.is_interstitial_supported()),
rewarded: available(advertisement.is_rewarded_supported()),
cloud: Availability::Available,
interstitial_schedule: self.config.interstitial_schedule,
}
}
fn label(&self) -> &str {
&self.label
}
}
fn listen(bridge: &Rc<Bridge>, inbox: &Rc<RefCell<Vec<PortalEvent>>>) {
let push = Rc::clone(inbox);
bridge
.platform
.audio_state_changed()
.connect(move |enabled| {
push.borrow_mut().push(PortalEvent::AudioEnabled(enabled));
});
let push = Rc::clone(inbox);
bridge
.platform
.pause_state_changed()
.connect(move |paused| {
push.borrow_mut().push(PortalEvent::HostPaused(paused));
});
let push = Rc::clone(inbox);
bridge
.advertisement
.interstitial_state_changed()
.connect(move |state| {
let event = match state {
Some(InterstitialState::Opened) => {
Some(PortalEvent::AdOpened(AdKind::Interstitial))
}
Some(InterstitialState::Closed) => Some(PortalEvent::AdEnded {
kind: AdKind::Interstitial,
ending: AdEnding::Closed,
}),
Some(InterstitialState::Failed) => Some(PortalEvent::AdEnded {
kind: AdKind::Interstitial,
ending: AdEnding::Failed,
}),
Some(InterstitialState::Loading) | None => None,
};
if let Some(event) = event {
push.borrow_mut().push(event);
}
});
let push = Rc::clone(inbox);
bridge
.advertisement
.rewarded_state_changed()
.connect(move |state| {
let event = match state {
Some(RewardedState::Opened) => Some(PortalEvent::AdOpened(AdKind::Rewarded)),
Some(RewardedState::Rewarded) => Some(PortalEvent::AdRewarded),
Some(RewardedState::Closed) => Some(PortalEvent::AdEnded {
kind: AdKind::Rewarded,
ending: AdEnding::Closed,
}),
Some(RewardedState::Failed) => Some(PortalEvent::AdEnded {
kind: AdKind::Rewarded,
ending: AdEnding::Failed,
}),
Some(RewardedState::Loading) | None => None,
};
if let Some(event) = event {
push.borrow_mut().push(event);
}
});
}
fn read(outcome: BridgeResult<Vec<StorageValue>>) -> Result<Option<Vec<u8>>, Refusal> {
match outcome {
Ok(values) => match values.into_iter().next() {
None | Some(StorageValue::Null) => Ok(None),
Some(StorageValue::Str(text)) if text.is_empty() => Ok(None),
Some(StorageValue::Str(text)) => Ok(Some(text.into_bytes())),
Some(_) => Err(Refusal::NotText),
},
Err(error) => Err(refusal(error)),
}
}
fn refusal(error: BridgeError) -> Refusal {
let message = error.message().to_string();
match error.raw_code() {
None if error.code().is_none() => Refusal::NotSupported,
None => Refusal::Host {
code: error.code().map(|code| code.as_str().to_string()),
message,
},
Some(code) => Refusal::Host {
code: Some(code.to_string()),
message,
},
}
}
fn available(supported: bool) -> Availability {
if supported {
Availability::Available
} else {
Availability::Unavailable(Refusal::NotSupported)
}
}
fn wire(notice: HostNotice) -> PlatformMessage {
match notice {
HostNotice::GameReady => PlatformMessage::GameReady,
HostNotice::GameplayStarted => PlatformMessage::GameplayStarted,
HostNotice::GameplayStopped => PlatformMessage::GameplayStopped,
}
}
fn label(bridge: &Bridge) -> String {
let id = bridge
.platform
.id()
.unwrap_or_else(|| "unknown".to_string());
let language = bridge
.platform
.language()
.map_or_else(String::new, |language| format!(" [{language}]"));
format!("playgama {id}{language}")
}
#[cfg(target_arch = "wasm32")]
#[derive(Clone, Debug, PartialEq)]
pub enum BootError {
Load(playgama_bridge::LoadError),
Attach(Refusal),
}
#[cfg(target_arch = "wasm32")]
impl std::fmt::Display for BootError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Load(error) => write!(f, "{error}"),
Self::Attach(reason) => write!(f, "the playgama bridge attached no host: {reason}"),
}
}
}
#[cfg(target_arch = "wasm32")]
impl std::error::Error for BootError {}
#[cfg(target_arch = "wasm32")]
pub async fn boot(
config: PlaygamaConfig,
cloud_key: impl Into<String>,
) -> Result<Playgama, BootError> {
playgama_bridge::set_game_loading_progress(0.0);
let bridge = playgama_bridge::load(playgama_bridge::LoadOptions::default())
.await
.map_err(BootError::Load)?;
playgama_bridge::set_game_loading_progress(100.0);
Playgama::attach(bridge, config, cloud_key).map_err(BootError::Attach)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_bridge_that_is_not_on_the_web_is_not_a_portal() {
assert!(matches!(
Playgama::attach(Bridge::new(), PlaygamaConfig::default(), "save"),
Err(Refusal::NoPortal)
));
}
#[test]
fn a_stored_value_that_is_not_text_is_refused_rather_than_handed_back() {
assert_eq!(read(Ok(vec![StorageValue::Int(7)])), Err(Refusal::NotText));
assert_eq!(read(Ok(vec![StorageValue::Null])), Ok(None));
assert_eq!(read(Ok(vec![])), Ok(None));
assert_eq!(
read(Ok(vec![StorageValue::Str(r#"{"best":3}"#.to_string())])),
Ok(Some(br#"{"best":3}"#.to_vec()))
);
}
#[test]
fn a_coded_rejection_keeps_the_platforms_code_and_a_bare_one_is_unsupported() {
let coded = refusal(BridgeError::new(
Some(playgama_bridge::ErrorCode::StorageQuotaExceeded),
Some("Storage quota exceeded".to_string()),
));
assert_eq!(
coded,
Refusal::Host {
code: Some("STORAGE_QUOTA_EXCEEDED".to_string()),
message: "Storage quota exceeded".to_string(),
}
);
assert_eq!(refusal(BridgeError::rejected()), Refusal::NotSupported);
}
}