mirror of
https://github.com/tiennm99/tiennm99bot.git
synced 2026-10-11 12:28:54 +00:00
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:
1 parent
9305f7a683
commit
2649c139db
15 files changed
+22839
-3
No files matched your search
+1
-1
@@ -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
@@ -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",
|
||||
|
||||
@@ -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)`);
|
||||
@@ -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.
|
||||
@@ -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,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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 "🥶";
|
||||
}
|
||||
@@ -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" },
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 <word></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)}`;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user