fix: project-wide review — trading safety, loldle drift guard, doc refresh

Code fixes:
- trading/handlers + stats-handler: guard ctx.from?.id to prevent
  cross-user state corruption when channel posts or inline queries lack
  a sender
- trading/prices + trading/symbols: encodeURIComponent on ticker before
  interpolating into TCBS API URLs
- trading/stats-handler: parallelize per-stock price fetches with
  Promise.allSettled so N-stock portfolios don't stack serial latency
- loldle/handlers: guard target champion lookup against champions.json
  refresh drift — start a fresh round or fall back to the stored id
- wordle + loldle: explicitly initialize giveup:false in startFreshGame
  for stable state shape
- wordle/lookup: fix stale JSDoc that claimed null return
- biome: ignore auto-generated champions.json / champions-data.js /
  words-data.js
- Apply formatter to src/index.js, loldle/handlers.js imports, and
  loldle/compare.test.js (previously red)

Docs refresh:
- README: 105+ tests -> 200+; wordle/loldle described as real modules
- architecture: module tree updated, test count 105 -> 200, runtime
  ~500ms -> ~2s, stub list narrowed to misc only
- codebase-summary: module table rewritten (wordle/loldle now Complete
  with real command lists and KV schema); test coverage table updated
- loldle/README: full rewrite matching the current implementation
  (was describing the original stub)
- New docs/development-roadmap.md tracking upcoming features
  (daily-mode for wordle + loldle, crypto/gold/forex trading, shared
  picker util, handler-level tests, coverage reporting, staging env)

