mirror of
https://github.com/tiennm99/miti99bot.git
synced 2026-09-10 04:20:01 +00:00
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:
@@ -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
|
||||
|
||||
|
||||
@@ -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_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(
|
||||
`🔁 <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 <word></code>.", {
|
||||
parse_mode: "HTML",
|
||||
});
|
||||
}
|
||||
|
||||
export async function handleGiveup(ctx, { db }) {
|
||||
const subject = getSubject(ctx);
|
||||
if (subject == null) return ctx.reply("Cannot identify chat.");
|
||||
|
||||
@@ -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 }),
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user