diff --git a/README.md b/README.md index 88e2c85..43b9621 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/biome.json b/biome.json index d3409c0..347a733 100644 --- a/biome.json +++ b/biome.json @@ -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" + ] } } diff --git a/docs/architecture.md b/docs/architecture.md index 85b9eae..9be3ced 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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. diff --git a/docs/codebase-summary.md b/docs/codebase-summary.md index 3d3ca6f..fd33a35 100644 --- a/docs/codebase-summary.md +++ b/docs/codebase-summary.md @@ -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 | diff --git a/docs/development-roadmap.md b/docs/development-roadmap.md new file mode 100644 index 0000000..ddafb9e --- /dev/null +++ b/docs/development-roadmap.md @@ -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:` record, or live + in a separate `daily-stats::` namespace? +- Do we keep `/wordle_new` and `/loldle_new` in daily mode, or hide them until + the next UTC rollover? diff --git a/src/index.js b/src/index.js index 28a0be6..a73d603 100644 --- a/src/index.js +++ b/src/index.js @@ -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; }, }; diff --git a/src/modules/loldle/README.md b/src/modules/loldle/README.md index e157e61..565e69e 100644 --- a/src/modules/loldle/README.md +++ b/src/modules/loldle/README.md @@ -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 ` — 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:` | JSON | Active round: target champion id, guesses, solved/giveup flags, startedAt | +| `stats:` | 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. diff --git a/src/modules/loldle/handlers.js b/src/modules/loldle/handlers.js index 57c27ae..253b0a6 100644 --- a/src/modules/loldle/handlers.js +++ b/src/modules/loldle/handlers.js @@ -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>} */ 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.`); } diff --git a/src/modules/trading/handlers.js b/src/modules/trading/handlers.js index af51f18..680f515 100644 --- a/src/modules/trading/handlers.js +++ b/src/modules/trading/handlers.js @@ -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 — 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 \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) | 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 \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) | 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 \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)}`, diff --git a/src/modules/trading/prices.js b/src/modules/trading/prices.js index 5ea1440..8296f3f 100644 --- a/src/modules/trading/prices.js +++ b/src/modules/trading/prices.js @@ -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(); diff --git a/src/modules/trading/stats-handler.js b/src/modules/trading/stats-handler.js index e684789..8db8a06 100644 --- a/src/modules/trading/stats-handler.js +++ b/src/modules/trading/stats-handler.js @@ -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; diff --git a/src/modules/trading/symbols.js b/src/modules/trading/symbols.js index 4d6c778..8735cd0 100644 --- a/src/modules/trading/symbols.js +++ b/src/modules/trading/symbols.js @@ -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(); diff --git a/src/modules/wordle/handlers.js b/src/modules/wordle/handlers.js index 7914c8c..8512456 100644 --- a/src/modules/wordle/handlers.js +++ b/src/modules/wordle/handlers.js @@ -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; } diff --git a/src/modules/wordle/lookup.js b/src/modules/wordle/lookup.js index 091533e..e2f2ee7 100644 --- a/src/modules/wordle/lookup.js +++ b/src/modules/wordle/lookup.js @@ -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} wordSet * @param {string} input diff --git a/tests/modules/loldle/compare.test.js b/tests/modules/loldle/compare.test.js index 2220710..ee8ded9 100644 --- a/tests/modules/loldle/compare.test.js +++ b/tests/modules/loldle/compare.test.js @@ -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"); });