PageSourceSearch

https://webcade.fun/panels.js?v=1025436e

js webcade.fun collected 2026-10-08 08:26:55 UTC 24,681 bytes, 480 lines download raw bytes

1// Shared sidebar rendering: prize pool, odds, weekly pot + high scores,
2// recent plays. Renders whichever panel elements exist on the page.
3
4// Cabinet display names, keyed by the internal game id used in routes, saved
5// scores and the API. The ids are deliberately stable — renaming them would
6// orphan every score already recorded against the old key — so this map is
7// the single place a player-facing name is decided. Order sets the order of
8// the high-score boards, and matches the cabinet order on the home page.
9window.GAME_NAMES = {
10  pacman: 'CHOMPER',
11  invaders: 'SWARM',
12  tetris: 'CASCADE',
13  snake: 'COIL',
14  breakout: 'RICOCHET',
15  frogger: 'HOPPER',
16  asteroids: 'DRIFT',
17  missiles: 'SALVO',
18  centipede: 'CRAWLER',
19  tempest: 'VORTEX',
20  qbert: 'ZIGGURAT',
21  lander: 'DESCENT',
22  climber: 'GIRDER',
23  fusion: 'FUSION',
24  roller: 'ROLLER',
25  pop: 'POP',
26  pool: 'BREAK',
27  tapcade: 'TAPCADE',
28  fizz: 'FIZZ',
29  // Not a roster cabinet — no daily board, no replay hook — but it is on the
30  // duel ledger, and the duel page, the arena and every duel row name their
31  // game from this map. gameicontest holds GAME_ICON to the same keys.
32  golf: 'GOLF',
33  // The same, and the second one: its runs are re-run on the server from
34  // their input recording (downhillreplay.js), so unlike golf it can rate.
35  downhillrush: 'DOWNHILL RUSH',
36};
37
38// The id a cabinet is SAVED under -> the name its files and its URL are under.
39//
40// The two were the same string until the cabinet scripts were renamed off other
41// people's trademarks: public/cascade.js, never public/tetris.js. Anything that
42// builds a script URL out of a game id has to come through here —
43// homeattract.js loading an attract demo on the floor, arena.js loading a
44// cabinet into a duel — and both of them appended '.js' to the id directly,
45// which is how /tempest.js became a 404 on the arcade floor the moment the
46// files moved.
47//
48// Defined beside GAME_NAMES because it is derived from it, and this file is
49// already the single place a cabinet's player-facing name is decided. Every
50// page that needs it loads panels.js first; index.html and duel.html both do.
51window.GAME_SLUG = id => String((window.GAME_NAMES || {})[id] || id).toLowerCase();
52
53// What each cabinet actually is, in words anyone recognises. The names above
54// are the arcade's own and say nothing about the game to someone who has not
55// played it yet — CASCADE could be anything. Shown where a cabinet is being
56// chosen rather than merely listed, so picking one is not guesswork.
57window.GAME_KINDS = {
58  pacman: 'maze muncher',
59  invaders: 'space shooter',
60  tetris: 'falling blocks',
61  snake: 'grow and dodge',
62  breakout: 'brick breaker',
63  frogger: 'cross the road',
64  asteroids: 'rock shooter',
65  missiles: 'city defence',
66  centipede: 'bug shooter',
67  tempest: 'tube shooter',
68  qbert: 'pyramid hopper',
69  lander: 'moon landing',
70  climber: 'girder climb',
71  fusion: 'tile merge',
72  pool: 'eight-ball pool',
73  // ROLLER and POP were missing, which the cabinet-page breadcrumb made
74  // visible: with no kind to show it fell back to the category, and the trail
75  // read ARCADE / ARCADE. The versus games are here for the same reason — they
76  // have cabinet pages too.
77  roller: 'lane bowler',
78  pop: 'bubble shooter',
79  tapcade: 'rhythm tapper',
80  fizz: 'soda fountain rush',
81  brawl: '1v1 fighter',
82  harbormaster: 'fishing tournament',
83  neonrivals: 'arcade racer',
84  dudasjump: 'endless climber',
85  ragingbull: 'endless runner',
86  golf: 'pixel golf',
87  downhillrush: 'endless skier',
88};
89
90// What kind of game each cabinet is, in the words a player filters a library
91// by. GAME_KINDS above says what a cabinet IS in a sentence ("maze muncher");
92// this says which shelf it sits on, and the two are different jobs — a filter
93// with fourteen categories is not a filter.
94//
95// Keyed by the same internal ids as everything else here, so the arcade floor
96// stamps a card by reading its own href rather than carrying a category typed
97// into the markup. A cabinet missing from this map is not hidden by any filter:
98// arcade.js treats "no category" as "shows under ALL", because a game vanishing
99// from the floor is a worse failure than a game appearing under the wrong tab.
100//
101// The versus games are keyed by route rather than by game id — they have no id
102// in GAME_LIST, because they have no daily board.
103window.GAME_CATS = {
104  pacman: 'arcade',
105  invaders: 'action',
106  tetris: 'puzzle',
107  snake: 'arcade',
108  breakout: 'arcade',
109  frogger: 'arcade',
110  asteroids: 'action',
111  missiles: 'action',
112  centipede: 'action',
113  tempest: 'action',
114  qbert: 'puzzle',
115  lander: 'arcade',
116  climber: 'arcade',
117  fusion: 'puzzle',
118  roller: 'arcade',
119  pop: 'puzzle',
120  pool: 'arcade',
121  tapcade: 'arcade',
122  fizz: 'arcade',
123  brawl: 'multiplayer',
124  // Its own shelf, and not for tidiness. The Harbormaster Cup is the one game
125  // on the floor whose results do NOT go to the daily stage board — it runs its
126  // own tournament with its own prizes. Filing it under ARCADE beside sixteen
127  // cabinets that all feed one board would say, in the one word a library card
128  // has, that it is the same kind of thing. It is not.
129  harbormaster: 'tournament',
130  neonrivals: 'multiplayer',
131  dudasjump: 'arcade',
132  ragingbull: 'arcade',
133  golf: 'arcade',
134  downhillrush: 'arcade',
135};
136
137// The route each cabinet answers on, so a card can be matched back to its id.
138// The floor links to display-name routes (/chomper), not to ids (/pacman), and
139// the old ids still 301 to them — so a lookup that only knew ids matched
140// nothing on the page it was reading.
141window.GAME_ROUTES = {
142  chomper: 'pacman', swarm: 'invaders', cascade: 'tetris', coil: 'snake',
143  ricochet: 'breakout', hopper: 'frogger', drift: 'asteroids', salvo: 'missiles',
144  crawler: 'centipede', vortex: 'tempest', ziggurat: 'qbert', descent: 'lander',
145  girder: 'climber', fusion: 'fusion', roller: 'roller', pop: 'pop', pool: 'pool',
146  tapcade: 'tapcade', fizz: 'fizz',
147  brawl: 'brawl', harbormaster: 'harbormaster', neonrivals: 'neonrivals',
148  dudasjump: 'dudasjump',
149  ragingbull: 'ragingbull',
150  golf: 'golf',
151  downhillrush: 'downhillrush',
152};
153
154// One glyph per cabinet, drawn rather than named.
155//
156// A duel row said CHOMPER, and CHOMPER is a word you have to already know. The
157// list is fourteen of those in a column, all set in the same weight, so telling
158// them apart was reading rather than seeing. A shape is recognised before it is
159// read, which is the entire job here.
160//
161// Inline SVG, in a map, the way tabbar.js does it: no files to fetch, no
162// sprite sheet to keep in step with this list, crisp at any size, and
163// currentColor means each one inherits whatever the row around it is doing —
164// dimmed on a settled duel, accented on your turn, without a second copy.
165//
166// Drawn for ~18px. That is small enough that a thin outline disappears, so
167// these are mostly solid: silhouettes, not line art.
168window.GAME_ICON = {
169  // A soda glass with a head of foam over the rim and a straw in it — the
170  // pour, which is the whole of FIZZ, drawn as a silhouette.
171  fizz: '<path d="M5.5 9.5h13l-1.8 13H7.3z"/>'
172      + '<path d="M4.8 9.8a3.2 3.2 0 0 1 3.5-3.5 3.7 3.7 0 0 1 7.4 0 3.2 3.2 0 0 1 3.5 3.5z" opacity=".6"/>'
173      + '<path d="M13.4 8.4 17.9 1l1.8 1.1-4.3 6.9z" opacity=".85"/>'
174      + '<circle cx="10.2" cy="17.5" r="1.3" fill="#fff" opacity=".9"/>'
175      + '<circle cx="13.6" cy="13.8" r="1" fill="#fff" opacity=".9"/>',
176  // A pine on the slope and two ski tracks carving past it.
177  downhillrush: '<path d="M16 1.5l5.5 7.5h-3.2l4.2 6.5h-13l4.2-6.5h-3.2z"/>'
178      + '<rect x="14.8" y="15" width="2.4" height="4"/>'
179      + '<path d="M1.5 12.5l8 8.5M5.5 11l7.2 7.6" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"/>',
180  // three hit rings in a row with an orb dropping onto the middle one: the
181  // lanes and the tap, which is the whole game at a glance
182  // A flag on its pole, the ball at the foot of it.
183  golf: '<rect x="8" y="3" width="2" height="16" rx="1"/>'
184      + '<path d="M10 3.5h9l-3 3.5 3 3.5h-9z"/>'
185      + '<circle cx="5.5" cy="19.5" r="2.6"/>',
186  tapcade: '<circle cx="4.5" cy="18" r="3.4" opacity=".55"/>'
187      + '<circle cx="19.5" cy="18" r="3.4" opacity=".55"/>'
188      + '<circle cx="12" cy="18" r="3.6" fill="none" stroke="currentColor" stroke-width="2.2"/>'
189      + '<circle cx="12" cy="7.5" r="4"/>'
190      + '<circle cx="10.6" cy="6.1" r="1.2" fill="#fff" opacity=".9"/>',
191  // a cue laid across the corner of a table, with the black in the pocket's
192  // mouth — the two things that say "pool" without a single word
193  pool: '<path d="M3.2 20.1 15.4 7.9l.9.9L4.1 21z" opacity=".85"/>'
194      + '<path d="M16.8 6.5 19 4.3a1.4 1.4 0 0 1 2 2l-2.2 2.2z"/>'
195      + '<circle cx="7.5" cy="7.5" r="3.6"/>'
196      + '<circle cx="7.5" cy="7.5" r="1.3" fill="#fff" opacity=".9"/>',
197  // the ring board seen from the lane, over the lane itself
198  roller: '<circle cx="12" cy="8.5" r="7" opacity=".3"/>',
199  // three touching spheres: the smallest group that pops
200  pop: '<circle cx="8" cy="9" r="4.6"/><circle cx="16" cy="9" r="4.6" opacity=".55"/><circle cx="12" cy="16" r="4.6" opacity=".8"/>'
201          + '<circle cx="12" cy="8.5" r="4.4" opacity=".55"/>'
202          + '<circle cx="12" cy="8.5" r="1.9"/>'
203          + '<path d="M6.5 17.5h11l-2 4.5h-7z"/>',
204  // a wedge with a pellet in front of it
205  pacman: '<path d="M10 12 16.5 8.25A7.5 7.5 0 1 0 16.5 15.75Z"/>'
206        + '<circle cx="20.6" cy="12" r="1.7"/>',
207  // the descending silhouette: antennae, shoulders, two feet
208  invaders: '<path d="M7 4h2v2H7zM15 4h2v2h-2zM9 6h6v2H9zM6 8h12v3H6zM4 11h16v4H4z'
209          + 'M4 15h3v2H4zM9 15h2v2H9zM13 15h2v2h-2zM17 15h3v2h-3z"/>',
210  // an S-piece mid-fall, with the well's floor under it
211  tetris: '<path d="M12 3h9v8h-9zM3 11h9v8H3zM12 11h9v8h-9z"/>',
212  // a body that turns back on itself, with a head
213  snake: '<path d="M3 3h18v3.5H6.5V9H18v9.5H3V15h11.5v-2.5H3z"/>'
214       + '<path d="M17.5 20.5h3.5V9h-3.5z" opacity=".75"/>',
215  // bricks above, ball between, paddle below
216  breakout: '<path d="M4 4h5v3H4zM10 4h4v3h-4zM15 4h5v3h-5zM4 8h4v3H4zM9 8h6v3H9zM16 8h4v3h-4z"/>'
217          + '<circle cx="12" cy="14.5" r="1.8"/>'
218          + '<path d="M7 18h10v2.5H7z"/>',
219  // a frog head-on: two eyes over a wide mouth, legs out
220  frogger: '<path d="M2 6.5h5v2.6H2zM9.5 6.5h5v2.6h-5zM17 6.5h5v2.6h-5z'
221         + 'M2 14.5h5v2.6H2zM9.5 14.5h5v2.6h-5zM17 14.5h5v2.6h-5z" opacity=".55"/>'
222         + '<path d="M12 1.5l5 5.5h-3v10h3l-5 5.5-5-5.5h3V7H7z"/>',
223  // an irregular rock, and the ship that is out-numbered by it
224  asteroids: '<path d="M10 2.5l6.5 1.5 4 5-2 6-6 3.5-6.5-2.5-3.5-6 2-5.5z"/>'
225           + '<path d="M18.5 16.5l3.5 1 .5 3.5-3.5 1-2.5-2 .5-3z" opacity=".7"/>',
226  // two trails arcing up to their intercepts
227  // The burst, not the rocket. Three rockets with three trails was nine shapes
228  // in eighteen pixels and read as a row of little figures; one impact reads at
229  // any size and shares its silhouette with nothing else here.
230  missiles: '<path d="M2 22l7.5-8 2.6 2.4L4.6 22z" opacity=".8"/>'
231          + '<path d="M13.00 1.20L14.02 5.46L17.35 2.61L15.67 6.66L20.04 6.31L16.30 8.60L20.04 10.89L15.67 10.54L17.35 14.59L14.02 11.74L13.00 16.00L11.98 11.74L8.65 14.59L10.33 10.54L5.96 10.89L9.70 8.60L5.96 6.31L10.33 6.66L8.65 2.61L11.98 5.46Z"/>',
232  // a segmented body coming down the column
233  centipede: '<circle cx="5" cy="7" r="2.5"/><circle cx="10.5" cy="7" r="2.2"/>'
234           + '<circle cx="15.5" cy="8.5" r="2.2"/><circle cx="19" cy="12" r="2.2"/>'
235           + '<circle cx="16" cy="15.5" r="2.2"/><circle cx="10.5" cy="16.5" r="2.2"/>'
236           + '<circle cx="5" cy="16.5" r="2.5"/>',
237  // the tube, receding
238  tempest: '<path d="M12 1.5 22.5 7.7v12.6L12 22.5 1.5 20.3V7.7z" fill="none" stroke="currentColor" stroke-width="2.6"/>'
239         + '<path d="M12 8.5l4.6 2.7v3.6L12 17.5l-4.6-2.7v-3.6z"/>',
240  // stepped cubes, seen from the corner
241  qbert: '<path d="M9 3h6v4H9zM6 8h12v4H6zM3 13h18v4H3z"/>'
242       + '<path d="M3 18.5h18v2.5H3z" opacity=".6"/>',
243  // a lander on its legs, with the burn under it
244  lander: '<path d="M5.5 5.5a6.5 6.5 0 0 1 13 0V11h-13z"/>'
245        + '<path d="M4 11.5h16v2.6H4z"/>'
246        + '<path d="M5 14.5l-3 7h3l2.2-7zM19 14.5l3 7h-3l-2.2-7z"/>'
247        + '<path d="M10 15h4l-2 6.5z" opacity=".8"/>',
248  // girders and the ladder between them
249  climber: '<path d="M6.5 2h2.8v17H6.5zM14.7 2h2.8v17h-2.8z"/>'
250         + '<path d="M6.5 5h11v2.4h-11zM6.5 10.3h11v2.4h-11zM6.5 15.6h11v2.4h-11z"/>'
251         + '<path d="M1.5 19.5h21v2.6h-21z" opacity=".75"/>',
252  // two tiles becoming one
253  fusion: '<path d="M2 5h8v8H2z" opacity=".65"/><path d="M11 5h8v8h-8z" opacity=".65"/>'
254        + '<path d="M6 14h12v7H6z"/>',
255};
256
257// One cabinet's glyph, sized. Returns markup rather than an element because
258// every caller here is building a template string, and aria-hidden because the
259// name is always right beside it — a screen reader announcing "image" before
260// "CHOMPER" is noise, not information.
261window.gameIcon = (game, px = 18) => {
262  const paths = window.GAME_ICON[game];
263  if (!paths) return '';
264  return `<svg class="gi" viewBox="0 0 24 24" width="${px}" height="${px}" `
265    + `fill="currentColor" aria-hidden="true" focusable="false">${paths}</svg>`;
266};
267
268// Profile names are the first attacker-controlled text this site displays, and
269// every panel here renders through innerHTML. Escaping is therefore not
270// optional: an unescaped nickname is script running on the arcade floor.
271window.esc = v => String(v == null ? '' : v)
272  .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
273  .replace(/"/g, '&quot;').replace(/'/g, '&#39;');
274
275// every wallet shown on the site links to its player page, labelled with its
276// nickname when it has one
277window.playerLink = (addr, profile) => {
278  const label = profile && profile.name ? window.esc(profile.name) : window.esc(addr);
279  return `<a class="pl-link" href="/player?w=${encodeURIComponent(addr)}">${label}</a>`;
280};
281
282window.loadArcadeConfig = async function () {
283  const $ = id => document.getElementById(id);
284  const fmt = n => Number(n).toLocaleString(undefined, { maximumFractionDigits: 2 });
285  // What a play costs, said the way a player thinks about it. Pegged to a
286  // dollar the SOL figure is a moving six-decimal number — the true amount
287  // signed for, but a poor headline. Falls back to SOL when the price is
288  // pinned to a constant, and says so plainly when there is no price at all.
289  const costLabel = c => (c.freeToPlay ? 'FREE'
290    : c.coinPriceUsd != null
291    ? '$' + c.coinPriceUsd
292    : (c.coinPriceSol ? c.coinPriceSol + ' SOL' : 'unavailable'));
293
294  // Cabinet pages set PAGE_GAME; the arcade floor does not. On a cabinet the
295  // score board and the play feed narrow to that machine — the pool, odds and
296  // weekly pot stay arcade-wide, because they are arcade-wide.
297  const PAGE = (window.PAGE_GAME || document.documentElement.dataset.game || null);
298  const res = await fetch('/api/config' + (PAGE ? '?game=' + encodeURIComponent(PAGE) : ''));
299  const config = await res.json();
300
301  // The cabinet cards used to be MARKED UP as "clear level 1 for the prize
302  // roll" and rewritten here when the arcade turned out to be free. That put
303  // the claim in the served HTML — which is what view-source, a reader, a
304  // crawler and anyone with JS off actually get — and left the real copy
305  // existing only if a script ran. The promise being briefly true on screen
306  // was the smaller half of that problem.
307  //
308  // So it is the other way round now. The markup says what is true, and the
309  // gate condition rides along in data-gate; if the arcade is ever put back on
310  // coins, THAT is the case this composes at runtime. A claim nobody is making
311  // cannot be found in a page nobody rendered.
312  // Under FREE PLAY nothing touches a card's caption here. The caption is the
313  // game's category — POP / ARCADE, CASCADE / PUZZLE — written by arcade.js
314  // from the same registry the filter chips read, and what clearing a stage is
315  // worth is stated once in the library's header instead of seventeen times in
316  // 11px grey.
317  //
318  // This block therefore only composes the COIN arcade's wording, from the gate
319  // condition each card still carries in data-gate. It runs after arcade.js
320  // (that one is synchronous, this waits on /api/config), so leaving it
321  // unconditional would have overwritten every category the moment the config
322  // landed — which is exactly what it used to do to the homepage rail.
323  if (!config.freeToPlay) {
324    document.querySelectorAll('.cab-card-gate').forEach(el => {
325      // A versus game has no gate and no points, so touching its caption would
326      // replace something true with something that is not. data-nogate says the
327      // same about a card that is neither: the Harbormaster derby is its own
328      // tournament with its own board, so a gate line is not a harmless generic
329      // caption there — it is a false statement about where its results go.
330      if (el.closest('.cab-card-vs') || 'nogate' in el.dataset) return;
331      if (!el.dataset.gate) return;
332      el.textContent = el.dataset.gate + ' for the prize roll';
333    });
334  }
335
336  // The overlay above the play button was the same thing on every cabinet
337  // page: "CLEAR WAVE 1 TO UNLOCK THE PRIZE ROLL", shipped in the HTML and
338  // overwritten here. Fifteen pages of it, which is fourteen more than the
339  // note above accounted for. Markup-first now, so this only has to build the
340  // paid line back if the arcade ever runs on coins again.
341  if (!config.freeToPlay) {
342    // The whole page is markup-first now — it says what the free arcade does,
343    // because that is the arcade that is running. This branch composes the coin
344    // arcade back if one is ever switched on again: the gate line, the offer
345    // above the button, the button itself, and the class that puts the coin
346    // slot back on it.
347    document.body.classList.add('paid-arcade');
348    const os = $('overlay-sub');
349    if (os && os.dataset.gate) os.textContent = os.dataset.gate + ' TO UNLOCK THE PRIZE ROLL';
350    const ot = $('overlay-title');
351    if (ot) ot.textContent = '1 COIN TO PLAY';
352    const tag = $('cab-mode');
353    if (tag) tag.textContent = costLabel(config) + ' a play';
354    const play = $('play-btn');
355    if (play) {
356      const slot = play.querySelector('.coin-slot');
357      play.innerHTML = '';
358      if (slot) play.appendChild(slot);
359      play.appendChild(document.createTextNode('INSERT COIN'));
360    }
361  }
362
363  if (config.freeToPlay) {
364    // Nothing to rewrite. The cabinet pages ship saying FREE PLAY / PLAY FREE
365    // in the markup, the same way overlay-sub and the floor's cards already
366    // did — so a crawler, a reader, and anybody with a slow script sees the
367    // arcade that is actually running rather than a coin arcade that briefly
368    // corrects itself. The paid wording is composed in the branch above.
369    // Featured cabinet of the week: badge its card on the floor and call it out
370    // under the "Pick your cabinet" line. Double board points, so it steers
371    // today's play toward it. No-ops on a cabinet page (no floor to mark).
372    if (config.featured) {
373      const card = document.querySelector(`.cab-card[href="/${config.featured}"]`);
374      if (card && !card.querySelector('.cab-featured-badge')) {
375        card.classList.add('cab-featured');
376        const badge = document.createElement('span');
377        badge.className = 'cab-featured-badge';
378        badge.textContent = 'FEATURED';
379        card.appendChild(badge);
380      }
381      // The note that used to hang off the ARCADE title — "this week POP pays
382      // double" — moved onto the badge's tooltip. A page title is a name, and
383      // a name with a sentence stapled to it stops being scannable; the badge
384      // is already on the card, in the one place somebody choosing a cabinet
385      // is looking, and it is the thing they would click.
386      if (card) {
387        const name = window.esc(window.GAME_NAMES[config.featured] || config.featured);
388        card.setAttribute('title', `Featured this week — ${name} pays double`);
389      }
390    }
391  }
392
393  if ($('hero-pool-n')) {
394    // THIS ELEMENT NO LONGER EXISTS ON ANY PAGE — the arcade floor's prize
395    // headline went with the wind-down, and the guard above means this block
396    // is already a no-op. Kept as a guard rather than deleted because the id
397    // is the only thing that would bring it back, and left EMPTY rather than
398    // computing a figure: the original note here was right that a pool total
399    // reads as a promotion's prize, and prize totals are what sweepstakes law
400    // measures. If the element ever returns, it returns blank rather than
401    // quietly republishing the treasury.
402    $('hero-pool-n').textContent = '';
403    // Signed out this shows the allowance everyone gets; wallet.js replaces it
404    // with the live count as soon as it knows who is asking. "FREE" was true
405    // but told a returning player nothing they did not already know.
406    if (config.daily) {
407      $('hero-cost-n').textContent = config.daily.playsPerDay;
408      if ($('hero-cost-x')) $('hero-cost-x').textContent = 'FREE PLAYS A DAY';
409    } else {
410      $('hero-cost-n').textContent = costLabel(config);
411      if ($('hero-cost-x')) $('hero-cost-x').textContent = 'PER PLAY';
412    }
413  }
414
415  // THE POOL BALANCE IS NOT SHOWN. This printed "N tokens today" from the
416  // day's budget, or the whole pool balance when there was no board — a
417  // treasury figure on the arcade floor. Nothing is paid out of it, so the
418  // panel says what the arcade has actually done instead.
419  if ($('pool-balance')) {
420    $('pool-balance').textContent = `${fmt(config.totals.plays)} plays all-time`;
421    $('burned-total').textContent = '';
422  }
423
424  const hs = config.weekly || {};
425
426  // The glory panel belongs to a cabinet, not to the arcade floor. Fourteen
427  // boards stacked on the home page was several screens of scrolling before a
428  // visitor reached anything else, so the floor no longer carries the panel at
429  // all and this only renders on a cabinet page.
430  if ($('hiscores') && PAGE) {
431    const list = hs[PAGE];
432    const board = list && list.length
433      ? `<ol class="hs-list">${list.map(s => `<li><b>${window.playerLink(s.player, s.profile)}</b><span>${fmt(s.score)}</span></li>`).join('')}</ol>`
434      : '<p class="muted hs-empty">No scores yet on this cabinet</p>';
435    $('hiscores').innerHTML = board
436      + '<p class="muted hs-reset">Bragging rights &mdash; the daily board ranks on stages cleared</p>';
437  }
438
439  if ($('wins-list')) {
440    // the server already scopes this to PAGE; filter again so an older server
441    // that ignores ?game= degrades to a short list rather than a wrong one
442    let plays = (config.recentPlays || []).slice().reverse();
443    if (PAGE) plays = plays.filter(p => p.game === PAGE);
444
445    // "won 0" on every row was the old model talking. Nothing is won per play
446    // any more, so the row now carries what a run is actually judged on —
447    // stages first, because that is what the board pays, then score.
448    const ago = ts => {
449      const m = Math.floor((Date.now() - ts) / 60000);
450      if (m < 1) return 'now';
451      if (m < 60) return m + 'm';
452      const h = Math.floor(m / 60);
453      return h < 24 ? h + 'h' : Math.floor(h / 24) + 'd';
454    };
455
456    // A run the boards refused shows a dash rather than its number. This feed
457    // is the most-read surface on the site, and it was the last one still
458    // printing a score at face value after the daily board, the week's rows
459    // and the all-time table had all declined to carry it — which is how a
460    // wallet banking nothing still appeared to have posted two million.
461    const rows = plays.map(p => `
462      <tr>
463        <td class="rp-who">${window.playerLink(p.player, p.profile)}</td>
464        ${PAGE ? '' : `<td class="rp-game">${window.esc(window.GAME_NAMES[p.game] || p.game)}</td>`}
465        <td class="dl-num rp-stages">${p.depth > 0 ? p.depth : '&mdash;'}</td>
466        <td class="dl-num rp-score">${p.counted === false ? '&mdash;' : fmt(p.score)}</td>
467        <td class="dl-num rp-ago">${ago(p.ts)}</td>
468      </tr>`).join('');
469
470    $('wins-list').innerHTML = plays.length
471      ? `<table class="dl-table rp-table">
472           <tr><th>PLAYER</th>${PAGE ? '' : '<th>CABINET</th>'}
473               <th class="dl-num">STAGES</th><th class="dl-num">SCORE</th><th class="dl-num"></th></tr>
474           ${rows}
475         </table>`
476      : `<p class="muted">${PAGE ? 'No plays yet on this cabinet' : 'No plays yet'}</p>`;
477  }
478
479  return config;
480};

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.