Skip to main content

thread_engine/
codex.rs

1//! In-world Codex reader — the "inspect" side of the Thread.
2//!
3//! When the player inspects a Codex-linked placement, this fetches the canonical
4//! entry from the main agent's live endpoint (`GET <base>/codex/entities/:slug`)
5//! on a background thread and hands back a display panel. Anonymous reads see
6//! the public tier; a traveler carrying a Passport presents it as a bearer
7//! token, unlocking whatever tier their identity holds (under-tier reads still
8//! answer — with the universe's in-fiction refusal).
9//! Failures degrade gracefully (the world keeps running — super-stable tenet);
10//! successes are cached. The base URL comes from `INFINITE_CODEX_BASE`
11//! (default `https://api.pixygon.io/v1`); when the resolver is wired it will
12//! supply `codexBase` per world.
13
14use std::collections::HashMap;
15use std::sync::mpsc::{channel, Receiver, TryRecvError};
16
17use serde::Deserialize;
18
19/// A Codex entry, shaped to the main agent's read API (tolerant to extra fields).
20#[derive(Debug, Clone, Default, Deserialize)]
21pub struct CodexEntry {
22    #[serde(default)]
23    pub name: String,
24    #[serde(default)]
25    pub slug: String,
26    #[serde(default)]
27    pub kind: String,
28    #[serde(default)]
29    pub subkind: Option<String>,
30    #[serde(default)]
31    pub summary: Option<String>,
32    #[serde(default)]
33    pub description: Option<String>,
34    #[serde(default)]
35    pub tags: Vec<String>,
36    #[serde(default, rename = "accessTier")]
37    pub access_tier: Option<String>,
38}
39
40/// What to draw for the Codex reader this frame (owned so egui closures are free).
41#[derive(Debug, Clone)]
42pub struct CodexPanel {
43    pub title: String,
44    pub subtitle: String,
45    pub body: String,
46    pub loading: bool,
47}
48
49enum State {
50    Idle,
51    Loading(String),
52    Loaded(Box<CodexEntry>),
53    Error(String, String),
54}
55
56/// The in-world Codex reader.
57pub struct CodexClient {
58    base: String,
59    /// Passport bearer token — presented on every read so tiered entries
60    /// unlock to whatever this identity holds. `None` reads as public.
61    token: Option<String>,
62    state: State,
63    cache: HashMap<String, CodexEntry>,
64    pending: Option<(String, Receiver<Result<CodexEntry, String>>)>,
65}
66
67impl CodexClient {
68    pub fn new(base: impl Into<String>) -> Self {
69        Self {
70            base: base.into(),
71            token: None,
72            state: State::Idle,
73            cache: HashMap::new(),
74            pending: None,
75        }
76    }
77
78    /// Present a Passport on Codex reads (`Authorization: Bearer <token>`),
79    /// unlocking the identity's access tier. `None`/empty → anonymous (public).
80    pub fn with_token(mut self, token: Option<String>) -> Self {
81        self.token = token.filter(|t| !t.trim().is_empty());
82        self
83    }
84
85    /// Read the base URL from `INFINITE_CODEX_BASE` (falls back to the public API).
86    pub fn from_env() -> Self {
87        let base = std::env::var("INFINITE_CODEX_BASE")
88            .unwrap_or_else(|_| "https://api.pixygon.io/v1".to_string());
89        Self::new(base)
90    }
91
92    /// Whether a reader panel is currently showing (or loading).
93    pub fn is_open(&self) -> bool {
94        !matches!(self.state, State::Idle)
95    }
96
97    pub fn close(&mut self) {
98        self.state = State::Idle;
99        self.pending = None;
100    }
101
102    /// Open a Codex entry by slug (cached, or fetched on a background thread).
103    pub fn open(&mut self, slug: &str) {
104        if let Some(entry) = self.cache.get(slug) {
105            self.state = State::Loaded(Box::new(entry.clone()));
106            return;
107        }
108        let url = entry_url(&self.base, slug);
109        let token = self.token.clone();
110        let (tx, rx) = channel();
111        std::thread::spawn(move || {
112            let _ = tx.send(fetch_blocking(&url, token.as_deref()));
113        });
114        self.state = State::Loading(slug.to_string());
115        self.pending = Some((slug.to_string(), rx));
116    }
117
118    /// Collect a finished background fetch, if any. Call once per frame.
119    pub fn poll(&mut self) {
120        let Some((slug, rx)) = &self.pending else {
121            return;
122        };
123        match rx.try_recv() {
124            Ok(Ok(entry)) => {
125                self.cache.insert(slug.clone(), entry.clone());
126                self.state = State::Loaded(Box::new(entry));
127                self.pending = None;
128            }
129            Ok(Err(msg)) => {
130                self.state = State::Error(slug.clone(), msg);
131                self.pending = None;
132            }
133            Err(TryRecvError::Empty) => {}
134            Err(TryRecvError::Disconnected) => {
135                self.state = State::Error(slug.clone(), "fetch thread stopped".into());
136                self.pending = None;
137            }
138        }
139    }
140
141    /// The panel to render, or `None` when the reader is closed.
142    pub fn panel(&self) -> Option<CodexPanel> {
143        match &self.state {
144            State::Idle => None,
145            State::Loading(slug) => Some(CodexPanel {
146                title: slug.clone(),
147                subtitle: "Consulting the Codex…".into(),
148                body: String::new(),
149                loading: true,
150            }),
151            State::Loaded(e) => Some(CodexPanel {
152                title: if e.name.is_empty() {
153                    e.slug.clone()
154                } else {
155                    e.name.clone()
156                },
157                subtitle: match &e.subkind {
158                    Some(s) if !s.is_empty() => format!("{} · {s}", e.kind),
159                    _ => e.kind.clone(),
160                },
161                body: e
162                    .description
163                    .clone()
164                    .filter(|s| !s.is_empty())
165                    .or_else(|| e.summary.clone())
166                    .unwrap_or_default(),
167                loading: false,
168            }),
169            State::Error(slug, msg) => Some(CodexPanel {
170                title: slug.clone(),
171                subtitle: "The Codex is out of reach".into(),
172                body: msg.clone(),
173                loading: false,
174            }),
175        }
176    }
177}
178
179/// Build the read URL for a slug.
180fn entry_url(base: &str, slug: &str) -> String {
181    format!("{}/codex/entities/{}", base.trim_end_matches('/'), slug)
182}
183
184/// Blocking fetch on a throwaway current-thread runtime (reqwest is async-only
185/// in this workspace). Runs on a background thread, so blocking is fine here.
186/// A Passport token, when present, rides as a bearer header (tier unlock).
187fn fetch_blocking(url: &str, token: Option<&str>) -> Result<CodexEntry, String> {
188    let rt = tokio::runtime::Builder::new_current_thread()
189        .enable_all()
190        .build()
191        .map_err(|e| e.to_string())?;
192    rt.block_on(async {
193        let client = reqwest::Client::new();
194        let mut req = client.get(url);
195        if let Some(t) = token {
196            req = req.bearer_auth(t);
197        }
198        let resp = req.send().await.map_err(|e| e.to_string())?;
199        let status = resp.status();
200        let text = resp.text().await.map_err(|e| e.to_string())?;
201        if !status.is_success() {
202            // Under-tier reads return a diegetic refusal voiced by the universe
203            // (the Codex speaks in-fiction). Surface that message — it's content.
204            return Err(
205                refusal_message(&text).unwrap_or_else(|| format!("HTTP {}", status.as_u16()))
206            );
207        }
208        parse_entry(&text)
209    })
210}
211
212fn parse_entry(json: &str) -> Result<CodexEntry, String> {
213    serde_json::from_str(json).map_err(|e| e.to_string())
214}
215
216/// The in-fiction refusal message from a gated read, if the body carries one.
217fn refusal_message(body: &str) -> Option<String> {
218    #[derive(Deserialize)]
219    struct Refusal {
220        in_lore: Option<InLore>,
221    }
222    #[derive(Deserialize)]
223    struct InLore {
224        message: String,
225    }
226    serde_json::from_str::<Refusal>(body)
227        .ok()?
228        .in_lore
229        .map(|l| l.message)
230}
231
232#[cfg(test)]
233mod tests {
234    use super::*;
235
236    #[test]
237    fn builds_entry_url() {
238        assert_eq!(
239            entry_url("https://api.pixygon.io/v1", "the-veil"),
240            "https://api.pixygon.io/v1/codex/entities/the-veil"
241        );
242        // Trailing slash is normalized.
243        assert_eq!(
244            entry_url("https://api.pixygon.io/v1/", "amebrak"),
245            "https://api.pixygon.io/v1/codex/entities/amebrak"
246        );
247    }
248
249    #[test]
250    fn parses_the_read_api_shape() {
251        let json = r#"{
252            "name": "The Veil", "slug": "the-veil", "kind": "concept",
253            "subkind": "membrane", "summary": "A temporary membrane.",
254            "description": "The magical, temporary membrane...", "tags": ["wardenship"],
255            "accessTier": "public", "extraFieldWeIgnore": 42
256        }"#;
257        let e = parse_entry(json).unwrap();
258        assert_eq!(e.name, "The Veil");
259        assert_eq!(e.kind, "concept");
260        assert_eq!(e.subkind.as_deref(), Some("membrane"));
261        assert_eq!(e.access_tier.as_deref(), Some("public"));
262
263        let panel = CodexClient {
264            base: String::new(),
265            token: None,
266            state: State::Loaded(Box::new(e)),
267            cache: HashMap::new(),
268            pending: None,
269        }
270        .panel()
271        .unwrap();
272        assert_eq!(panel.title, "The Veil");
273        assert_eq!(panel.subtitle, "concept · membrane");
274        assert!(!panel.loading);
275    }
276
277    #[test]
278    fn blank_tokens_read_as_anonymous() {
279        assert!(CodexClient::new("x").with_token(None).token.is_none());
280        assert!(CodexClient::new("x")
281            .with_token(Some("  ".into()))
282            .token
283            .is_none());
284        assert_eq!(
285            CodexClient::new("x")
286                .with_token(Some("tok".into()))
287                .token
288                .as_deref(),
289            Some("tok")
290        );
291    }
292
293    #[test]
294    fn surfaces_diegetic_refusal_from_a_gated_read() {
295        let body = r#"{"error":"access_denied","in_lore":{"message":"The Archivist meets your gaze. 'Walk on, traveler.'","seal":"oath-of-the-archive","tier":"archivist"}}"#;
296        assert_eq!(
297            refusal_message(body).as_deref(),
298            Some("The Archivist meets your gaze. 'Walk on, traveler.'")
299        );
300        // A plain error body has no in-fiction message.
301        assert!(refusal_message(r#"{"error":"nope"}"#).is_none());
302    }
303}