Tests: 200/200 passing. Lint: clean.
This commit is contained in:
2026-04-20 22:08:58 +07:00
parent 574d855806
commit bd270a6835
15 changed files with 211 additions and 58 deletions
+3 -3
View File
@@ -11,7 +11,7 @@ Modules are added or removed via a single `MODULES` env var. Each module registe
- **Dual storage backends.** Modules talk to a small `KVStore` interface (Cloudflare KV for simple state) or `SqlStore` interface (D1 for relational data, scans, leaderboards). Swappable with one-file changes.
- **Scheduled jobs.** Modules declare cron-based cleanup, stats refresh, or maintenance tasks — registered via `wrangler.toml` and dispatched automatically.
- **Zero admin surface.** No in-Worker `/admin/*` routes, no admin secret. `setWebhook` + `setMyCommands` run at deploy time from a local node script.
- **Tested.** 105+ vitest unit tests cover registry, storage, dispatcher, cron validation, help renderer, validators, HTML escaping, and the trading module.
- **Tested.** 200+ vitest unit tests cover registry, storage, dispatcher, cron validation, help renderer, validators, HTML escaping, and the trading / loldle / wordle modules.
## How a request flows
@@ -64,8 +64,8 @@ src/
│ ├── trading/ # paper trading — VN stocks (D1 storage, daily cron)
│ │ └── migrations/
│ │ └── 0001_trades.sql
│ ├── wordle/ # stub — proves plugin system
│ ├── loldle/ # stub
│ ├── wordle/ # 5-letter guessing game (KV storage, 14k-word dict)
│ ├── loldle/ # classic-mode LoL champion guessing (KV storage)
│ └── misc/ # stub (KV storage)
└── util/
└── escape-html.js
+9 -1
View File
@@ -28,6 +28,14 @@
}
},
"files": {
"ignore": ["node_modules", ".wrangler", "dist", "coverage"]
"ignore": [
"node_modules",
".wrangler",
"dist",
"coverage",
"src/modules/loldle/champions.json",
"src/modules/loldle/champions-data.js",
"src/modules/wordle/words-data.js"
]
}
}
+4 -3
View File
@@ -30,7 +30,8 @@ src/
│ ├── validate-command.js ── shared validators
│ ├── util/ ── fully implemented: /info + /help
│ ├── trading/ ── paper trading: VN stocks (dynamic symbol resolution)
│ ├── wordle/ loldle/ ── stub modules proving the plugin system
│ ├── wordle/ ── 5-letter guessing game (KV storage)
│ ├── loldle/ ── classic-mode LoL champion guesser (KV storage)
│ └── misc/ ── stub that exercises the DB (ping/mstats)
└── util/
└── escape-html.js
@@ -359,7 +360,7 @@ A previous design sketched a `POST /admin/setup` route inside the Worker, gated
## 12. Testing philosophy
Pure-logic unit tests only. No `workerd` pool, no Telegram fixtures, no integration-level tooling. 105 tests run in ~500ms.
Pure-logic unit tests only. No `workerd` pool, no Telegram fixtures, no integration-level tooling. 200 tests run in ~2s.
Test seams:
@@ -381,7 +382,7 @@ Each module maintains its own `README.md` with commands, data model, and impleme
## 14. Non-goals (for now)
- Real game logic in `wordle` / `loldle` / `misc`they're stubs that exercise the framework. Real implementations can land later.
- Real game logic in `misc`it's a stub that exercises the DB. Real game modules (`wordle`, `loldle`, `trading`) are live; `misc` stays a framework sanity check.
- A sandbox between modules. Same-origin trust model: all modules are first-party code.
- Per-user rate limiting. Cloudflare's own rate limiting is available as a higher layer if needed.
- `nodejs_compat` flag. Not needed — grammY + this codebase use only Web APIs.
+8 -6
View File
@@ -20,10 +20,10 @@ Telegram bot on Cloudflare Workers with a plug-n-play module system. grammY hand
| Module | Status | Commands | Storage | Crons | Description |
|--------|--------|----------|---------|-------|-------------|
| `util` | Complete | `/info`, `/help` | — | — | Bot info and command help renderer |
| `trading` | Complete | `/trade_topup`, `/trade_buy`, `/trade_sell`, `/trade_convert`, `/trade_stats`, `/history` | D1 (trades) | Daily 5PM trim | Paper trading — VN stocks with dynamic symbol resolution. Crypto/gold/forex coming soon. |
| `trading` | Complete | `/trade_topup`, `/trade_buy`, `/trade_sell`, `/trade_convert`, `/trade_stats`, `/history` | D1 (trades) + KV (portfolio, symbol cache) | Daily 5PM trim | Paper trading — VN stocks with dynamic symbol resolution. Crypto/gold/forex coming soon. |
| `wordle` | Complete | `/wordle`, `/wordle_new`, `/wordle_giveup`, `/wordle_stats` | KV (game, stats) | — | Classic 5-letter word game. 14,855-word dict sourced from [dracos's gist](https://gist.github.com/dracos/dd0668f281e685bad51479e5acaadb93). |
| `loldle` | Complete | `/loldle`, `/loldle_new`, `/loldle_giveup`, `/loldle_stats` | KV (game, stats) | — | Classic-mode LoL champion guesser. Champion data synced from `tiennm99/loldle-data`. |
| `misc` | Stub | `/ping`, `/mstats`, `/fortytwo` | KV | — | Health check + DB demo |
| `wordle` | Stub | `/wordle`, `/wstats`, `/konami` | — | — | Placeholder for word game |
| `loldle` | Stub | `/loldle`, `/ggwp` | — | — | Placeholder for LoL game |
## Key Data Flows
@@ -70,12 +70,14 @@ Each module maintains its own `README.md` with commands, data model, and impleme
## Test Coverage
105+ tests across 11+ test files:
200 tests across 21 test files (run via `npm test` — ~2s):
| Area | Tests | What's Covered |
|------|-------|---------------|
| DB layer (KV) | 19 | KV store, prefixing, JSON helpers, pagination |
| DB layer (D1) | — | Fake D1 in-memory implementation (fake-d1.js) |
| DB layer (D1) | — | Fake D1 in-memory implementation (fake-d1.js) backs trading tests |
| Module framework | 33 | Registry, dispatcher, validators, help renderer, cron validation |
| Utilities | 4 | HTML escaping |
| Trading module | 49 | Dynamic symbol resolution, formatters, flat portfolio CRUD, command handlers, history/retention |
| Trading module | 79 | Symbol resolution, formatters, flat portfolio CRUD, command handlers, history/retention |
| Loldle module | 18 | Classic-mode champion comparison, champion lookup, daily picker |
| Wordle module | 13 | Duplicate-letter two-pass comparison, guess validation |
+62
View File
@@ -0,0 +1,62 @@
# Development Roadmap
Forward-looking plan for upcoming features and milestones. This document
tracks what's **next**, not what's done — for completed work, see git log and
`plans/*/` directories.
## Guiding Principles
- Each item lists the *future* value, not the past state.
- Cross-module infra lands before per-module features that need it.
- Keep items small enough to ship in one PR when possible.
## Modules
### Wordle
- **Daily mode.** Wire up `pickDaily(words, todayUtc())` so `/wordle` defaults
to the shared puzzle of the day (one target per UTC date for every player).
Give per-day stats a dedicated key so historical streaks aren't lost when
random mode and daily mode are mixed. `pickDaily` already exists in
`src/modules/wordle/daily.js` and has tests; the wiring in `handlers.js` is
the missing piece.
- Decide: should random-mode `/wordle_new` still work alongside daily, or
should the flow be "one puzzle per day, no reroll"?
### Loldle
- **Daily mode.** Same shape as wordle's daily-mode plan — use `pickDaily`
from `src/modules/loldle/daily.js` instead of `pickRandom`.
### Trading
- **Crypto support.** Add BTC / ETH price feed + asset category. Mirror the
stock flow: dynamic symbol resolution, cache in KV, reuse `portfolio.assets`.
- **Gold support.** SJC / PNJ spot price feed, treated as a single asset.
- **Currency exchange.** `/trade_convert VND USD 1000000` — already scaffolded
as "coming soon". Needs a forex buy/sell step that debits one currency and
credits another.
- **Leaderboard.** Cross-user ranking by realized P&L over a time window.
D1 query on `trading_trades` plus portfolio snapshots.
## Infrastructure
- **Shared `picker` util.** Extract the duplicated `todayUtc` / `pickDaily` /
`pickRandom` / djb2 hash from `loldle/daily.js` and `wordle/daily.js` into a
single `src/util/picker.js`. Consolidate tests.
- **Handler-level tests** for wordle + loldle mirroring
`tests/modules/trading/handlers.test.js` (subject resolution, giveup flow,
stats rendering, finished-round branch).
- **Coverage reporting.** Add vitest `--coverage` config and wire a threshold
gate into `npm test`.
- **Staging environment.** Separate D1 database + KV namespace for a staging
Worker, so migrations can be validated before prod.
## Unresolved Questions
- Daily-mode scope: global puzzle (everyone gets the same word / champion) vs
per-chat daily (each group has its own seed)?
- Does daily mode count toward the existing `stats:<subject>` record, or live
in a separate `daily-stats:<subject>:<date>` namespace?
- Do we keep `/wordle_new` and `/loldle_new` in daily mode, or hide them until
the next UTC rollover?
+9 -7
View File
@@ -63,13 +63,15 @@ export default {
const response = await route(request, env, pathname);
// Structured request log for Workers Observability dashboard.
console.log(JSON.stringify({
msg: "req",
method,
path: pathname,
status: response.status,
ms: Date.now() - start,
}));
console.log(
JSON.stringify({
msg: "req",
method,
path: pathname,
status: response.status,
ms: Date.now() - start,
}),
);
return response;
},
};
+43 -7
View File
@@ -1,20 +1,56 @@
# Loldle Module
League of Legends guessing game — currently a stub proving the plugin system.
Classic-mode League of Legends champion guessing game — ported from
[`tiennm99/loldle`](https://github.com/tiennm99/loldle) (`lib/classic-mode.js`).
Champion data is synced from
[`tiennm99/loldle-data`](https://github.com/tiennm99/loldle-data) via a
GitHub Actions workflow that regenerates `champions-data.js`.
## Commands
| Command | Visibility | Description |
|---------|-----------|-------------|
| `/loldle` | public | Start a loldle game (stub) |
| `/lstats` | protected | Show loldle stats (stub) |
| `/ggwp` | private | Easter egg — "gg well played" |
| `/loldle` | public | Show current board, start a game, or submit a champion guess when an argument is provided |
| `/loldle_new` | public | Start a new round (auto-gives-up any in-progress one) |
| `/loldle_giveup` | public | Reveal the current loldle answer |
| `/loldle_stats` | public | Show your loldle stats (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`).
## Architecture
- All commands return stub responses. Real game logic is not yet implemented.
- No `init()` hook — this module does not use KV storage yet.
- `compare.js` — pure attribute comparison across 7 classic-mode attributes
(gender, genre, range, resource, region, lane, year). 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-friendly plain-text rendering (✅/🟨/❌ 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-data.js` — auto-generated ES-module wrapper over `champions.json`
(do not edit by hand; regenerate with `node scripts/build-loldle-data.js`).
Subject resolution: private chats track per-user games; groups track per-chat
shared games (everyone plays the same round).
## Database
**No KV usage currently.** When game logic is implemented, it will use KV namespace prefix `loldle:`.
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.
## Credits
Champion data © Riot Games. The comparison attribute definitions and scoring
rules are ported from the original `tiennm99/loldle` project.
+19 -2
View File
@@ -18,7 +18,7 @@ import { compareChampions } from "./compare.js";
import { pickRandom } from "./daily.js";
import { findChampion } from "./lookup.js";
import { renderBoard, renderGuess } from "./render.js";
import { loadGame, loadStats, MAX_GUESSES, recordResult, saveGame } from "./state.js";
import { MAX_GUESSES, loadGame, loadStats, recordResult, saveGame } from "./state.js";
/** @type {Array<Record<string, any>>} */
const champions = championsData;
@@ -59,7 +59,13 @@ async function getOrInitGame(db, subject) {
async function startFreshGame(db, subject) {
const target = pickRandom(champions);
const fresh = { target: target.id, guesses: [], solved: false, startedAt: Date.now() };
const fresh = {
target: target.id,
guesses: [],
solved: false,
giveup: false,
startedAt: Date.now(),
};
await saveGame(db, subject, fresh);
return fresh;
}
@@ -94,6 +100,13 @@ export async function handleLoldle(ctx, db) {
if (!guess) return ctx.reply(`Champion not found: "${arg}".`);
const target = champions.find((c) => c.id === game.target);
// champions.json can be refreshed between rounds — an active target may disappear.
if (!target) {
await startFreshGame(db, subject);
return ctx.reply(
"Champion data was updated since this round started. Starting a fresh round — try again.",
);
}
const results = compareChampions(guess, target);
game.guesses.push({ champion: guess.name, results });
const won = guess.id === target.id;
@@ -129,6 +142,7 @@ export async function handleNew(ctx, db) {
if (prior && !isFinished(prior)) {
await recordResult(db, subject, false);
const prev = champions.find((c) => c.id === prior.target);
// prev may be undefined if champion data was refreshed — fall back to the id we stored.
prelude = `🏳️ Previous round abandoned (auto-giveup). Answer was ${prev?.name ?? prior.target}.\n\n`;
}
@@ -150,6 +164,9 @@ export async function handleGiveup(ctx, db) {
await saveGame(db, subject, game);
await recordResult(db, subject, false);
const target = champions.find((c) => c.id === game.target);
if (!target) {
return ctx.reply(`🏳️ Answer was ${game.target}. /loldle_new for another.`);
}
return ctx.reply(`🏳️ Answer was ${target.name}${target.title}. /loldle_new for another.`);
}
+22 -6
View File
@@ -28,8 +28,20 @@ function usageReply(ctx, usage) {
return ctx.reply(`Usage: ${usage}`);
}
// channel posts / inline queries may lack ctx.from — per-user state would collide under key "user:undefined".
function requireUid(ctx) {
const id = uid(ctx);
if (id == null) {
ctx.reply("Cannot identify user — trading only works in private or group chats with a sender.");
return null;
}
return id;
}
/** /trade_topup <amount> — add VND to account */
export async function handleTopup(ctx, db) {
const id = requireUid(ctx);
if (id == null) return;
const args = parseArgs(ctx);
if (args.length < 1)
return usageReply(ctx, "/trade_topup <amount>\nExample: /trade_topup 5000000");
@@ -37,10 +49,10 @@ export async function handleTopup(ctx, db) {
if (!Number.isFinite(amount) || amount <= 0)
return ctx.reply("Amount must be a positive number.");
const p = await getPortfolio(db, uid(ctx));
const p = await getPortfolio(db, id);
addCurrency(p, "VND", amount);
p.meta.invested += amount;
await savePortfolio(db, uid(ctx), p);
await savePortfolio(db, id, p);
await ctx.reply(`Topped up ${formatVND(amount)}.\nBalance: ${formatVND(p.currency.VND)}`);
}
@@ -52,6 +64,8 @@ export async function handleTopup(ctx, db) {
* @param {((trade: {symbol:string, side:"buy"|"sell", qty:number, priceVnd:number}) => Promise<void>) | null} [onTrade]
*/
export async function handleBuy(ctx, db, onTrade = null) {
const id = requireUid(ctx);
if (id == null) return;
const args = parseArgs(ctx);
if (args.length < 2)
return usageReply(ctx, "/trade_buy <qty> <TICKER>\nExample: /trade_buy 100 TCB");
@@ -73,7 +87,7 @@ export async function handleBuy(ctx, db, onTrade = null) {
if (price == null) return ctx.reply(`No price available for ${info.symbol}.`);
const cost = amount * price;
const p = await getPortfolio(db, uid(ctx));
const p = await getPortfolio(db, id);
const result = deductCurrency(p, "VND", cost);
if (!result.ok) {
return ctx.reply(
@@ -81,7 +95,7 @@ export async function handleBuy(ctx, db, onTrade = null) {
);
}
addAsset(p, info.symbol, amount);
await savePortfolio(db, uid(ctx), p);
await savePortfolio(db, id, p);
if (onTrade) await onTrade({ symbol: info.symbol, side: "buy", qty: amount, priceVnd: price });
await ctx.reply(
`Bought ${formatStock(amount)} ${info.symbol} @ ${formatVND(price)}\nCost: ${formatVND(cost)}`,
@@ -96,6 +110,8 @@ export async function handleBuy(ctx, db, onTrade = null) {
* @param {((trade: {symbol:string, side:"buy"|"sell", qty:number, priceVnd:number}) => Promise<void>) | null} [onTrade]
*/
export async function handleSell(ctx, db, onTrade = null) {
const id = requireUid(ctx);
if (id == null) return;
const args = parseArgs(ctx);
if (args.length < 2)
return usageReply(ctx, "/trade_sell <qty> <TICKER>\nExample: /trade_sell 100 TCB");
@@ -105,7 +121,7 @@ export async function handleSell(ctx, db, onTrade = null) {
if (!Number.isInteger(amount)) return ctx.reply("Stock quantities must be whole numbers.");
const symbol = args[1].toUpperCase();
const p = await getPortfolio(db, uid(ctx));
const p = await getPortfolio(db, id);
const result = deductAsset(p, symbol, amount);
if (!result.ok) return ctx.reply(`Insufficient ${symbol}. You have: ${formatStock(result.held)}`);
@@ -119,7 +135,7 @@ export async function handleSell(ctx, db, onTrade = null) {
const revenue = amount * price;
addCurrency(p, "VND", revenue);
await savePortfolio(db, uid(ctx), p);
await savePortfolio(db, id, p);
if (onTrade) await onTrade({ symbol, side: "sell", qty: amount, priceVnd: price });
await ctx.reply(
`Sold ${formatStock(amount)} ${symbol} @ ${formatVND(price)}\nRevenue: ${formatVND(revenue)}`,
+1 -1
View File
@@ -15,7 +15,7 @@ const STALE_LIMIT_MS = 300_000;
*/
export async function fetchStockPrice(ticker) {
const to = Math.floor(Date.now() / 1000);
const url = `https://apipubaws.tcbs.com.vn/stock-insight/v1/stock/bars-long-term?ticker=${ticker}&type=stock&resolution=D&countBack=1&to=${to}`;
const url = `https://apipubaws.tcbs.com.vn/stock-insight/v1/stock/bars-long-term?ticker=${encodeURIComponent(ticker)}&type=stock&resolution=D&countBack=1&to=${to}`;
const res = await fetch(url);
if (!res.ok) return null;
const json = await res.json();
+17 -14
View File
@@ -1,6 +1,10 @@
/**
* @file /trade_stats handler — portfolio summary with P&L breakdown.
* Fetches live stock prices for each held asset.
*
* Price fetches are issued in parallel with Promise.allSettled so a portfolio
* holding N stocks only waits for the slowest fetch, not the sum. Without this
* 10+ symbols would serially stack TCBS latency and can blow Cloudflare's
* subrequest budget.
*/
import { formatPnL, formatStock, formatVND } from "./format.js";
@@ -9,30 +13,29 @@ import { getStockPrice } from "./prices.js";
/** /trade_stats — show full portfolio valued in VND with P&L */
export async function handleStats(ctx, db) {
const p = await getPortfolio(db, ctx.from?.id);
const id = ctx.from?.id;
if (id == null) {
return ctx.reply("Cannot identify user — /trade_stats needs a sender.");
}
const p = await getPortfolio(db, id);
const lines = ["📊 Portfolio Summary\n"];
let totalValue = 0;
// VND balance
const vnd = p.currency.VND || 0;
if (vnd > 0) {
totalValue += vnd;
lines.push(`VND: ${formatVND(vnd)}`);
}
// stock assets
const assetEntries = Object.entries(p.assets);
if (assetEntries.length > 0) {
const held = Object.entries(p.assets).filter(([, qty]) => qty !== 0);
if (held.length > 0) {
lines.push("\nStocks:");
for (const [sym, qty] of assetEntries) {
if (qty === 0) continue;
let price;
try {
price = await getStockPrice(sym);
} catch {
price = null;
}
const prices = await Promise.allSettled(held.map(([sym]) => getStockPrice(sym)));
for (let i = 0; i < held.length; i++) {
const [sym, qty] = held[i];
const settled = prices[i];
const price = settled.status === "fulfilled" ? settled.value : null;
if (price == null) {
lines.push(` ${sym} x${formatStock(qty)} (no price)`);
continue;
+1 -1
View File
@@ -30,7 +30,7 @@ export async function resolveSymbol(db, ticker) {
// query TCBS to verify this is a real VN stock
const to = Math.floor(Date.now() / 1000);
const url = `https://apipubaws.tcbs.com.vn/stock-insight/v1/stock/bars-long-term?ticker=${symbol}&type=stock&resolution=D&countBack=1&to=${to}`;
const url = `https://apipubaws.tcbs.com.vn/stock-insight/v1/stock/bars-long-term?ticker=${encodeURIComponent(symbol)}&type=stock&resolution=D&countBack=1&to=${to}`;
const res = await fetch(url);
if (!res.ok) return null;
const json = await res.json();
+7 -1
View File
@@ -53,7 +53,13 @@ async function getOrInitGame(db, subject) {
async function startFreshGame(db, subject) {
const target = pickRandom(words);
const fresh = { target, guesses: [], solved: false, startedAt: Date.now() };
const fresh = {
target,
guesses: [],
solved: false,
giveup: false,
startedAt: Date.now(),
};
await saveGame(db, subject, fresh);
return fresh;
}
+5 -2
View File
@@ -28,8 +28,11 @@ export function makeWordSet(words) {
/**
* Validate a guess against the dictionary.
* null — input empty, wrong length, or not in dictionary (for "not a word")
* Returns a discriminated result so the caller can tell *why* validation failed.
*
* Returns a discriminated result so the caller can tell *why* validation failed:
* - "empty" — input was blank or stripped to nothing.
* - "length" — normalized word is not WORD_LENGTH letters.
* - "unknown" — right length, but not in the dictionary.
*
* @param {Set<string>} wordSet
* @param {string} input
+1 -4
View File
@@ -59,10 +59,7 @@ describe("compareChampions", () => {
});
it("multi-value identical sets are correct even if order/case differ", () => {
const r = compareChampions(
{ ...akali, genre: "assassin" },
{ ...akali, genre: "Assassin" },
);
const r = compareChampions({ ...akali, genre: "assassin" }, { ...akali, genre: "Assassin" });
expect(byKey(r, "genre").result).toBe("correct");
});