PageSourceSearch

https://topdowntabletop.com/shared/codex-data.js

js topdowntabletop.com collected 2026-10-08 09:15:47 UTC 14,624 bytes, 299 lines download raw bytes

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.