-
v3.18.0 - Published artifacts move to the query API
StableAll checks were successfulCI / build-and-test (push) Successful in 2m1sreleased this
2026-08-08 10:08:49 -05:00 | 40 commits to main since this releaseUpgrading: 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 fromhttps://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 intosrc/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
tierListthe 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. -
ChampionDamageProviderconverged 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.jsonencrypted at rest viasafeStorage, 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
401is 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 logsartifact.no_api_key.
Downloads
-
Source code (ZIP)
0 downloads
-
Source code (TAR.GZ)
0 downloads
-
-
v3.17.0 — Classic Rift tier badges
StableAll checks were successfulCI / build-and-test (push) Successful in 1m51sreleased this
2026-08-07 23:38:30 -05:00 | 41 commits to main since this releaseFixed
-
Classic Rift shows tier badges, and guesses enemy lanes again. Both
symptoms read the same per-role tier snapshot, and that snapshot was built
fromchampion-counters.jsonunconditionally — a file keyed by live champion
ids. Every60xxxlookup 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.jsonhas carried a completetierListsince 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
same60xxxids 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 at60000 + 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'schampionId, 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.
buildOpggTierListUrltakes noqueueparameter, and ranked rows served as
classic would be the confident wrong answer rather than a missing one.
Downloads
-
Source code (ZIP)
0 downloads
-
Source code (TAR.GZ)
0 downloads
-
-
v3.16.0 — Classic Rift item builds
StableAll checks were successfulCI / build-and-test (push) Successful in 1m55sreleased this
2026-08-07 23:11:03 -05:00 | 42 commits to main since this releaseFixed
-
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 champion60051, 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.jsonis 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.ItemOptionsProviderroutes on the60xxxblock 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.ItemBuildOptionis now a discriminated union onbasis, 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 readwinRatewithout
first narrowing onbasis.
Changed
- Classic core sequences carry their boots inside the order, unlike ranked
ones — op.gg publishes them that way — socoreItemIdsnow 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 same60xxxid block.
Downloads
-
Source code (ZIP)
0 downloads
-
Source code (TAR.GZ)
0 downloads
-
-
v3.15.0 Stable
released this
2026-08-04 22:44:22 -05:00 | 49 commits to main since this releaseChanged
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.tsis 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.
ItemOptionsProvideris a synchronous map lookup with no rate limiter, timeout or failure path; theitem-optionsmessage goes straight toready; 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
-
Source code (ZIP)
0 downloads
-
Source code (TAR.GZ)
0 downloads
-
released this
2026-08-03 15:31:31 -05:00 | 51 commits to main since this releaseFixed
-
"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.useFollowScrollre-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
adjustedscrollTopto 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
undici8.5.0 → 8.10.0 (lockfile only, the^8.5.0range 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'snpm audit --omit=dev --audit-level=highstep fails
without it.
Notes
- Read-only against the League Client (LCU) — never writes to the client.
Downloads
-
Source code (ZIP)
0 downloads
-
Source code (TAR.GZ)
0 downloads
-
-
v3.14.0 — log panel shows line context
StableAll checks were successfulCI / build-and-test (push) Successful in 2m27sreleased this
2026-08-03 13:38:30 -05:00 | 52 commits to main since this releaseThe 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.forwardStreamthen 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
championIdandroletodebug.log, and showed
the panelRune options fetch failedand 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=133Trailing
key=valuechips, dimmed so a row still reads message-first. The
filter box matches them too, sochampionId=63narrows 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 indebug.log— the panel is for triage, so
a row that stays scannable beats a row that is complete. errgets 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
Erroritself 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 broadcatchblocks, 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
-
Source code (ZIP)
0 downloads
-
Source code (TAR.GZ)
0 downloads
- An open bag, not named fields. Context arrives as whatever the call site
-
released this
2026-07-31 20:30:34 -05:00 | 53 commits to main since this releaseAdded
-
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 === 453and never on a mode-name substring. Queues 2450 and 3280 report aKIWI_JADEmode ("ARAM: Mayhem Classic-ish") on map 12, so anincludes('''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 onmarkReady()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=rankedand keyed by live champion ids, so every60xxxlookup 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. Seedocs/classic-opgg-plan.md.
Downloads
-
Source code (ZIP)
0 downloads
-
Source code (TAR.GZ)
0 downloads
-
-
released this
2026-07-31 08:54:22 -05:00 | 54 commits to main since this releaseAdded
-
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.countersArtifactRouterpicks 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
CountersArtifactAdapternow takes the champion id when asking for its
artifact, since which file answers depends on which queue the champion is
from.
Downloads
-
Source code (ZIP)
0 downloads
-
Source code (TAR.GZ)
0 downloads
-
-
released this
2026-07-30 23:18:34 -05:00 | 55 commits to main since this releaseAdded
-
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=rankedwith ajade_*slug that 307-redirects. op.gg does publish the
mode — counters, runes and item builds — undertype=classic, so the adapters
now ask for it.The champion id decides, not a flag. A
60xxxsubject can only have come
from a Classic Rift champ select and a live id can only have come from
anything else, soopggSubjectderives 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 the77xxxx/775xxxblocks, 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 reportgameMode
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
tierparameter 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
-
Source code (ZIP)
0 downloads
-
Source code (TAR.GZ)
0 downloads
-
-
released this
2026-07-30 20:28:26 -05:00 | 58 commits to main since this releaseEverything 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
+0for 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 was0 − 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.
getItemCostreturned0for both "this item is free" and "no idea what this
item is", anditemGoldsummed straight through that. Absence now means
unknown: the generator keeps zero-cost entries (>= 0, not> 0) so a
trinket is a real 0, anditemGoldreturns 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 a60xxxsubject matches nothing
it will ever return.HarvestQueue.enqueuenow refuses those ids outright and
logsharvest.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
ignoringgameModewas 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
theJade_champion aliases, which makes it a clean mode-level discriminator.
Detection now triesrawChampionName→gameMode→mapNumber→mapName,
strongest first, andmapNumber: 453is 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
Map453spelling 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 withtype=rankedand thejade_*slug. op.gg does publish the mode
undertype=classicwith the base slug, so this is fixable and is not a
limitation of the source; it is simply not in this release.
Downloads
-
Source code (ZIP)
0 downloads
-
Source code (TAR.GZ)
0 downloads
-