In The Game
  • Home
  • World Cup
  • Blog
  • Games
  • AFL
    • Overview

    • Matches
    • Ladder

    • Player Stats
    • Player Ratings
    • Player Game Logs
    • Player Comparison
    • Card Deck
    • Age Curves

    • Team Stats
    • Team Game Logs
    • Team Ratings

    • Definitions
  • Football
    • Overview

    • Matches
    • Leagues

    • World Cup 2026
    • Simulator
    • Wall Chart
    • Pick Your Bracket
    • Title Race
    • Group Projections
    • Match Predictions
    • Player Stats
    • Player Ratings
    • Venues
    • Team Strength
    • Head to Head

    • Player Stats
    • Player Ratings
    • Player Game Logs
    • Player Comparison
    • Card Deck

    • Team Stats
    • Team Game Logs
    • Team Ratings

    • Definitions
  • About

Top Trumps — Football Card Deck

Skip to content

One Top Trumps card per rated footballer — our bespoke Panna ratings as the stat rows, each with a percentile bar drawn across the rated pool (≥ 600 minutes) so a card doubles as a quick scouting glance. Headshots are CC-licensed photos from Wikimedia Commons; players without a free photo get a monogram. Click any card for the full profile.

Show code
statsEsc = window.statsEsc
fetchParquet = window.fetchParquet
base_url = window.DATA_BASE_URL

ratings = fetchParquet(base_url + "football/ratings.parquet")

// Bio (nationality flag) + photo credits — small JSONs, fetched once.
footballBio = {
  try {
    const r = await fetch(base_url + "football/player-bio.json")
    if (!r.ok) { console.warn("[cards] bio fetch HTTP " + r.status); return {} }
    return await r.json()
  }
  catch (e) { console.warn("[cards] bio load failed:", e); return {} }
}
footballCredits = {
  try {
    const r = await fetch(base_url + "football/headshot-credits.json")
    if (!r.ok) { console.warn("[cards] credits fetch HTTP " + r.status); return {} }
    return await r.json()
  }
  catch (e) { console.warn("[cards] credits load failed:", e); return {} }
}
Show code
deckPool = {
  if (!ratings) return null
  const pool = ratings.filter(x => (x.total_minutes || 0) >= 600)
  const col = key => pool.map(x => x[key]).filter(v => v != null).sort((a, b) => a - b)
  return {
    panna: col("panna"), offense: col("offense"), defense: col("defense"),
    spm_overall: col("spm_overall"), total_minutes: col("total_minutes")
  }
}

// Percentile of v within a sorted-ascending array. lowGood inverts (smaller is
// better, e.g. Panna defense: negative = more xG prevented). Matches the
// profile card's semantics: highGood = %(values ≤ v), lowGood = %(values ≥ v).
deckPctRank = function (sorted, v, lowGood) {
  if (v == null || !sorted || !sorted.length) return null
  const n = sorted.length
  // lower bound (first index ≥ v) and upper bound (first index > v)
  let lo = 0, hi = n
  while (lo < hi) { const m = (lo + hi) >> 1; if (sorted[m] < v) lo = m + 1; else hi = m }
  const lb = lo
  lo = 0; hi = n
  while (lo < hi) { const m = (lo + hi) >> 1; if (sorted[m] <= v) lo = m + 1; else hi = m }
  const ub = lo
  const countLE = ub, countGE = n - lb
  return 100 * (lowGood ? countGE : countLE) / n
}
Show code
fm = window.footballMaps

leagueOpts = {
  if (!ratings) return ["All leagues"]
  const codes = [...new Set(ratings.map(d => d.league).filter(Boolean))]
  const named = codes.map(c => [fm.leagueNames[fm.pannaLeagueMapInverse[c]] || c, c])
  named.sort((a, b) => a[0].localeCompare(b[0]))
  return ["All leagues", ...named.map(x => x[0])]
}
// Display league name → raw league code (for filtering)
leagueNameToCode = {
  const m = {}
  if (ratings) for (const c of new Set(ratings.map(d => d.league).filter(Boolean))) {
    m[fm.leagueNames[fm.pannaLeagueMapInverse[c]] || c] = c
  }
  return m
}

