1/* Codex I/O for the signed-in shell (/app/codex). 2 * 3 * Hand-written, exactly like leaderboard-data.js next to it: a thin fetch + 4 * promise-cache wrapper around window.BFAuth, nothing else. There is no pure 5 * logic in this file worth extracting to a TS core (roster()/sets()/facets()/ 6 * cards()/card()/version() all return the endpoint's own shape verbatim) â 7 * see leaderboard-data.js's own header comment on why that split matters and 8 * why an I/O shell must stay this thin. B4 (Dice Throne roster grid) and B5 9 * (TCG faceted browser) are the actual consumers of most of this; B3 only 10 * calls roster() and version() itself, for the Codex banner's stat tiles. 11 * 12 * Every one of these endpoints is `requireAuth` (see CLAUDE.md's Authentication 13 * wall section) EXCEPT version(), which is public â it's still routed through 14 * authFetch for consistency with every other call here (same reasoning 15 * home.js's loadEvents() gives for its own public endpoint): it degrades to a 16 * plain fetch when signed out, and attaches a token harmlessly when one exists. 17 * 18 * `game` here is ALWAYS the raw TCG-catalog id (`TCG_GAMES` in 19 * apps/server/src/routes/tcg-catalog.ts: 'mtg' | 'pokemon-tcg' | 'lorcana' | 20 * 'onepiece' | 'swu' | 'yugioh' | 'riftbound') â the exact string the 21 * `/api/tcg/:game/*` routes expect. It is NOT a GameType enum value. codex.js 22 * owns the GameType-enum-id (used by the ?game= URL and the shared GAMES 23 * roster) -> TCG-catalog-id translation before calling sets()/facets()/ 24 * cards()/card() â see that file's GAMES array for the mapping. Keeping the 25 * translation out of this shell is the same "this file does no business 26 * logic, only fetches" rule leaderboard-data.js documents for itself. 27 * 28 * Load order: auth-bridge.js, then this (leaderboard-data.js's own load-order 29 * note applies here too â this file only needs window.BFAuth, so it can load 30 * any time after auth-bridge.js, not necessarily immediately after it). 31 */ 32(function () { 33 'use strict'; 34 35 var TTL_MS = 60 * 1000; 36 var cache = Object.create(null); 37 38 function authFetch(url, opts) { 39 if (window.BFAuth && typeof window.BFAuth.authFetch === 'function') { 40 return window.BFAuth.authFetch(url, opts); 41 } 42 return fetch(url, opts); 43 } 44 45 // Shared response handler for every endpoint below: 401 is surfaced as 46 // `e.status = 401` so a caller can distinguish "sign in again" from any 47 // other failure; any other non-2xx throws with the real status attached. 48 function toJson(r) { 49 if (r.status === 401) { 50 var e = new Error('unauthorized'); 51 e.status = 401; 52 throw e; 53 } 54 if (!r.ok) { 55 var e2 = new Error('Request failed: ' + r.status); 56 e2.status = r.status; 57 throw e2; 58 } 59 return r.json(); 60 } 61 62 /* Same contract as leaderboard-data.js's get(): caches the PROMISE (so 63 * concurrent callers during one paint share a request), and a rejection is 64 * evicted immediately rather than poisoning the key for the rest of the 65 * TTL. */ 66 function get(key, url) { 67 var hit = cache[key]; 68 if (hit && Date.now() - hit.ts < TTL_MS) return hit.p; 69 var p = authFetch(url, { cache: 'no-store' }).then(toJson); 70 p.catch(function () { 71 if (cache[key] && cache[key].p === p) delete cache[key]; 72 }); 73 cache[key] = { ts: Date.now(), p: p }; 74 return p; 75 } 76 77 // An array value (C7 fix round 1, item 2) serialises as REPEATED params -- 78 // `?type=A&type=B` -- never comma-joined. A comma-joined single string 79 // broke on real data: Lorcana's `type` facet advertises the literal value 80 // "Action, Song" (160 cards), and a comma separator can't tell "one value 81 // containing a comma" apart from "two values" no matter how it's escaped 82 // (percent-encoding doesn't help either -- the server's query parser 83 // decodes before splitting). Repeated params are the only encoding where no 84 // facet value can ever collide with the separator. 85 function qs(params) { 86 var out = []; 87 Object.keys(params || {}).forEach(function (k) { 88 var v = params[k]; 89 if (v === undefined || v === null || v === '') return; 90 if (Array.isArray(v)) { 91 v.forEach(function (item) { 92 if (item === undefined || item === null || item === '') return; 93 out.push(encodeURIComponent(k) + '=' + encodeURIComponent(item)); 94 }); 95 return; 96 } 97 out.push(encodeURIComponent(k) + '=' + encodeURIComponent(v)); 98 }); 99 return out.length ? '?' + out.join('&') : ''; 100 } 101
102 // GET /api/encyclopedia/characters â gated. Full Dice Throne roster + 103 // enriched character data: { version, count, sets, characters: [...] }. 104 // Not scoped by `game` â this IS the Dice Throne data, unconditionally. 105 function roster() { 106 return get('roster', '/api/encyclopedia/characters'); 107 } 108 109 // GET /api/tcg/:game/sets â gated. [{ code, name, releasedAt?, count }] 110 function sets(game) { 111 return get('sets:' + game, '/api/tcg/' + encodeURIComponent(game) + '/sets'); 112 } 113 114 // GET /api/tcg/:game/facets?set= â gated. C7 pass 1: returns FacetDef[], a 115 // bare ORDERED array (id/label/kind/values-or-min-max per 116 // routes/tcg-catalog.ts), not a fixed {rarity,type} wrapper â display order 117 // IS the per-game contract, so this passes the payload through verbatim. 118 function facets(game, opts) { 119 opts = opts || {}; 120 var key = 'facets:' + game + ':' + (opts.set || ''); 121 return get(key, '/api/tcg/' + encodeURIComponent(game) + '/facets' + qs({ set: opts.set })); 122 } 123 124 // GET /api/tcg/:game/cards?set&q&page&pageSize&<facetId>&<facetId>Min& 125 // <facetId>Max â gated. { total, page, pageSize, cards: [...] }. Params are 126 // passed through verbatim (server-side filtering, per CLAUDE.md's "Card 127 // filtering stays server-side" rule) â this file adds no client-side 128 // filtering of its own, and does not know or care which keys are facet ids 129 // (codex.js's buildTcgFacetParams() builds those from the per-game schema). 130 // 131 // The cache key is built generically from every key actually present in 132 // `params`, not a hardcoded list of field names (the old 133 // `[set,q,rarity,type,page,pageSize]` list predates C7 pass 1's per-game 134 // facet ids â hand-listing 'rarity'/'type' here would silently cache-collide 135 // two different filter selections that differ only in a facet key this list 136 // doesn't know about). 137 function cards(game, params) { 138 params = params || {}; 139 var keys = Object.keys(params).sort(); 140 // A facet id's value may now be an ARRAY (repeated params, C7 fix round 1 141 // item 2) rather than a single comma-joined string -- Array#join here is 142 // just this cache key's own stringification, unrelated to qs()'s wire 143 // encoding below, so two selections differing only in array ORDER still 144 // correctly produce different keys (['A','B'] joins to 'A,B', ['B','A'] 145 // to 'B,A'). 146 var parts = keys.map(function (k) { 147 var v = params[k]; 148 return k + '=' + (Array.isArray(v) ? v.join(',') : v); 149 }); 150 var key = 'cards:' + game + ':' + parts.join('&'); 151 return get(key, '/api/tcg/' + encodeURIComponent(game) + '/cards' + qs(params)); 152 } 153
154 // GET /api/tcg/:game/card?id= â gated. One card. Card ids contain '/' 155 // (set/file), hence the querystring rather than a path segment (mirrors 156 // the server route's own comment). 157 function card(game, id) { 158 return get('card:' + game + ':' + id, '/api/tcg/' + encodeURIComponent(game) + '/card' + qs({ id: id })); 159 } 160 161 // GET /api/tcg/version â PUBLIC. { games: { [tcgId]: { version, cards } }, 162 // artBase, packsBase, formats, gameNames, trackerLabels }. Cheap: one 163 // request reports every installed TCG pack's real card count, which is 164 // what the Codex banner's "CARDS" stat tile is built from (see codex.js). 165 function version() { 166 return get('tcg-version', '/api/tcg/version'); 167 } 168 169 // GET /api/encyclopedia/shared â gated. Hero-independent shared art 170 // (general cards + component backs/reference charts, see 171 // encyclopedia-data.ts's readSharedComponents()) â NOT part of the roster() 172 // payload above. Deliberately its OWN promise cache, not routed through 173 // get()'s 60s TTL: this data cannot change mid-session, and the /app hero 174 // detail's own contract (C5c) is that reopening a hero overlay never 175 // refetches it, which a 60s-expiring cache can't guarantee. Callers 176 // (codex.js) are expected to call this at most once per page load. 177 var sharedPromise = null; 178 function shared() { 179 if (!sharedPromise) { 180 // Hoisted to a local so the eviction guard below can tell "the promise 181 // THIS call started" apart from a newer one â same defensive pattern 182 // get()'s own `cache[key].p === p` check uses, needed here because 183 // clearCache() can null out the module-scoped `sharedPromise` while a 184 // fetch is still in flight (P1 rejects after clearCache() has already 185 // allowed a fresh P2 to start) â a bare `sharedPromise = null` in that 186 // catch would clobber the live P2 instead of the dead P1. 187 var p = authFetch('/api/encyclopedia/shared', { cache: 'no-store' }).then(toJson); 188 p.catch(function () { 189 // Transient failure â allow the next caller to retry rather than 190 // permanently blocking shared content for the rest of the session. 191 if (sharedPromise === p) sharedPromise = null; 192 }); 193 sharedPromise = p; 194 } 195 return sharedPromise; 196 } 197 198 // GET /api/leaderboard/DICE_THRONE/characters + GET /api/leaderboard/ 199 // draft-stats â gated (C6). Both return the FULL character list for every 200 // hero (not scoped to one), so, exactly like shared() above, this is 201 // fetched at most ONCE per session and callers filter the combined result 202 // by characterSlug themselves (see hero-detail.js's Community Stats 203 // section and the C6 brief). Combined into ONE promise rather than two â 204 // both endpoints have identical caching needs (hero-independent, full 205 // list, no per-hero staleness), so a caller needing "the community stats 206 // blob" needs exactly one seam, not two near-identical copies of the 207 // eviction-guard dance shared() already documents above. 208 // 209 // Each fetch is caught INDEPENDENTLY rather than the whole pair going 210 // through a bare `Promise.all` â the two routes are separate Fastify 211 // handlers with separate failure modes: `/leaderboard/:gameType/characters` 212 // (routes/leaderboard.ts) runs raw SQL in its own try/catch and returns an 213 // explicit 500; `/leaderboard/draft-stats` runs Prisma client queries with 214 // no try/catch of its own, so a DB fault there becomes a 500 via Fastify's 215 // default handler instead. They also sit behind different cache keys and 216 // TTLs (db/cache.ts: `lb:chars:*` at 300s vs `lb:draft:1` at 600s), so one 217 // being down does not imply the other is. The render layer already 218 // degrades gracefully when one list is missing (hero-detail.js's 219 // findBySlug([], slug) => null, draftRatesPanelHtml() renders just the 220 // present card), so one endpoint failing should cost only that endpoint's 221 // cards, not the whole feature. 222 // 223 // One contract this seam deliberately drops: toJson()'s `e.status = 401` 224 // marker (see its comment above) does not survive the per-branch catches â 225 // a 401 arrives here as a plain `null` like any other failure, and both 226 // branches failing surfaces as `rankings unavailable` with no `.status`.
227 // Nothing reads it today (codex.js's loadRankingsOnce() catch ignores 228 // `e.status` entirely, and an expired session fails both branches anyway), 229 // but a future caller wanting to tell "sign in again" apart from "the 230 // leaderboard is down" must re-thread it through, not assume it is there. 231 // 232 // Do NOT replace this with a bare `Promise.allSettled([...])` over the two 233 // raw fetches. allSettled never rejects, so the eviction guard below (the 234 // `p.catch` that clears rankingsPromise) would never fire â a permanently 235 // "successful" empty blob would get cached as rankingsPromise for the rest 236 // of the session, with no retry on the next overlay open. Each branch 237 // below catches to null itself, and the combinator only rejects when BOTH 238 // are null, which keeps the retry contract intact. 239 var rankingsPromise = null; 240 function rankings() { 241 if (!rankingsPromise) { 242 // Hoisted to a local for the same reason shared()'s `p` is: the 243 // eviction guard below must tell "the promise THIS call started" apart 244 // from a newer one that a clearCache() + retry may have already 245 // installed by the time this one rejects. 246 var charsP = authFetch('/api/leaderboard/DICE_THRONE/characters', { cache: 'no-store' }) 247 .then(toJson) 248 .catch(function () { return null; }); 249 var draftP = authFetch('/api/leaderboard/draft-stats', { cache: 'no-store' }) 250 .then(toJson) 251 .catch(function () { return null; }); 252 var p = Promise.all([charsP, draftP]).then(function (results) { 253 var charsRes = results[0]; 254 var draftRes = results[1]; 255 // Both down -- reject so the eviction guard below fires and the next 256 // overlay open retries, rather than caching an empty blob for the 257 // rest of the session. 258 if (charsRes === null && draftRes === null) { 259 throw new Error('rankings unavailable'); 260 } 261 return { 262 characters: (charsRes && Array.isArray(charsRes.characters)) ? charsRes.characters : [], 263 draftCharacters: (draftRes && Array.isArray(draftRes.characters)) ? draftRes.characters : [], 264 }; 265 }); 266 p.catch(function () { 267 // Transient failure â allow the next caller to retry rather than 268 // permanently blocking Community Stats for the rest of the session. 269 if (rankingsPromise === p) rankingsPromise = null; 270 }); 271 rankingsPromise = p; 272 } 273 return rankingsPromise; 274 } 275 276 // NOTE: does not reset codex.js's own `sharedRequested`/`rankingsRequested` 277 // flags (those flags only gate whether codex.js has attached its 278 // "setX() + maybe re-render" callback to THIS module's promise, not 279 // whether the promise itself is cached) â latent only, since nothing calls 280 // clearCache() today. 281 function clearCache() { 282 cache = Object.create(null); 283 sharedPromise = null; 284 rankingsPromise = null; 285 } 286 287 window.TDTT = window.TDTT || {}; 288 window.TDTT.codex = { 289 roster: roster, 290 sets: sets, 291 facets: facets, 292 cards: cards, 293 card: card, 294 version: version, 295 shared: shared, 296 rankings: rankings, 297 clearCache: clearCache, 298 }; 299})();
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.