feat(loldle): port classic-mode game from loldle repo

Adds loldle module with classic-mode champion guessing. Ports comparison
logic from tiennm99/loldle (lib/classic-mode.js) and bundles champion
data from tiennm99/loldle-data. Adds GH Actions workflow that re-syncs
champions.json on cross-repo dispatch from loldle-data.

- Three public commands: /loldle, /loldle_giveup, /loldle_stats
- Per-user daily state + streak stats in KV (3-day TTL on games)
- champions-data.js wrapper sidesteps Node 24 / esbuild disagreement on
  JSON import attributes; generator script + npm run build:loldle-data
- register script now tolerates missing .env.deploy (env-file-if-exists)
  so Workers Builds can inject env vars directly
- fix(scripts): escape stray */ in migrate.js docstring that broke node
- 16 new unit tests (compare, daily, lookup); dispatcher test updated
  for the new command set
This commit is contained in:
tiennm99 committed 2026-04-20 17:10:08 +07:00
1 parent 3b16e9fb25
commit 64d37e603e
18 files changed
+8251 -27

No files matched your search

+50
View File
@@ -0,0 +1,50 @@
name: Sync loldle data
on:
repository_dispatch:
types: [loldle-data-updated]
workflow_dispatch:
schedule:
# Safety net: runs Friday 07:00 UTC (~25h after loldle-data's Thursday job)
# in case the dispatch event was missed.
- cron: "0 7 * * 5"
permissions:
contents: write
jobs:
sync:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v5
- name: Download latest champions.json
run: |
curl -fsSL -o src/modules/loldle/champions.json.new \
https://raw.githubusercontent.com/tiennm99/loldle-data/main/champions.json
- name: Compare and replace
id: diff
run: |
if cmp -s src/modules/loldle/champions.json src/modules/loldle/champions.json.new; then
echo "No changes."
rm src/modules/loldle/champions.json.new
echo "changed=false" >> "$GITHUB_OUTPUT"
else
mv src/modules/loldle/champions.json.new src/modules/loldle/champions.json
echo "changed=true" >> "$GITHUB_OUTPUT"
fi
- name: Regenerate champions-data.js
if: steps.diff.outputs.changed == 'true'
run: node scripts/build-loldle-data.js
- name: Commit and push
if: steps.diff.outputs.changed == 'true'
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add src/modules/loldle/champions.json src/modules/loldle/champions-data.js
git commit -m "chore(loldle): sync champions.json from loldle-data"
git push
+4 -3
View File
@@ -9,10 +9,11 @@
},
"scripts": {
"dev": "wrangler dev",
"deploy": "wrangler deploy && npm run db:migrate && npm run register",
"build:loldle-data": "node scripts/build-loldle-data.js",
"deploy": "npm run build:loldle-data && wrangler deploy && npm run db:migrate && npm run register",
"db:migrate": "node scripts/migrate.js",
"register": "node --env-file=.env.deploy scripts/register.js",
"register:dry": "node --env-file=.env.deploy scripts/register.js --dry-run",
"register": "node --env-file-if-exists=.env.deploy scripts/register.js",
"register:dry": "node --env-file-if-exists=.env.deploy scripts/register.js --dry-run",
"lint": "biome check src tests scripts && eslint src",
"format": "biome format --write src tests scripts",
"test": "vitest run"
+27
View File
@@ -0,0 +1,27 @@
#!/usr/bin/env node
/**
* @file build-loldle-data — wraps src/modules/loldle/champions.json
* as an ES module export to sidestep Node/esbuild disagreement on
* JSON import attributes (Node 24 requires `with { type: "json" }`,
* wrangler's bundled esbuild doesn't parse it yet).
*
* Run after any update to champions.json.
*/
import { readFileSync, writeFileSync } from "node:fs";
import { resolve } from "node:path";
const root = resolve(import.meta.dirname, "..");
const src = resolve(root, "src/modules/loldle/champions.json");
const dst = resolve(root, "src/modules/loldle/champions-data.js");
const json = readFileSync(src, "utf8").trimEnd();
const out = [
"// Auto-generated from champions.json — do NOT edit by hand.",
"// Regenerate with: node scripts/build-loldle-data.js",
`export default ${json};`,
"",
].join("\n");
writeFileSync(dst, out);
console.log(`wrote ${dst}`);
+1 -1
View File
@@ -2,7 +2,7 @@
/**
* @file migrate — custom D1 migration runner for per-module SQL files.
*
* Discovers all `src/modules/*/ migrations; /*.sql` files, sorts them
* Discovers all SQL files under `src/modules/<name>/migrations/`, sorts them
* deterministically (by `{moduleName}/{filename}`), then applies each NEW
* migration via `wrangler d1 execute miti99bot-db --remote --file=<path>`.
*
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+119
View File
@@ -0,0 +1,119 @@
/**
* @file Classic-mode champion comparison — ported from tiennm99/loldle
* (lib/classic-mode.js). Pure functions, no DOM/React.
*/
export const CLASSIC_ATTRIBUTES = [
{ key: "gender", label: "Gender", type: "exact" },
{ key: "genre", label: "Genre", type: "multi" },
{ key: "attackType", label: "Range", type: "exact" },
{ key: "resource", label: "Resource", type: "exact" },
{ key: "region", label: "Region", type: "exact" },
{ key: "lane", label: "Lane", type: "multi" },
{ key: "releaseDate", label: "Year", type: "year" },
];
/**
* Compare a guess champion against the target.
* @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] ?? "";
switch (attr.type) {
case "exact":
return {
...attr,
guessValue: formatValue(attr.key, guessVal),
targetValue: formatValue(attr.key, targetVal),
result:
String(guessVal).toLowerCase() === String(targetVal).toLowerCase()
? "correct"
: "wrong",
};
case "multi":
return {
...attr,
guessValue: formatValue(attr.key, guessVal),
targetValue: formatValue(attr.key, targetVal),
result: compareMultiValue(guessVal, targetVal),
};
case "year":
return {
...attr,
guessValue: guessVal || "?",
targetValue: targetVal || "?",
...compareYear(guessVal, targetVal),
};
default:
return { ...attr, guessValue: guessVal, targetValue: targetVal, result: "wrong" };
}
});
}
function compareMultiValue(guessStr, targetStr) {
const guessSet = parseSet(guessStr);
const targetSet = parseSet(targetStr);
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";
}
return "wrong";
}
function compareYear(guessYear, targetYear) {
const g = Number(guessYear);
const t = Number(targetYear);
if (!g || !t) return { result: "wrong" };
if (g === t) return { result: "correct" };
return { result: "wrong", direction: g < t ? "up" : "down" };
}
function parseSet(str) {
if (!str) return new Set();
return new Set(
String(str)
.split(",")
.map((s) => s.trim().toLowerCase())
.filter(Boolean),
);
}
function setsEqual(a, b) {
if (a.size !== b.size) return false;
for (const val of a) {
if (!b.has(val)) return false;
}
return true;
}
function formatValue(key, value) {
if (!value) return "—";
const str = String(value);
switch (key) {
case "gender":
return capitalize(str);
case "attackType":
return str === "close" ? "Melee" : "Ranged";
case "region":
return str.split("-").map(capitalize).join(" ");
case "genre":
case "lane":
return str
.split(",")
.map((s) => capitalize(s.trim()))
.join(", ");
default:
return str;
}
}
function capitalize(str) {
return str.charAt(0).toUpperCase() + str.slice(1);
}
+33
View File
@@ -0,0 +1,33 @@
/**
* @file Daily puzzle seeding — deterministic per UTC date.
* Uses djb2 hash of YYYY-MM-DD to pick champion index.
*/
/** 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;
}
/**
* Pick today's target champion deterministically.
* @template T
* @param {T[]} champions
* @param {string} [seed] — defaults to today's UTC date
* @returns {T}
*/
export function pickDaily(champions, seed) {
if (!Array.isArray(champions) || champions.length === 0) {
throw new Error("pickDaily: champions array is empty");
}
const s = seed ?? todayUtc();
return champions[hash(s) % champions.length];
}
+119
View File
@@ -0,0 +1,119 @@
/**
* @file Command handlers for loldle module.
*
* - /loldle → show board / start puzzle
* - /loldle <champion> → submit a guess
* - /loldle_giveup → reveal answer, end today's game
* - /loldle_stats → show personal stats
*/
import championsData from "./champions-data.js";
import { compareChampions } from "./compare.js";
import { pickDaily, todayUtc } from "./daily.js";
import { findChampion } from "./lookup.js";
import { renderBoard, renderGuess } from "./render.js";
import { loadGame, loadStats, MAX_GUESSES, recordResult, saveGame } from "./state.js";
/** @type {Array<Record<string, any>>} */
const champions = championsData;
function argAfterCommand(text) {
if (!text) return "";
const idx = text.indexOf(" ");
return idx === -1 ? "" : text.slice(idx + 1).trim();
}
async function getOrInitGame(db, userId, date) {
const existing = await loadGame(db, userId, date);
if (existing) return existing;
const target = pickDaily(champions, date);
const fresh = { target: target.id, guesses: [], solved: false };
await saveGame(db, userId, date, fresh);
return fresh;
}
/**
* @param {import("grammy").Context} ctx
* @param {import("../../db/kv-store-interface.js").KVStore} db
*/
export async function handleLoldle(ctx, db) {
const userId = ctx.from?.id;
if (!userId) return ctx.reply("Cannot identify user.");
const date = todayUtc();
const arg = argAfterCommand(ctx.message?.text ?? "");
const game = await getOrInitGame(db, userId, date);
if (!arg) {
const status = game.solved
? `🎉 Solved in ${game.guesses.length}/${MAX_GUESSES}.`
: game.giveup
? `🏳️ Gave up. Answer was ${game.target}.`
: `Guess ${game.guesses.length}/${MAX_GUESSES}. Use \`/loldle <champion>\`.`;
return ctx.reply(`${status}\n\n${renderBoard(game.guesses)}`);
}
if (game.solved || game.giveup) {
return ctx.reply(`Today's game is over. Answer was ${game.target}.`);
}
if (game.guesses.length >= MAX_GUESSES) {
return ctx.reply(`Out of guesses. Answer was ${game.target}.`);
}
const guess = findChampion(champions, arg);
if (!guess) return ctx.reply(`Champion not found: "${arg}".`);
const target = champions.find((c) => c.id === game.target);
const results = compareChampions(guess, target);
game.guesses.push({ champion: guess.name, results });
const won = guess.id === target.id;
if (won) game.solved = true;
await saveGame(db, userId, date, game);
const reply = renderGuess(guess.name, results);
if (won) {
const s = await recordResult(db, userId, date, true);
return ctx.reply(`${reply}\n\n🎉 Solved in ${game.guesses.length}/${MAX_GUESSES}! Streak: ${s.streak}.`);
}
if (game.guesses.length >= MAX_GUESSES) {
await recordResult(db, userId, date, false);
return ctx.reply(`${reply}\n\n❌ Out of guesses. Answer was ${target.name}.`);
}
return ctx.reply(`${reply}\n\nGuess ${game.guesses.length}/${MAX_GUESSES}.`);
}
/**
* @param {import("grammy").Context} ctx
* @param {import("../../db/kv-store-interface.js").KVStore} db
*/
export async function handleGiveup(ctx, db) {
const userId = ctx.from?.id;
if (!userId) return ctx.reply("Cannot identify user.");
const date = todayUtc();
const game = await getOrInitGame(db, userId, date);
if (game.solved) return ctx.reply(`Already solved today — ${game.target}.`);
if (game.giveup) return ctx.reply(`Already gave up — ${game.target}.`);
game.giveup = true;
await saveGame(db, userId, date, game);
await recordResult(db, userId, date, false);
const target = champions.find((c) => c.id === game.target);
return ctx.reply(`🏳️ Answer was ${target.name} — ${target.title}.`);
}
/**
* @param {import("grammy").Context} ctx
* @param {import("../../db/kv-store-interface.js").KVStore} db
*/
export async function handleStats(ctx, db) {
const userId = ctx.from?.id;
if (!userId) return ctx.reply("Cannot identify user.");
const s = await loadStats(db, userId);
const winRate = s.played ? Math.round((s.wins / s.played) * 100) : 0;
return ctx.reply(
`📊 Loldle stats\n` +
`Played: ${s.played}\n` +
`Wins: ${s.wins} (${winRate}%)\n` +
`Current streak: ${s.streak}\n` +
`Best streak: ${s.bestStreak}`,
);
}
+21 -18
View File
@@ -1,36 +1,39 @@
/**
* @file loldle module stub — proves the plugin system end-to-end.
* @file Loldle module — classic-mode champion guessing game.
*
* One public, one protected, one private slash command.
* Ported from tiennm99/loldle (lib/classic-mode.js). Data sourced from
* tiennm99/loldle-data's champions.json (synced via GH Actions).
*/
import { handleGiveup, handleLoldle, handleStats } from "./handlers.js";
/** @type {import("../../db/kv-store-interface.js").KVStore | null} */
let db = null;
/** @type {import("../registry.js").BotModule} */
const loldleModule = {
name: "loldle",
init: async ({ db: store }) => {
db = store;
},
commands: [
{
name: "loldle",
visibility: "public",
description: "Play loldle (stub)",
handler: async (ctx) => {
await ctx.reply("Loldle stub.");
},
description: "Classic loldle — guess today's champion",
handler: (ctx) => handleLoldle(ctx, db),
},
{
name: "lstats",
visibility: "protected",
description: "Loldle stats",
handler: async (ctx) => {
await ctx.reply("loldle stats stub");
},
name: "loldle_giveup",
visibility: "public",
description: "Reveal today's loldle answer",
handler: (ctx) => handleGiveup(ctx, db),
},
{
name: "ggwp",
visibility: "private",
description: "Easter egg — post-match courtesy",
handler: async (ctx) => {
await ctx.reply("gg well played (stub)");
},
name: "loldle_stats",
visibility: "public",
description: "Show your loldle stats (wins, streak)",
handler: (ctx) => handleStats(ctx, db),
},
],
};
+30
View File
@@ -0,0 +1,30 @@
/**
* @file Champion name lookup — normalizes user input to a champion record.
* Matches by exact id/name (case/space/punct-insensitive).
* Falls back to prefix match when unique.
*/
function normalize(s) {
return String(s || "")
.toLowerCase()
.replace(/[^a-z0-9]/g, "");
}
/**
* @param {Array<Record<string, any>>} champions
* @param {string} input
* @returns {Record<string, any> | null}
*/
export function findChampion(champions, input) {
const q = normalize(input);
if (!q) return null;
for (const c of champions) {
if (normalize(c.id) === q || normalize(c.name) === q) return c;
}
const prefixMatches = champions.filter(
(c) => normalize(c.id).startsWith(q) || normalize(c.name).startsWith(q),
);
return prefixMatches.length === 1 ? prefixMatches[0] : null;
}
+35
View File
@@ -0,0 +1,35 @@
/**
* @file Render comparison results as a Telegram-friendly plain-text grid.
* Avoids HTML to keep escaping trivial; uses emoji markers:
* ✅ correct · 🟨 partial · ❌ wrong · ⬆ direction-up · ⬇ direction-down
*/
const MARKER = { correct: "✅", partial: "🟨", wrong: "❌" };
/**
* Render a single guess row.
* @param {string} championName
* @param {ReturnType<import("./compare.js").compareChampions>} results
*/
export function renderGuess(championName, results) {
const header = `🎯 ${championName}`;
const lines = results.map((r) => {
const mark = MARKER[r.result] ?? "❌";
let arrow = "";
if (r.key === "releaseDate" && r.result !== "correct") {
if (r.direction === "up") arrow = " ⬆";
else if (r.direction === "down") arrow = " ⬇";
}
return `${mark} ${r.label}: ${r.guessValue}${arrow}`;
});
return [header, ...lines].join("\n");
}
/**
* Render the current board = all prior guesses stacked.
* @param {Array<{champion:string, results: any[]}>} guesses
*/
export function renderBoard(guesses) {
if (guesses.length === 0) return "No guesses yet. Reply with `/loldle <champion>`.";
return guesses.map((g) => renderGuess(g.champion, g.results)).join("\n\n");
}
+90
View File
@@ -0,0 +1,90 @@
/**
* @file Per-user daily game state in KV.
* Key layout (inside module-prefixed store):
* game:<userId>:<yyyy-mm-dd> -> { target, guesses[], solved, giveup }
* stats:<userId> -> { played, wins, streak, bestStreak, lastDate }
*/
const MAX_GUESSES = 8;
/** @param {number} userId @param {string} date */
const gameKey = (userId, date) => `game:${userId}:${date}`;
/** @param {number} userId */
const statsKey = (userId) => `stats:${userId}`;
/**
* @typedef {object} GameState
* @property {string} target — champion id
* @property {Array<{champion:string, results:any[]}>} guesses
* @property {boolean} solved
* @property {boolean} [giveup]
*/
/**
* @param {import("../../db/kv-store-interface.js").KVStore} db
* @param {number} userId
* @param {string} date
* @returns {Promise<GameState|null>}
*/
export async function loadGame(db, userId, date) {
return db.getJSON(gameKey(userId, date));
}
/**
* @param {import("../../db/kv-store-interface.js").KVStore} db
* @param {number} userId
* @param {string} date
* @param {GameState} state
*/
export async function saveGame(db, userId, date, state) {
// Expire after 3 days — stats are the only long-lived record.
await db.putJSON(gameKey(userId, date), state, { expirationTtl: 60 * 60 * 24 * 3 });
}
export { MAX_GUESSES };
/**
* @param {import("../../db/kv-store-interface.js").KVStore} db
* @param {number} userId
*/
export async function loadStats(db, userId) {
return (
(await db.getJSON(statsKey(userId))) ?? {
played: 0,
wins: 0,
streak: 0,
bestStreak: 0,
lastDate: null,
}
);
}
/**
* Record a finished game and update streaks.
* @param {import("../../db/kv-store-interface.js").KVStore} db
* @param {number} userId
* @param {string} date
* @param {boolean} won
*/
export async function recordResult(db, userId, date, won) {
const s = await loadStats(db, userId);
s.played += 1;
if (won) {
s.wins += 1;
const prev = s.lastDate;
const yesterday = prevDate(date);
s.streak = prev === yesterday || prev === date ? s.streak + (prev === date ? 0 : 1) : 1;
if (s.streak > s.bestStreak) s.bestStreak = s.streak;
} else {
s.streak = 0;
}
s.lastDate = date;
await db.putJSON(statsKey(userId), s);
return s;
}
function prevDate(ymd) {
const d = new Date(`${ymd}T00:00:00Z`);
d.setUTCDate(d.getUTCDate() - 1);
return d.toISOString().slice(0, 10);
}
+1 -2
View File
@@ -24,7 +24,7 @@ describe("installDispatcher", () => {
const env = { MODULES: "util,wordle,loldle,misc", KV: makeFakeKv() };
const reg = await installDispatcher(bot, env);
// Expect 11 total commands (5 public + 3 protected + 3 private).
// Expect 11 total commands (7 public + 2 protected + 2 private).
expect(bot.commandCalls).toHaveLength(11);
expect(reg.allCommands.size).toBe(11);
@@ -34,7 +34,6 @@ describe("installDispatcher", () => {
// Assert private commands ARE registered (the whole point of unified routing).
expect(registeredNames).toContain("konami");
expect(registeredNames).toContain("ggwp");
expect(registeredNames).toContain("fortytwo");
});
+87
View File
@@ -0,0 +1,87 @@
import { describe, expect, it } from "vitest";
import { CLASSIC_ATTRIBUTES, compareChampions } from "../../../src/modules/loldle/compare.js";
const aatrox = {
id: "Aatrox",
gender: "male",
genre: "Fighter",
attackType: "close",
resource: "Blood Well",
region: "runeterra",
lane: "top",
releaseDate: 2013,
};
const ahri = {
id: "Ahri",
gender: "female",
genre: "Mage,Assassin",
attackType: "range",
resource: "Mana",
region: "ionia",
lane: "mid",
releaseDate: 2011,
};
const akali = {
id: "Akali",
gender: "female",
genre: "Assassin",
attackType: "close",
resource: "Energy",
region: "ionia",
lane: "mid,top",
releaseDate: 2010,
};
function byKey(results, key) {
return results.find((r) => r.key === key);
}
describe("compareChampions", () => {
it("all correct when guess === target", () => {
const r = compareChampions(aatrox, aatrox);
for (const row of r) expect(row.result).toBe("correct");
});
it("exact mismatch is wrong", () => {
const r = compareChampions(aatrox, ahri);
expect(byKey(r, "gender").result).toBe("wrong");
expect(byKey(r, "attackType").result).toBe("wrong");
expect(byKey(r, "resource").result).toBe("wrong");
expect(byKey(r, "region").result).toBe("wrong");
});
it("multi-value partial overlap is partial", () => {
const r = compareChampions(akali, ahri);
expect(byKey(r, "genre").result).toBe("partial");
expect(byKey(r, "lane").result).toBe("partial");
});
it("multi-value identical sets are correct even if order/case differ", () => {
const r = compareChampions(
{ ...akali, genre: "assassin" },
{ ...akali, genre: "Assassin" },
);
expect(byKey(r, "genre").result).toBe("correct");
});
it("year direction hints up when guess < target", () => {
const r = compareChampions(akali, aatrox); // 2010 vs 2013
const y = byKey(r, "releaseDate");
expect(y.result).toBe("wrong");
expect(y.direction).toBe("up");
});
it("year direction hints down when guess > target", () => {
const r = compareChampions(aatrox, akali); // 2013 vs 2010
const y = byKey(r, "releaseDate");
expect(y.result).toBe("wrong");
expect(y.direction).toBe("down");
});
it("returns 7 attributes in declared order", () => {
const r = compareChampions(aatrox, ahri);
expect(r.map((x) => x.key)).toEqual(CLASSIC_ATTRIBUTES.map((a) => a.key));
});
});
+29
View File
@@ -0,0 +1,29 @@
import { describe, expect, it } from "vitest";
import { pickDaily, todayUtc } from "../../../src/modules/loldle/daily.js";
describe("daily", () => {
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("throws on empty list", () => {
expect(() => pickDaily([], "x")).toThrow();
});
});
+36
View File
@@ -0,0 +1,36 @@
import { describe, expect, it } from "vitest";
import { findChampion } from "../../../src/modules/loldle/lookup.js";
const champions = [
{ id: "Aatrox", name: "Aatrox" },
{ id: "Ahri", name: "Ahri" },
{ id: "KhaZix", name: "Kha'Zix" },
{ id: "MissFortune", name: "Miss Fortune" },
];
describe("findChampion", () => {
it("matches by exact id (case-insensitive)", () => {
expect(findChampion(champions, "aatrox").id).toBe("Aatrox");
expect(findChampion(champions, "AATROX").id).toBe("Aatrox");
});
it("normalizes punctuation and spaces", () => {
expect(findChampion(champions, "kha'zix").id).toBe("KhaZix");
expect(findChampion(champions, "miss fortune").id).toBe("MissFortune");
expect(findChampion(champions, "MissFortune").id).toBe("MissFortune");
});
it("returns null for non-matching input", () => {
expect(findChampion(champions, "zzz")).toBeNull();
expect(findChampion(champions, "")).toBeNull();
});
it("falls back to unique prefix match", () => {
expect(findChampion(champions, "aat").id).toBe("Aatrox");
});
it("prefix match returns null on ambiguity", () => {
const ambig = [...champions, { id: "Aatrox2", name: "Aatrox 2" }];
expect(findChampion(ambig, "aa")).toBeNull();
});
});
+3 -3
View File
@@ -14,8 +14,8 @@ MODULES = "util,wordle,loldle,misc,trading"
# then paste the returned IDs below.
[[kv_namespaces]]
binding = "KV"
id = "REPLACE_ME"
preview_id = "REPLACE_ME"
id = "f7f190fcb2fa42eb84a05542911334b0"
preview_id = "0ad72e1c34174f8cadf828e62db099a8"
# D1 database for module persistent storage. Each module prefixes its tables
# with `{moduleName}_` (e.g. `trading_trades`). Migrations are applied via
@@ -25,7 +25,7 @@ preview_id = "REPLACE_ME"
[[d1_databases]]
binding = "DB"
database_name = "miti99bot-db"
database_id = "REPLACE_ME_D1_UUID"
database_id = "261b54e7-0fdb-4fe7-8ed9-2e8a8bcf459c"
# Cron Triggers — union of all schedules declared by modules.
# When adding a module with cron entries, append its schedule(s) here.