feat(doantu): add Vietnamese semantle module (protected visibility)

Near-clone of the semantle module, adapted for Vietnamese:
- Targets from duyet/vietnamese-wordlist Viet22K (~22k entries, GPL).
  Regenerate via scripts/build-doantu-words.js; chained into npm run build.
- ConceptNet client uses /c/vi/<term> URIs; multi-word guesses (e.g.
  "con chó") are space-to-underscore converted at URL build time so the
  board keeps the natural display.
- lookup.js permits Unicode letters + combining marks + single internal
  spaces; rejects digits/punctuation.
- All three commands (/doantu, /doantu_giveup, /doantu_stats) are
  visibility=protected — shown in /help, hidden from Telegram's native /
  autocomplete menu while the module is still experimental.

Wired into src/modules/index.js, wrangler.toml MODULES, .env.deploy(.example),
and package.json build chain.

Separate module rather than a shared base with semantle — matches the
repo's one-module-per-game convention (see loldle vs wordle); factor later
if a third language appears.
This commit is contained in:
tiennm99 committed 2026-04-22 23:29:36 +07:00
1 parent 9305f7a683
commit 2649c139db
15 files changed
+22839 -3

No files matched your search

+1 -1
View File
@@ -12,4 +12,4 @@ WORKER_URL=
# Same MODULES value as wrangler.toml [vars]. Duplicated here so the register
# script can derive the public command list without parsing wrangler.toml.
MODULES=util,wordle,loldle,misc,trading,lolschedule,semantle
MODULES=util,wordle,loldle,misc,trading,lolschedule,semantle,doantu
+2 -1
View File
@@ -9,9 +9,10 @@
},
"scripts": {
"dev": "wrangler dev",
"build": "npm run build:wordle-data && npm run build:semantle-words",
"build": "npm run build:wordle-data && npm run build:semantle-words && npm run build:doantu-words",
"build:wordle-data": "node scripts/build-wordle-data.js",
"build:semantle-words": "node scripts/build-semantle-words.js",
"build:doantu-words": "node scripts/build-doantu-words.js",
"scrape:loldle-data": "node scripts/scrape-loldle-data.js",
"deploy": "npm run build && wrangler deploy && npm run db:migrate && npm run register",
"db:migrate": "node scripts/migrate.js",
+62
View File
@@ -0,0 +1,62 @@
#!/usr/bin/env node
/**
* @file build-doantu-words — fetches the middle-sized Vietnamese wordlist
* (Viet22K) from duyet/vietnamese-wordlist and writes it to
* src/modules/doantu/words-data.js as a static ES-module array.
*
* Source is an alphabetically-sorted Unicode dictionary, one word or phrase
* per line. We normalize only (trim, lowercase, dedupe); NO length/char
* filtering — ConceptNet's verify-and-fallback in api-client handles
* unusable picks at round start.
*
* Source: https://github.com/duyet/vietnamese-wordlist
* Credits: Ho Ngoc Duc — Vietnamese word list (GPL).
*
* Usage:
* node scripts/build-doantu-words.js
*/
import { writeFileSync } from "node:fs";
import { resolve } from "node:path";
// Size options from the upstream repo: Viet11K / Viet22K / Viet39K / Viet74K.
// Viet22K is a good balance — enough variety, not overwhelmed by archaic terms.
const SOURCE_URL = "https://raw.githubusercontent.com/duyet/vietnamese-wordlist/master/Viet22K.txt";
const root = resolve(import.meta.dirname, "..");
const dst = resolve(root, "src/modules/doantu/words-data.js");
const res = await fetch(SOURCE_URL);
if (!res.ok) throw new Error(`fetch failed: ${res.status} ${res.statusText}`);
const text = await res.text();
// Normalize only: trim, lowercase, drop blanks, dedupe. Preserve source order.
// Multi-word entries keep their spaces — the api-client converts to underscore
// only at URL-build time, so the board still displays them naturally.
const words = Array.from(
new Set(
text
.split(/\r?\n/)
.map((w) => w.trim().toLowerCase())
.filter((w) => w.length > 0),
),
);
if (words.length === 0) throw new Error("no words parsed from source");
// JSON.stringify each word — safer than manual quoting for Vietnamese
// diacritics and the occasional character that needs escaping.
const body = words.map((w) => ` ${JSON.stringify(w)},`).join("\n");
const out = [
"// Auto-generated from https://github.com/duyet/vietnamese-wordlist (Viet22K.txt)",
"// Credits: Ho Ngoc Duc — Vietnamese word list (GPL).",
"// Normalized (lowercased + deduped) but otherwise unfiltered.",
"// Regenerate with: node scripts/build-doantu-words.js",
"export default [",
body,
"];",
"",
].join("\n");
writeFileSync(dst, out);
console.log(`wrote ${dst} (${words.length} words)`);
+74
View File
@@ -0,0 +1,74 @@
# Doantu Module
Vietnamese "đoán từ" (guess-the-word) — same core mechanic as `semantle`,
but targets come from a Vietnamese wordlist and similarity is computed
against ConceptNet's `/c/vi/<term>` concept URIs. Unlimited guesses per
round; solve on exact match.
**Visibility: `protected`** — commands appear in `/help` but are hidden
from Telegram's native `/` autocomplete menu while the module is still
experimental.
## Commands
| Command | Visibility | Description |
|---------|-----------|-------------|
| `/doantu` | protected | Show current board or submit a word guess |
| `/doantu_giveup` | protected | Reveal the answer and end the round (next `/doantu` starts a fresh one) |
| `/doantu_stats` | protected | Show per-subject stats |
Submit with `/doantu <word>` (e.g. `/doantu con chó`). Multi-syllable words
with single spaces between them are accepted. Matching is case-insensitive
and diacritic-sensitive — `cá` and `ca` are different targets.
## Data source
**Target pool:** [duyet/vietnamese-wordlist](https://github.com/duyet/vietnamese-wordlist)'s
Viet22K list (~22k entries), sorted alphabetically. The raw list is
normalized (lowercase + deduped) but otherwise used verbatim — ConceptNet's
verify-and-fallback at round start rejects any pick that has no concept
edges. License: GPL-2.0 (Ho Ngoc Duc).
**Similarity + vocabulary:** [ConceptNet 5](https://conceptnet.io).
Multi-word Vietnamese terms are converted to underscore form (`con chó`
→ `/c/vi/con_chó`) only when building URIs — the board keeps the
space-separated display.
## Architecture
- `api-client.js` — ConceptNet wrapper (`randomWord`, `similarity`, and
lower-level `concept` / `relatedness`). Hardcoded `LANG = "vi"`.
- `wordlist.js` — three-function API (`LINE_COUNT`, `randomLine`, `getLine`)
over `words-data.js`.
- `words-data.js` — auto-generated (regenerate via `npm run build:doantu-words`).
- `state.js` — KV persistence for game + stats. Same shape as semantle.
- `lookup.js` — guess normalization + shape validation. Accepts Unicode
letters + combining marks + single internal spaces.
- `format.js` — warmth-percent and emoji-bucket formatters (unchanged).
- `render.js` — Telegram HTML `<pre>` monospace board with a 🇻🇳 header.
- `handlers.js` — subject resolution + the three command entry points.
Near-clone of the semantle sibling — kept separate per the repo's
existing one-module-per-game convention rather than factoring out a
shared base. Diff your changes against `../semantle/` when fixing bugs
that apply to both.
## Storage
KV namespace prefix: `doantu:`
| Key | Value |
|-----|-------|
| `game:<subject>` | `{ target, startedAt, solved, guesses[] }` — active round (TTL 7 days). |
| `stats:<subject>` | `{ played, solved, totalGuesses, bestGuessCount, lastResultAt }` |
## Config
No env vars. ConceptNet base (`https://api.conceptnet.io`) is hardcoded;
pass an override to `createClient(url)` if you need a mirror or test double.
## Credits
- Wordlist: [duyet/vietnamese-wordlist](https://github.com/duyet/vietnamese-wordlist) by Ho Ngoc Duc (GPL-2.0).
- Similarity + vocabulary: [ConceptNet 5](https://conceptnet.io) by Robyn Speer et al.
- Game concept: [Semantle](https://semantle.com/) by David Turner.
+144
View File
@@ -0,0 +1,144 @@
/**
* @file ConceptNet API client for the doantu module (Vietnamese).
*
* Mirrors semantle/api-client.js one-for-one — same endpoints, same
* response shape — with two Vietnamese-specific changes:
* 1. Concept URIs use `/c/vi/…` instead of `/c/en/…`.
* 2. Multi-word terms are joined with an underscore for URL building
* (`con chó` → `/c/vi/con_chó`), so the board can still display the
* space-separated form while ConceptNet resolves the canonical URI.
*/
import { randomLine } from "./wordlist.js";
const DEFAULT_API_BASE = "https://api.conceptnet.io";
const DEFAULT_TIMEOUT_MS = 5000;
const USER_AGENT = "miti99bot/doantu";
const MAX_RANDOM_ATTEMPTS = 5;
const LANG = "vi";
export class UpstreamError extends Error {
/** @param {string} message @param {{status?: number, body?: string, cause?: unknown}} [meta] */
constructor(message, meta = {}) {
super(message);
this.name = "UpstreamError";
this.status = meta.status;
this.body = meta.body;
if (meta.cause !== undefined) this.cause = meta.cause;
}
}
/** `con chó` → `con_chó` — ConceptNet's concept-URI convention for phrases. */
function toConceptTerm(word) {
return String(word).trim().replace(/\s+/g, "_");
}
function buildUrl(base, path, params = {}) {
const normalized = String(base).replace(/\/+$/, "");
const url = new URL(`${normalized}${path}`);
for (const [k, v] of Object.entries(params)) {
if (v === undefined || v === null) continue;
url.searchParams.set(k, String(v));
}
return url.toString();
}
async function fetchJson(url, timeoutMs) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
let res;
try {
res = await fetch(url, {
headers: { "User-Agent": USER_AGENT, Accept: "application/json" },
signal: controller.signal,
});
} catch (err) {
clearTimeout(timer);
throw new UpstreamError("conceptnet fetch failed", { cause: err });
}
clearTimeout(timer);
const text = await res.text();
if (!res.ok) {
throw new UpstreamError(`conceptnet HTTP ${res.status}`, {
status: res.status,
body: text.slice(0, 500),
});
}
try {
return JSON.parse(text);
} catch (err) {
throw new UpstreamError("conceptnet non-JSON response", { cause: err });
}
}
function hasEdges(concept) {
return Array.isArray(concept?.edges) && concept.edges.length > 0;
}
/**
* @param {string} [apiBase] — override for mirrors/tests.
* @param {{ timeoutMs?: number }} [opts]
*/
export function createClient(apiBase = DEFAULT_API_BASE, { timeoutMs = DEFAULT_TIMEOUT_MS } = {}) {
/** @param {string} term */
function concept(term) {
const cn = toConceptTerm(term);
return fetchJson(buildUrl(apiBase, `/c/${LANG}/${encodeURIComponent(cn)}`), timeoutMs);
}
/** @param {string} a @param {string} b */
function relatedness(a, b) {
return fetchJson(
buildUrl(apiBase, "/relatedness", {
node1: `/c/${LANG}/${toConceptTerm(a)}`,
node2: `/c/${LANG}/${toConceptTerm(b)}`,
}),
timeoutMs,
);
}
return {
concept,
relatedness,
/**
* Pick a target word from the local pool, verify via the concept endpoint,
* fall back to an unverified pick after a few misses.
* @returns {Promise<{ word: string, verified: boolean }>}
*/
async randomWord() {
for (let i = 0; i < MAX_RANDOM_ATTEMPTS; i++) {
const candidate = randomLine();
try {
const c = await concept(candidate);
if (hasEdges(c)) return { word: candidate, verified: true };
} catch {
// swallow — try the next candidate
}
}
return { word: randomLine(), verified: false };
},
/**
* Cosine-like similarity. Runs concept edge-check for `b` in parallel
* with the relatedness call so OOV guesses are caught on the same round-trip.
* Shape mirrors the semantle sibling.
* @param {string} a
* @param {string} b
*/
async similarity(a, b) {
const [conceptB, rel] = await Promise.all([concept(b), relatedness(a, b)]);
const inVocabB = hasEdges(conceptB);
const value = typeof rel?.value === "number" ? rel.value : null;
return {
a,
b,
canonical_a: a,
canonical_b: b,
in_vocab_a: true,
in_vocab_b: inVocabB,
similarity: inVocabB ? value : null,
};
},
};
}
+20
View File
@@ -0,0 +1,20 @@
/**
* @file Display formatting helpers for similarity scores.
* Identical to semantle/format.js — score display is language-agnostic.
*/
/** @param {number} similarity */
export function formatWarmth(similarity) {
const pct = Math.round(similarity * 100);
const sign = pct >= 0 ? "+" : "-";
return `${sign}${String(Math.abs(pct)).padStart(2, "0")}`;
}
/** @param {number} similarity */
export function warmthEmoji(similarity) {
if (similarity >= 0.8) return "🎯";
if (similarity >= 0.6) return "🔥";
if (similarity >= 0.4) return "🌡️";
if (similarity >= 0.2) return "😐";
return "🥶";
}
+166
View File
@@ -0,0 +1,166 @@
/**
* @file Command handlers for the doantu module (Vietnamese semantle).
*
* Subject resolution mirrors loldle/semantle:
* private chat → user id (per-user game)
* group/supergroup chat → chat id (shared game)
*
* Commands (all protected — listed in /help but not pushed to the Telegram
* native / menu):
* /doantu → show the board (or lazy-start a round)
* /doantu <word> → submit a guess
* /doantu_giveup → reveal target (next /doantu auto-starts fresh)
* /doantu_stats → show per-subject stats
*/
import { escapeHtml } from "../../util/escape-html.js";
import { UpstreamError } from "./api-client.js";
import { isValidShape, normalize } from "./lookup.js";
import { renderBoard, renderGuess } from "./render.js";
import { clearGame, loadGame, loadStats, recordResult, saveGame } from "./state.js";
const UPSTREAM_FAIL = "⚠️ Upstream hiccup — try again in a few seconds.";
function getSubject(ctx) {
const type = ctx.chat?.type;
if (type === "group" || type === "supergroup") return ctx.chat.id;
return ctx.from?.id ?? null;
}
function argAfterCommand(text) {
if (!text) return "";
const idx = text.indexOf(" ");
return idx === -1 ? "" : text.slice(idx + 1).trim();
}
function logFail(stage, err) {
console.log(
JSON.stringify({
msg: "doantu_upstream_fail",
stage,
err: err instanceof UpstreamError ? { status: err.status, body: err.body } : String(err),
}),
);
}
async function startFreshGame(db, client, subject) {
const picked = await client.randomWord();
const target = String(picked?.word ?? "").toLowerCase();
if (!target) throw new UpstreamError("empty target from randomWord");
const fresh = { target, startedAt: null, solved: false, guesses: [] };
await saveGame(db, subject, fresh);
return fresh;
}
async function getOrInitGame(db, client, subject) {
const existing = await loadGame(db, subject);
if (existing && !existing.solved) return existing;
return startFreshGame(db, client, subject);
}
export async function handleDoantu(ctx, { db, client }) {
const subject = getSubject(ctx);
if (subject == null) return ctx.reply("Cannot identify chat.");
const arg = argAfterCommand(ctx.message?.text ?? "");
let game;
try {
game = await getOrInitGame(db, client, subject);
} catch (err) {
logFail("random", err);
return ctx.reply(UPSTREAM_FAIL);
}
if (!arg) return ctx.reply(renderBoard(game.guesses), { parse_mode: "HTML" });
return submitGuess(ctx, { db, client }, subject, game, arg);
}
async function submitGuess(ctx, { db, client }, subject, game, arg) {
const guess = normalize(arg);
if (!isValidShape(guess)) {
return ctx.reply("Please provide a Vietnamese word (letters + optional single spaces).");
}
// Fast-path dedup: exact text OR prior canonical. Avoids wasted API calls.
if (game.guesses.some((g) => g.word === guess || g.canonical === guess)) {
return ctx.reply(
`🔁 <b>${escapeHtml(guess)}</b> was already guessed this round — try another word.`,
{ parse_mode: "HTML" },
);
}
let res;
try {
res = await client.similarity(game.target, guess);
} catch (err) {
logFail("similarity", err);
return ctx.reply(UPSTREAM_FAIL);
}
if (!res?.in_vocab_b || res.similarity == null) {
return ctx.reply(`🤔 <code>${escapeHtml(guess)}</code> isn't in the vocabulary.`, {
parse_mode: "HTML",
});
}
const entry = {
word: guess,
canonical: String(res.canonical_b ?? guess).toLowerCase(),
similarity: Number(res.similarity),
};
if (game.guesses.some((g) => g.canonical === entry.canonical)) {
return ctx.reply(
`🔁 <b>${escapeHtml(entry.canonical)}</b> was already guessed this round — try another word.`,
{ parse_mode: "HTML" },
);
}
game.guesses.push(entry);
if (game.startedAt === null) game.startedAt = Date.now();
if (entry.canonical === game.target) {
game.solved = true;
const count = game.guesses.length;
await recordResult(db, subject, { solved: true, guessCount: count });
await clearGame(db, subject);
const board = renderBoard(game.guesses, entry.canonical);
return ctx.reply(`${board}\n✅ Solved in ${count} guess${count === 1 ? "" : "es"}!`, {
parse_mode: "HTML",
});
}
await saveGame(db, subject, game);
const body = `${renderGuess(entry)}\n${renderBoard(game.guesses, entry.canonical)}`;
return ctx.reply(body, { parse_mode: "HTML" });
}
export async function handleGiveup(ctx, { db }) {
const subject = getSubject(ctx);
if (subject == null) return ctx.reply("Cannot identify chat.");
const game = await loadGame(db, subject);
if (!game) {
return ctx.reply("No active round. Send <code>/doantu</code> to start one.", {
parse_mode: "HTML",
});
}
await recordResult(db, subject, { solved: false, guessCount: game.guesses.length });
await clearGame(db, subject);
return ctx.reply(
`🏳️ The target was <b>${escapeHtml(game.target)}</b>. Send <code>/doantu</code> for a new round.`,
{ parse_mode: "HTML" },
);
}
export async function handleStats(ctx, { db }) {
const subject = getSubject(ctx);
if (subject == null) return ctx.reply("Cannot identify chat.");
const s = await loadStats(db, subject);
if (s.played === 0) return ctx.reply("No doantu games played yet.");
const solveRate = Math.round((s.solved / s.played) * 100);
const avgPerRound = s.played > 0 ? Math.round(s.totalGuesses / s.played) : "—";
return ctx.reply(
[
"🇻🇳 <b>Đoán từ stats</b>",
`Played: ${s.played}`,
`Solved: ${s.solved} (${solveRate}%)`,
`Total guesses: ${s.totalGuesses}`,
`Fewest to solve: ${s.bestGuessCount ?? "—"}`,
`Avg per round: ${avgPerRound}`,
].join("\n"),
{ parse_mode: "HTML" },
);
}
+47
View File
@@ -0,0 +1,47 @@
/**
* @file Doantu module — Vietnamese semantle.
*
* Targets from a curated local wordlist (duyet/vietnamese-wordlist Viet22K);
* similarity scores from api.conceptnet.io's `/relatedness` endpoint against
* `/c/vi/<term>` concept URIs. All commands are `protected` — listed in
* /help but hidden from Telegram's native / autocomplete menu.
*/
import { createClient } from "./api-client.js";
import { handleDoantu, handleGiveup, handleStats } from "./handlers.js";
/** @type {import("../../db/kv-store-interface.js").KVStore | null} */
let db = null;
/** @type {ReturnType<typeof createClient> | null} */
let client = null;
/** @type {import("../registry.js").BotModule} */
const doantuModule = {
name: "doantu",
init: async ({ db: store }) => {
db = store;
client = createClient();
},
commands: [
{
name: "doantu",
visibility: "protected",
description: "Đoán từ — Vietnamese semantic word guessing (unlimited tries)",
handler: (ctx) => handleDoantu(ctx, { db, client }),
},
{
name: "doantu_giveup",
visibility: "protected",
description: "Reveal the current doantu answer (auto-starts a fresh round)",
handler: (ctx) => handleGiveup(ctx, { db, client }),
},
{
name: "doantu_stats",
visibility: "protected",
description: "Show your doantu stats",
handler: (ctx) => handleStats(ctx, { db, client }),
},
],
};
export default doantuModule;
+22
View File
@@ -0,0 +1,22 @@
/**
* @file Guess normalization + shape validation (Vietnamese).
*
* Allows Unicode letters (including diacritics via combining marks) plus
* single spaces between syllables for compound words (`con chó`,
* `máy bay`). Rejects digits, punctuation, and underscores so the board
* stays clean; the api-client handles the space→underscore conversion
* internally when building ConceptNet URIs.
*/
/** @param {string} raw */
export function normalize(raw) {
if (typeof raw !== "string") return "";
return raw.trim().replace(/\s+/g, " ").toLowerCase();
}
/** @param {string} word — must already be normalized */
export function isValidShape(word) {
if (!word) return false;
if (word.length > 64) return false;
return /^[\p{L}\p{M}]+(?: [\p{L}\p{M}]+)*$/u.test(word);
}
+44
View File
@@ -0,0 +1,44 @@
/**
* @file Render a doantu board as a Telegram HTML monospace block.
* Mirrors semantle/render.js; header text is the only visible change.
*/
import { escapeHtml } from "../../util/escape-html.js";
import { formatWarmth, warmthEmoji } from "./format.js";
const MAX_ROWS = 15;
const LATEST_MARKER = "➡️";
const PLAIN_MARKER = " ";
/** @typedef {import("./state.js").DoantuGuess} DoantuGuess */
/**
* @param {DoantuGuess[]} guesses
* @param {string|null} [latestCanonical]
*/
export function renderBoard(guesses, latestCanonical = null) {
const count = guesses.length;
const header = `🇻🇳 Đoán từ — ${count} guess${count === 1 ? "" : "es"}`;
if (count === 0) {
return `${header}\n🆕 Round ready — reply with <code>/doantu &lt;word&gt;</code>.`;
}
const sorted = [...guesses].sort((a, b) => b.similarity - a.similarity).slice(0, MAX_ROWS);
const wordWidth = Math.min(20, Math.max(...sorted.map((g) => g.canonical.length)));
const rows = sorted.map((g, i) => {
const marker = g.canonical === latestCanonical ? LATEST_MARKER : PLAIN_MARKER;
const rank = String(i + 1).padStart(2);
const warmth = formatWarmth(g.similarity).padStart(3);
const word = escapeHtml(g.canonical.padEnd(wordWidth));
return `${marker} ${rank} ${warmth} ${word} ${warmthEmoji(g.similarity)}`;
});
const hidden = count - sorted.length;
const footer = hidden > 0 ? `\n…${hidden} older guess${hidden === 1 ? "" : "es"} hidden.` : "";
return `${header}\n<pre>${rows.join("\n")}</pre>${footer}`;
}
/** @param {DoantuGuess} guess */
export function renderGuess(guess) {
return `<code>${escapeHtml(guess.canonical)}</code> → ${formatWarmth(guess.similarity)} ${warmthEmoji(guess.similarity)}`;
}
+94
View File
@@ -0,0 +1,94 @@
/**
* @file Game + stats persistence in KV, keyed by "subject"
* (user id in DMs, chat id in groups). Target stored lowercased;
* guesses are append-only until solve, giveup, or fresh-start.
*
* Key layout (inside the module-prefixed store):
* game:<subject> -> { target, startedAt, solved, guesses[] }
* stats:<subject> -> { played, solved, totalGuesses, bestGuessCount, lastResultAt }
*/
const GAME_TTL_SECONDS = 60 * 60 * 24 * 7;
const gameKey = (subject) => `game:${subject}`;
const statsKey = (subject) => `stats:${subject}`;
/**
* @typedef {object} DoantuGuess
* @property {string} word — raw user input (normalized)
* @property {string} canonical — canonical form, lowercased
* @property {number} similarity
*/
/**
* @typedef {object} DoantuGameState
* @property {string} target
* @property {number|null} startedAt
* @property {boolean} solved
* @property {DoantuGuess[]} guesses
*/
/**
* @param {import("../../db/kv-store-interface.js").KVStore} db
* @param {number|string} subject
* @returns {Promise<DoantuGameState|null>}
*/
export async function loadGame(db, subject) {
return db.getJSON(gameKey(subject));
}
/**
* @param {import("../../db/kv-store-interface.js").KVStore} db
* @param {number|string} subject
* @param {DoantuGameState} state
*/
export async function saveGame(db, subject, state) {
await db.putJSON(gameKey(subject), state, { expirationTtl: GAME_TTL_SECONDS });
}
/**
* @param {import("../../db/kv-store-interface.js").KVStore} db
* @param {number|string} subject
*/
export async function clearGame(db, subject) {
await db.delete(gameKey(subject));
}
/**
* @param {import("../../db/kv-store-interface.js").KVStore} db
* @param {number|string} subject
*/
export async function loadStats(db, subject) {
return (
(await db.getJSON(statsKey(subject))) ?? {
played: 0,
solved: 0,
totalGuesses: 0,
bestGuessCount: null,
lastResultAt: null,
}
);
}
/**
* Record a finished round. `guessCount` counts scored guesses only —
* OOV rejections never reach the state and don't increment.
*
* @param {import("../../db/kv-store-interface.js").KVStore} db
* @param {number|string} subject
* @param {{ solved: boolean, guessCount: number }} outcome
*/
export async function recordResult(db, subject, { solved, guessCount }) {
const s = await loadStats(db, subject);
s.played += 1;
s.totalGuesses += guessCount;
if (solved) {
s.solved += 1;
if (s.bestGuessCount === null || guessCount < s.bestGuessCount) {
s.bestGuessCount = guessCount;
}
}
s.lastResultAt = Date.now();
await db.putJSON(statsKey(subject), s);
return s;
}
+26
View File
@@ -0,0 +1,26 @@
/**
* @file Target-word pool for the doantu module (Vietnamese).
*
* Data lives in the sibling `words-data.js` (auto-generated by
* `scripts/build-doantu-words.js` from duyet/vietnamese-wordlist's
* Viet22K list, lowercased + deduped).
*
* API mirrors wordle/loldle and semantle's english sibling:
* `LINE_COUNT` — total entries
* `randomLine()` — uniform random pick
* `getLine(n)` — read the nth entry (alphabetical rank in the source list)
*/
import LINES from "./words-data.js";
export const LINE_COUNT = LINES.length;
/** @param {number} n @returns {string | undefined} */
export function getLine(n) {
return LINES[n];
}
/** @returns {string} */
export function randomLine() {
return LINES[Math.floor(Math.random() * LINE_COUNT)];
}
File diff suppressed because it is too large. Load diff
+1
View File
@@ -16,4 +16,5 @@ export const moduleRegistry = {
trading: () => import("./trading/index.js"),
lolschedule: () => import("./lolschedule/index.js"),
semantle: () => import("./semantle/index.js"),
doantu: () => import("./doantu/index.js"),
};
+1 -1
View File
@@ -5,7 +5,7 @@ compatibility_date = "2025-10-01"
# Enabled modules at runtime. Comma-separated. Must match static-map keys in src/modules/index.js.
# Also duplicate this value into .env.deploy so scripts/register.js derives the same public command list.
[vars]
MODULES = "util,wordle,loldle,misc,trading,lolschedule,semantle"
MODULES = "util,wordle,loldle,misc,trading,lolschedule,semantle,doantu"
# KV namespace holding all module state. Each module auto-prefixes its keys via createStore().
# Production-only — no preview namespace. Create with: