use std::sync::mpsc::{channel, Receiver, TryRecvError};
use serde::Deserialize;
use serde_json::Value;
#[derive(Debug, Clone, PartialEq)]
pub struct Offer {
pub item: String,
pub name: String,
pub price_label: Option<String>,
pub price_ref: String,
pub world_id: String,
}
pub fn offer_from(item: &str, price_ref: &Value, data: &Value, world_id: &str) -> Offer {
let name = data
.get("name")
.and_then(Value::as_str)
.filter(|s| !s.is_empty())
.unwrap_or(item)
.to_string();
let price_label =
data.get("price")
.map(|p| match data.get("currency").and_then(Value::as_str) {
Some(c) => format!("{p} {c}"),
None => p.to_string(),
});
let price_ref = price_ref_string(price_ref)
.or_else(|| {
data.get("priceRef")
.and_then(Value::as_str)
.map(str::to_string)
})
.unwrap_or_else(|| item.to_string());
Offer {
item: item.to_string(),
name,
price_label,
price_ref,
world_id: world_id.to_string(),
}
}
fn price_ref_string(v: &Value) -> Option<String> {
if let Some(s) = v.as_str() {
return (!s.is_empty()).then(|| s.to_string());
}
for key in ["priceRef", "productKey", "key", "ref"] {
if let Some(s) = v.get(key).and_then(Value::as_str) {
if !s.is_empty() {
return Some(s.to_string());
}
}
}
None
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct Receipt {
#[serde(default)]
pub grant: Grant,
#[serde(default)]
pub charge: Charge,
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct Grant {
#[serde(default, rename = "itemStructuredId")]
pub item: String,
#[serde(default)]
pub granted: bool,
#[serde(default)]
pub persisted: bool,
#[serde(default, rename = "ref")]
pub reference: String,
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct Charge {
#[serde(default)]
pub amount: f64,
#[serde(default)]
pub currency: String,
#[serde(default)]
pub recorded: bool,
#[serde(default)]
pub duplicate: bool,
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct Entitlement {
#[serde(default)]
pub plus: bool,
#[serde(default)]
pub tier: String,
#[serde(default)]
pub source: String,
#[serde(default, rename = "ownsGame")]
pub owns_game: bool,
#[serde(default)]
pub credits: f64,
}
pub struct CommerceClient {
base: String,
token: Option<String>,
pending: Option<(Offer, Receiver<Result<Receipt, String>>)>,
ent_pending: Option<Receiver<Result<Entitlement, String>>>,
}
impl CommerceClient {
pub fn new(base: impl Into<String>) -> Self {
Self {
base: base.into(),
token: None,
pending: None,
ent_pending: None,
}
}
pub fn from_env() -> Self {
let base = std::env::var("INFINITE_COMMERCE_BASE")
.unwrap_or_else(|_| "https://api.pixygon.io/v1".to_string());
Self::new(base)
}
pub fn with_token(mut self, token: Option<String>) -> Self {
self.token = token.filter(|t| !t.trim().is_empty());
self
}
pub fn has_token(&self) -> bool {
self.token.is_some()
}
pub fn in_flight(&self) -> Option<&Offer> {
self.pending.as_ref().map(|(o, _)| o)
}
pub fn buy(&mut self, offer: Offer) -> bool {
let Some(token) = self.token.clone() else {
return false;
};
if self.pending.is_some() {
return false;
}
let url = format!("{}/thread/purchase", self.base.trim_end_matches('/'));
let body = serde_json::json!({
"worldId": offer.world_id,
"itemStructuredId": offer.item,
"priceRef": offer.price_ref,
});
let (tx, rx) = channel();
std::thread::spawn(move || {
let _ = tx.send(post_blocking(&url, &token, &body));
});
self.pending = Some((offer, rx));
true
}
pub fn poll(&mut self) -> Option<(Offer, Result<Receipt, String>)> {
let (_, rx) = self.pending.as_ref()?;
let result = match rx.try_recv() {
Ok(r) => r,
Err(TryRecvError::Empty) => return None,
Err(TryRecvError::Disconnected) => Err("purchase thread stopped".into()),
};
let (offer, _) = self.pending.take()?;
Some((offer, result))
}
pub fn entitlements(&mut self, project_id: &str) {
let Some(token) = self.token.clone() else {
return;
};
if self.ent_pending.is_some() || project_id.trim().is_empty() {
return;
}
let url = format!(
"{}/entitlements/{}",
self.base.trim_end_matches('/'),
project_id.trim()
);
let (tx, rx) = channel();
std::thread::spawn(move || {
let _ = tx.send(get_blocking(&url, &token));
});
self.ent_pending = Some(rx);
}
pub fn poll_entitlements(&mut self) -> Option<Result<Entitlement, String>> {
let rx = self.ent_pending.as_ref()?;
let result = match rx.try_recv() {
Ok(r) => r,
Err(TryRecvError::Empty) => return None,
Err(TryRecvError::Disconnected) => Err("entitlements thread stopped".into()),
};
self.ent_pending = None;
Some(result)
}
}
fn error_message(body: &str) -> Option<String> {
#[derive(Deserialize)]
struct Err_ {
error: String,
}
serde_json::from_str::<Err_>(body).ok().map(|e| e.error)
}
fn post_blocking(url: &str, token: &str, body: &Value) -> Result<Receipt, String> {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.map_err(|e| e.to_string())?;
rt.block_on(async {
let resp = reqwest::Client::new()
.post(url)
.bearer_auth(token)
.json(body)
.send()
.await
.map_err(|e| e.to_string())?;
let status = resp.status();
let text = resp.text().await.map_err(|e| e.to_string())?;
if !status.is_success() {
return Err(error_message(&text).unwrap_or_else(|| format!("HTTP {}", status.as_u16())));
}
serde_json::from_str(&text).map_err(|e| e.to_string())
})
}
fn get_blocking(url: &str, token: &str) -> Result<Entitlement, String> {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.map_err(|e| e.to_string())?;
rt.block_on(async {
let resp = reqwest::Client::new()
.get(url)
.bearer_auth(token)
.send()
.await
.map_err(|e| e.to_string())?;
let status = resp.status();
let text = resp.text().await.map_err(|e| e.to_string())?;
if !status.is_success() {
return Err(error_message(&text).unwrap_or_else(|| format!("HTTP {}", status.as_u16())));
}
serde_json::from_str(&text).map_err(|e| e.to_string())
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn offers_read_market_stall_data() {
let data = serde_json::json!({
"item": "21010001", "name": "Veilwalker Blade", "price": 250, "currency": "gold"
});
let o = offer_from("21010001", &Value::Null, &data, "45010001");
assert_eq!(o.name, "Veilwalker Blade");
assert_eq!(o.price_label.as_deref(), Some("250 gold"));
assert_eq!(o.price_ref, "21010001");
assert_eq!(o.world_id, "45010001");
}
#[test]
fn price_ref_resolution_order() {
let o = offer_from(
"21010001",
&serde_json::json!("blade-sku"),
&Value::Null,
"w",
);
assert_eq!(o.price_ref, "blade-sku");
let o = offer_from(
"21010001",
&serde_json::json!({"productKey": "blade-pk"}),
&Value::Null,
"w",
);
assert_eq!(o.price_ref, "blade-pk");
let data = serde_json::json!({"priceRef": "from-data"});
let o = offer_from("21010001", &Value::Null, &data, "w");
assert_eq!(o.price_ref, "from-data");
let o = offer_from("21010001", &Value::Null, &Value::Null, "w");
assert_eq!(o.name, "21010001");
assert!(o.price_label.is_none());
}
#[test]
fn parses_the_purchase_response_shape() {
let json = r#"{
"grant": { "itemStructuredId": "21010001", "worldId": "45010001",
"userId": "u1", "granted": true, "persisted": false,
"ref": "thread:u1:45010001:21010001" },
"charge": { "amount": 25, "currency": "NOK", "recorded": true, "duplicate": false }
}"#;
let r: Receipt = serde_json::from_str(json).unwrap();
assert!(r.grant.granted);
assert!(!r.grant.persisted);
assert_eq!(r.grant.reference, "thread:u1:45010001:21010001");
assert_eq!(r.charge.amount, 25.0);
assert_eq!(r.charge.currency, "NOK");
assert!(r.charge.recorded);
}
#[test]
fn parses_the_entitlement_shape() {
let json = r#"{
"projectId": "p1", "projectSlug": "infinite", "userId": "u1",
"plus": true, "tier": "premium", "source": "subscription",
"ownsGame": true, "credits": 120, "purchasedCredits": 100,
"features": {}, "checkedAt": "2026-07-14T12:00:00Z"
}"#;
let e: Entitlement = serde_json::from_str(json).unwrap();
assert!(e.plus);
assert_eq!(e.tier, "premium");
assert!(e.owns_game);
assert_eq!(e.credits, 120.0);
}
#[test]
fn surfaces_the_server_error_body() {
assert_eq!(
error_message(
r#"{"error":"No server-side price for priceRef \"x\" in this world's project."}"#
)
.as_deref(),
Some("No server-side price for priceRef \"x\" in this world's project.")
);
assert!(error_message("not json").is_none());
}
#[test]
fn buying_requires_a_passport_and_one_at_a_time() {
let offer = offer_from("21010001", &Value::Null, &Value::Null, "w");
let mut anon = CommerceClient::new("http://x").with_token(Some(" ".into()));
assert!(!anon.has_token());
assert!(!anon.buy(offer.clone()));
assert!(anon.in_flight().is_none());
let mut c = CommerceClient::new("http://127.0.0.1:9").with_token(Some("tok".into()));
assert!(c.buy(offer.clone()));
assert!(c.in_flight().is_some());
assert!(!c.buy(offer));
}
#[test]
fn entitlements_reads_need_a_token_and_a_project() {
let mut anon = CommerceClient::new("http://x");
anon.entitlements("p1");
assert!(anon.poll_entitlements().is_none()); let mut c = CommerceClient::new("http://x").with_token(Some("tok".into()));
c.entitlements(" ");
assert!(c.ent_pending.is_none()); }
}