Skip to main content

thread_engine/
commerce.rs

1//! In-world commerce — the "buy" side of the Thread.
2//!
3//! A world's `buy` behavior returns [`Action::CommerceBuy`](crate::Action); the
4//! *browser* performs it here. The flow is deliberately server-authoritative
5//! (Theme B): the client sends only `{ worldId, itemStructuredId, priceRef }` to
6//! `POST <base>/thread/purchase` with the traveler's Passport as the bearer —
7//! the server resolves the price from its own catalog (client-set prices are
8//! dead on arrival), records the charge idempotently, and returns a grant.
9//!
10//! Entitlements — "what does this traveler already hold?" — come from
11//! `GET <base>/entitlements/:projectId` with the same bearer.
12//!
13//! All network runs on background threads and lands via [`CommerceClient::poll`]
14//! once per frame; failures degrade to a message, never a stall (super-stable
15//! tenet). The base URL comes from `INFINITE_COMMERCE_BASE` (default
16//! `https://api.pixygon.io/v1` — same origin as the Codex).
17
18use std::sync::mpsc::{channel, Receiver, TryRecvError};
19
20use serde::Deserialize;
21use serde_json::Value;
22
23/// What's on the counter — everything the purchase confirm dialog shows and
24/// everything the wire call needs. Built from the behavior's `commerce.buy`
25/// action plus the placement's `data` block via [`offer_from`].
26#[derive(Debug, Clone, PartialEq)]
27pub struct Offer {
28    /// The item being bought, as a StructuredId string ("CCSSNNNN").
29    pub item: String,
30    /// Display name (from placement `data.name`, falling back to the item id).
31    pub name: String,
32    /// The world's *displayed* price ("250 gold"), informative only — the
33    /// server resolves the authoritative charge from `price_ref`.
34    pub price_label: Option<String>,
35    /// The price-catalog key the server resolves the charge from.
36    pub price_ref: String,
37    /// The world the stall stands in (cat-45 world id).
38    pub world_id: String,
39}
40
41/// Build an [`Offer`] from a `commerce.buy` action + the focused placement's
42/// `data` block. `price_ref` resolution order: the action's value (string, or
43/// an object's `priceRef`/`productKey`/`key`) → `data.priceRef` → the item id.
44pub fn offer_from(item: &str, price_ref: &Value, data: &Value, world_id: &str) -> Offer {
45    let name = data
46        .get("name")
47        .and_then(Value::as_str)
48        .filter(|s| !s.is_empty())
49        .unwrap_or(item)
50        .to_string();
51    let price_label =
52        data.get("price")
53            .map(|p| match data.get("currency").and_then(Value::as_str) {
54                Some(c) => format!("{p} {c}"),
55                None => p.to_string(),
56            });
57    let price_ref = price_ref_string(price_ref)
58        .or_else(|| {
59            data.get("priceRef")
60                .and_then(Value::as_str)
61                .map(str::to_string)
62        })
63        .unwrap_or_else(|| item.to_string());
64    Offer {
65        item: item.to_string(),
66        name,
67        price_label,
68        price_ref,
69        world_id: world_id.to_string(),
70    }
71}
72
73/// Read a priceRef out of the action's value: a plain string, or an object
74/// carrying one under a conventional key.
75fn price_ref_string(v: &Value) -> Option<String> {
76    if let Some(s) = v.as_str() {
77        return (!s.is_empty()).then(|| s.to_string());
78    }
79    for key in ["priceRef", "productKey", "key", "ref"] {
80        if let Some(s) = v.get(key).and_then(Value::as_str) {
81            if !s.is_empty() {
82                return Some(s.to_string());
83            }
84        }
85    }
86    None
87}
88
89/// The settled purchase, shaped to `POST /thread/purchase` (tolerant to extras).
90#[derive(Debug, Clone, Default, Deserialize)]
91pub struct Receipt {
92    #[serde(default)]
93    pub grant: Grant,
94    #[serde(default)]
95    pub charge: Charge,
96}
97
98#[derive(Debug, Clone, Default, Deserialize)]
99pub struct Grant {
100    #[serde(default, rename = "itemStructuredId")]
101    pub item: String,
102    #[serde(default)]
103    pub granted: bool,
104    /// Whether the grant landed on the traveler's durable inventory yet (the
105    /// server acknowledges before persistence is wired; surface honestly).
106    #[serde(default)]
107    pub persisted: bool,
108    /// The idempotency reference for this (traveler, world, item) charge.
109    #[serde(default, rename = "ref")]
110    pub reference: String,
111}
112
113#[derive(Debug, Clone, Default, Deserialize)]
114pub struct Charge {
115    /// Major-unit amount the server actually recorded (its price, not ours).
116    #[serde(default)]
117    pub amount: f64,
118    #[serde(default)]
119    pub currency: String,
120    #[serde(default)]
121    pub recorded: bool,
122    /// True when this (traveler, world, item) was already charged — the server
123    /// deduplicates, so "buying again" is free and safe.
124    #[serde(default)]
125    pub duplicate: bool,
126}
127
128/// What the traveler already holds against a project, shaped to
129/// `GET /entitlements/:projectId` (tolerant to extras).
130#[derive(Debug, Clone, Default, Deserialize)]
131pub struct Entitlement {
132    #[serde(default)]
133    pub plus: bool,
134    #[serde(default)]
135    pub tier: String,
136    #[serde(default)]
137    pub source: String,
138    #[serde(default, rename = "ownsGame")]
139    pub owns_game: bool,
140    #[serde(default)]
141    pub credits: f64,
142}
143
144/// The browser's commerce client — one purchase in flight at a time (buying is
145/// a deliberate act, not a queue), plus an independent entitlements read.
146pub struct CommerceClient {
147    base: String,
148    /// Passport bearer token — purchases REQUIRE one (the server charges an
149    /// authenticated traveler). `None` = walking anonymous, browsing only.
150    token: Option<String>,
151    pending: Option<(Offer, Receiver<Result<Receipt, String>>)>,
152    ent_pending: Option<Receiver<Result<Entitlement, String>>>,
153}
154
155impl CommerceClient {
156    pub fn new(base: impl Into<String>) -> Self {
157        Self {
158            base: base.into(),
159            token: None,
160            pending: None,
161            ent_pending: None,
162        }
163    }
164
165    /// Read the base URL from `INFINITE_COMMERCE_BASE` (falls back to the public API).
166    pub fn from_env() -> Self {
167        let base = std::env::var("INFINITE_COMMERCE_BASE")
168            .unwrap_or_else(|_| "https://api.pixygon.io/v1".to_string());
169        Self::new(base)
170    }
171
172    /// Present a Passport on purchases (`Authorization: Bearer <token>`).
173    /// `None`/empty → anonymous: browsing works, buying doesn't.
174    pub fn with_token(mut self, token: Option<String>) -> Self {
175        self.token = token.filter(|t| !t.trim().is_empty());
176        self
177    }
178
179    /// Whether a Passport is presented — the purchase UI gates its Buy on this.
180    pub fn has_token(&self) -> bool {
181        self.token.is_some()
182    }
183
184    /// The purchase currently settling, if any.
185    pub fn in_flight(&self) -> Option<&Offer> {
186        self.pending.as_ref().map(|(o, _)| o)
187    }
188
189    /// Start a purchase on a background thread. Returns `false` (and does
190    /// nothing) without a Passport or while another purchase is settling.
191    pub fn buy(&mut self, offer: Offer) -> bool {
192        let Some(token) = self.token.clone() else {
193            return false;
194        };
195        if self.pending.is_some() {
196            return false;
197        }
198        let url = format!("{}/thread/purchase", self.base.trim_end_matches('/'));
199        let body = serde_json::json!({
200            "worldId": offer.world_id,
201            "itemStructuredId": offer.item,
202            "priceRef": offer.price_ref,
203        });
204        let (tx, rx) = channel();
205        std::thread::spawn(move || {
206            let _ = tx.send(post_blocking(&url, &token, &body));
207        });
208        self.pending = Some((offer, rx));
209        true
210    }
211
212    /// Collect a settled purchase, if any. Call once per frame.
213    pub fn poll(&mut self) -> Option<(Offer, Result<Receipt, String>)> {
214        let (_, rx) = self.pending.as_ref()?;
215        let result = match rx.try_recv() {
216            Ok(r) => r,
217            Err(TryRecvError::Empty) => return None,
218            Err(TryRecvError::Disconnected) => Err("purchase thread stopped".into()),
219        };
220        let (offer, _) = self.pending.take()?;
221        Some((offer, result))
222    }
223
224    /// Fetch what the traveler holds against a project (background thread).
225    /// No-op without a Passport or while a previous read is in flight.
226    pub fn entitlements(&mut self, project_id: &str) {
227        let Some(token) = self.token.clone() else {
228            return;
229        };
230        if self.ent_pending.is_some() || project_id.trim().is_empty() {
231            return;
232        }
233        let url = format!(
234            "{}/entitlements/{}",
235            self.base.trim_end_matches('/'),
236            project_id.trim()
237        );
238        let (tx, rx) = channel();
239        std::thread::spawn(move || {
240            let _ = tx.send(get_blocking(&url, &token));
241        });
242        self.ent_pending = Some(rx);
243    }
244
245    /// Collect a finished entitlements read, if any. Call once per frame.
246    pub fn poll_entitlements(&mut self) -> Option<Result<Entitlement, String>> {
247        let rx = self.ent_pending.as_ref()?;
248        let result = match rx.try_recv() {
249            Ok(r) => r,
250            Err(TryRecvError::Empty) => return None,
251            Err(TryRecvError::Disconnected) => Err("entitlements thread stopped".into()),
252        };
253        self.ent_pending = None;
254        Some(result)
255    }
256}
257
258/// The server's `{ "error": "..." }` body, when a call fails.
259fn error_message(body: &str) -> Option<String> {
260    #[derive(Deserialize)]
261    struct Err_ {
262        error: String,
263    }
264    serde_json::from_str::<Err_>(body).ok().map(|e| e.error)
265}
266
267/// Blocking POST on a throwaway current-thread runtime (reqwest is async-only
268/// in this workspace). Runs on a background thread, so blocking is fine here.
269fn post_blocking(url: &str, token: &str, body: &Value) -> Result<Receipt, String> {
270    let rt = tokio::runtime::Builder::new_current_thread()
271        .enable_all()
272        .build()
273        .map_err(|e| e.to_string())?;
274    rt.block_on(async {
275        let resp = reqwest::Client::new()
276            .post(url)
277            .bearer_auth(token)
278            .json(body)
279            .send()
280            .await
281            .map_err(|e| e.to_string())?;
282        let status = resp.status();
283        let text = resp.text().await.map_err(|e| e.to_string())?;
284        if !status.is_success() {
285            return Err(error_message(&text).unwrap_or_else(|| format!("HTTP {}", status.as_u16())));
286        }
287        serde_json::from_str(&text).map_err(|e| e.to_string())
288    })
289}
290
291/// Blocking GET with bearer, same runtime pattern as [`post_blocking`].
292fn get_blocking(url: &str, token: &str) -> Result<Entitlement, String> {
293    let rt = tokio::runtime::Builder::new_current_thread()
294        .enable_all()
295        .build()
296        .map_err(|e| e.to_string())?;
297    rt.block_on(async {
298        let resp = reqwest::Client::new()
299            .get(url)
300            .bearer_auth(token)
301            .send()
302            .await
303            .map_err(|e| e.to_string())?;
304        let status = resp.status();
305        let text = resp.text().await.map_err(|e| e.to_string())?;
306        if !status.is_success() {
307            return Err(error_message(&text).unwrap_or_else(|| format!("HTTP {}", status.as_u16())));
308        }
309        serde_json::from_str(&text).map_err(|e| e.to_string())
310    })
311}
312
313#[cfg(test)]
314mod tests {
315    use super::*;
316
317    #[test]
318    fn offers_read_market_stall_data() {
319        // The Bazaar's actual stall shape.
320        let data = serde_json::json!({
321            "item": "21010001", "name": "Veilwalker Blade", "price": 250, "currency": "gold"
322        });
323        let o = offer_from("21010001", &Value::Null, &data, "45010001");
324        assert_eq!(o.name, "Veilwalker Blade");
325        assert_eq!(o.price_label.as_deref(), Some("250 gold"));
326        // No explicit priceRef anywhere → the item id is the catalog key.
327        assert_eq!(o.price_ref, "21010001");
328        assert_eq!(o.world_id, "45010001");
329    }
330
331    #[test]
332    fn price_ref_resolution_order() {
333        // The action's value wins…
334        let o = offer_from(
335            "21010001",
336            &serde_json::json!("blade-sku"),
337            &Value::Null,
338            "w",
339        );
340        assert_eq!(o.price_ref, "blade-sku");
341        // …including when it's an object carrying a conventional key…
342        let o = offer_from(
343            "21010001",
344            &serde_json::json!({"productKey": "blade-pk"}),
345            &Value::Null,
346            "w",
347        );
348        assert_eq!(o.price_ref, "blade-pk");
349        // …then the placement data's priceRef…
350        let data = serde_json::json!({"priceRef": "from-data"});
351        let o = offer_from("21010001", &Value::Null, &data, "w");
352        assert_eq!(o.price_ref, "from-data");
353        // …and a nameless placement falls back to the item id for display too.
354        let o = offer_from("21010001", &Value::Null, &Value::Null, "w");
355        assert_eq!(o.name, "21010001");
356        assert!(o.price_label.is_none());
357    }
358
359    #[test]
360    fn parses_the_purchase_response_shape() {
361        // Verbatim shape from PixygonServer controllers/thread.js#purchase.
362        let json = r#"{
363            "grant": { "itemStructuredId": "21010001", "worldId": "45010001",
364                       "userId": "u1", "granted": true, "persisted": false,
365                       "ref": "thread:u1:45010001:21010001" },
366            "charge": { "amount": 25, "currency": "NOK", "recorded": true, "duplicate": false }
367        }"#;
368        let r: Receipt = serde_json::from_str(json).unwrap();
369        assert!(r.grant.granted);
370        assert!(!r.grant.persisted);
371        assert_eq!(r.grant.reference, "thread:u1:45010001:21010001");
372        assert_eq!(r.charge.amount, 25.0);
373        assert_eq!(r.charge.currency, "NOK");
374        assert!(r.charge.recorded);
375    }
376
377    #[test]
378    fn parses_the_entitlement_shape() {
379        // Verbatim shape from PixygonServer services/entitlementService.js.
380        let json = r#"{
381            "projectId": "p1", "projectSlug": "infinite", "userId": "u1",
382            "plus": true, "tier": "premium", "source": "subscription",
383            "ownsGame": true, "credits": 120, "purchasedCredits": 100,
384            "features": {}, "checkedAt": "2026-07-14T12:00:00Z"
385        }"#;
386        let e: Entitlement = serde_json::from_str(json).unwrap();
387        assert!(e.plus);
388        assert_eq!(e.tier, "premium");
389        assert!(e.owns_game);
390        assert_eq!(e.credits, 120.0);
391    }
392
393    #[test]
394    fn surfaces_the_server_error_body() {
395        assert_eq!(
396            error_message(
397                r#"{"error":"No server-side price for priceRef \"x\" in this world's project."}"#
398            )
399            .as_deref(),
400            Some("No server-side price for priceRef \"x\" in this world's project.")
401        );
402        assert!(error_message("not json").is_none());
403    }
404
405    #[test]
406    fn buying_requires_a_passport_and_one_at_a_time() {
407        let offer = offer_from("21010001", &Value::Null, &Value::Null, "w");
408        // Anonymous (or blank-token) travelers browse; they don't buy.
409        let mut anon = CommerceClient::new("http://x").with_token(Some("  ".into()));
410        assert!(!anon.has_token());
411        assert!(!anon.buy(offer.clone()));
412        assert!(anon.in_flight().is_none());
413        // A second buy while one is settling is refused, not queued.
414        let mut c = CommerceClient::new("http://127.0.0.1:9").with_token(Some("tok".into()));
415        assert!(c.buy(offer.clone()));
416        assert!(c.in_flight().is_some());
417        assert!(!c.buy(offer));
418    }
419
420    #[test]
421    fn entitlements_reads_need_a_token_and_a_project() {
422        let mut anon = CommerceClient::new("http://x");
423        anon.entitlements("p1");
424        assert!(anon.poll_entitlements().is_none()); // never started
425        let mut c = CommerceClient::new("http://x").with_token(Some("tok".into()));
426        c.entitlements("  ");
427        assert!(c.ent_pending.is_none()); // blank project → no call
428    }
429}