refactor(semantle): drop /semantle_new; reply on duplicate guesses

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).
This commit is contained in:
2026-04-22 22:20:47 +07:00
parent 08ff72985a
commit 51d36272c7
4 changed files with 62 additions and 112 deletions
+3 -2
View File
@@ -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 <word>` (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
+18 -29
View File
@@ -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 <word> → submit a guess
* /semantle_new abandon current round + start fresh
* /semantle_giveup → reveal target and end current round
* /semantle_giveupreveal 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(
`🔁 <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);
@@ -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(
`🔁 <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) {
@@ -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 <code>/semantle &lt;word&gt;</code>.", {
parse_mode: "HTML",
});
}
export async function handleGiveup(ctx, { db }) {
const subject = getSubject(ctx);
if (subject == null) return ctx.reply("Cannot identify chat.");
+2 -8
View File
@@ -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 }),
},
{
+39 -73
View File
@@ -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 });