No description
  • TypeScript 97.5%
  • CSS 1.8%
  • JavaScript 0.6%
Find a file
Joey Davis 8ed5b79f68
All checks were successful
CI / build-and-test (push) Successful in 2m51s
Sum ally pairings across roles so the row means its label
"Your Pool This Patch" renders "50.0% with Hwei - 62 games" beside a pool
entry. The artifact splits every pairing by the ally's ROLE, and the consumer
matched on champion id alone, so whichever single lane happened to clear the
50-game floor got shown next to a teammate hovering that champion in a
different lane. Briar's Hwei row was a 62-game mid-Hwei sample displayed
beside a bot-lane Hwei; the pairing that actually applied was 27 games at
29.6%. The label never said which lane, so nothing on screen gave it away.

indexChampionSynergies now sums each ally's rows across the roles it was
played at and floors the total, emitting one row per ally CHAMPION. Exact,
not approximate: the artifact carries `wins` per row, so the combined rate is
sum(wins)/sum(games) rather than an average of rates.

Splitting by ally lane was also the wrong statistical call here. This file
carries no measurable pairing effect at any sample floor (see the schema
module's residual test), 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 - a third more evidence at the same
floor, not a lowered bar.

Consequences worth naming:

- SynergyAllyRow loses `allyRole`, which also retires a latent duplicate
  React key in PoolStrengthSection (it keys ally lines on champion id, and
  two roles of one ally could previously both be selected).
- The indexer can no longer break early on the producer's games-descending
  order: rows under the floor still contribute to a total that clears it
  (Nocturne + Hwei is 46 + 18 + 6 + 1). It walks the full ally list, ~150
  rows per entry, once per artifact refresh.
- MAX_ALLY_EVIDENCE_PER_ENTRY stays at 2 and is now what usually limits the
  rows on screen rather than the floor. A full team of hovering allies puts
  three or four pairings over the floor and the two best-sampled win, so a
  qualifying pairing can be computed and not shown. Intended, and documented
  at the constant.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 22:01:41 -05:00
.forgejo/workflows Encrypt Riot API key at rest; harden CSP and CI audit 2026-07-20 18:42:25 -05:00
build Package app as installer + move Riot key to Settings (Rung 1) 2026-07-06 21:52:07 -05:00
scripts Price Classic Rift items and stop the overlay claiming +0 2026-07-30 20:28:25 -05:00
src Sum ally pairings across roles so the row means its label 2026-08-10 22:01:41 -05:00
test Sum ally pairings across roles so the row means its label 2026-08-10 22:01:41 -05:00
.editorconfig Declare the Node floor and editor defaults 2026-07-18 14:35:18 -05:00
.env.example Package app as installer + move Riot key to Settings (Rung 1) 2026-07-06 21:52:07 -05:00
.gitattributes Add .gitattributes to normalize line endings (LF) 2026-06-23 22:49:31 -05:00
.gitignore Ignore every .env variant, not two named ones 2026-08-08 10:19:48 -05:00
.markdownlint.json Release v1.0.1: README/lint polish 2026-06-28 15:45:55 -05:00
.nvmrc Declare the Node floor and editor defaults 2026-07-18 14:35:18 -05:00
CHANGELOG.md Sum ally pairings across roles so the row means its label 2026-08-10 22:01:41 -05:00
CLAUDE.md Cut the test suite to a third of its wall clock 2026-08-09 00:01:41 -05:00
eng.traineddata Track eng.traineddata as a packaging asset 2026-07-06 21:55:25 -05:00
eslint.config.mjs Add type-checked ESLint with a CI lint step; fix the findings 2026-07-18 14:35:31 -05:00
LICENSE Add MIT license 2026-06-26 21:00:21 -05:00
package-lock.json Sum ally pairings across roles so the row means its label 2026-08-10 22:01:41 -05:00
package.json Sum ally pairings across roles so the row means its label 2026-08-10 22:01:41 -05:00
README.md Delete the op.gg scrapers; one source, no fallbacks 2026-08-08 22:54:10 -05:00
tsconfig.json Convert Vite config to ESM (.mts); gitignore UPGRADE_PLAN.md 2026-06-28 16:39:38 -05:00
tsconfig.main.json Release v3.4.3: full Chromium sandbox + navigation hardening 2026-07-19 10:32:13 -05:00
vite.config.mts Add type-checked ESLint with a CI lint step; fix the findings 2026-07-18 14:35:31 -05:00
vite.preload.config.mts Release v3.4.3: full Chromium sandbox + navigation hardening 2026-07-19 10:32:13 -05:00
vitest.config.ts Cut the test suite to a third of its wall clock 2026-08-09 00:01:41 -05:00

LoL Companion

A read-only League of Legends companion — ban/pick help during champ select, and a live in-game overlay. It only reads the game; it never hovers, bans, locks, or sends anything.

Features

  • Defensive Bans — champions to ban that hard-counter your teammates' hovered picks.
  • Suggested Picks — champions you could pick that beat your locked lane opponent, drawn from your champion pool and/or the current meta tier list.
  • Your Pool This Patch — your own champions at your assigned role, ranked by how they are actually performing this patch. Unlike Suggested Picks it needs only your role, not a locked enemy, so it is up for the whole time you are deciding.
  • Rune and item-set import — one click at lock-in writes a rune page or an item set into the client. Both are opt-in and off by default.
  • In-game overlay — a transparent, click-through window showing your live CS/min vs. your tier's average and your gold difference vs. your lane opponent.

It runs as Electron windows and reads Riot's official local APIs — the LCU (champ select) and Live Client Data (in-game). Read-only is enforced at the connection boundary: only HTTP GET and WebSocket subscribe are allowed; everything state-changing is rejected.

Quick start

npm install
npm start

Requires Node 22.19+ (undici@8 needs it; package.json pins the floor in engines). The League client doesn't need to be running to launch — the app waits for it and shows "Waiting for League client."

Optional — Riot API key (enables the scouting report and the overlay's tier-average comparison). Enter it in the app's Settings panel → Riot API key; it's stored locally in settings.json and used only for Riot API calls — there is no .env fallback for the app. Get one at https://developer.riotgames.com/ (a Personal key is permanent; the dev key expires every 24h). A newly-entered key applies on the next launch. Without a key, the overlay still shows your CS/min and gold difference — just no comparison target — and the scouting report stays off.

(The standalone npm run harvest:benchmarks CLI is separate and still reads RIOT_API_KEY from the environment — see .env.example — since it runs outside the app, with no Settings panel to read from.)

Required for published stats — the data API key. Most of what the app shows during champ select (damage bars, counters, tier badges, rune and item builds, "Your Pool This Patch") comes from a private published-data API rather than being scraped live. Enter that key in SettingsPublished data key; it is encrypted at rest with Electron's safeStorage and applies on the next refresh, no relaunch needed.

Without it the app still runs, but every artifact fetch is refused and each feature falls back to its on-disk cache — or to nothing on a fresh install. That state is deliberately quiet rather than an error: the settings panel shows the key status, and startup logs artifact.no_api_key.

Overlay display mode: set League to Borderless (or Fullscreen with Windows "Fullscreen Optimizations" on — the Win10/11 default). True exclusive fullscreen bypasses the Windows compositor, so no separate-window overlay can draw over it.


How it works

The Electron main process runs a few components; the React renderer is a thin presentation layer that subscribes to main-process events over a typed contextBridge.

  • LCU Connector — discovers the League lockfile, authenticates, and subscribes to champ-select/gameflow events (read-only).
  • Session Tracker — extracts the live champ-select model (your role, ally hovers, bans, locked picks).
  • Stats Provider — resolves counter-rating data for the current patch from the published counters artifact, with on-disk caching. There is no second source behind it: the app used to scrape op.gg live whenever the artifact could not answer, and that path is deleted. A champion the published data has no row for is reported as unavailable rather than answered from somewhere else.
  • Artifact providers — one per published JSON file, fetched from the data API over TLS, validated, and cached on disk so a dead network or a missing key degrades to the last good copy instead of an error.
  • Recommender — pure functions that rank ban targets by a threat score and counter-picks by a sample-weighted pick score.
  • Live poller — while a game is running, reads the Live Client Data API once per second and drives the overlay.
  • Harvest trigger — when you lock a champion, kicks off a background benchmark harvest for it (skip-if-fresh) so its tier-average is ready next time.

Suggested Picks reuse the opponent's counters page read in reverse: a champion that strongly counters your lane opponent sits near the top of their counters list. So the app fetches one page — your locked opponent's counters at your role — and ranks the champions that beat them, intersected with your pool / the meta list. Riot doesn't expose enemy hovers, so these appear once the enemy laner locks in.

Your Pool This Patch exists because of that last sentence: waiting for a lock means the section is empty for most of champ select, which is exactly when you are choosing. This one needs only your assigned role. It ranks your pool by each champion's own measured win rate at that role, drawn from Riot match data, using a win rate shrunk toward 50% by sample size — so 53% over 900 games outranks 56% over 60. Displayed rates stay raw; only the ordering is shrunk, and every row shows the games behind it.

When a teammate hovers or locks, a row may also carry a line like 54.7% with Thresh · 192 games. That is a measurement, not a recommendation, and it never affects the ranking. Pair win rates in this sample vary no more than chance would predict — even the best-sampled duos have intervals wide enough to straddle "no effect" — so treating them as a reason to pick would be reading noise. They are shown, with their sample attached, and left at that.

Configuration

The Settings panel (bottom of the window) edits the settings you tune most — your Riot API key, your published-data key, the in-game tracker metrics, the scouting-report sections, your per-lane champion pool, and which op.gg tiers feed the "Top meta" list — live, without a restart (the Riot key is the exception: it applies on the next launch). Edits save to settings.json immediately. Turning a scouting-report section off also skips its Riot API lookup, so you can trim the report to just what you want.

settings.json lives in Electron's per-user data directory (hand-editable):

  • Windows: %APPDATA%\champ-select-assistant\settings.json
  • macOS: ~/Library/Application Support/champ-select-assistant/settings.json
  • Linux: ~/.config/champ-select-assistant/settings.json

Any missing/invalid field falls back to its default. The runtime caches sit alongside it: stats-cache.json, tier-list-cache.json, your harvested benchmarks.json (see below), and one champion-*-cache.json per published artifact. All of these are per-user runtime data and live only here — they are never written into the repo.

Both API keys live in settings.json encrypted via Electron's safeStorage, which is tied to the OS user account — so copying the file to another machine leaves the keys unreadable there. That is not a failure: the app drops what it cannot decrypt, logs it, and carries on as if no key were set.

Field Range Default Description
cacheTtlHours 1168 24 How long cached stats stay fresh. A day, not a week: the measured sample starts nearly empty on a new patch and fills in over several days, so a long TTL froze day-one numbers for the whole patch
dataApiKey string (unset) Key for the published-data API. Encrypted at rest; set it in the Settings panel rather than by hand
threatMatchupThreshold -1.00.0 0 Counter rating at/below which an opponent counts as a "threat." 0 admits every counter past the source's game-count floor; lower toward -1 to require stronger counters
myPool per-role name lists {} Your champion pool for Suggested Picks, keyed by role (TOP/JUNGLE/MIDDLE/BOTTOM/UTILITY). Unrecognized names are skipped
metaTierFilter op.gg buckets 05 (0 = OP) [0, 1] Which tiers the "Top meta" list draws from
{
  "cacheTtlHours": 24,
  "threatMatchupThreshold": 0,
  "myPool": {
    "JUNGLE": ["Briar", "Lee Sin", "Vi"],
    "MIDDLE": ["Ahri", "Sylas"]
  },
  "metaTierFilter": [0, 1]
}

myPool and metaTierFilter are optional — omit myPool and Suggested Picks fall back to the meta list for your role. "Your Pool This Patch" has no such fallback: it is about your pool, so with none configured it says so and points you here.

Benchmark data (overlay tier averages)

The overlay's "tier average" numbers come from an offline harvest of Riot's Match-V5 API, stored per (champion, role, tier). Riot has no "matches for champion X" endpoint, so the harvester pulls whole ranked games at a tier and records every participant — filling many champions' data at once.

Seed vs. live data. Benchmarks are per-user runtime data (harvested from your own Riot key, region, and tier), so the live copy lives in userData (<userData>/benchmarks.json) and is never committed — the same as the stats and tier-list caches. The committed src/shared/benchmarks/benchmarks.json is only a first-run seed: the app compares against it until your own harvest produces a userData file, then that takes over. At startup the app loads userData and falls back to the seed when it's missing or unreadable.

Automatic: locking a champion starts a background harvest for it (at the compare tier, currently GOLD) unless its data is already fresh. The app runs the harvester with --out <userData>/benchmarks.json, so it writes there, not into the repo. A harvest takes minutes, so the data lands after that game and is used on the next launch. Requires a Riot API key (set in the Settings panel).

Manual: run without --out to refresh the committed seed (e.g. to bump the snapshot a fresh clone ships with), or pass --out to write elsewhere:

npm run harvest:benchmarks -- --champion Briar --tier GOLD                       # refresh the repo seed
npm run harvest:benchmarks -- --championId 233 --tier GOLD --targetSamples 200

Runs are rate-limited, checkpointed, and resumable (Ctrl+C and re-run). Default output is the seed at src/shared/benchmarks/benchmarks.json; --out/--checkpoint override the output and resume-checkpoint paths.

Development
npm run dev        # Vite dev server + Electron, hot-reloading UI (Ctrl+C stops both)
npm start          # clean rebuild, then launch against dist/
npm test           # all suites (unit, property, integration)
npm run typecheck  # tsc --noEmit

npm start always clean-rebuilds first, so a stale build is impossible. Editing main-process code (src/main, src/preload) needs a npm run dev restart — only the renderer hot-reloads.

Windows gotcha: if ELECTRON_RUN_AS_NODE is set in your environment (even to ""), npm start crashes with Cannot read properties of undefined (reading 'isPackaged') — Electron runs as plain Node instead of launching the GUI. Remove it (PowerShell: Remove-Item Env:\ELECTRON_RUN_AS_NODE); emptying it is not enough.

Script What it does
npm run dev Dev server + Electron, hot-reloading UI
npm run build Build main + renderer into dist/
npm start Clean rebuild, then launch
npm test All test suites
npm run typecheck Type-check without emitting
npm run harvest:benchmarks Harvest overlay tier-average data
npm run gen:tags Regenerate champion tags from Data Dragon
npm run gen:item-costs Regenerate item costs from Data Dragon

The test suite includes property-based tests (read-only contract, session extraction, stats cache, single-attempt lookup and cache fallback, threat scoring, list invariants) plus integration tests against a mock LCU and mock stats source.

src/
  main/       Electron main process (LCU, session, stats, recommender, live, pipeline, IPC)
  preload/    contextBridge (typed, read-only message contract)
  renderer/   React companion window + in-game overlay
  shared/     zod schemas, serialization, benchmarks asset + loader
scripts/      Offline tooling (benchmark harvester, tag/item-cost generators)
test/         unit / property / integration

Disclaimer

Unofficial tool that reads the local League Client API. It is observation-only and issues no actions, but use it at your own discretion.

Its numbers all come from one outside source and are only as current as that source's last publish: a private published-data API carrying artifacts built nightly from Riot's Match-V5 and from op.gg's aggregate pages. The app no longer scrapes anything itself, so when an artifact is missing or out of date the affected panel is empty rather than filled from a second opinion. The published win rates are sampled from NA solo queue, Gold through Emerald — the app labels them on screen rather than letting you assume they describe your own ladder.