• v3.18.0 305227ba54

    v3.18.0 - Published artifacts move to the query API
    All checks were successful
    CI / build-and-test (push) Successful in 2m1s
    Stable

    joeyr released this 2026-08-08 10:08:49 -05:00 | 40 commits to main since this release

    Upgrading: this release needs a Published data key (Settings -> Published data key).
    Without one the app still runs, but every published-artifact fetch is refused and each
    feature serves whatever is already cached on disk.

    Changed

    • Published data now comes from the query API, not raw Forgejo URLs. All
      seven consumed artifacts are fetched from https://api.jrendar.org/v1/artifacts/{name}
      over TLS. Nothing about what they contain changed — same schemas, same caches,
      same patch policy. This is the transport and nothing else.

      It is not a cosmetic move. The producer's next phase deletes the raw-URL path,
      and an app still reading one would not have failed loudly: the fetch would
      404, the provider would fall back to its cache, and the board would keep
      working while serving data that got a night older every night. That
      silent-staleness failure is the reason this shipped ahead of the deletion
      rather than after it.

      The seven URL literals that used to be copied provider-to-provider are gone,
      collapsed into src/main/stats/artifact-source.ts — one base plus a name per
      artifact. The count had grown by one in each of the last two releases.

    • Artifacts are validated envelope-in-full, rows-once. Provenance and every
      small top-level dictionary (items, perks, styles, and the counters
      tierList the board's badges read) are strict-parsed on every fetch; one row
      of the big champion-keyed record goes through the real row schema; the rest
      are parsed and trusted.

      Strict whole-file parsing was the right posture while the app fetched a public
      URL it did not control. It is now reading bytes this household's own crawler
      generated, checksummed, and round-trip verified at publish time. But a
      checksum proves integrity, not shape agreement — a producer-side schema change
      publishes with a perfectly valid digest — so the sample stays, and it is what
      catches that. Measured against today's published files, validation cost drops
      from 184 ms to 19 ms across the five largest.

    • ChampionDamageProvider converged onto the shared artifact path. It had
      reimplemented fetch/validate/persist/fall-back by hand since before that path
      existed, which was harmless duplication right up until it meant being the one
      provider still pointed at a raw URL. It is now a thin indexing wrapper. Its
      on-disk cache format is unchanged, so nothing re-downloads.

    Added

    • A Published data key field in Settings. The API authenticates every route
      but health, so the app carries a per-install key — never one baked into the
      build, since an Electron asar unpacks in minutes and a shared key cannot be
      rotated without breaking every install that has not updated. Stored in
      settings.json encrypted at rest via safeStorage, alongside the Riot key
      and now through the same code path.

      Unlike the Riot key it applies immediately rather than on the next launch —
      the providers read it per fetch — because the state it fixes is a fresh
      install looking at an empty board.

      Without a key the app still runs. A 401 is treated as fatal-but-quiet: it
      takes the same branch an unreachable server already takes, so every provider
      serves its cached copy and champ select never sees an error. That makes an
      unconfigured install a supported but invisible state, so the panel shows the
      key status and startup logs artifact.no_api_key.

    Downloads
  • v3.17.0 031d0ba8e7

    v3.17.0 — Classic Rift tier badges
    All checks were successful
    CI / build-and-test (push) Successful in 1m51s
    Stable

    joeyr released this 2026-08-07 23:38:30 -05:00 | 41 commits to main since this release

    Fixed

    • Classic Rift shows tier badges, and guesses enemy lanes again. Both
      symptoms read the same per-role tier snapshot, and that snapshot was built
      from champion-counters.json unconditionally — a file keyed by live champion
      ids. Every 60xxx lookup missed, so classic cells rendered no badge and every
      locked enemy fell into the board's "Unassigned" group until the user picked a
      lane by hand.

      champion-counters-classic.json has carried a complete tierList since data
      v0.11.0 and the app has been downloading it since 3.14.0 for counters. It is
      now read for tiers too: 90 champion-roles across all five lanes, keyed by the
      same 60xxx ids champ select already hands us.

    Changed

    • One tier snapshot holds both rosters, and the board needs no mode signal.
      Riot allocated the Classic Rift roster at 60000 + base id, so a classic id
      can never equal a live one and a single map can carry both without a
      collision. Every renderer consumer of that map does an exact-id lookup — the
      badge reads its own cell's championId, the lane inference reads each
      enemy's — so a classic cell finds classic rows and a live cell finds live ones
      out of the same map, with no map-id plumbing reaching the renderer at all.

      The provider still keeps the two apart internally. getAll() answers for the
      live roster only, because its consumer — the Suggested Picks candidate pool —
      walks a role's entire list and offers everything inside the user's meta
      filter, where a merged map would suggest Jade Annie in a ranked game.

    • The classic counters fetch is awaited before the tier snapshot is built.
      It was fire-and-forget, which lost the race it needed to win: the tier refresh
      reads the artifact synchronously and has nothing to fall back on when it is
      missing. The file may still legitimately 404 — the producer's classic pass is
      opt-in and the mode is limited-time — so a rejection is caught rather than
      allowed to skip the publish, and there is deliberately no scrape behind it.
      buildOpggTierListUrl takes no queue parameter, and ranked rows served as
      classic would be the confident wrong answer rather than a missing one.

    Downloads
  • v3.16.0 962ec3193c

    v3.16.0 — Classic Rift item builds
    All checks were successful
    CI / build-and-test (push) Successful in 1m55s
    Stable

    joeyr released this 2026-08-07 23:11:03 -05:00 | 42 commits to main since this release

    Fixed

    • Classic Rift shows item builds again. Deleting the op.gg items scraper in
      3.15.0 took classic's item list with it: every request routed to
      champion-builds.json, which is keyed by live ids and built from queue 420
      only, so a classic lock-in asked for champion 60051, found no key, and
      rendered "No item builds for this champion and role in the latest data."

      The list now comes from champion-items-classic.json, which the sibling
      crawler has been publishing nightly since data v0.9.0 — 88 champion-roles and
      1,132 builds, already laid out in the four sections the panel renders.

    Added

    • A second item artifact, routed on the champion id block. Ranked and
      classic cannot share one file. champion-builds.json is counted from Riot's
      own match timelines and Riot's match-v5 does not cover Classic Rift, so op.gg's
      aggregation is the only measurement that exists for the mode. They cannot share
      champion ids either — op.gg keys classic pages by BASE id, so id 24 exists in
      both naming two different kits, and a classic Jax resolved against the ranked
      file would return ranked Jax's build: correctly shaped, plausibly ranged, and
      confidently wrong. ItemOptionsProvider routes on the 60xxx block and never
      merges the two lists.

      The file may legitimately 404 — the producer's classic pass is opt-in and the
      mode is limited-time — and a miss is an empty panel, since the scraper this
      replaced is gone.

    • Builds op.gg recommends are shown as recommendations, not as zeroes. op.gg
      pads every classic page with editorial item orders carrying no play, win or
      pick figures — 557 of the 1,132 published rows, and a typical champion-role has
      one measured core against two recommended ones. The old scraper dropped every
      row without statistics, which is most of what these pages contain and why the
      panel was close to empty even before it was deleted.

      ItemBuildOption is now a discriminated union on basis, with the stat fields
      absent rather than zeroed on a recommended row. A zero would sort and filter
      alongside real rows and read as "measured, nobody played it" — a stronger claim
      than op.gg is making. The card shows "Recommended by op.gg" where it shows the
      stat line, and the type is what enforces it: nothing can read winRate without
      first narrowing on basis.

    Changed

    • Classic core sequences carry their boots inside the order, unlike ranked
      ones — op.gg publishes them that way — so coreItemIds now accepts up to six
      items and an item may appear in both the Core and Boots strips. The strips
      carry no rates, so nothing is double-counted.
    • Classic item rows carry their names inline and no icons, so the panel renders
      text nameplates there. Adding icons is a producer-side follow-up.
    • Nothing about the write path changed: classic builds remain display-only, with
      the Import buttons hidden in the renderer and the write refused at the IPC
      boundary on the same 60xxx id block.
    Downloads
  • v3.15.0 57c216117c

    v3.15.0 Stable

    joeyr released this 2026-08-04 22:44:22 -05:00 | 49 commits to main since this release

    Changed

    Item builds now come from Riot match data instead of a live op.gg scrape.

    Locking in a champion no longer fetches an op.gg page. The list is read out of champion-builds.json, which the sibling crawler builds nightly by replaying Riot match timelines and counting what players actually bought.

    What that takes out of champ select: a page fetch through the shared rate limiter against a draft timer, a 10-second timeout, and a scraper that balanced-bracket scanned a JSON literal out of op.gg's RSC payload and broke whenever they redeployed their frontend. adapters/op-gg-items.ts is deleted.

    No fallback behind it, unlike every other artifact

    Runes and counters short-circuit a scraper that measures the same op.gg numbers, so a miss there costs only latency. Items are not comparable: op.gg published its aggregation of its sample, this counts Riot's timelines. Keeping both would mean the Import button sometimes writing a build measured over a different population than the win rate displayed next to it.

    So the scraper is gone rather than demoted. ItemOptionsProvider is a synchronous map lookup with no rate limiter, timeout or failure path; the item-options message goes straight to ready; and every miss — no artifact yet, off-patch, champion unseen at that role — collapses to an empty list with one line explaining it, because none of them is worth retrying at the user's expense.

    Three samples, one item set

    The four blocks an import writes are drawn from measurements of different sizes: starter items and the core sequence from the sampled timelines, boots from every recorded match, and situational from order.late — the items measured as bought after the core, which is the field added upstream in v0.21.0 for exactly this.

    Only the core row's win rate, pick rate and games reach the card, and all three are measured over the same sample.

    Rows are no longer re-sorted or floored. op.gg needed a 50-game floor because its page mixed in rows it had surfaced by win rate, where a three-game Mejai's build could outrank a real one. These rows are ranked by how often the build was actually assembled.

    Names come from the artifact

    Item names and icons are published alongside the ids, pinned to the exact Data Dragon version those ids were classified under (lol-companion-data v0.22.0). The bundled extract in src/shared/item-costs/ remains the overlay's gold table — resolving names against it would let them drift from the ids they describe every time Riot renames an item.

    Fixed

    • The Item Import section said nothing at all when there were no builds to show. It now explains why, and no longer offers a spinner for a lookup that cannot take any time.

    Gate green: typecheck, lint, 966 tests across 96 files, build.

    Downloads
  • v3.14.1 9bb2917429

    v3.14.1
    All checks were successful
    CI / build-and-test (push) Successful in 2m25s
    Stable

    joeyr released this 2026-08-03 15:31:31 -05:00 | 51 commits to main since this release

    Fixed

    • "Follow" stopped auto-scrolling the log panels once their buffer filled up.
      On the Diagnostic event stream that is seconds after the League client
      connects — the LCU firehose logged 1,422 events in 20 seconds in the session
      that surfaced this — so in practice Follow worked briefly at startup and then
      did nothing for the rest of the session. Unchecking and re-checking it, or
      scrolling back to the bottom, did not revive it.

      useFollowScroll re-pinned to the bottom in a layout effect keyed on the
      visible row count. Every panel's buffer is capped (MAX_DIAG_ENTRIES,
      MAX_APP_LOG_ENTRIES, MAX_LOG_ENTRIES), so once full, each new record evicts
      a row from the top and appends one at the bottom and the count is pinned at the
      cap forever. React saw unchanged deps and skipped the effect from then on.

      The effect is now keyed on rowsKey(rows) — row count and newest row id.
      Both halves matter: the id catches an append at the cap, the count catches a
      filter that drops older rows while leaving the newest one in place.

    • The log lists set overflow-anchor: none. They evict from the top and their
      rows are not a fixed height (long URIs wrap), so Chromium's scroll anchoring
      adjusted scrollTop to hold the anchored row still — walking the view off the
      bottom and firing scroll events that raced the re-pin, which was enough to
      uncheck Follow on its own.

    • The Status log was keyed the same way and had the same latent bug at its own
      200-entry cap; it is fixed alongside.

    Changed

    • undici 8.5.0 → 8.10.0 (lockfile only, the ^8.5.0 range is unchanged) for
      GHSA-4cwx-7wf7-3272, a HIGH advisory on cross-user information disclosure via
      degenerate private cache directives, plus four moderate advisories in the same
      range. The CI gate's npm audit --omit=dev --audit-level=high step fails
      without it.

    Notes

    • Read-only against the League Client (LCU) — never writes to the client.
    Downloads
  • v3.14.0 3c34b2f0cf

    v3.14.0 — log panel shows line context
    All checks were successful
    CI / build-and-test (push) Successful in 2m27s
    Stable

    joeyr released this 2026-08-03 13:38:30 -05:00 | 52 commits to main since this release

    The application log panel was showing you less than the log file it mirrors.
    This release closes that gap.

    What was wrong

    Every log call in this app already attaches structured context — which champion,
    which role, which attempt, which error. forwardStream then parsed each pino
    line and rebuilt it as a fixed four-field record:

    const record: AppLogRecord = {
      at, level, msg, event,   // <- everything else dropped here
    };
    

    So a failed rune fetch wrote championId and role to debug.log, and showed
    the panel Rune options fetch failed and nothing more. The panel was strictly
    worse than the file for the same line — which is backwards, because the panel is
    the log view you can actually reach while a game is running.

    The block now

    18:42:07 WARN  rune_options.fetch_failed Rune options fetch failed  championId=63 role=mid err=Timeout: op.gg did not answer in 8000ms
    18:42:09 ERROR window.renderer_gone      Renderer "companion" exited (crashed)  window=companion reason=crashed exitCode=133
    

    Trailing key=value chips, dimmed so a row still reads message-first. The
    filter box matches them too, so championId=63 narrows to one champion's lines.

    Design

    • An open bag, not named fields. Context arrives as whatever the call site
      attached, minus the four keys the panel renders in their own columns. A new
      field therefore reaches the UI without touching the logger, the IPC contract,
      the store and the component — which is what the old shape charged for it.
    • One line high, always. Scalars print bare; objects and arrays collapse to
      compact JSON. The full record is in debug.log — the panel is for triage, so
      a row that stays scannable beats a row that is complete.
    • err gets its own treatment. Pino's {type, message, stack} renders as
      err=Timeout: op.gg did not answer, with the stack on hover. Inlining a stack
      in a scrolling list would bury the twenty lines around it.

    Errors now keep their stacks

    52 call sites across 26 files logged err instanceof Error ? err.message : String(err) — the stack discarded before it ever reached the log. They pass the
    Error itself now; pino serialises it, so no logger configuration changed.

    Being straight about the value: 44 of those 52 are warns on expected failure
    paths — a fetch that failed, an LCU frame that would not parse — where the
    message is the diagnosis and the stack points into Node internals. This
    matters for the handful of broad catch blocks, where "Cannot read properties
    of undefined" previously named no location at all. It also stops a non-Error
    throw stringifying to [object Object]; pino passes such values through whole.


    Minor rather than patch for the new panel output. No settings, no LCU writes and
    no data-source changes; nothing to reconfigure after updating.

    Downloads
  • v3.13.0 abea86a9fb

    v3.13.0
    All checks were successful
    CI / build-and-test (push) Successful in 2m25s
    Stable

    joeyr released this 2026-07-31 20:30:34 -05:00 | 53 commits to main since this release

    Added

    • The champ-select board says which mode it is showing. A badge beside "In champ select" names the map — muted for an ordinary game, accented for Classic Rift. Two boards for two modes were pixel-identical while being backed by entirely different champions, so the only way to tell a Classic Rift board from a Summoner'''s Rift one was to recognize the Jade roster by its champion art.

      The mode comes from the LCU gameflow session'''s map.id, read once on entering champ select. That is the only signal available at that point: the Live Client Data API the in-game paths use does not exist until the game process launches, and by then there is no pick left to advise on.

      Keyed on map.id === 453 and never on a mode-name substring. Queues 2450 and 3280 report a KIWI_JADE mode ("ARAM: Mayhem Classic-ish") on map 12, so an includes('''JADE''') test would call those Classic Rift; a test pins that case. A session that cannot be read, or reports no map, is simply not badged — it is never guessed at.

    • game-mode, a retained IPC message carrying the roster, map id and map name, re-delivered on markReady() like the other retained state so a reload mid-champ-select repaints the badge.

    Notes

    • This does not yet restore tier badges or automatic enemy lane placement in Classic Rift. Both read the op.gg tier list, which is still fetched as type=ranked and keyed by live champion ids, so every 60xxx lookup misses. A per-role tier list is the one op.gg request that cannot derive its queue from a champion id, which is exactly why the session-level mode above had to exist first. See docs/classic-opgg-plan.md.
    Downloads
  • v3.12.0 1e2ea09731

    v3.12.0
    All checks were successful
    CI / build-and-test (push) Successful in 2m26s
    Stable

    joeyr released this 2026-07-31 08:54:22 -05:00 | 54 commits to main since this release

    Added

    • Classic Rift champ select reads the published counters artifact instead of
      scraping op.gg.
      3.11.0 made the live path work for the mode; this puts the
      nightly file in front of it, the same short-circuit ranked has had — a map
      lookup instead of one page fetch per enemy champion against a draft timer.

      It is a second file, not a second key in the existing one. op.gg indexes
      classic pages by base champion id, so id 24 exists in both queues meaning two
      different kits, and one map holding both would answer a classic Jax with
      ranked Jax's matchups: correctly shaped, plausibly ranged, confidently wrong,
      and undetectable by anything downstream. countersArtifactRouter picks the
      file from the id block and has its own tests for exactly that reason.

      A missing classic file is an expected state rather than an outage — the
      producer's classic pass is opt-in and the mode is limited-time — so the router
      reports nothing and the request falls through to op.gg, behaving exactly as it
      did before the file existed. It never substitutes the other queue's
      artifact
      .

    • rosterForChampionId, for the places a champion arrives as a bare number with
      no alias or map beside it.

    Changed

    • CountersArtifactAdapter now takes the champion id when asking for its
      artifact, since which file answers depends on which queue the champion is
      from.
    Downloads
  • v3.11.0 08a99e107a

    v3.11.0
    All checks were successful
    CI / build-and-test (push) Successful in 1m52s
    Stable

    joeyr released this 2026-07-30 23:18:34 -05:00 | 55 commits to main since this release

    Added

    • Classic Rift champ select gets real advice. v3.10.0 fixed the in-game
      overlay; the board was still blank, because every op.gg request went out as
      type=ranked with a jade_* slug that 307-redirects. op.gg does publish the
      mode — counters, runes and item builds — under type=classic, so the adapters
      now ask for it.

      The champion id decides, not a flag. A 60xxx subject can only have come
      from a Classic Rift champ select and a live id can only have come from
      anything else, so opggSubject derives the queue and the slug id from the id
      itself. Nothing is threaded through the pipeline, and there is no state that
      can disagree with the id it describes.

      Only the CHAMPION slug needs translating (60028 → evelynn): op.gg keys
      classic items and runes in the 77xxxx / 775xxx blocks, which is what the
      live client reports, so those ids pass straight through — including into the
      item-set import, which needed no change at all.

    Fixed

    • Every classic counter would have silently failed to apply. op.gg names
      opponents by base display name, so "Jax" resolves to live id 24 — while a
      Classic Rift board is holding 60024. The matchup and the board would never
      have matched, with no error raised anywhere: the advice simply never fires.
      Matchups are now re-keyed into the subject's namespace, and the fix is
      idempotent so an already-classic id is never shifted twice.

      Caught end-to-end against a captured live page rather than by reading, which
      is the only way this one surfaces — every layer looks correct in isolation.

    Notes

    Champ-select roster detection (rosterForMapId) keys on the gameflow map id
    453, never on the mode string. Queues 2450 and 3280 report gameMode
    KIWI_JADE — "ARAM: Mayhem Classic-ish" — on map 12, so any substring test
    against the mode name routes those ARAM games to classic pages. The four real
    Classic Rift queues are all on map 453, which also survives Riot adding a fifth.
    The Live Client Data API cannot help here: port 2999 is not listening until the
    game process starts, and champ select has picks to advise on before that.

    op.gg does not segment this mode by rank — the tier parameter is accepted and
    discarded, with every band and a deliberately invalid value returning identical
    rows. Classic figures therefore describe all ranks, and nothing should caption
    them with a tier.

    Downloads
  • v3.10.0 dda79c2c24

    v3.10.0
    All checks were successful
    CI / build-and-test (push) Successful in 1m59s
    Stable

    joeyr released this 2026-07-30 20:28:26 -05:00 | 58 commits to main since this release

    Everything here came out of one real Classic Rift game, watched live through the
    Live Client Data API. The mode was already detected correctly (v3.9.2); what
    this release fixes is everything downstream that quietly assumed live-roster
    data — most of which reported a confident wrong number rather than staying quiet.

    Fixed

    • Every lane's gold difference read +0 for an entire game, and the team-gold
      bar with it.
      Classic Rift namespaces items exactly the way it namespaces
      champions: 770000 + base id, so Doran's Ring is 771056. The committed cost
      table was generated from patch 16.12.1 — three patches before the mode shipped
      — so not one of the 69 items seen across a 34-minute game had a known cost.
      Every inventory summed to 0, so every difference was 0 − 0, and the overlay
      stated "dead even" on all five lanes while the game was decidedly not.

      The root cause was not the stale table, which is merely how it surfaced.
      getItemCost returned 0 for both "this item is free" and "no idea what this
      item is", and itemGold summed straight through that. Absence now means
      unknown: the generator keeps zero-cost entries (>= 0, not > 0) so a
      trinket is a real 0, and itemGold returns null if any held item cannot be
      priced. A partial sum was the worst available answer — it reads as "this player
      is behind" rather than "we don't know" — so it is all-or-nothing, and null
      propagates to — on the lane rows, the gold/min metric, and the team-gold bar,
      which now hides rather than drawing a 50/50 split beside a meaningless +0.

      Regenerating against 16.15.1 also picked up three live items whose prices had
      drifted since 16.12.1 (Bastionbreaker 3200 → 3000, Protoplasm Harness
      2500 → 2600, Yun Tal Wildarrows 3100 → 3000), which had been skewing normal
      games too.

    • The benchmark harvester ground a full game at 0/150 against a classic
      champion id.
      Match-V5 reports live ids, so a 60xxx subject matches nothing
      it will ever return. HarvestQueue.enqueue now refuses those ids outright and
      logs harvest.skipped_classic; the Riot key is shared with the nightly crawl,
      so the wasted budget was not free.

    Changed

    • Roster detection gained two stronger signals, and the reasoning behind
      ignoring gameMode was wrong.
      The previous comment dismissed the field
      because ordinary Summoner's Rift reports "CLASSIC" — true, but it does not
      follow that the field is useless. Classic Rift reports "JADE", matching
      the Jade_ champion aliases, which makes it a clean mode-level discriminator.
      Detection now tries rawChampionName → gameMode → mapNumber → mapName,
      strongest first, and mapNumber: 453 is preferred over "Map453" because it
      is the id rather than a rendering of it. All four were observed together in the
      real game; the redundancy is for the payload shapes Riot ships next. CLASSIC
      is still explicitly rejected, with a test pinning it.

      The Map453 spelling that v3.9.2 guessed at is now confirmed rather than
      assumed.

    Added

    • The in-game tracker sits 120px higher in Classic Rift. Its HUD is the
      taller 2009-era layout, so a bottom-anchored panel at the normal 12px margin
      overlapped the scoreboard and champion frame. The detected roster now travels
      on the live-metrics IPC message, and only the bottom edge moves — side margins,
      top-anchored corners, and live games are all untouched.

    Known gaps

    • Champ select still resolves nothing in Classic — counters, tier badges,
      defensive bans, runes and item builds all fall through to op.gg, which is
      queried with type=ranked and the jade_* slug. op.gg does publish the mode
      under type=classic with the base slug, so this is fixable and is not a
      limitation of the source; it is simply not in this release.
    Downloads