-
released this
2026-08-10 22:01:45 -05:00 | 0 commits to main since this releaseFixed
-
"Your Pool This Patch" was quoting an ally pairing from the wrong lane. The
champion-synergies artifact splits every pairing by the ally's role — Hwei mid
and Hwei bottom are separate rows — but the row rendered beside a pool entry
reads "50.0% with Hwei · 62 games" and says nothing about which lane Hwei
played. The consumer matched on champion id alone, so whichever single lane
happened to clear the 50-game floor was shown beside a teammate hovering that
champion in a different lane. In a real champ select, Briar's Hwei row was a
62-game mid-Hwei sample displayed next to a bot-lane Hwei; the pairing that
actually applied was 27 games at 29.6%.The indexer now sums each ally's rows across the roles it was played at and
applies the floor to the total, so a row means what its label says: games with
that champion anywhere on the team. Summing is exact rather than an
approximation — the artifact carrieswinsper row, so the combined rate is
Σwins / Σgames, not an average of rates.Splitting by ally lane was also the wrong statistical call for this section.
The same file carries no measurable pairing effect at any sample floor (see
src/shared/champion-synergies/schema.ts), so subdividing a pairing by lane
subdivides noise, and the thin slices read louder: Nocturne beside a bottom
Hwei is 66.7% over 18 games, while Nocturne beside Hwei anywhere is 59.1% over
71. Against the patch-16.15 file (26,698 matches, 119,760 pair rows) the change
lifts qualifying pairings from 3,980 to 5,331 — about a third more evidence at
the same floor, not a lowered bar.
Changed
SynergyAllyRowno longer carriesallyRole, andindexChampionSynergies
emits one row per ally champion rather than per (champion, role). This also
retires a latent duplicate-React-key case inPoolStrengthSection, which keys
ally lines on the champion id.MAX_ALLY_EVIDENCE_PER_ENTRYstays at 2, and is now what usually limits the
rows on screen rather than the game floor: a full team of hovering allies
routinely puts three or four pairings over the floor, and the two best-sampled
win. A qualifying pairing can be computed and not shown — intended, and now
documented at the constant.
Downloads
-
Source code (ZIP)
0 downloads
-
Source code (TAR.GZ)
0 downloads
-
-
released this
2026-08-09 00:01:41 -05:00 | 2 commits to main since this releaseChanged
-
The test suite runs in about a third of the wall clock, with no coverage
given up. Two independent causes, both pure waste:-
Renderer interaction tests now share a
setupUser()helper that passes
delay: nulltouserEvent. The defaultdelay: 0awaits a real macrotask
between every sub-event of an interaction (pointerover → pointerdown →
mousedown → focus → pointerup → mouseup → click), so an interaction test's
cost was set by timer scheduling rather than by the component. The same
events fire in the same order with the same assertions available between
them; only the waiting is gone. In isolationsettings-panel.test.tsxwent
from 4191 ms to 937 ms. -
Only 21 of the 86 unit test files touch the DOM, but all of them ran under
jsdom.test/unit/renderer/**is now its ownrendererproject and the
other 65 run innode. That cost was invisible in the per-file durations
Vitest prints — about 1 ms there — because it lands in the run summary's
environmenttotal, which was 510 s on CI.
Locally the full suite went from ~45 s to ~17 s, and
environmentfrom 264 s
to 57 s.npm run test:unitruns both halves, so the split is invisible to
the scripts and to CI. All 1054 tests still pass. -
Fixed
- Two Vitest projects were handed the same
defineobject reference. Vitest
mutates it in place while resolving a project, so the second clobbered the
first and__APP_VERSION__survived as a bare identifier — every test that
rendersAppdied onReferenceError. Each project now spreads its own copy.
No user-facing effect; recorded because the failure is remote from its cause.
Downloads
-
Source code (ZIP)
0 downloads
-
Source code (TAR.GZ)
0 downloads
-
-
released this
2026-08-08 22:54:17 -05:00 | 3 commits to main since this releaseChanged
-
The op.gg scrapers are gone, and with them every fallback in the data
layer. Counters, tier badges and rune pages each used to consult the
published artifact first and then scrape op.gg live for anything it could not
answer. That second path is deleted. Every provider now reads one published
artifact, and a question it cannot answer is reported as unanswered rather
than filled in from somewhere else.The reasoning is that the two paths were never the same answer. The crawler
fetches those op.gg pages overnight, at its own pace, under fixed filters; a
mid-draft scrape fetched a different page at a different moment. A champ
select could be served by both, and nothing on screen said which — so a
defensive-ban suggestion, a tier badge, or the rune page the Import button
wrote could depend on which source happened to answer first.What you will notice: on patch-drop morning the board shows no counters and
no tier badges until the crawler publishes for the new patch. Those
artifacts refuse an off-patch copy on purpose — a tier list is a claim about
this patch's meta — and the scrape that had already rolled over is no longer
there to cover the gap. A blank badge is visibly blank; last patch's meta
rendered as this patch's is not.Caches are unaffected and are not fallbacks under this rule: every provider
still serves its own last good copy when a fetch fails, and damage splits and
overlay benchmarks still serve an off-patch copy, because a champion's kit
survives a patch that did not touch it.
Removed
Settings.statsSourceandSettings.rateLimitPerSecond. One selected between
op.gg adapters and the other paced their page fetches; with no scraper there
is nothing to select and nothing to pace. Both keys are ignored if present in
an existingsettings.jsonand dropped the next time it is written — no
action needed. Riot API traffic (the scouting report and the benchmark
harvest) keeps its own rate limiter at 0.75 req/s.- The tier-list disk cache (
tier-list-cache.jsonunder%APPDATA%). It held
scraped pages; the counters artifact keeps its own cache, and two files with
independent TTLs disagreeing about which was stale is a bug waiting to happen.
The stale file is simply left unread and can be deleted.
Fixed
CountersArtifactAdapter.fetchChampionStatsthrew synchronously out of a
method typed to return a promise, becausePromise.resolve(lookup(req))
evaluates the lookup first. Every caller happened to sit inside atry, so it
never surfaced — but a failure that arrives as a throw rather than a rejection
is a trap for the next one.
Downloads
-
Source code (ZIP)
0 downloads
-
Source code (TAR.GZ)
0 downloads
-
-
v3.19.0 - Your Pool This Patch
StableAll checks were successfulCI / build-and-test (push) Successful in 2m6sreleased this
2026-08-08 14:06:05 -05:00 | 6 commits to main since this releaseNeeds a published-data API key. This section reads the same key as every other artifact — paste it in Settings if you have not already, or it stays on the no-data placeholder.
Added
-
"Your Pool This Patch" — a third pick list, and the only one that can be on
screen while you are still deciding. Suggested Picks cannot compute anything
until the enemy laner locks, because Riot never exposes enemy hovers; that is
most of champ select, and the section spends it showing "waiting for the enemy
laner". The new list needs only your assigned role, so it fills that window.It ranks your configured pool by each champion's own measured win rate at your
role this patch, from thechampion-synergies.jsonartifact — Riot match data,
a genuinely independent second opinion from the op.gg tier badge shown beside
it. Every row carries the games behind it, and the section names the sample it
came from (NA solo queue, Gold-Emerald), because those are one ladder's numbers
and you may not be on it.Ranking is by a shrunk win rate — pulled toward 50% by
games / (games + 400)
— so 53% over 900 games outranks 56% over 60. The displayed rate stays raw; only
the ordering is shrunk. -
Ally pairings are shown as evidence, and deliberately do not rank anything.
When a teammate hovers or locks, a row can carry a line like54.7% with Thresh · 192 gamesfor pairs with at least 50 games behind them.That restraint is the main design decision in this release, and it is measured
rather than stylistic. The artifact's headline content is 100k+ ally pairing
rows, and the obvious feature ranks picks by them. Against the published patch
16.15 file, each pair's win rate was compared to an additive baseline (the
champion's own rate plus the ally's own rate minus 50) and the spread of those
residuals compared to the binomial noise expected if pairing did not matter at
all — with the null corrected for a pair's games being a subset of both
champions' totals, which otherwise widens the noise band and manufactures a
signal:Games floor Pairs Observed SD Noise-only SD ≥ 20 9,948 8.65 8.65 ≥ 50 1,828 5.76 5.82 ≥ 100 200 3.80 4.23 Zero excess variance at every floor; a 3-point pairing effect would have shown
as 5.2 in the last row. The consequence is not that the ranking needed better
tuning — a shrinkage estimator fitted to this data shrinks every pair back to
its baseline, so the optimal ranker is the one that ignores the rows. Ranking
on them would have sorted champ-select advice by coin flips, and the top of
that list is visibly nonsense: a 35-game Lucian+Amumu pair at 77%. Even the ten
best-sampled pairs in 9.5 MB — Lucian+Nami among them — have confidence
intervals of ±5 to ±7 points around rates 1-4 points above baseline.So the pair line is a measurement with its sample attached, phrased
symmetrically ("with Thresh", never "thanks to Thresh" — both champions won or
lost the same games), and it never touches the sort. Live against production:
Syndra shows the best pairings in a five-champion mid pool and ranks fourth,
because its own record is 48.4%.This is a statement about sample size, not about the game. The producer samples
~21k matches per patch and does not carry them across a rollover; if that rises
roughly 5-7x, re-run the residual test before assuming the finding holds. -
champion-synergies.jsonis now consumed — eight of the ten published
artifacts have a consumer here. It is much the largest at 9.5 MB, which the
existing machinery absorbed better than expected: 450 KB on the wire (the API
serves it brotli), ~1 ms to validate under the sampled posture where a strict
whole-file parse of 103k rows would cost ~125 ms, and 9.2 MB retained — pruned
to 0.3 MB, since the section reads about 2% of the pair rows.It refuses an off-patch copy, matching counters and opposite to damage and
benchmarks: last patch's win rates are not a degraded answer to "how is this
champion doing now", they answer a different question. On the morning of a
patch, before the crawler has caught up, the section goes quiet rather than
showing yesterday's meta as today's.Entries below 50 games are dropped outright rather than kept and filtered at
the call site — the artifact publishes everything down togames: 1, so that
judgement had to be made somewhere, and a caller must not be able to rank on a
two-game record by forgetting a check.
Downloads
-
Source code (ZIP)
0 downloads
-
Source code (TAR.GZ)
0 downloads
-
-
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 | 9 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 | 10 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 | 11 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 | 18 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 | 20 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 | 21 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