use std::fmt;
use serde::Deserialize;
use web_time::Duration;
use super::super::protocol::{AdKind, AdSchedule};
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct PlaygamaConfig {
pub interstitial: PlacementList,
pub rewarded: PlacementList,
pub interstitial_schedule: AdSchedule,
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct PlacementList {
pub placements: Vec<String>,
pub fallback: Option<String>,
}
impl PlaygamaConfig {
pub fn from_json(text: &str) -> Result<Self, ConfigError> {
let wire: Wire = serde_json::from_str(text).map_err(|error| ConfigError::Parse {
detail: error.to_string(),
})?;
Ok(Self {
interstitial: wire.advertisement.interstitial.into(),
rewarded: wire.advertisement.rewarded.into(),
interstitial_schedule: AdSchedule {
initial_delay: delay(
wire.advertisement.initial_interstitial_delay,
"initialInterstitialDelay",
)?,
minimum_delay: delay(
wire.advertisement.minimum_delay_between_interstitial,
"minimumDelayBetweenInterstitial",
)?,
},
})
}
pub fn placement(&self, kind: AdKind) -> Option<&str> {
let list = self.list(kind);
list.fallback
.as_deref()
.or_else(|| list.placements.first().map(String::as_str))
}
pub fn is_declared(&self, kind: AdKind, placement: &str) -> bool {
self.list(kind)
.placements
.iter()
.any(|declared| declared == placement)
}
fn list(&self, kind: AdKind) -> &PlacementList {
match kind {
AdKind::Interstitial => &self.interstitial,
AdKind::Rewarded => &self.rewarded,
}
}
}
#[derive(Clone, Debug, PartialEq)]
pub enum ConfigError {
Parse { detail: String },
Delay { field: &'static str, value: f64 },
}
impl fmt::Display for ConfigError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Parse { detail } => write!(f, "the bridge config is not readable: {detail}"),
Self::Delay { field, value } => {
write!(f, "{field} is {value}, which is not a delay")
}
}
}
}
impl std::error::Error for ConfigError {}
#[derive(Deserialize)]
struct Wire {
#[serde(default)]
advertisement: WireAdvertisement,
}
#[derive(Deserialize, Default)]
#[serde(rename_all = "camelCase")]
struct WireAdvertisement {
#[serde(default)]
minimum_delay_between_interstitial: Option<f64>,
#[serde(default)]
initial_interstitial_delay: Option<f64>,
#[serde(default)]
interstitial: WirePlacements,
#[serde(default)]
rewarded: WirePlacements,
}
#[derive(Deserialize, Default)]
#[serde(rename_all = "camelCase")]
struct WirePlacements {
#[serde(default)]
placements: Vec<WirePlacement>,
#[serde(default)]
placement_fallback: Option<String>,
}
#[derive(Deserialize)]
struct WirePlacement {
id: String,
}
impl From<WirePlacements> for PlacementList {
fn from(wire: WirePlacements) -> Self {
Self {
placements: wire
.placements
.into_iter()
.map(|placement| placement.id)
.collect(),
fallback: wire.placement_fallback,
}
}
}
fn delay(value: Option<f64>, field: &'static str) -> Result<Duration, ConfigError> {
let Some(value) = value else {
return Ok(Duration::ZERO);
};
if !value.is_finite() || value < 0.0 {
return Err(ConfigError::Delay { field, value });
}
Ok(Duration::from_secs_f64(value))
}
#[cfg(test)]
mod tests {
use super::*;
const SHIPPED: &str = r#"{
"$schema": "https://bridge.playgama.com/v2/stable/schema.json",
"advertisement": {
"minimumDelayBetweenInterstitial": 120,
"initialInterstitialDelay": 120,
"interstitial": {
"preloadOnStart": "game_over",
"placementFallback": "game_over",
"placements": [
{ "id": "game_over" }
]
},
"rewarded": {
"preloadOnStart": "revive",
"placementFallback": "revive",
"placements": [
{ "id": "revive" }
]
},
"useBuiltInErrorPopup": true
},
"device": {
"useBuiltInOrientationPopup": true,
"supportedOrientations": ["landscape"]
},
"payments": [],
"leaderboards": [],
"sendAnalyticsEvents": true
}"#;
#[test]
fn the_shipped_config_reads_its_placements_and_its_rate_limit() {
let config = PlaygamaConfig::from_json(SHIPPED).expect("the shipped file parses");
assert_eq!(config.placement(AdKind::Interstitial), Some("game_over"));
assert_eq!(config.placement(AdKind::Rewarded), Some("revive"));
assert!(config.is_declared(AdKind::Rewarded, "revive"));
assert!(!config.is_declared(AdKind::Rewarded, "bonus"));
assert_eq!(
config.interstitial_schedule,
AdSchedule {
initial_delay: Duration::from_secs(120),
minimum_delay: Duration::from_secs(120),
}
);
}
#[test]
fn a_config_without_an_advertisement_section_declares_nothing() {
let config = PlaygamaConfig::from_json("{}").expect("an empty object parses");
assert_eq!(config, PlaygamaConfig::default());
assert_eq!(config.placement(AdKind::Interstitial), None);
assert_eq!(config.interstitial_schedule, AdSchedule::default());
}
#[test]
fn a_missing_fallback_uses_the_first_declared_placement() {
let config = PlaygamaConfig::from_json(
r#"{"advertisement": {"rewarded": {"placements": [{"id": "first"}, {"id": "second"}]}}}"#,
)
.expect("parses");
assert_eq!(config.placement(AdKind::Rewarded), Some("first"));
assert_eq!(config.rewarded.placements.len(), 2);
}
#[test]
fn a_delay_that_is_not_a_delay_is_reported_field_by_field() {
let error =
PlaygamaConfig::from_json(r#"{"advertisement": {"initialInterstitialDelay": -5}}"#)
.expect_err("a negative delay is not a duration");
assert_eq!(
error,
ConfigError::Delay {
field: "initialInterstitialDelay",
value: -5.0,
}
);
assert!(PlaygamaConfig::from_json("not json").is_err());
assert!(
PlaygamaConfig::from_json(r#"{"advertisement": {"rewarded": {"placements": [{}]}}}"#)
.is_err()
);
}
}