viewof deckLeague = Inputs.select(leagueOpts, { label: "League", value: "All leagues" })
viewof deckPos = Inputs.select(["All positions", "GK", "DEF", "MID", "FWD"], { label: "Position", value: "All positions" })
viewof deckSort = Inputs.select(
  [["Panna", "panna"], ["Offense", "offense"], ["Defense", "defense"], ["Skill/90", "spm_overall"], ["Minutes", "total_minutes"], ["Name (A–Z)", "name"]],
  { label: "Sort by", format: x => x[0], value: ["Panna", "panna"] }
)
viewof deckSearch = Inputs.text({ label: "Search", placeholder: "Player name…", width: 220 })
viewof deckMinMins = Inputs.range([0, 3000], { label: "Min minutes", step: 100, value: 600 })
viewof deckPack = Inputs.select((window.CardsGame ? window.CardsGame.STAT_PACKS.map(p => p.label) : ["Standard (10)"]), { label: "Game stat pack", value: "Standard (10)" })
viewof deckHasStats = Inputs.toggle({ label: "Only players with stats" })
Show code
deckCard = function (r) {
  const name = r.player_name || "?"
  const longPos = r.position || ""
  const code = fm.optaToPanna[longPos] || longPos || "?"
  const posColour = fm.pannaBadge[code] || "#5a9a7a"
  const leagueName = fm.leagueNames[fm.pannaLeagueMapInverse[r.league]] || r.league || ""
  const initials = name.split(" ").map(w => w[0] || "").join("").slice(0, 2).toUpperCase()
  const bio = (footballBio && footballBio[r.player_id]) || {}
  const flagImg = bio.iso
    ? `<img class="ttc-flag" src="https://flagcdn.com/h20/${bio.iso}.png" srcset="https://flagcdn.com/h40/${bio.iso}.png 2x" alt="${statsEsc(bio.nat || "")}" loading="lazy" onerror="this.style.display='none'">`
    : ""
  const photo = r.player_id ? `${base_url}football/headshots-card/${r.player_id}.webp` : null
  const credit = (footballCredits && footballCredits[r.player_id]) || null
  const creditTitle = credit ? statsEsc(`Photo: ${credit.a} · ${credit.l} · Wikimedia Commons`) : ""

  const fmt3 = v => v == null ? "—" : v.toFixed(3)
  const rows = [
    { label: "Panna",   val: fmt3(r.panna),       pct: r.panna_percentile ?? deckPctRank(deckPool.panna, r.panna) },
    { label: "Offense", val: fmt3(r.offense),     pct: deckPctRank(deckPool.offense, r.offense) },
    { label: "Defense", val: fmt3(r.defense),     pct: deckPctRank(deckPool.defense, r.defense, true) },
    { label: "SPM",     val: fmt3(r.spm_overall), pct: deckPctRank(deckPool.spm_overall, r.spm_overall) },
    { label: "Minutes", val: Math.round(r.total_minutes || 0).toLocaleString(), pct: deckPctRank(deckPool.total_minutes, r.total_minutes) }
  ]
  const rowsHtml = rows.map(s => {
    const w = s.pct == null ? 0 : Math.max(0, Math.min(100, s.pct))
    return `<div class="ttc-row">
      <span class="ttc-row-label">${s.label}</span>
      <span class="ttc-row-val">${s.val}</span>
      <span class="ttc-row-bar"><i style="width:${w}%"></i></span>
      <span class="ttc-row-pct">${s.pct == null ? "" : Math.round(w)}</span>
    </div>`
  }).join("")

  const a = document.createElement("a")
  a.className = "ttc"
  a.href = "player.html#name=" + encodeURIComponent(name)
  a.style.setProperty("--c", posColour)
  a.innerHTML = `
    <div class="ttc-head">
      <span class="ttc-cat">Panna · Football</span>
      <span class="ttc-pos">${statsEsc(code)}</span>
    </div>
    <div class="ttc-portrait"${creditTitle ? ` title="${creditTitle}"` : ""}>
      ${photo ? `<img class="ttc-photo" src="${photo}" alt="" loading="lazy" draggable="false" onerror="this.style.display='none';this.nextElementSibling.style.display=''">` : ""}
      <span class="ttc-monogram"${photo ? ' style="display:none"' : ''}>${statsEsc(initials)}</span>
    </div>
    <div class="ttc-id">
      <div class="ttc-name">${statsEsc(name)}</div>
      <div class="ttc-sub">${flagImg}${statsEsc([bio.nat, leagueName].filter(Boolean).join(" · "))}</div>
    </div>
    <div class="ttc-stats">${rowsHtml}</div>
    <div class="ttc-foot"><span>In The Game</span><span>Panna ${fmt3(r.panna)}</span></div>`

  // Pointer-based drag into the hand tray (native HTML5 DnD is flaky and has no
  // touch support; pointer events cover mouse + touch + pen). A press that never
  // crosses the drag threshold still navigates to the profile. The "+ Hand"
  // button is the explicit tap-to-add path. All converge on window._deckAddToHand.
  if (r.player_id) {
    // Disable the browser's native anchor/image drag — otherwise the <a> gets
    // dragged as a hyperlink (URL shows in the status bar) and preempts our
    // pointer drag. dragstart bubbles, so this one listener covers the inner <img>.
    a.draggable = false
    a.addEventListener("dragstart", e => e.preventDefault())
    a.addEventListener("pointerdown", e => {
      if (e.pointerType === "mouse" && e.button !== 0) return
      if (e.target.closest(".ttc-add")) return            // let the button handle itself
      if (window._deckBeginCardDrag) window._deckBeginCardDrag(e, r.player_id, name, a)
    })
    a.addEventListener("click", e => {
      if (window._deckDidDrag) { e.preventDefault(); e.stopPropagation() }  // a drag, not a click → don't navigate
    })
    const add = document.createElement("button")
    add.type = "button"; add.className = "ttc-add"; add.title = "Add to your hand"
    add.textContent = "+ Hand"
    add.addEventListener("click", e => {
      e.preventDefault(); e.stopPropagation()
      if (!window._deckAddToHand) return
      const added = window._deckAddToHand(r.player_id)
      add.textContent = added ? "Added" : "In hand"
      add.classList.add("added")
      setTimeout(() => { add.textContent = "+ Hand"; add.classList.remove("added") }, 1000)
    })
    a.querySelector(".ttc-portrait").appendChild(add)
  }
  return a
}

