mirror of
https://github.com/tiennm99/miti99bot.git
synced 2026-08-23 18:25:26 +00:00
refactor(loldle): trim module to current behavior only
KV payload cleanup: - drop lastResultAt from stats (never read) - drop solved/giveup flags from game state (round is immediately replaced after finish, making the flags transient noise) - skip redundant saveGame on winning/giveup/out-of-guesses paths; startFreshGame overwrites anyway Code cleanup: - delete daily.js + daily.test.js (pickDaily/todayUtc were speculative "future use" — only pickRandom was wired in, inlined into handlers) - drop the dead switch default in compare.js - trim file preambles across the module Docs: rewrite README around current behavior with loldle.net as the sole data source; update scraper header to match the raw schema.
This commit is contained in:
@@ -42,9 +42,7 @@ jobs:
|
||||
title: "data: weekly loldle.net champion refresh"
|
||||
body: |
|
||||
Automated weekly refresh of `src/modules/loldle/champions.json`
|
||||
from loldle.net's JS bundle — the canonical source for all
|
||||
classic-mode fields (`gender`, `species`, `resource`,
|
||||
`attackType`, `region`, `lane`, `releaseDate`).
|
||||
from loldle.net's JS bundle.
|
||||
|
||||
Review the diff, merge, then run `npm run deploy` to ship.
|
||||
add-paths: src/modules/loldle/champions.json
|
||||
|
||||
@@ -1,19 +1,13 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* @file scrape-loldle-data — rebuilds src/modules/loldle/champions.json from
|
||||
* loldle.net's JS bundle, the canonical source for the classic-mode axes:
|
||||
* gender, species, resource, attackType, region, lane, releaseDate.
|
||||
* @file Rebuilds src/modules/loldle/champions.json from loldle.net's JS
|
||||
* bundle. The bundle embeds the full champion array in plaintext — one
|
||||
* record per champion with fields: _id, championId, championName, gender,
|
||||
* positions, species, resource, range_type, regions, release_date.
|
||||
*
|
||||
* loldle.net embeds the full champion array in plaintext inside its JS bundle
|
||||
* at `<script src="js/index.<hash>.js">`, one record per champion with the
|
||||
* exact shape the bot needs. No CryptoJS decoding, no ddragon merge.
|
||||
*
|
||||
* Writes src/modules/loldle/champions.json. The bot imports this JSON
|
||||
* directly via `with { type: "json" }` (Node 24 + wrangler 4.x).
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/scrape-loldle-data.js
|
||||
* The bot imports the resulting JSON directly via `with { type: "json" }`.
|
||||
*
|
||||
* Usage: node scripts/scrape-loldle-data.js
|
||||
* Schedule: weekly via .github/workflows/scrape-loldle-data.yml
|
||||
*/
|
||||
|
||||
|
||||
@@ -1,58 +1,67 @@
|
||||
# Loldle Module
|
||||
|
||||
Classic-mode League of Legends champion guessing game — ported from
|
||||
[`tiennm99/loldle`](https://github.com/tiennm99/loldle) (`lib/classic-mode.js`).
|
||||
Champion data is scraped weekly from [loldle.net](https://loldle.net/classic)'s
|
||||
JS bundle into `champions.json` via `.github/workflows/scrape-loldle-data.yml`
|
||||
(runs `node scripts/scrape-loldle-data.js`).
|
||||
Classic-mode League of Legends champion guessing game. Players get 8 guesses
|
||||
to identify a hidden champion; each guess is compared across 7 attributes
|
||||
and the board is rendered as a monospace Telegram table.
|
||||
|
||||
## Data source
|
||||
|
||||
All champion data comes from **[loldle.net](https://loldle.net/classic)**.
|
||||
The site embeds its classic-mode dataset in plaintext inside its JS bundle,
|
||||
so we scrape and store it verbatim — no transformation, no external merge.
|
||||
|
||||
`scripts/scrape-loldle-data.js` fetches `loldle.net/classic`, extracts the
|
||||
hashed `js/index.<hash>.js` bundle URL, pulls every champion record via a
|
||||
single regex, and writes the array to `src/modules/loldle/champions.json`.
|
||||
|
||||
The bot imports that JSON directly (`with { type: "json" }`). No build step,
|
||||
no wrapper module.
|
||||
|
||||
**Regenerate manually:** `npm run scrape:loldle-data`
|
||||
**Weekly refresh:** `.github/workflows/scrape-loldle-data.yml` (Mon 06:00 UTC)
|
||||
opens a PR whenever the data changes.
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Visibility | Description |
|
||||
|---------|-----------|-------------|
|
||||
| `/loldle` | public | Show current board, start a game, or submit a champion guess when an argument is provided |
|
||||
| `/loldle_giveup` | public | Reveal the current loldle answer (auto-starts a fresh round) |
|
||||
| `/loldle_stats` | public | Show your loldle stats (wins, streak) |
|
||||
| `/loldle` | public | Show current board, or submit a champion guess |
|
||||
| `/loldle_giveup` | public | Reveal the answer and auto-start a fresh round |
|
||||
| `/loldle_stats` | public | Show your 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`). A round that ends (solved, gave up, or ran out of guesses)
|
||||
immediately rolls into a fresh round — no manual "new round" command needed.
|
||||
Submit a guess with `/loldle <champion>` (e.g. `/loldle Ahri`). Names match
|
||||
case/space/punctuation-insensitive with a unique-prefix fallback. A round
|
||||
that ends (solved, gave up, or out of guesses) is immediately replaced by a
|
||||
fresh one — no manual "new round" command.
|
||||
|
||||
## Architecture
|
||||
|
||||
- `compare.js` — pure attribute comparison across 7 classic-mode attributes
|
||||
matching loldle.net's raw schema (`gender`, `species`, `range_type`,
|
||||
`resource`, `regions`, `positions`, `release_date`). 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 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)
|
||||
to the pure functions above.
|
||||
- `champions.json` — auto-generated from loldle.net (do not edit by hand;
|
||||
regenerate with `npm run scrape:loldle-data`). Imported directly via
|
||||
`with { type: "json" }`.
|
||||
- `compare.js` — attribute comparison across 7 axes from loldle.net's raw
|
||||
schema (`gender`, `species`, `range_type`, `resource`, `regions`,
|
||||
`positions`, `release_date`). Returns `correct` / `partial` / `wrong` per
|
||||
row, plus an up/down `direction` hint for the year.
|
||||
- `lookup.js` — normalizes user input to a champion record.
|
||||
- `render.js` — Telegram HTML `<pre>` monospace table with auto-widthed
|
||||
label column.
|
||||
- `state.js` — KV persistence (`MAX_GUESSES = 8`, per-subject stats).
|
||||
- `handlers.js` — subject resolution (user id in DMs, chat id in groups) +
|
||||
command flow.
|
||||
- `flavor.js` — win-message text helpers.
|
||||
- `stickers.js` — Telegram sticker pools per outcome.
|
||||
- `champions.json` — auto-generated data (never edit by hand).
|
||||
|
||||
Subject resolution: private chats track per-user games; groups track per-chat
|
||||
shared games (everyone plays the same round).
|
||||
Subject resolution: private chats track per-user games; groups track
|
||||
per-chat shared games.
|
||||
|
||||
## Database
|
||||
## Storage
|
||||
|
||||
KV namespace prefix: `loldle:`
|
||||
|
||||
| Key | Type | Description |
|
||||
|-----|------|-------------|
|
||||
| `game:<subject>` | JSON | Active round: target champion id, guesses, solved/giveup flags, startedAt |
|
||||
| `stats:<subject>` | JSON | Aggregate stats: played, wins, streak, bestStreak, lastResultAt |
|
||||
|
||||
Active rounds expire after 7 days if untouched.
|
||||
| Key | Value |
|
||||
|-----|-------|
|
||||
| `game:<subject>` | `{ target, guesses, startedAt }` — active round (TTL 7 days). `guesses` is a championName array; comparison rows are recomputed at render time. |
|
||||
| `stats:<subject>` | `{ played, wins, streak, bestStreak }` |
|
||||
|
||||
## Credits
|
||||
|
||||
Champion data © Riot Games. The comparison attribute definitions and scoring
|
||||
rules are ported from the original `tiennm99/loldle` project.
|
||||
Champion data © Riot Games, via loldle.net.
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
/**
|
||||
* @file Classic-mode champion comparison against the raw loldle.net schema.
|
||||
* Pure functions, no DOM/React.
|
||||
* @file Classic-mode champion comparison.
|
||||
*
|
||||
* Champion records use the shape emitted by scripts/scrape-loldle-data.js
|
||||
* (identical to loldle.net's JS bundle): gender is a string ("Male"), the
|
||||
* multi-value axes (positions, species, regions, range_type) are arrays,
|
||||
* and release_date is an ISO "YYYY-MM-DD" string.
|
||||
* Champion records use loldle.net's raw schema: `gender` is a string
|
||||
* ("Male"/"Female"/"Other"), multi-value axes (positions, species, regions,
|
||||
* range_type) are arrays, and `release_date` is an ISO "YYYY-MM-DD" string.
|
||||
*/
|
||||
|
||||
export const CLASSIC_ATTRIBUTES = [
|
||||
@@ -19,62 +17,51 @@ export const CLASSIC_ATTRIBUTES = [
|
||||
];
|
||||
|
||||
/**
|
||||
* Compare a guess champion against the target.
|
||||
* Compare a guess champion against the target, returning one row per attribute.
|
||||
* @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];
|
||||
const g = guess[attr.key];
|
||||
const t = target[attr.key];
|
||||
|
||||
switch (attr.type) {
|
||||
case "exact":
|
||||
return {
|
||||
...attr,
|
||||
guessValue: formatValue(guessVal),
|
||||
targetValue: formatValue(targetVal),
|
||||
result:
|
||||
String(guessVal ?? "").toLowerCase() === String(targetVal ?? "").toLowerCase()
|
||||
? "correct"
|
||||
: "wrong",
|
||||
};
|
||||
case "multi":
|
||||
return {
|
||||
...attr,
|
||||
guessValue: formatValue(guessVal),
|
||||
targetValue: formatValue(targetVal),
|
||||
result: compareMultiValue(guessVal, targetVal),
|
||||
};
|
||||
case "year":
|
||||
return {
|
||||
...attr,
|
||||
guessValue: parseYear(guessVal) || "?",
|
||||
targetValue: parseYear(targetVal) || "?",
|
||||
...compareYear(guessVal, targetVal),
|
||||
};
|
||||
default:
|
||||
return { ...attr, guessValue: guessVal, targetValue: targetVal, result: "wrong" };
|
||||
if (attr.type === "year") {
|
||||
return {
|
||||
...attr,
|
||||
guessValue: parseYear(g) || "?",
|
||||
targetValue: parseYear(t) || "?",
|
||||
...compareYear(g, t),
|
||||
};
|
||||
}
|
||||
|
||||
const row = {
|
||||
...attr,
|
||||
guessValue: formatValue(g),
|
||||
targetValue: formatValue(t),
|
||||
};
|
||||
row.result =
|
||||
attr.type === "exact"
|
||||
? String(g ?? "").toLowerCase() === String(t ?? "").toLowerCase()
|
||||
? "correct"
|
||||
: "wrong"
|
||||
: compareMultiValue(g, t);
|
||||
return row;
|
||||
});
|
||||
}
|
||||
|
||||
function compareMultiValue(guess, target) {
|
||||
const guessSet = toSet(guess);
|
||||
const targetSet = toSet(target);
|
||||
|
||||
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";
|
||||
}
|
||||
const a = toSet(guess);
|
||||
const b = toSet(target);
|
||||
if (a.size === 0 && b.size === 0) return "correct";
|
||||
if (a.size === 0 || b.size === 0) return "wrong";
|
||||
if (setsEqual(a, b)) return "correct";
|
||||
for (const v of a) if (b.has(v)) return "partial";
|
||||
return "wrong";
|
||||
}
|
||||
|
||||
function parseYear(val) {
|
||||
if (!val) return 0;
|
||||
const m = String(val).match(/^(\d{4})/);
|
||||
const m = String(val ?? "").match(/^(\d{4})/);
|
||||
return m ? Number(m[1]) : 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
/**
|
||||
* @file Champion pickers — deterministic daily seeding and fresh-random.
|
||||
*/
|
||||
|
||||
/** 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deterministic pick seeded by date (or any string).
|
||||
* @template T
|
||||
* @param {T[]} champions
|
||||
* @param {string} [seed]
|
||||
* @returns {T}
|
||||
*/
|
||||
export function pickDaily(champions, seed) {
|
||||
assertNonEmpty(champions);
|
||||
const s = seed ?? todayUtc();
|
||||
return champions[hash(s) % champions.length];
|
||||
}
|
||||
|
||||
/**
|
||||
* Uniformly random pick. `rng` defaults to Math.random — override for tests.
|
||||
* @template T
|
||||
* @param {T[]} champions
|
||||
* @param {() => number} [rng]
|
||||
* @returns {T}
|
||||
*/
|
||||
export function pickRandom(champions, rng = Math.random) {
|
||||
assertNonEmpty(champions);
|
||||
return champions[Math.floor(rng() * champions.length)];
|
||||
}
|
||||
|
||||
function assertNonEmpty(arr) {
|
||||
if (!Array.isArray(arr) || arr.length === 0) {
|
||||
throw new Error("picker: champions array is empty");
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,5 @@
|
||||
/**
|
||||
* @file Small text helpers for the loldle win message.
|
||||
*
|
||||
* Kept separate so handlers.js stays under the 200-LoC guideline and so these
|
||||
* pure functions are easy to unit-test without a grammY context.
|
||||
* @file Win-message text helpers — one-word reaction + elapsed-time format.
|
||||
*/
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,24 +1,20 @@
|
||||
/**
|
||||
* @file Command handlers for loldle module.
|
||||
* @file Loldle command handlers.
|
||||
*
|
||||
* Subject resolution:
|
||||
* private chat → user id (per-user game)
|
||||
* group/supergroup chat → chat id (shared game — all members play together)
|
||||
* group/supergroup chat → chat id (shared game — everyone plays together)
|
||||
*
|
||||
* Commands:
|
||||
* /loldle → show board / start puzzle
|
||||
* /loldle → show the current board (or start a round)
|
||||
* /loldle <champion> → submit a guess
|
||||
* /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.
|
||||
* /loldle_giveup → reveal the answer and auto-start a fresh round
|
||||
* /loldle_stats → show wins / streak for the current subject
|
||||
*/
|
||||
|
||||
import { escapeHtml } from "../../util/escape-html.js";
|
||||
import championsData from "./champions.json" with { type: "json" };
|
||||
import { compareChampions } from "./compare.js";
|
||||
import { pickRandom } from "./daily.js";
|
||||
import { attemptFlavor, formatDuration } from "./flavor.js";
|
||||
import { findChampion } from "./lookup.js";
|
||||
import { renderBoard, renderGuess } from "./render.js";
|
||||
@@ -28,19 +24,10 @@ import { GIVEUP_STICKERS, LOSE_STICKERS, WIN_STICKERS, pickSticker } from "./sti
|
||||
/** @type {Array<Record<string, any>>} */
|
||||
const champions = championsData;
|
||||
|
||||
// Sent inside HTML parse_mode replies — must be HTML-safe.
|
||||
// `<champion>` as a literal tag would make Telegram reject the whole message.
|
||||
const NEW_ROUND_HINT = "🆕 New round started. Use <code>/loldle <champion></code> to guess.";
|
||||
|
||||
/**
|
||||
* Returns the stable subject identifier for the current chat.
|
||||
* In private chat: user id. In groups: chat id (shared across all members).
|
||||
* @param {import("grammy").Context} ctx
|
||||
* @returns {number|null}
|
||||
*/
|
||||
function getSubject(ctx) {
|
||||
const type = ctx.chat?.type;
|
||||
if (type === "private") return ctx.from?.id ?? null;
|
||||
if (type === "group" || type === "supergroup") return ctx.chat.id;
|
||||
return ctx.from?.id ?? null;
|
||||
}
|
||||
@@ -51,68 +38,48 @@ function argAfterCommand(text) {
|
||||
return idx === -1 ? "" : text.slice(idx + 1).trim();
|
||||
}
|
||||
|
||||
function isFinished(game) {
|
||||
return game.solved || game.giveup || game.guesses.length >= MAX_GUESSES;
|
||||
function pickRandomChampion() {
|
||||
return champions[Math.floor(Math.random() * champions.length)];
|
||||
}
|
||||
|
||||
/**
|
||||
* Recompute comparison rows for each stored guess against the current target.
|
||||
* Guesses that no longer resolve (e.g. champion removed from loldle.net) are
|
||||
* dropped silently — an edge case for stale rounds spanning a data refresh.
|
||||
*/
|
||||
function findByName(name) {
|
||||
return champions.find((c) => c.championName === name);
|
||||
}
|
||||
|
||||
/** Recompute comparison rows for each stored guess against the current target. */
|
||||
function rehydrateGuesses(game) {
|
||||
const target = champions.find((c) => c.championName === game.target);
|
||||
const target = findByName(game.target);
|
||||
if (!target) return [];
|
||||
const rows = [];
|
||||
for (const name of game.guesses) {
|
||||
const guess = champions.find((c) => c.championName === name);
|
||||
const guess = findByName(name);
|
||||
if (!guess) continue;
|
||||
rows.push({ champion: name, results: compareChampions(guess, target) });
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 && !isFinished(existing)) return existing;
|
||||
return startFreshGame(db, subject);
|
||||
}
|
||||
|
||||
async function startFreshGame(db, subject) {
|
||||
const target = pickRandom(champions);
|
||||
const fresh = {
|
||||
target: target.championName,
|
||||
guesses: [],
|
||||
solved: false,
|
||||
giveup: false,
|
||||
startedAt: Date.now(),
|
||||
};
|
||||
const target = pickRandomChampion();
|
||||
const fresh = { target: target.championName, guesses: [], startedAt: Date.now() };
|
||||
await saveGame(db, subject, fresh);
|
||||
return fresh;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a random sticker from the pool, swallowing errors so a rotten file_id
|
||||
* (Telegram rejection) never blocks the follow-up text reply.
|
||||
*
|
||||
* @param {import("grammy").Context} ctx
|
||||
* @param {readonly string[]} pool
|
||||
*/
|
||||
async function getOrInitGame(db, subject) {
|
||||
const existing = await loadGame(db, subject);
|
||||
if (existing && existing.guesses.length < MAX_GUESSES) return existing;
|
||||
return startFreshGame(db, subject);
|
||||
}
|
||||
|
||||
/** Send a sticker, swallowing errors so a bad file_id never blocks the reply. */
|
||||
async function trySendSticker(ctx, pool) {
|
||||
const sticker = pickSticker(pool);
|
||||
if (!sticker) return;
|
||||
try {
|
||||
await ctx.replyWithSticker(sticker);
|
||||
} catch {
|
||||
// Ignore — the outcome text reply is what matters. An invalid file_id
|
||||
// or transient Telegram error must not derail the game flow.
|
||||
// Invalid file_id or transient Telegram error — the outcome text is what matters.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,7 +110,7 @@ export async function handleLoldle(ctx, db) {
|
||||
);
|
||||
}
|
||||
|
||||
const target = champions.find((c) => c.championName === game.target);
|
||||
const target = findByName(game.target);
|
||||
// champions.json can be refreshed between rounds — an active target may disappear.
|
||||
if (!target) {
|
||||
await startFreshGame(db, subject);
|
||||
@@ -151,14 +118,13 @@ export async function handleLoldle(ctx, db) {
|
||||
"Champion data was updated since this round started. Starting a fresh round — try again.",
|
||||
);
|
||||
}
|
||||
|
||||
const results = compareChampions(guess, target);
|
||||
game.guesses.push(guess.championName);
|
||||
const won = guess.championName === target.championName;
|
||||
if (won) game.solved = true;
|
||||
await saveGame(db, subject, game);
|
||||
|
||||
const reply = renderGuess(guess.championName, results);
|
||||
const elapsed = formatDuration(Date.now() - (game.startedAt ?? Date.now()));
|
||||
const elapsed = formatDuration(Date.now() - game.startedAt);
|
||||
const champ = escapeHtml(target.championName);
|
||||
|
||||
if (won) {
|
||||
@@ -171,6 +137,7 @@ export async function handleLoldle(ctx, db) {
|
||||
{ parse_mode: "HTML" },
|
||||
);
|
||||
}
|
||||
|
||||
if (game.guesses.length >= MAX_GUESSES) {
|
||||
await recordResult(db, subject, false);
|
||||
await startFreshGame(db, subject);
|
||||
@@ -179,6 +146,8 @@ export async function handleLoldle(ctx, db) {
|
||||
parse_mode: "HTML",
|
||||
});
|
||||
}
|
||||
|
||||
await saveGame(db, subject, game);
|
||||
return ctx.reply(`${reply}\n\nGuess ${game.guesses.length}/${MAX_GUESSES}.`, {
|
||||
parse_mode: "HTML",
|
||||
});
|
||||
@@ -192,11 +161,8 @@ 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);
|
||||
// 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.championName === game.target);
|
||||
const target = findByName(game.target);
|
||||
await startFreshGame(db, subject);
|
||||
await trySendSticker(ctx, GIVEUP_STICKERS);
|
||||
const answer = target ? escapeHtml(target.championName) : escapeHtml(game.target);
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
/**
|
||||
* @file Loldle module — classic-mode champion guessing game.
|
||||
*
|
||||
* Ported from tiennm99/loldle (lib/classic-mode.js). Data sourced from
|
||||
* tiennm99/loldle-data's champions.json (synced via GH Actions).
|
||||
* Champion data is scraped weekly from loldle.net.
|
||||
*/
|
||||
|
||||
import { handleGiveup, handleLoldle, handleStats } from "./handlers.js";
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
/**
|
||||
* @file Champion name lookup — normalizes user input to a champion record.
|
||||
* Matches championName case/space/punct-insensitive. Falls back to prefix
|
||||
* match when unique.
|
||||
* @file Champion name lookup — resolve user input to a champion record.
|
||||
* Match is case/space/punctuation-insensitive with a unique-prefix fallback.
|
||||
*/
|
||||
|
||||
function normalize(s) {
|
||||
|
||||
@@ -1,13 +1,8 @@
|
||||
/**
|
||||
* @file Render comparison results as a monospace-aligned table.
|
||||
* @file Render comparison results as a monospace-aligned Telegram HTML 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
|
||||
* Markers: 🎯 guessed champion · ✅ correct · 🟨 partial · ❌ wrong
|
||||
* ⬆️ / ⬇️ year direction hint.
|
||||
*/
|
||||
|
||||
import { escapeHtml } from "../../util/escape-html.js";
|
||||
|
||||
+13
-21
@@ -1,34 +1,28 @@
|
||||
/**
|
||||
* @file Game state in KV, keyed by "subject" (user in DM, chat in groups).
|
||||
* @file Game + stats persistence in KV, keyed by "subject"
|
||||
* (user id in DMs, chat id in groups — so a group shares one round).
|
||||
*
|
||||
* One active round per subject at a time. Rounds are self-paced: players
|
||||
* can /loldle_giveup to reveal (a fresh round auto-starts). Streak = consecutive wins.
|
||||
* Key layout (inside the module-prefixed store):
|
||||
* game:<subject> -> { target, guesses, startedAt }
|
||||
* stats:<subject> -> { played, wins, streak, bestStreak }
|
||||
*
|
||||
* Key layout (inside module-prefixed store):
|
||||
* game:<subject> -> { target, guesses[championName...], solved, giveup, startedAt }
|
||||
* stats:<subject> -> { played, wins, streak, bestStreak, lastResultAt }
|
||||
*
|
||||
* Only championName strings are stored in `guesses` — comparison rows are
|
||||
* recomputed at render time from the live champions.json. This keeps payloads
|
||||
* tiny and avoids stale results if loldle.net data shifts mid-round.
|
||||
* `guesses` is a string[] of championNames — comparison rows are recomputed
|
||||
* at render time, so the board always reflects the live champions.json.
|
||||
*/
|
||||
|
||||
const MAX_GUESSES = 8;
|
||||
// 7 days — a round can't linger forever, but is far longer than typical play.
|
||||
// Upper bound for a round — long enough for any real session, short enough
|
||||
// that stale KV entries get reclaimed automatically.
|
||||
const GAME_TTL_SECONDS = 60 * 60 * 24 * 7;
|
||||
|
||||
/** @param {number|string} subject */
|
||||
const gameKey = (subject) => `game:${subject}`;
|
||||
/** @param {number|string} subject */
|
||||
const statsKey = (subject) => `stats:${subject}`;
|
||||
|
||||
/**
|
||||
* @typedef {object} GameState
|
||||
* @property {string} target — championName of the hidden champion
|
||||
* @property {string} target — hidden champion's championName
|
||||
* @property {string[]} guesses — championNames already tried this round
|
||||
* @property {boolean} solved
|
||||
* @property {boolean} [giveup]
|
||||
* @property {number} [startedAt] — epoch ms
|
||||
* @property {number} startedAt — epoch ms
|
||||
*/
|
||||
|
||||
/**
|
||||
@@ -62,14 +56,13 @@ export async function loadStats(db, subject) {
|
||||
wins: 0,
|
||||
streak: 0,
|
||||
bestStreak: 0,
|
||||
lastResultAt: null,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a finished round (win/loss/giveup) and update streaks.
|
||||
* Streak increments on each win, resets to 0 on any non-win.
|
||||
* Record a finished round and update the streak. Streak increments on each
|
||||
* win and resets to 0 on any non-win.
|
||||
*
|
||||
* @param {import("../../db/kv-store-interface.js").KVStore} db
|
||||
* @param {number|string} subject
|
||||
@@ -85,7 +78,6 @@ export async function recordResult(db, subject, won) {
|
||||
} else {
|
||||
s.streak = 0;
|
||||
}
|
||||
s.lastResultAt = Date.now();
|
||||
await db.putJSON(statsKey(subject), s);
|
||||
return s;
|
||||
}
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { pickDaily, pickRandom, todayUtc } from "../../../src/modules/loldle/daily.js";
|
||||
|
||||
describe("picker", () => {
|
||||
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("pickDaily throws on empty list", () => {
|
||||
expect(() => pickDaily([], "x")).toThrow();
|
||||
});
|
||||
|
||||
it("pickRandom honors injected rng", () => {
|
||||
const champions = [{ id: "A" }, { id: "B" }, { id: "C" }, { id: "D" }];
|
||||
expect(pickRandom(champions, () => 0).id).toBe("A");
|
||||
expect(pickRandom(champions, () => 0.999).id).toBe("D");
|
||||
expect(pickRandom(champions, () => 0.5).id).toBe("C");
|
||||
});
|
||||
|
||||
it("pickRandom throws on empty list", () => {
|
||||
expect(() => pickRandom([])).toThrow();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user