mirror of
https://github.com/tiennm99/tiennm99bot.git
synced 2026-10-14 10:18:59 +00:00
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:
1 parent
3b16e9fb25
commit
64d37e603e
18 files changed
+8251
-27
No files matched your search
@@ -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
@@ -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"
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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
@@ -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),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user