deckPosGroup = code => (fm.panna_GROUP && fm.panna_GROUP[code]) || code

{
  if (!ratings || !deckPool) return html`<p class="text-muted">Loading the deck…</p>`
  const PAGE = 48

  // Filter
  const q = (deckSearch || "").trim().toLowerCase()
  const wantLeague = deckLeague === "All leagues" ? null : leagueNameToCode[deckLeague]
  const statsOnly = deckStatsReady   // Set of player_ids with stats, or null (toggle off / loading)
  let rows = ratings.filter(r => {
    if ((r.total_minutes || 0) < deckMinMins) return false
    if (wantLeague && r.league !== wantLeague) return false
    if (statsOnly && !statsOnly.has(r.player_id)) return false
    if (deckPos !== "All positions") {
      const code = fm.optaToPanna[r.position] || r.position
      if (deckPosGroup(code) !== deckPos) return false
    }
    if (q && !(r.player_name || "").toLowerCase().includes(q)) return false
    return true
  })

  // Sort
  const sortKey = deckSort[1]
  if (sortKey === "name") rows.sort((a, b) => (a.player_name || "").localeCompare(b.player_name || ""))
  else if (sortKey === "defense") rows.sort((a, b) => (a.defense ?? 1e9) - (b.defense ?? 1e9)) // low = better
  else rows.sort((a, b) => (b[sortKey] ?? -1e9) - (a[sortKey] ?? -1e9))

  // Expose the currently-filtered pool so "Quick Play" can deal a random hand
  // from it — filtering by league/position first IS how you pick the duel pool.
  window._deckCurrentPool = rows.map(r => r.player_id).filter(Boolean)

  // Paginated render with imperative "page" state local to this cell.
  const wrap = document.createElement("div")
  const bar = document.createElement("div"); bar.className = "deck-bar"
  const grid = document.createElement("div"); grid.className = "ttc-deck"
  wrap.append(bar, grid)
  let page = 0
  const pages = Math.max(1, Math.ceil(rows.length / PAGE))

  function render() {
    grid.replaceChildren()
    const slice = rows.slice(page * PAGE, page * PAGE + PAGE)
    for (const r of slice) grid.appendChild(deckCard(r))
    const lo = rows.length ? page * PAGE + 1 : 0
    const hi = Math.min(rows.length, (page + 1) * PAGE)
    bar.replaceChildren()
    const count = document.createElement("span")
    count.className = "deck-count"
    count.textContent = rows.length
      ? `${lo}–${hi} of ${rows.length.toLocaleString()} cards`
      : "No players match these filters"
    bar.appendChild(count)
    if (pages > 1) {
      const nav = document.createElement("span"); nav.className = "deck-nav"
      const mk = (label, disabled, fn) => {
        const b = document.createElement("button")
        b.className = "deck-pg"; b.textContent = label; b.disabled = disabled
        if (!disabled) b.onclick = () => { fn(); render(); wrap.scrollIntoView({ block: "start", behavior: "smooth" }) }
        return b
      }
      nav.append(
        mk("‹ Prev", page === 0, () => page--),
        Object.assign(document.createElement("span"), { className: "deck-pgn", textContent: `${page + 1} / ${pages}` }),
        mk("Next ›", page >= pages - 1, () => page++)
      )
      bar.appendChild(nav)
    }
  }
  render()
  return wrap
}
Show code
deckById = ratings ? new Map(ratings.filter(r => r.player_id).map(r => [r.player_id, r])) : new Map()

