refactor(loldle): trim module to current behavior only

KV payload cleanup:
- drop lastResultAt from stats (never read)
- drop solved/giveup flags from game state (round is immediately
  replaced after finish, making the flags transient noise)
- skip redundant saveGame on winning/giveup/out-of-guesses paths;
  startFreshGame overwrites anyway

Code cleanup:
- delete daily.js + daily.test.js (pickDaily/todayUtc were speculative
  "future use" — only pickRandom was wired in, inlined into handlers)
- drop the dead switch default in compare.js
- trim file preambles across the module

Docs: rewrite README around current behavior with loldle.net as the
sole data source; update scraper header to match the raw schema.
This commit is contained in:
2026-04-22 13:49:38 +07:00
parent 8992424947
commit e1f7cdf645
12 changed files with 140 additions and 293 deletions
+1 -3
View File
@@ -42,9 +42,7 @@ jobs:
title: "data: weekly loldle.net champion refresh"
body: |
Automated weekly refresh of `src/modules/loldle/champions.json`
from loldle.net's JS bundle — the canonical source for all
classic-mode fields (`gender`, `species`, `resource`,
`attackType`, `region`, `lane`, `releaseDate`).
from loldle.net's JS bundle.
Review the diff, merge, then run `npm run deploy` to ship.
add-paths: src/modules/loldle/champions.json
+6 -12
View File
@@ -1,19 +1,13 @@
#!/usr/bin/env node
/**
* @file scrape-loldle-data — rebuilds src/modules/loldle/champions.json from
* loldle.net's JS bundle, the canonical source for the classic-mode axes:
* gender, species, resource, attackType, region, lane, releaseDate.
* @file Rebuilds src/modules/loldle/champions.json from loldle.net's JS
* bundle. The bundle embeds the full champion array in plaintext — one
* record per champion with fields: _id, championId, championName, gender,
* positions, species, resource, range_type, regions, release_date.
*
* loldle.net embeds the full champion array in plaintext inside its JS bundle
* at `<script src="js/index.<hash>.js">`, one record per champion with the
* exact shape the bot needs. No CryptoJS decoding, no ddragon merge.
*
* Writes src/modules/loldle/champions.json. The bot imports this JSON
* directly via `with { type: "json" }` (Node 24 + wrangler 4.x).
*
* Usage:
* node scripts/scrape-loldle-data.js
* The bot imports the resulting JSON directly via `with { type: "json" }`.
*
* Usage: node scripts/scrape-loldle-data.js
* Schedule: weekly via .github/workflows/scrape-loldle-data.yml
*/
+48 -39
View File
@@ -1,58 +1,67 @@
# Loldle Module
Classic-mode League of Legends champion guessing game — ported from
[`tiennm99/loldle`](https://github.com/tiennm99/loldle) (`lib/classic-mode.js`).
Champion data is scraped weekly from [loldle.net](https://loldle.net/classic)'s
JS bundle into `champions.json` via `.github/workflows/scrape-loldle-data.yml`
(runs `node scripts/scrape-loldle-data.js`).
Classic-mode League of Legends champion guessing game. Players get 8 guesses
to identify a hidden champion; each guess is compared across 7 attributes
and the board is rendered as a monospace Telegram table.
## Data source
All champion data comes from **[loldle.net](https://loldle.net/classic)**.
The site embeds its classic-mode dataset in plaintext inside its JS bundle,
so we scrape and store it verbatim — no transformation, no external merge.
`scripts/scrape-loldle-data.js` fetches `loldle.net/classic`, extracts the
hashed `js/index.<hash>.js` bundle URL, pulls every champion record via a
single regex, and writes the array to `src/modules/loldle/champions.json`.
The bot imports that JSON directly (`with { type: "json" }`). No build step,
no wrapper module.
**Regenerate manually:** `npm run scrape:loldle-data`
**Weekly refresh:** `.github/workflows/scrape-loldle-data.yml` (Mon 06:00 UTC)
opens a PR whenever the data changes.
## Commands
| Command | Visibility | Description |
|---------|-----------|-------------|
| `/loldle` | public | Show current board, start a game, or submit a champion guess when an argument is provided |
| `/loldle_giveup` | public | Reveal the current loldle answer (auto-starts a fresh round) |
| `/loldle_stats` | public | Show your loldle stats (wins, streak) |
| `/loldle` | public | Show current board, or submit a champion guess |
| `/loldle_giveup` | public | Reveal the answer and auto-start a fresh round |
| `/loldle_stats` | public | Show your wins / streak |
Submit a guess with `/loldle <champion>` e.g. `/loldle Ahri`. Champion names
are matched case/space/punctuation-insensitive with a unique-prefix fallback
(see `lookup.js`). A round that ends (solved, gave up, or ran out of guesses)
immediately rolls into a fresh round — no manual "new round" command needed.
Submit a guess with `/loldle <champion>` (e.g. `/loldle Ahri`). Names match
case/space/punctuation-insensitive with a unique-prefix fallback. A round
that ends (solved, gave up, or out of guesses) is immediately replaced by a
fresh one — no manual "new round" command.
## Architecture
- `compare.js` pure attribute comparison across 7 classic-mode attributes
matching loldle.net's raw schema (`gender`, `species`, `range_type`,
`resource`, `regions`, `positions`, `release_date`). Returns `correct`,
`partial`, or `wrong` per attribute, plus a `direction` hint for year.
- `lookup.js` — normalizes user input and resolves it to a champion record.
- `daily.js``pickRandom` / `pickDaily` (djb2-hashed date seed for future
daily-mode use).
- `render.js`Telegram HTML `<pre>` monospace table with auto-widthed label
column (✅/🟨/❌ markers and ⬆️/⬇️ year direction hints).
- `state.js` — KV persistence with `MAX_GUESSES = 8`, per-subject stats with
streak tracking.
- `handlers.js`wires subject resolution (user id in DMs, chat id in groups)
to the pure functions above.
- `champions.json` — auto-generated from loldle.net (do not edit by hand;
regenerate with `npm run scrape:loldle-data`). Imported directly via
`with { type: "json" }`.
- `compare.js` — attribute comparison across 7 axes from loldle.net's raw
schema (`gender`, `species`, `range_type`, `resource`, `regions`,
`positions`, `release_date`). Returns `correct` / `partial` / `wrong` per
row, plus an up/down `direction` hint for the year.
- `lookup.js` — normalizes user input to a champion record.
- `render.js`Telegram HTML `<pre>` monospace table with auto-widthed
label column.
- `state.js`KV persistence (`MAX_GUESSES = 8`, per-subject stats).
- `handlers.js` — subject resolution (user id in DMs, chat id in groups) +
command flow.
- `flavor.js` — win-message text helpers.
- `stickers.js`Telegram sticker pools per outcome.
- `champions.json` — auto-generated data (never edit by hand).
Subject resolution: private chats track per-user games; groups track per-chat
shared games (everyone plays the same round).
Subject resolution: private chats track per-user games; groups track
per-chat shared games.
## Database
## Storage
KV namespace prefix: `loldle:`
| Key | Type | Description |
|-----|------|-------------|
| `game:<subject>` | JSON | Active round: target champion id, guesses, solved/giveup flags, startedAt |
| `stats:<subject>` | JSON | Aggregate stats: played, wins, streak, bestStreak, lastResultAt |
Active rounds expire after 7 days if untouched.
| Key | Value |
|-----|-------|
| `game:<subject>` | `{ target, guesses, startedAt }` — active round (TTL 7 days). `guesses` is a championName array; comparison rows are recomputed at render time. |
| `stats:<subject>` | `{ played, wins, streak, bestStreak }` |
## Credits
Champion data © Riot Games. The comparison attribute definitions and scoring
rules are ported from the original `tiennm99/loldle` project.
Champion data © Riot Games, via loldle.net.
+34 -47
View File
@@ -1,11 +1,9 @@
/**
* @file Classic-mode champion comparison against the raw loldle.net schema.
* Pure functions, no DOM/React.
* @file Classic-mode champion comparison.
*
* Champion records use the shape emitted by scripts/scrape-loldle-data.js
* (identical to loldle.net's JS bundle): gender is a string ("Male"), the
* multi-value axes (positions, species, regions, range_type) are arrays,
* and release_date is an ISO "YYYY-MM-DD" string.
* Champion records use loldle.net's raw schema: `gender` is a string
* ("Male"/"Female"/"Other"), multi-value axes (positions, species, regions,
* range_type) are arrays, and `release_date` is an ISO "YYYY-MM-DD" string.
*/
export const CLASSIC_ATTRIBUTES = [
@@ -19,62 +17,51 @@ export const CLASSIC_ATTRIBUTES = [
];
/**
* Compare a guess champion against the target.
* Compare a guess champion against the target, returning one row per attribute.
* @param {Record<string, unknown>} guess
* @param {Record<string, unknown>} target
*/
export function compareChampions(guess, target) {
return CLASSIC_ATTRIBUTES.map((attr) => {
const guessVal = guess[attr.key];
const targetVal = target[attr.key];
const g = guess[attr.key];
const t = target[attr.key];
switch (attr.type) {
case "exact":
return {
...attr,
guessValue: formatValue(guessVal),
targetValue: formatValue(targetVal),
result:
String(guessVal ?? "").toLowerCase() === String(targetVal ?? "").toLowerCase()
? "correct"
: "wrong",
};
case "multi":
return {
...attr,
guessValue: formatValue(guessVal),
targetValue: formatValue(targetVal),
result: compareMultiValue(guessVal, targetVal),
};
case "year":
return {
...attr,
guessValue: parseYear(guessVal) || "?",
targetValue: parseYear(targetVal) || "?",
...compareYear(guessVal, targetVal),
};
default:
return { ...attr, guessValue: guessVal, targetValue: targetVal, result: "wrong" };
if (attr.type === "year") {
return {
...attr,
guessValue: parseYear(g) || "?",
targetValue: parseYear(t) || "?",
...compareYear(g, t),
};
}
const row = {
...attr,
guessValue: formatValue(g),
targetValue: formatValue(t),
};
row.result =
attr.type === "exact"
? String(g ?? "").toLowerCase() === String(t ?? "").toLowerCase()
? "correct"
: "wrong"
: compareMultiValue(g, t);
return row;
});
}
function compareMultiValue(guess, target) {
const guessSet = toSet(guess);
const targetSet = toSet(target);
if (guessSet.size === 0 && targetSet.size === 0) return "correct";
if (guessSet.size === 0 || targetSet.size === 0) return "wrong";
if (setsEqual(guessSet, targetSet)) return "correct";
for (const val of guessSet) {
if (targetSet.has(val)) return "partial";
}
const a = toSet(guess);
const b = toSet(target);
if (a.size === 0 && b.size === 0) return "correct";
if (a.size === 0 || b.size === 0) return "wrong";
if (setsEqual(a, b)) return "correct";
for (const v of a) if (b.has(v)) return "partial";
return "wrong";
}
function parseYear(val) {
if (!val) return 0;
const m = String(val).match(/^(\d{4})/);
const m = String(val ?? "").match(/^(\d{4})/);
return m ? Number(m[1]) : 0;
}
-48
View File
@@ -1,48 +0,0 @@
/**
* @file Champion pickers — deterministic daily seeding and fresh-random.
*/
/** UTC date string YYYY-MM-DD. */
export function todayUtc(now = new Date()) {
return now.toISOString().slice(0, 10);
}
/** djb2 string hash. */
function hash(str) {
let h = 5381;
for (let i = 0; i < str.length; i++) {
h = (h * 33) ^ str.charCodeAt(i);
}
return h >>> 0;
}
/**
* Deterministic pick seeded by date (or any string).
* @template T
* @param {T[]} champions
* @param {string} [seed]
* @returns {T}
*/
export function pickDaily(champions, seed) {
assertNonEmpty(champions);
const s = seed ?? todayUtc();
return champions[hash(s) % champions.length];
}
/**
* Uniformly random pick. `rng` defaults to Math.random — override for tests.
* @template T
* @param {T[]} champions
* @param {() => number} [rng]
* @returns {T}
*/
export function pickRandom(champions, rng = Math.random) {
assertNonEmpty(champions);
return champions[Math.floor(rng() * champions.length)];
}
function assertNonEmpty(arr) {
if (!Array.isArray(arr) || arr.length === 0) {
throw new Error("picker: champions array is empty");
}
}
+1 -4
View File
@@ -1,8 +1,5 @@
/**
* @file Small text helpers for the loldle win message.
*
* Kept separate so handlers.js stays under the 200-LoC guideline and so these
* pure functions are easy to unit-test without a grammY context.
* @file Win-message text helpers — one-word reaction + elapsed-time format.
*/
/**
+31 -65
View File
@@ -1,24 +1,20 @@
/**
* @file Command handlers for loldle module.
* @file Loldle command handlers.
*
* Subject resolution:
* private chat → user id (per-user game)
* group/supergroup chat → chat id (shared game — all members play together)
* group/supergroup chat → chat id (shared game — everyone plays together)
*
* Commands:
* /loldle → show board / start puzzle
* /loldle → show the current board (or start a round)
* /loldle <champion> → submit a guess
* /loldle_giveup → reveal answer, end round (a fresh round auto-starts)
* /loldle_stats → show stats (per-user in DM, per-group in groups)
*
* A finished round (solved, gave up, or out of guesses) is immediately
* replaced by a fresh round, so the user can just keep playing.
* /loldle_giveup → reveal the answer and auto-start a fresh round
* /loldle_stats → show wins / streak for the current subject
*/
import { escapeHtml } from "../../util/escape-html.js";
import championsData from "./champions.json" with { type: "json" };
import { compareChampions } from "./compare.js";
import { pickRandom } from "./daily.js";
import { attemptFlavor, formatDuration } from "./flavor.js";
import { findChampion } from "./lookup.js";
import { renderBoard, renderGuess } from "./render.js";
@@ -28,19 +24,10 @@ import { GIVEUP_STICKERS, LOSE_STICKERS, WIN_STICKERS, pickSticker } from "./sti
/** @type {Array<Record<string, any>>} */
const champions = championsData;
// Sent inside HTML parse_mode replies — must be HTML-safe.
// `<champion>` as a literal tag would make Telegram reject the whole message.
const NEW_ROUND_HINT = "🆕 New round started. Use <code>/loldle &lt;champion&gt;</code> to guess.";
/**
* Returns the stable subject identifier for the current chat.
* In private chat: user id. In groups: chat id (shared across all members).
* @param {import("grammy").Context} ctx
* @returns {number|null}
*/
function getSubject(ctx) {
const type = ctx.chat?.type;
if (type === "private") return ctx.from?.id ?? null;
if (type === "group" || type === "supergroup") return ctx.chat.id;
return ctx.from?.id ?? null;
}
@@ -51,68 +38,48 @@ function argAfterCommand(text) {
return idx === -1 ? "" : text.slice(idx + 1).trim();
}
function isFinished(game) {
return game.solved || game.giveup || game.guesses.length >= MAX_GUESSES;
function pickRandomChampion() {
return champions[Math.floor(Math.random() * champions.length)];
}
/**
* Recompute comparison rows for each stored guess against the current target.
* Guesses that no longer resolve (e.g. champion removed from loldle.net) are
* dropped silently — an edge case for stale rounds spanning a data refresh.
*/
function findByName(name) {
return champions.find((c) => c.championName === name);
}
/** Recompute comparison rows for each stored guess against the current target. */
function rehydrateGuesses(game) {
const target = champions.find((c) => c.championName === game.target);
const target = findByName(game.target);
if (!target) return [];
const rows = [];
for (const name of game.guesses) {
const guess = champions.find((c) => c.championName === name);
const guess = findByName(name);
if (!guess) continue;
rows.push({ champion: name, results: compareChampions(guess, target) });
}
return rows;
}
/**
* Load existing round, or create + persist a fresh random one.
* A previously-finished round is discarded and replaced with a fresh one so
* the game auto-continues without needing a manual "new round" command.
* @param {import("../../db/kv-store-interface.js").KVStore} db
* @param {number} subject
*/
async function getOrInitGame(db, subject) {
const existing = await loadGame(db, subject);
if (existing && !isFinished(existing)) return existing;
return startFreshGame(db, subject);
}
async function startFreshGame(db, subject) {
const target = pickRandom(champions);
const fresh = {
target: target.championName,
guesses: [],
solved: false,
giveup: false,
startedAt: Date.now(),
};
const target = pickRandomChampion();
const fresh = { target: target.championName, guesses: [], startedAt: Date.now() };
await saveGame(db, subject, fresh);
return fresh;
}
/**
* Send a random sticker from the pool, swallowing errors so a rotten file_id
* (Telegram rejection) never blocks the follow-up text reply.
*
* @param {import("grammy").Context} ctx
* @param {readonly string[]} pool
*/
async function getOrInitGame(db, subject) {
const existing = await loadGame(db, subject);
if (existing && existing.guesses.length < MAX_GUESSES) return existing;
return startFreshGame(db, subject);
}
/** Send a sticker, swallowing errors so a bad file_id never blocks the reply. */
async function trySendSticker(ctx, pool) {
const sticker = pickSticker(pool);
if (!sticker) return;
try {
await ctx.replyWithSticker(sticker);
} catch {
// Ignore — the outcome text reply is what matters. An invalid file_id
// or transient Telegram error must not derail the game flow.
// Invalid file_id or transient Telegram error — the outcome text is what matters.
}
}
@@ -143,7 +110,7 @@ export async function handleLoldle(ctx, db) {
);
}
const target = champions.find((c) => c.championName === game.target);
const target = findByName(game.target);
// champions.json can be refreshed between rounds — an active target may disappear.
if (!target) {
await startFreshGame(db, subject);
@@ -151,14 +118,13 @@ export async function handleLoldle(ctx, db) {
"Champion data was updated since this round started. Starting a fresh round — try again.",
);
}
const results = compareChampions(guess, target);
game.guesses.push(guess.championName);
const won = guess.championName === target.championName;
if (won) game.solved = true;
await saveGame(db, subject, game);
const reply = renderGuess(guess.championName, results);
const elapsed = formatDuration(Date.now() - (game.startedAt ?? Date.now()));
const elapsed = formatDuration(Date.now() - game.startedAt);
const champ = escapeHtml(target.championName);
if (won) {
@@ -171,6 +137,7 @@ export async function handleLoldle(ctx, db) {
{ parse_mode: "HTML" },
);
}
if (game.guesses.length >= MAX_GUESSES) {
await recordResult(db, subject, false);
await startFreshGame(db, subject);
@@ -179,6 +146,8 @@ export async function handleLoldle(ctx, db) {
parse_mode: "HTML",
});
}
await saveGame(db, subject, game);
return ctx.reply(`${reply}\n\nGuess ${game.guesses.length}/${MAX_GUESSES}.`, {
parse_mode: "HTML",
});
@@ -192,11 +161,8 @@ export async function handleGiveup(ctx, db) {
const subject = getSubject(ctx);
if (subject == null) return ctx.reply("Cannot identify chat.");
const game = await getOrInitGame(db, subject);
// getOrInitGame guarantees the returned game is unfinished, so mark + record.
game.giveup = true;
await saveGame(db, subject, game);
await recordResult(db, subject, false);
const target = champions.find((c) => c.championName === game.target);
const target = findByName(game.target);
await startFreshGame(db, subject);
await trySendSticker(ctx, GIVEUP_STICKERS);
const answer = target ? escapeHtml(target.championName) : escapeHtml(game.target);
+1 -3
View File
@@ -1,8 +1,6 @@
/**
* @file Loldle module — classic-mode champion guessing game.
*
* Ported from tiennm99/loldle (lib/classic-mode.js). Data sourced from
* tiennm99/loldle-data's champions.json (synced via GH Actions).
* Champion data is scraped weekly from loldle.net.
*/
import { handleGiveup, handleLoldle, handleStats } from "./handlers.js";
+2 -3
View File
@@ -1,7 +1,6 @@
/**
* @file Champion name lookup — normalizes user input to a champion record.
* Matches championName case/space/punct-insensitive. Falls back to prefix
* match when unique.
* @file Champion name lookup — resolve user input to a champion record.
* Match is case/space/punctuation-insensitive with a unique-prefix fallback.
*/
function normalize(s) {
+3 -8
View File
@@ -1,13 +1,8 @@
/**
* @file Render comparison results as a monospace-aligned table.
* @file Render comparison results as a monospace-aligned Telegram HTML table.
*
* Output uses Telegram HTML parse mode wrapped in <pre> so columns line up
* in Telegram's fixed-width font. The label column auto-widths based on the
* longest label in the block, so new attributes drop in without re-tuning.
*
* Markers:
* 🎯 the guessed champion (name row header)
* ✅ correct · 🟨 partial · ❌ wrong · ⬆️ / ⬇️ direction hint for year
* Markers: 🎯 guessed champion · ✅ correct · 🟨 partial · ❌ wrong
* ⬆️ / ⬇️ year direction hint.
*/
import { escapeHtml } from "../../util/escape-html.js";
+13 -21
View File
@@ -1,34 +1,28 @@
/**
* @file Game state in KV, keyed by "subject" (user in DM, chat in groups).
* @file Game + stats persistence in KV, keyed by "subject"
* (user id in DMs, chat id in groups — so a group shares one round).
*
* One active round per subject at a time. Rounds are self-paced: players
* can /loldle_giveup to reveal (a fresh round auto-starts). Streak = consecutive wins.
* Key layout (inside the module-prefixed store):
* game:<subject> -> { target, guesses, startedAt }
* stats:<subject> -> { played, wins, streak, bestStreak }
*
* Key layout (inside module-prefixed store):
* game:<subject> -> { target, guesses[championName...], solved, giveup, startedAt }
* stats:<subject> -> { played, wins, streak, bestStreak, lastResultAt }
*
* Only championName strings are stored in `guesses` — comparison rows are
* recomputed at render time from the live champions.json. This keeps payloads
* tiny and avoids stale results if loldle.net data shifts mid-round.
* `guesses` is a string[] of championNames — comparison rows are recomputed
* at render time, so the board always reflects the live champions.json.
*/
const MAX_GUESSES = 8;
// 7 days — a round can't linger forever, but is far longer than typical play.
// Upper bound for a round long enough for any real session, short enough
// that stale KV entries get reclaimed automatically.
const GAME_TTL_SECONDS = 60 * 60 * 24 * 7;
/** @param {number|string} subject */
const gameKey = (subject) => `game:${subject}`;
/** @param {number|string} subject */
const statsKey = (subject) => `stats:${subject}`;
/**
* @typedef {object} GameState
* @property {string} target — championName of the hidden champion
* @property {string} target — hidden champion's championName
* @property {string[]} guesses — championNames already tried this round
* @property {boolean} solved
* @property {boolean} [giveup]
* @property {number} [startedAt] — epoch ms
* @property {number} startedAt — epoch ms
*/
/**
@@ -62,14 +56,13 @@ export async function loadStats(db, subject) {
wins: 0,
streak: 0,
bestStreak: 0,
lastResultAt: null,
}
);
}
/**
* Record a finished round (win/loss/giveup) and update streaks.
* Streak increments on each win, resets to 0 on any non-win.
* Record a finished round and update the streak. Streak increments on each
* win and resets to 0 on any non-win.
*
* @param {import("../../db/kv-store-interface.js").KVStore} db
* @param {number|string} subject
@@ -85,7 +78,6 @@ export async function recordResult(db, subject, won) {
} else {
s.streak = 0;
}
s.lastResultAt = Date.now();
await db.putJSON(statsKey(subject), s);
return s;
}
-40
View File
@@ -1,40 +0,0 @@
import { describe, expect, it } from "vitest";
import { pickDaily, pickRandom, todayUtc } from "../../../src/modules/loldle/daily.js";
describe("picker", () => {
it("todayUtc returns YYYY-MM-DD", () => {
expect(todayUtc(new Date("2026-04-20T23:30:00Z"))).toBe("2026-04-20");
expect(todayUtc(new Date("2026-01-01T00:00:00Z"))).toBe("2026-01-01");
});
it("pickDaily is deterministic for same seed", () => {
const champions = [{ id: "A" }, { id: "B" }, { id: "C" }, { id: "D" }];
const a = pickDaily(champions, "2026-04-20");
const b = pickDaily(champions, "2026-04-20");
expect(a).toBe(b);
});
it("pickDaily produces different picks over time", () => {
const champions = Array.from({ length: 100 }, (_, i) => ({ id: `C${i}` }));
const picks = new Set();
for (let d = 1; d <= 30; d++) {
picks.add(pickDaily(champions, `2026-04-${String(d).padStart(2, "0")}`).id);
}
expect(picks.size).toBeGreaterThan(5);
});
it("pickDaily throws on empty list", () => {
expect(() => pickDaily([], "x")).toThrow();
});
it("pickRandom honors injected rng", () => {
const champions = [{ id: "A" }, { id: "B" }, { id: "C" }, { id: "D" }];
expect(pickRandom(champions, () => 0).id).toBe("A");
expect(pickRandom(champions, () => 0.999).id).toBe("D");
expect(pickRandom(champions, () => 0.5).id).toBe("C");
});
it("pickRandom throws on empty list", () => {
expect(() => pickRandom([])).toThrow();
});
});