From 51d36272c7f19cd931da6c60ffd32f2c42cffc3a Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Wed, 22 Apr 2026 22:20:47 +0700 Subject: [PATCH] refactor(semantle): drop /semantle_new; reply on duplicate guesses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Giveup already auto-starts a fresh round on next /semantle, so /semantle_new was redundant. Duplicate guesses now match loldle's behavior: reply with "🔁 already guessed" and skip the similarity API call (fast-path dedup against prior word or canonical, with a post-API fallback for different inputs that canonicalize to the same token). --- src/modules/semantle/README.md | 5 +- src/modules/semantle/handlers.js | 47 ++++------ src/modules/semantle/index.js | 10 +-- tests/modules/semantle/handlers.test.js | 112 +++++++++--------------- 4 files changed, 62 insertions(+), 112 deletions(-) diff --git a/src/modules/semantle/README.md b/src/modules/semantle/README.md index b43a4f3..df875d8 100644 --- a/src/modules/semantle/README.md +++ b/src/modules/semantle/README.md @@ -10,12 +10,13 @@ scored by cosine similarity against the target. Unlimited guesses per round | Command | Visibility | Description | |---------|-----------|-------------| | `/semantle` | public | Show current board or submit a word guess | -| `/semantle_new` | public | Abandon current round and start a fresh one | -| `/semantle_giveup` | public | Reveal the answer and end the round | +| `/semantle_giveup` | public | Reveal the answer and end the round (next `/semantle` starts a fresh one) | | `/semantle_stats` | public | Show wins / best count / averages | Submit with `/semantle ` (e.g. `/semantle ocean`). Matching is case-insensitive. Out-of-vocabulary words don't count toward the guess tally. +Repeating a prior guess replies with a `🔁 already guessed` notice and is +ignored (no cost, no stat inflation). ## Data source diff --git a/src/modules/semantle/handlers.js b/src/modules/semantle/handlers.js index c318773..26f7a53 100644 --- a/src/modules/semantle/handlers.js +++ b/src/modules/semantle/handlers.js @@ -6,10 +6,9 @@ * group/supergroup chat → chat id (shared game — everyone plays together) * * Commands: - * /semantle → show the board (or start a round) + * /semantle → show the board (or lazy-start a round) * /semantle → submit a guess - * /semantle_new → abandon current round + start fresh - * /semantle_giveup → reveal target and end current round + * /semantle_giveup → reveal target and end current round (next /semantle starts fresh) * /semantle_stats → show per-subject stats */ @@ -85,6 +84,14 @@ async function submitGuess(ctx, { db, client }, subject, game, arg) { if (!isValidShape(guess)) { return ctx.reply("Please provide a single letter-only word."); } + // Fast-path duplicate check: normalized guess matches a prior raw input + // or a prior canonical form. Avoids a wasted API call on repeat submissions. + if (game.guesses.some((g) => g.word === guess || g.canonical === guess)) { + return ctx.reply( + `🔁 ${escapeHtml(guess)} was already guessed this round — try another word.`, + { parse_mode: "HTML" }, + ); + } let res; try { res = await client.similarity(game.target, guess); @@ -103,9 +110,14 @@ async function submitGuess(ctx, { db, client }, subject, game, arg) { canonical: String(res.canonical_b ?? guess).toLowerCase(), similarity: Number(res.similarity), }; - // Dedupe: re-submitting the same word shouldn't inflate the board or stats. - const isDuplicate = game.guesses.some((g) => g.canonical === entry.canonical); - if (!isDuplicate) game.guesses.push(entry); + // Post-API dedup: a different input can canonicalize to a prior canonical. + if (game.guesses.some((g) => g.canonical === entry.canonical)) { + return ctx.reply( + `🔁 ${escapeHtml(entry.canonical)} 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) { @@ -124,29 +136,6 @@ async function submitGuess(ctx, { db, client }, subject, game, arg) { return ctx.reply(body, { parse_mode: "HTML" }); } -export async function handleNew(ctx, { db, client }) { - const subject = getSubject(ctx); - if (subject == null) return ctx.reply("Cannot identify chat."); - const existing = await loadGame(db, subject); - if (existing && existing.guesses.length > 0 && !existing.solved) { - await recordResult(db, subject, { - solved: false, - guessCount: existing.guesses.length, - }); - } - // startFreshGame overwrites via saveGame; don't pre-clear or a failing /random - // would leave the subject with no game at all. - try { - await startFreshGame(db, client, subject); - } catch (err) { - logFail("random", err); - return ctx.reply(UPSTREAM_FAIL); - } - return ctx.reply("🆕 New round started — reply with /semantle <word>.", { - parse_mode: "HTML", - }); -} - export async function handleGiveup(ctx, { db }) { const subject = getSubject(ctx); if (subject == null) return ctx.reply("Cannot identify chat."); diff --git a/src/modules/semantle/index.js b/src/modules/semantle/index.js index 89564ef..0a9dbbc 100644 --- a/src/modules/semantle/index.js +++ b/src/modules/semantle/index.js @@ -7,7 +7,7 @@ */ import { createClient } from "./api-client.js"; -import { handleGiveup, handleNew, handleSemantle, handleStats } from "./handlers.js"; +import { handleGiveup, handleSemantle, handleStats } from "./handlers.js"; const DEFAULT_API_URL = "https://word2sim.sg.miti99.com"; @@ -31,16 +31,10 @@ const semantleModule = { description: "Semantle — guess the hidden word (unlimited tries)", handler: (ctx) => handleSemantle(ctx, { db, client }), }, - { - name: "semantle_new", - visibility: "public", - description: "Abandon the current semantle round and start a fresh one", - handler: (ctx) => handleNew(ctx, { db, client }), - }, { name: "semantle_giveup", visibility: "public", - description: "Reveal the current semantle answer", + description: "Reveal the current semantle answer (auto-starts a fresh round)", handler: (ctx) => handleGiveup(ctx, { db, client }), }, { diff --git a/tests/modules/semantle/handlers.test.js b/tests/modules/semantle/handlers.test.js index 7f6df8e..8802126 100644 --- a/tests/modules/semantle/handlers.test.js +++ b/tests/modules/semantle/handlers.test.js @@ -3,7 +3,6 @@ import { createStore } from "../../../src/db/create-store.js"; import { Word2SimError } from "../../../src/modules/semantle/api-client.js"; import { handleGiveup, - handleNew, handleSemantle, handleStats, } from "../../../src/modules/semantle/handlers.js"; @@ -191,7 +190,7 @@ describe("semantle/handlers", () => { expect(game.guesses.length).toBe(0); }); - it("deduplicates re-submitted words", async () => { + it("replies already-guessed and skips API on re-submit", async () => { client.randomWord.mockResolvedValue({ word: "apple", rank: 1000 }); client.similarity.mockResolvedValue({ a: "apple", @@ -204,14 +203,51 @@ describe("semantle/handlers", () => { const ctx1 = makeCtx(1, "private", "/semantle orange"); await handleSemantle(ctx1, { db, client }); + expect(client.similarity).toHaveBeenCalledTimes(1); const ctx2 = makeCtx(1, "private", "/semantle orange"); await handleSemantle(ctx2, { db, client }); + // Fast-path dedup: no second API call. + expect(client.similarity).toHaveBeenCalledTimes(1); + expect(ctx2.replies[0].text).toContain("already guessed"); + expect(ctx2.replies[0].text).toContain("🔁"); + + const { loadGame } = await import("../../../src/modules/semantle/state.js"); + const game = await loadGame(db, 1); + expect(game.guesses.length).toBe(1); + }); + + it("post-API dedup when canonical collides with a prior canonical", async () => { + client.randomWord.mockResolvedValue({ word: "apple", rank: 1000 }); + // First guess "running" → canonical "run" recorded. + client.similarity.mockResolvedValueOnce({ + a: "apple", + b: "running", + in_vocab_a: true, + in_vocab_b: true, + canonical_b: "run", + similarity: 0.3, + }); + // Second guess "runs" (different word) → also canonical "run" — should reject. + client.similarity.mockResolvedValueOnce({ + a: "apple", + b: "runs", + in_vocab_a: true, + in_vocab_b: true, + canonical_b: "run", + similarity: 0.3, + }); + + const ctx1 = makeCtx(1, "private", "/semantle running"); + await handleSemantle(ctx1, { db, client }); + const ctx2 = makeCtx(1, "private", "/semantle runs"); + await handleSemantle(ctx2, { db, client }); + + expect(ctx2.replies[0].text).toContain("already guessed"); const { loadGame } = await import("../../../src/modules/semantle/state.js"); const game = await loadGame(db, 1); expect(game.guesses.length).toBe(1); - expect(ctx2.replies[0].text).toContain("1 guess"); }); it("sets startedAt on first guess", async () => { @@ -322,76 +358,6 @@ describe("semantle/handlers", () => { }); }); - describe("handleNew", () => { - it("starts fresh game with no prior game", async () => { - client.randomWord.mockResolvedValue({ word: "apple", rank: 1000 }); - - const ctx = makeCtx(1, "private", "/semantle_new"); - await handleNew(ctx, { db, client }); - - expect(ctx.reply).toHaveBeenCalledOnce(); - expect(ctx.replies[0].text).toContain("🆕 New round started"); - }); - - it("abandons unsolved game and records non-solve", async () => { - client.randomWord.mockResolvedValue({ word: "apple", rank: 1000 }); - client.similarity.mockResolvedValue({ - a: "apple", - b: "orange", - in_vocab_a: true, - in_vocab_b: true, - canonical_b: "orange", - similarity: 0.45, - }); - - const ctx1 = makeCtx(1, "private", "/semantle orange"); - await handleSemantle(ctx1, { db, client }); - - client.randomWord.mockResolvedValueOnce({ word: "banana", rank: 1000 }); - const ctx2 = makeCtx(1, "private", "/semantle_new"); - await handleNew(ctx2, { db, client }); - - const { loadStats } = await import("../../../src/modules/semantle/state.js"); - const stats = await loadStats(db, 1); - expect(stats.played).toBe(1); - expect(stats.solved).toBe(0); - expect(stats.totalGuesses).toBe(1); - }); - - it("does not record result if game had zero guesses", async () => { - client.randomWord.mockResolvedValue({ word: "apple", rank: 1000 }); - - const ctx1 = makeCtx(1, "private", "/semantle"); - await handleSemantle(ctx1, { db, client }); - - client.randomWord.mockResolvedValueOnce({ word: "banana", rank: 1000 }); - const ctx2 = makeCtx(1, "private", "/semantle_new"); - await handleNew(ctx2, { db, client }); - - const { loadStats } = await import("../../../src/modules/semantle/state.js"); - const stats = await loadStats(db, 1); - expect(stats.played).toBe(0); - }); - - it("replies UPSTREAM_FAIL on randomWord error", async () => { - client.randomWord.mockRejectedValue(new Word2SimError("timeout")); - - const ctx = makeCtx(1, "private", "/semantle_new"); - await handleNew(ctx, { db, client }); - - expect(ctx.replies[0].text).toContain("⚠️ Upstream hiccup"); - }); - - it("handles group chat", async () => { - client.randomWord.mockResolvedValue({ word: "apple", rank: 1000 }); - - const ctx = makeCtx(-123456, "group", "/semantle_new"); - await handleNew(ctx, { db, client }); - - expect(ctx.reply).toHaveBeenCalledOnce(); - }); - }); - describe("handleGiveup", () => { it("reveals target and clears game", async () => { client.randomWord.mockResolvedValue({ word: "apple", rank: 1000 });