deckHandTray = {
  if (!ratings) return html``
  const KEY = "ig-fb-hand", MAX = 30
  let hand = []
  try { hand = (JSON.parse(localStorage.getItem(KEY) || "[]") || []).filter(id => deckById.has(id)).slice(0, MAX) } catch (e) {}
  const save = () => { try { localStorage.setItem(KEY, JSON.stringify(hand)) } catch (e) {} }

  // Re-runs (e.g. bio loading) must not stack trays — remove any prior one.
  const prior = document.querySelector(".deck-tray"); if (prior) prior.remove()

  const tray = document.createElement("div"); tray.className = "deck-tray"
  const head = document.createElement("div"); head.className = "deck-tray-head"
  const title = document.createElement("span"); title.className = "deck-tray-title"
  const actions = document.createElement("span"); actions.className = "deck-tray-actions"
  const clearBtn = document.createElement("button"); clearBtn.type = "button"; clearBtn.className = "deck-tray-btn"; clearBtn.textContent = "Clear"
  const playBtn = document.createElement("button"); playBtn.type = "button"; playBtn.className = "deck-tray-btn play"; playBtn.textContent = "Play"; playBtn.title = "Duel your hand as Top Trumps (need 2+ cards)"
  playBtn.addEventListener("click", () => { if (hand.length >= 2 && window._deckStartDuel) window._deckStartDuel([...hand]) })
  // Quick Play — deal a random even-sized hand from the CURRENT filter (so
  // filtering to a league/position first scopes the duel) and start immediately.
  const quickBtn = document.createElement("button"); quickBtn.type = "button"; quickBtn.className = "deck-tray-btn quick"; quickBtn.textContent = "Quick Play"; quickBtn.title = "Deal a random hand (players with stats) from the current filter and duel the CPU"
  quickBtn.addEventListener("click", () => { if (window._deckStartQuick) window._deckStartQuick() })
  // Higher / Lower — endless streak game from the current filter pool.
  const hlBtn = document.createElement("button"); hlBtn.type = "button"; hlBtn.className = "deck-tray-btn hl"; hlBtn.textContent = "Higher / Lower"; hlBtn.title = "Endless streak: guess if the next card's stat is higher or lower"
  hlBtn.addEventListener("click", () => {
    if (!window._deckStartHL) return
    window._deckStartHL({ pool: (window._deckCurrentPool && window._deckCurrentPool.length) ? window._deckCurrentPool : null })
  })
  // Gaffer's Run — the roguelike deckbuilder (flagship).
  const runBtn = document.createElement("button"); runBtn.type = "button"; runBtn.className = "deck-tray-btn run"; runBtn.textContent = "Gaffer's Run"; runBtn.title = "Roguelike: climb a ladder of CPUs, draft a card after each win, lose and it's over"
  runBtn.addEventListener("click", () => { if (window._deckStartRun) window._deckStartRun() })
  // Daily Run — today's seeded run (same for everyone), best depth + streak tracked.
  const dailyBtn = document.createElement("button"); dailyBtn.type = "button"; dailyBtn.className = "deck-tray-btn daily"; dailyBtn.textContent = "Daily"; dailyBtn.title = "Today's seeded Gaffer's Run — same opponents for everyone; your best depth + streak are saved"
  dailyBtn.addEventListener("click", () => { if (window._deckStartDaily) window._deckStartDaily() })
  actions.append(dailyBtn, runBtn, hlBtn, quickBtn, clearBtn, playBtn)
  head.append(title, actions)
  const strip = document.createElement("div"); strip.className = "deck-tray-strip"
  tray.append(head, strip)

  function miniCard(id) {
    const r = deckById.get(id); if (!r) return null
    const name = r.player_name || "?"
    const code = fm.optaToPanna[r.position] || r.position || "?"
    const colour = fm.pannaBadge[code] || "#5a9a7a"
    const initials = name.split(" ").map(w => w[0] || "").join("").slice(0, 2).toUpperCase()
    const photo = `${base_url}football/headshots/${id}.webp`   // tight circle variant
    const el = document.createElement("div"); el.className = "tray-card"; el.style.setProperty("--c", colour)
    el.dataset.id = id
    el.innerHTML = `
      <div class="tray-thumb">
        <img src="${photo}" alt="" draggable="false" onerror="this.style.display='none';this.nextElementSibling.style.display=''">
        <span class="tray-mono" style="display:none">${statsEsc(initials)}</span>
      </div>
      <div class="tray-meta"><span class="tray-name">${statsEsc(name)}</span><span class="tray-panna">${r.panna == null ? "—" : r.panna.toFixed(3)}</span></div>
      <button type="button" class="tray-x" title="Remove">×</button>`
    const goProfile = () => { if (window._deckDidDrag) return; location.href = "player.html#name=" + encodeURIComponent(name) }
    el.querySelector(".tray-thumb").addEventListener("click", goProfile)
    el.querySelector(".tray-meta").addEventListener("click", goProfile)
    el.querySelector(".tray-x").addEventListener("click", e => { e.stopPropagation(); hand = hand.filter(x => x !== id); save(); render() })
    el.style.touchAction = "none"
    el.draggable = false
    el.addEventListener("dragstart", e => e.preventDefault())
    el.addEventListener("pointerdown", e => {
      if (e.pointerType === "mouse" && e.button !== 0) return
      if (e.target.closest(".tray-x")) return
      beginDrag(e, id, name, "reorder", el, el)
    })
    return el
  }

  function render() {
    title.textContent = hand.length ? `Your Hand · ${hand.length}${hand.length >= MAX ? " (max)" : ""}` : "Your Hand"
    clearBtn.disabled = !hand.length
    playBtn.disabled = hand.length < 2
    playBtn.title = hand.length < 2 ? "Add 2+ cards to duel" : "Duel your hand as Top Trumps"
    strip.replaceChildren()
    if (!hand.length) {
      const hint = document.createElement("span"); hint.className = "deck-tray-hint"
      hint.textContent = "Drag cards here (or tap “+ Hand”) to build your deck"
      strip.appendChild(hint); return
    }
    for (const id of hand) { const m = miniCard(id); if (m) strip.appendChild(m) }
  }

  window._deckAddToHand = (id) => {
    if (!deckById.has(id) || hand.includes(id) || hand.length >= MAX) return false
    hand.push(id); save(); render(); return true
  }
  clearBtn.addEventListener("click", () => { hand = []; save(); render() })

  // ── Pointer-drag controller (mouse + touch + pen) ───────────
  // A gallery card drags with kind="add"; a hand mini-card drags with
  // kind="reorder". A floating clone of the card follows the pointer, and the
  // hand cards slide aside to open a gap at the drop slot. Drop inside the tray
  // adds (or reorders at that slot); a reorder dropped OUTSIDE the tray removes
  // the card. window._deckDidDrag (set once past the 6px threshold) tells the
  // gallery <a>'s click handler to suppress navigation after a drag.
  const overTray = (x, y) => { const el = document.elementFromPoint(x, y); return !!(el && el.closest(".deck-tray")) }
  // The "live" cards are those NOT currently being dragged; the gap index is
  // computed in their coordinate space, so it's already the post-removal slot.
  const liveCards = () => [...strip.querySelectorAll(".tray-card:not(.dragging)")]
  const gapIndex = clientX => {
    const items = liveCards()
    for (let i = 0; i < items.length; i++) { const b = items[i].getBoundingClientRect(); if (clientX < b.left + b.width / 2) return i }
    return items.length
  }
  let gap = null
  function showGap(idx) {
    if (!gap) { gap = document.createElement("div"); gap.className = "tray-gap" }
    const ref = liveCards()[idx] || null
    if (gap.nextElementSibling !== ref || gap.parentNode !== strip) strip.insertBefore(gap, ref)
    requestAnimationFrame(() => gap && gap.classList.add("open"))
  }
  const hideGap = () => { if (gap) { gap.remove(); gap = null } }

  let drag = null
  function makeGhost(d) {
    if (d.cloneEl) {
      const c = d.cloneEl.cloneNode(true)
      c.className = (d.kind === "add" ? "ttc" : "tray-card") + " deck-ghost-card"
      c.style.setProperty("--c", d.colour)
      c.querySelectorAll(".ttc-add, .tray-x").forEach(b => b.remove())
      if (c.removeAttribute) c.removeAttribute("href")
      return c
    }
    const g = document.createElement("div"); g.className = "deck-ghost"; g.style.setProperty("--c", d.colour); g.textContent = d.name
    return g
  }
  function onMove(e) {
    if (!drag) return
    if (!drag.moved && Math.hypot(e.clientX - drag.x0, e.clientY - drag.y0) < 6) return
    if (!drag.moved) {
      drag.moved = true; window._deckDidDrag = true
      if (drag.sourceEl) drag.sourceEl.classList.add("dragging")   // dim the source first so the clone matches the post-removal layout
      drag.ghost = makeGhost(drag); document.body.appendChild(drag.ghost)
      document.body.style.userSelect = "none"
    }
    drag.ghost.style.left = e.clientX + "px"; drag.ghost.style.top = e.clientY + "px"
    const inTray = overTray(e.clientX, e.clientY)
    tray.classList.toggle("drop-hot", inTray)
    if (inTray && hand.length) showGap(gapIndex(e.clientX)); else hideGap()
    e.preventDefault()
  }
  function onUp(e) {
    document.removeEventListener("pointermove", onMove)
    tray.classList.remove("drop-hot"); document.body.style.userSelect = ""
    const inTray = drag && drag.moved && overTray(e.clientX, e.clientY)
    const slot = inTray ? gapIndex(e.clientX) : -1
    hideGap()
    if (drag) {
      if (drag.sourceEl) drag.sourceEl.classList.remove("dragging")
      if (drag.ghost) drag.ghost.remove()
      if (drag.moved) {
        if (drag.kind === "add" && inTray) {
          if (window._deckAddToHand(drag.id)) {           // appended; move to the requested slot
            const cur = hand.indexOf(drag.id), to = Math.min(slot, hand.length - 1)
            if (cur !== to && to >= 0) { hand.splice(cur, 1); hand.splice(to, 0, drag.id); save(); render() }
          }
        } else if (drag.kind === "reorder") {
          const from = hand.indexOf(drag.id)
          if (from >= 0 && inTray) { hand.splice(from, 1); hand.splice(Math.max(0, Math.min(hand.length, slot)), 0, drag.id); save(); render() }
          else if (from >= 0 && !inTray) { hand = hand.filter(x => x !== drag.id); save(); render() }   // dragged out → remove
        }
      }
    }
    setTimeout(() => { window._deckDidDrag = false }, 0)   // survive the click that fires right after pointerup
    drag = null
  }
  function beginDrag(e, id, name, kind, sourceEl, cloneEl) {
    const rr = deckById.get(id), code = rr ? (fm.optaToPanna[rr.position] || rr.position) : null
    drag = { id, name, kind, x0: e.clientX, y0: e.clientY, moved: false, colour: fm.pannaBadge[code] || "#5a9a7a", sourceEl: sourceEl || null, cloneEl: cloneEl || null }
    window._deckDidDrag = false
    document.addEventListener("pointermove", onMove, { passive: false })
    document.addEventListener("pointerup", onUp, { once: true })
    document.addEventListener("pointercancel", onUp, { once: true })
  }
  window._deckBeginCardDrag = (e, id, name, cardEl) => beginDrag(e, id, name, "add", null, cardEl)

  render()
  document.body.appendChild(tray)   // body-level so position:fixed can't be trapped by an ancestor transform
  return html``
}
Show code
// ── Duel wiring — delegates to the shared CardsGame engine (cards-game.js) ──
// The game logic (card render, overlay, duel loop, seeded RNG) now lives in the
// module so Higher/Lower and the roguelike reuse one engine. This cell just builds
// the data `ctx` and points the hand tray's "Play"/"Quick Play" at the engine.
deckDuel = {
  const CG = window.CardsGame
  if (!CG || !deckById || !deckPool) { if (!CG) console.warn("[cards] CardsGame module not loaded"); return html`` }
  window._deckCtx = { sport: "football", byId: deckById, pools: deckPool, fm: fm, baseUrl: base_url, esc: statsEsc, bio: footballBio, rng: Math.random }

  // Lazy-load player-skills.parquet (16MB) on first use of a counting-totals pack
  // and compute season TOTALS (p90 × weighted_90s) onto the card rows + ctx.pools.
  // Memoised. The "Quick (5)" pack never triggers it.
  window._ensureSkills = (() => {
    let p = null
    return () => p || (p = (async () => {
      const skills = await fetchParquet(base_url + "football/player-skills.parquet")
      if (!skills) return false
      const latest = new Map()
      for (const s of skills) { if (!s.player_id) continue; const c = latest.get(s.player_id); if (!c || (s.season_end_year || 0) >= (c.season_end_year || 0)) latest.set(s.player_id, s) }
      const DT = CG.DERIVED_TOTALS
      const statsIds = new Set()
      for (const [id, row] of deckById) {
        const sk = latest.get(id); if (!sk) continue
        const w = sk.weighted_90s || (sk.total_minutes ? sk.total_minutes / 90 : null)
        if (w) statsIds.add(id)   // has a skills row with minutes → counting totals exist
        for (const disp in DT) { const v = sk[DT[disp]]; row[disp] = (v != null && w) ? v * w : null }
      }
      window._deckStatsIds = statsIds   // ~86% of rated players (rest lack a career-sample skills row: weighted_90s < 3, or an Opta-uncovered comp)
      const rows = [...deckById.values()]
      for (const disp in DT) deckPool[disp] = rows.map(r => r[disp]).filter(v => v != null).sort((a, b) => a - b)
      return true
    })())
  })()

  // Launch wrapper: if the active pack needs skills, load them first (with a brief
  // loading overlay so a first game isn't a silent ~3s stall), then start the game.
  window._gameLaunch = async (fn) => {
    const pack = window._deckActivePack
    if (pack && pack.needsSkills && window._ensureSkills && !window._skillsReady) {
      const o = CG.overlay()
      o.modal.innerHTML = '<div class="duel-banner" style="padding:2.4rem 1rem;text-align:center">Loading player stats…</div>'
      const ok = await window._ensureSkills()
      window._skillsReady = ok
      o.close()
      if (!ok) { window._deckPackStats = CG.BASE_STATS; window._deckActivePack = CG.STAT_PACKS.find(p => p.id === "core") }
    }
    fn()
  }

  // When the active pack needs skills, restrict an auto-dealt pool to players who
  // actually HAVE stats (the ~66% Opta covers) — so games never deal all-"—" cards.
  // Falls back to the unfiltered pool if filtering would leave too few.
  window._deckFilterPlayable = (ids) => {
    const pack = window._deckActivePack
    const base = (ids && ids.length) ? ids : [...deckById.keys()]
    if (!pack || !pack.needsSkills || !window._deckStatsIds) return base
    const f = base.filter(id => window._deckStatsIds.has(id))
    return f.length >= 2 ? f : base
  }

  window._deckStartDuel = (ids) => window._gameLaunch(() => CG.startDuel(window._deckCtx, ids, { stats: window._deckPackStats }))   // hand = user's choice, not filtered
  window._deckStartQuick = () => window._gameLaunch(() => {
    const base = window._deckFilterPlayable(window._deckCurrentPool)
    if (!base || base.length < 2) return
    const a = base.slice()
    for (let i = a.length - 1; i > 0; i--) { const j = Math.floor(Math.random() * (i + 1));[a[i], a[j]] = [a[j], a[i]] }
    const n = Math.min(12, a.length - (a.length % 2))
    CG.startDuel(window._deckCtx, a.slice(0, n), { stats: window._deckPackStats })
  })
  window._deckStartHL = (opts) => window._gameLaunch(() => {
    opts = opts || {}
    const pool = window._deckFilterPlayable(opts.pool || window._deckCurrentPool)
    CG.startHigherLower(window._deckCtx, { stats: window._deckPackStats, statKey: opts.statKey, pool })
  })
  window._deckStartRun = () => window._gameLaunch(() => {
    const fp = window._deckCurrentPool
    const base = (fp && fp.length >= 40) ? fp : [...deckById.keys()]
    CG.startRun(window._deckCtx, { stats: window._deckPackStats, pool: window._deckFilterPlayable(base) })
  })
  // Daily Run — a date-seeded Gaffer's Run (same opponents/draws for everyone today),
  // best depth + streak saved locally. Always the full playable pool (not the filter).
  window._deckStartDaily = () => window._gameLaunch(() => {
    const date = CG.today()
    CG.startRun(window._deckCtx, {
      stats: window._deckPackStats, pool: window._deckFilterPlayable([...deckById.keys()]),
      rng: CG.makeRng("fb-" + date), title: "Daily Run · " + date, noResume: true, daily: true,
      onEnd: (depth) => CG.dailyPanel("football", date, depth)
    })
  })
  return html``
}
Show code
deckPackReady = {
  deckDuel
  const CG = window.CardsGame; if (!CG) return html``
  const pack = CG.STAT_PACKS.find(p => p.label === deckPack) || CG.STAT_PACKS[0]
  // Just record the active pack; skills (for counting-totals packs) load lazily at
  // game launch via _gameLaunch, so merely selecting a pack never fetches 16MB.
  window._deckActivePack = pack
  window._deckPackStats = pack.stats
  return html``
}

deckStatsReady = {
  deckDuel   // ensure window._ensureSkills is defined
  if (!deckHasStats || !window._ensureSkills) return null
  const ok = await window._ensureSkills()   // loads the 16MB skills parquet on toggle-on
  return ok ? window._deckStatsIds : null
}
 

Pete Owen · Sydney · © 2026 · Source

My Teams | Settings | Photo Credits | Privacy | Disclaimer