mirror of
https://github.com/tiennm99/tiennm99bot.git
synced 2026-10-14 08:18:44 +00:00
feat(loldle): monospace aligned board, auto-restart round
- Remove /loldle_new; finished rounds (solve/giveup/out-of-guesses) immediately roll into a fresh round. - Render guesses as an HTML <pre> monospace table with auto-widthed label column and a 🎯 Name row (uppercase champion name). - Year direction uses ⬆️ / ⬇️.
This commit is contained in:
1 parent
bd266c0385
commit
7997c8eb5b
9 files changed
+176
-82
No files matched your search
@@ -22,7 +22,7 @@ Telegram bot on Cloudflare Workers with a plug-n-play module system. grammY hand
|
||||
| `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) + 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`. |
|
||||
| `loldle` | Complete | `/loldle`, `/loldle_giveup`, `/loldle_stats` | KV (game, stats) | — | Classic-mode LoL champion guesser (auto-starts a new round after solve/giveup). Champion data synced from `tiennm99/loldle-data`. |
|
||||
| `misc` | Stub | `/ping`, `/mstats`, `/fortytwo` | KV | — | Health check + DB demo |
|
||||
|
||||
## Key Data Flows
|
||||
|
||||
@@ -58,5 +58,6 @@ tracks what's **next**, not what's done — for completed work, see git log and
|
||||
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?
|
||||
- Do we keep `/wordle_new` in daily mode, or hide it until the next UTC
|
||||
rollover? (Loldle auto-starts a fresh round after solve/giveup, so the
|
||||
question only applies to wordle now.)
|
||||
@@ -11,13 +11,13 @@ GitHub Actions workflow that regenerates `champions-data.js`.
|
||||
| Command | Visibility | Description |
|
||||
|---------|-----------|-------------|
|
||||
| `/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_giveup` | public | Reveal the current loldle answer (auto-starts a fresh round) |
|
||||
| `/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`).
|
||||
(see `lookup.js`). A round that ends (solved, gave up, or ran out of guesses)
|
||||
immediately rolls into a fresh round — no manual "new round" command needed.
|
||||
|
||||
## Architecture
|
||||
|
||||
@@ -27,8 +27,8 @@ are matched case/space/punctuation-insensitive with a unique-prefix fallback
|
||||
- `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).
|
||||
- `render.js` — Telegram HTML `<pre>` monospace table with auto-widthed label
|
||||
column (✅/🟨/❌ markers and ⬆️/⬇️ year direction hints).
|
||||
- `state.js` — KV persistence with `MAX_GUESSES = 8`, per-subject stats with
|
||||
streak tracking.
|
||||
- `handlers.js` — wires subject resolution (user id in DMs, chat id in groups)
|
||||
|
||||
@@ -8,9 +8,11 @@
|
||||
* Commands:
|
||||
* /loldle → show board / start puzzle
|
||||
* /loldle <champion> → submit a guess
|
||||
* /loldle_new → abandon current round (counts as giveup) + start fresh
|
||||
* /loldle_giveup → reveal answer, end current round
|
||||
* /loldle_giveup → reveal answer, end round (a fresh round auto-starts)
|
||||
* /loldle_stats → show stats (per-user in DM, per-group in groups)
|
||||
*
|
||||
* A finished round (solved, gave up, or out of guesses) is immediately
|
||||
* replaced by a fresh round, so the user can just keep playing.
|
||||
*/
|
||||
|
||||
import championsData from "./champions-data.js";
|
||||
@@ -23,6 +25,8 @@ import { MAX_GUESSES, loadGame, loadStats, recordResult, saveGame } from "./stat
|
||||
/** @type {Array<Record<string, any>>} */
|
||||
const champions = championsData;
|
||||
|
||||
const NEW_ROUND_HINT = "🆕 New round started. Use `/loldle <champion>` to guess.";
|
||||
|
||||
/**
|
||||
* Returns the stable subject identifier for the current chat.
|
||||
* In private chat: user id. In groups: chat id (shared across all members).
|
||||
@@ -48,12 +52,14 @@ function isFinished(game) {
|
||||
|
||||
/**
|
||||
* Load existing round, or create + persist a fresh random one.
|
||||
* A previously-finished round is discarded and replaced with a fresh one so
|
||||
* the game auto-continues without needing a manual "new round" command.
|
||||
* @param {import("../../db/kv-store-interface.js").KVStore} db
|
||||
* @param {number} subject
|
||||
*/
|
||||
async function getOrInitGame(db, subject) {
|
||||
const existing = await loadGame(db, subject);
|
||||
if (existing) return existing;
|
||||
if (existing && !isFinished(existing)) return existing;
|
||||
return startFreshGame(db, subject);
|
||||
}
|
||||
|
||||
@@ -82,18 +88,8 @@ export async function handleLoldle(ctx, db) {
|
||||
const game = await getOrInitGame(db, subject);
|
||||
|
||||
if (!arg) {
|
||||
const header = game.solved
|
||||
? `🎉 Solved in ${game.guesses.length}/${MAX_GUESSES}. /loldle_new for another.`
|
||||
: game.giveup
|
||||
? `🏳️ Gave up. Answer was ${game.target}. /loldle_new for another.`
|
||||
: `Guess ${game.guesses.length}/${MAX_GUESSES}. Use \`/loldle <champion>\`.`;
|
||||
return ctx.reply(`${header}\n\n${renderBoard(game.guesses)}`);
|
||||
}
|
||||
|
||||
if (isFinished(game)) {
|
||||
return ctx.reply(
|
||||
`Current round is over. Use /loldle_new to start another. Answer was ${game.target}.`,
|
||||
);
|
||||
const header = `Guess ${game.guesses.length}/${MAX_GUESSES}. Use \`/loldle <champion>\`.`;
|
||||
return ctx.reply(`${header}\n\n${renderBoard(game.guesses)}`, { parse_mode: "HTML" });
|
||||
}
|
||||
|
||||
const guess = findChampion(champions, arg);
|
||||
@@ -116,38 +112,23 @@ export async function handleLoldle(ctx, db) {
|
||||
const reply = renderGuess(guess.name, results);
|
||||
if (won) {
|
||||
const s = await recordResult(db, subject, true);
|
||||
await startFreshGame(db, subject);
|
||||
return ctx.reply(
|
||||
`${reply}\n\n🎉 Solved in ${game.guesses.length}/${MAX_GUESSES}! Streak: ${s.streak}. /loldle_new for another.`,
|
||||
`${reply}\n\n🎉 Solved in ${game.guesses.length}/${MAX_GUESSES}! Streak: ${s.streak}.\n${NEW_ROUND_HINT}`,
|
||||
{ parse_mode: "HTML" },
|
||||
);
|
||||
}
|
||||
if (game.guesses.length >= MAX_GUESSES) {
|
||||
await recordResult(db, subject, false);
|
||||
await startFreshGame(db, subject);
|
||||
return ctx.reply(
|
||||
`${reply}\n\n❌ Out of guesses. Answer was ${target.name}. /loldle_new to retry.`,
|
||||
`${reply}\n\n❌ Out of guesses. Answer was ${target.name}.\n${NEW_ROUND_HINT}`,
|
||||
{ parse_mode: "HTML" },
|
||||
);
|
||||
}
|
||||
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 handleNew(ctx, db) {
|
||||
const subject = getSubject(ctx);
|
||||
if (subject == null) return ctx.reply("Cannot identify chat.");
|
||||
|
||||
const prior = await loadGame(db, subject);
|
||||
let prelude = "";
|
||||
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`;
|
||||
}
|
||||
|
||||
await startFreshGame(db, subject);
|
||||
return ctx.reply(`${prelude}🆕 New round started. Use \`/loldle <champion>\` to guess.`);
|
||||
return ctx.reply(`${reply}\n\nGuess ${game.guesses.length}/${MAX_GUESSES}.`, {
|
||||
parse_mode: "HTML",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -158,16 +139,16 @@ export async function handleGiveup(ctx, db) {
|
||||
const subject = getSubject(ctx);
|
||||
if (subject == null) return ctx.reply("Cannot identify chat.");
|
||||
const game = await getOrInitGame(db, subject);
|
||||
if (game.solved) return ctx.reply(`Already solved — ${game.target}.`);
|
||||
if (game.giveup) return ctx.reply(`Already gave up — ${game.target}.`);
|
||||
// getOrInitGame guarantees the returned game is unfinished, so mark + record.
|
||||
game.giveup = true;
|
||||
await saveGame(db, subject, game);
|
||||
await recordResult(db, subject, false);
|
||||
const target = champions.find((c) => c.id === game.target);
|
||||
await startFreshGame(db, subject);
|
||||
if (!target) {
|
||||
return ctx.reply(`🏳️ Answer was ${game.target}. /loldle_new for another.`);
|
||||
return ctx.reply(`🏳️ Answer was ${game.target}.\n${NEW_ROUND_HINT}`);
|
||||
}
|
||||
return ctx.reply(`🏳️ Answer was ${target.name} — ${target.title}. /loldle_new for another.`);
|
||||
return ctx.reply(`🏳️ Answer was ${target.name} — ${target.title}.\n${NEW_ROUND_HINT}`);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* tiennm99/loldle-data's champions.json (synced via GH Actions).
|
||||
*/
|
||||
|
||||
import { handleGiveup, handleLoldle, handleNew, handleStats } from "./handlers.js";
|
||||
import { handleGiveup, handleLoldle, handleStats } from "./handlers.js";
|
||||
|
||||
/** @type {import("../../db/kv-store-interface.js").KVStore | null} */
|
||||
let db = null;
|
||||
@@ -23,16 +23,10 @@ const loldleModule = {
|
||||
description: "Classic loldle — guess the current champion",
|
||||
handler: (ctx) => handleLoldle(ctx, db),
|
||||
},
|
||||
{
|
||||
name: "loldle_new",
|
||||
visibility: "public",
|
||||
description: "Start a new round (auto-gives-up any in-progress one)",
|
||||
handler: (ctx) => handleNew(ctx, db),
|
||||
},
|
||||
{
|
||||
name: "loldle_giveup",
|
||||
visibility: "public",
|
||||
description: "Reveal the current loldle answer",
|
||||
description: "Reveal the current loldle answer (auto-starts a fresh round)",
|
||||
handler: (ctx) => handleGiveup(ctx, db),
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,35 +1,72 @@
|
||||
/**
|
||||
* @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
|
||||
* @file Render comparison results as a monospace-aligned table.
|
||||
*
|
||||
* Output uses Telegram HTML parse mode wrapped in <pre> so columns line up
|
||||
* in Telegram's fixed-width font. The label column auto-widths based on the
|
||||
* longest label in the block, so new attributes drop in without re-tuning.
|
||||
*
|
||||
* Markers:
|
||||
* 🎯 the guessed champion (name row header)
|
||||
* ✅ correct · 🟨 partial · ❌ wrong · ⬆️ / ⬇️ direction hint for year
|
||||
*/
|
||||
|
||||
import { escapeHtml } from "../../util/escape-html.js";
|
||||
|
||||
const MARKER = { correct: "✅", partial: "🟨", wrong: "❌" };
|
||||
const ARROW = { up: "⬆️", down: "⬇️" };
|
||||
const NAME_LABEL = "Name";
|
||||
const NAME_MARKER = "🎯";
|
||||
|
||||
/**
|
||||
* Render a single guess row.
|
||||
* Build the label/value rows for a single guess (name row + one row per attribute).
|
||||
* @param {string} championName
|
||||
* @param {ReturnType<import("./compare.js").compareChampions>} results
|
||||
* @returns {Array<{marker: string, label: string, value: string}>}
|
||||
*/
|
||||
function buildRows(championName, results) {
|
||||
const rows = [{ marker: NAME_MARKER, label: NAME_LABEL, value: championName.toUpperCase() }];
|
||||
for (const r of results) {
|
||||
const marker = MARKER[r.result] ?? MARKER.wrong;
|
||||
let value = String(r.guessValue ?? "");
|
||||
if (r.key === "releaseDate" && r.result !== "correct" && r.direction) {
|
||||
const arrow = ARROW[r.direction];
|
||||
if (arrow) value = `${value} ${arrow}`;
|
||||
}
|
||||
rows.push({ marker, label: r.label, value });
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render one or more row-groups as a single aligned monospace block. Label
|
||||
* column width = max label length across ALL groups, so stacked guesses on a
|
||||
* board line up with each other.
|
||||
* @param {Array<Array<{marker: string, label: string, value: string}>>} rowGroups
|
||||
*/
|
||||
function formatRowGroups(rowGroups) {
|
||||
const width = Math.max(...rowGroups.flat().map((r) => r.label.length));
|
||||
const blocks = rowGroups.map((rows) =>
|
||||
rows.map((r) => `${r.marker} ${r.label.padEnd(width)} ${escapeHtml(r.value)}`).join("\n"),
|
||||
);
|
||||
return `<pre>${blocks.join("\n\n")}</pre>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a single guess row-group.
|
||||
* @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");
|
||||
return formatRowGroups([buildRows(championName, results)]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the current board = all prior guesses stacked.
|
||||
* @param {Array<{champion:string, results: any[]}>} guesses
|
||||
* Render the current board — all prior guesses stacked in one aligned block.
|
||||
* @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");
|
||||
if (guesses.length === 0) {
|
||||
return "No guesses yet. Reply with <code>/loldle <champion></code>.";
|
||||
}
|
||||
return formatRowGroups(guesses.map((g) => buildRows(g.champion, g.results)));
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
* @file Game state in KV, keyed by "subject" (user in DM, chat in groups).
|
||||
*
|
||||
* One active round per subject at a time. Rounds are self-paced: players
|
||||
* can /loldle_new to abandon and reroll. Streak = consecutive wins.
|
||||
* can /loldle_giveup to reveal (a fresh round auto-starts). Streak = consecutive wins.
|
||||
*
|
||||
* Key layout (inside module-prefixed store):
|
||||
* game:<subject> -> { target, guesses[], solved, giveup, startedAt }
|
||||
|
||||
@@ -24,9 +24,9 @@ describe("installDispatcher", () => {
|
||||
const env = { MODULES: "util,wordle,loldle,misc", KV: makeFakeKv() };
|
||||
const reg = await installDispatcher(bot, env);
|
||||
|
||||
// Expect 13 total commands (11 public + 1 protected + 1 private).
|
||||
expect(bot.commandCalls).toHaveLength(13);
|
||||
expect(reg.allCommands.size).toBe(13);
|
||||
// Expect 12 total commands (10 public + 1 protected + 1 private).
|
||||
expect(bot.commandCalls).toHaveLength(12);
|
||||
expect(reg.allCommands.size).toBe(12);
|
||||
|
||||
const registeredNames = bot.commandCalls.map((c) => c.name).sort();
|
||||
const expected = [...reg.allCommands.keys()].sort();
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { renderBoard, renderGuess } from "../../../src/modules/loldle/render.js";
|
||||
|
||||
/** Mirror the shape compareChampions returns — only the fields render.js reads. */
|
||||
const sampleResults = [
|
||||
{ key: "gender", label: "Gender", result: "correct", guessValue: "Male" },
|
||||
{ key: "genre", label: "Genre", result: "correct", guessValue: "Mage, Support" },
|
||||
{ key: "attackType", label: "Range", result: "correct", guessValue: "Ranged" },
|
||||
{ key: "resource", label: "Resource", result: "correct", guessValue: "Mana" },
|
||||
{ key: "region", label: "Region", result: "correct", guessValue: "Runeterra" },
|
||||
{ key: "lane", label: "Lane", result: "partial", guessValue: "Jungle, Support" },
|
||||
{
|
||||
key: "releaseDate",
|
||||
label: "Year",
|
||||
result: "wrong",
|
||||
direction: "up",
|
||||
guessValue: "2011",
|
||||
},
|
||||
];
|
||||
|
||||
describe("renderGuess", () => {
|
||||
it("wraps output in <pre> for Telegram HTML monospace", () => {
|
||||
const out = renderGuess("Brand", sampleResults);
|
||||
expect(out.startsWith("<pre>")).toBe(true);
|
||||
expect(out.endsWith("</pre>")).toBe(true);
|
||||
});
|
||||
|
||||
it("uppercases champion name on the 🎯 row", () => {
|
||||
const out = renderGuess("Rakan", sampleResults);
|
||||
expect(out).toContain("🎯 Name");
|
||||
expect(out).toContain("RAKAN");
|
||||
expect(out).not.toContain(" Rakan");
|
||||
});
|
||||
|
||||
it("auto-widths label column to the longest label (Resource = 8)", () => {
|
||||
const out = renderGuess("Brand", sampleResults);
|
||||
// "Name" (4) padded to 8 → 4 trailing spaces + the single separator space.
|
||||
expect(out).toContain("🎯 Name BRAND");
|
||||
// "Resource" (8) = exact width, single separator space before value.
|
||||
expect(out).toContain("✅ Resource Mana");
|
||||
// "Gender" (6) padded to 8 → 2 trailing + 1 separator = 3 spaces before value.
|
||||
expect(out).toContain("✅ Gender Male");
|
||||
});
|
||||
|
||||
it("appends ⬆️ / ⬇️ year direction hints only when wrong", () => {
|
||||
const up = renderGuess("Brand", sampleResults);
|
||||
expect(up).toContain("❌ Year");
|
||||
expect(up).toContain("2011 ⬆️");
|
||||
|
||||
const correctYear = sampleResults.map((r) =>
|
||||
r.key === "releaseDate" ? { ...r, result: "correct", direction: undefined } : r,
|
||||
);
|
||||
const out = renderGuess("Brand", correctYear);
|
||||
expect(out).not.toContain("⬆️");
|
||||
expect(out).not.toContain("⬇️");
|
||||
});
|
||||
|
||||
it("HTML-escapes values so < and > render literally", () => {
|
||||
const evil = [{ key: "region", label: "Region", result: "wrong", guessValue: "<script>" }];
|
||||
const out = renderGuess("Foo", evil);
|
||||
expect(out).toContain("<script>");
|
||||
expect(out).not.toContain("<script>");
|
||||
});
|
||||
});
|
||||
|
||||
describe("renderBoard", () => {
|
||||
it("returns an empty-state prompt when there are no guesses", () => {
|
||||
expect(renderBoard([])).toContain("No guesses yet");
|
||||
});
|
||||
|
||||
it("stacks multiple guesses in one <pre> with aligned labels", () => {
|
||||
const out = renderBoard([
|
||||
{ champion: "Ahri", results: sampleResults },
|
||||
{ champion: "Brand", results: sampleResults },
|
||||
]);
|
||||
expect(out.startsWith("<pre>")).toBe(true);
|
||||
expect(out.endsWith("</pre>")).toBe(true);
|
||||
expect(out).toContain("🎯 Name AHRI");
|
||||
expect(out).toContain("🎯 Name BRAND");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user