From 1412166ad7bd362f83311e617e8a631aa8d6817d Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Fri, 24 Apr 2026 16:26:55 +0700 Subject: [PATCH] feat(twentyq): LLM-generated category + initial hint from bare keyword seeds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously seeds carried hand-curated {category, target, initialHint}. Now SEEDS is a flat string[] of keywords — at round-start, the model generates {category, initialHint} on the fly. Benefits: - adding a seed is trivial (just append a word) - every round gets a fresh cryptic opener (varies across plays of the same word) - HINT STYLE rules apply to the opening hint too, so the initial clue isn't a definitional giveaway Implementation: - prompts.buildStartRoundPrompt(target) — with good/bad examples - ai-client.generateRoundStart(env, target) — same JSON-in-content approach as judge(), with defensive fallbacks + redactSecret - handlers.startFreshGame now async; surfaces roundstart errors via the existing UPSTREAM_FAIL path Tests: 449 pass (5 new for generateRoundStart, 1 for roundstart error path). --- src/modules/twentyq/README.md | 19 ++- src/modules/twentyq/ai-client.js | 60 ++++++- src/modules/twentyq/handlers.js | 20 ++- src/modules/twentyq/prompts.js | 40 +++++ src/modules/twentyq/seeds.js | 212 ++++++++---------------- tests/fakes/fake-ai.js | 10 ++ tests/modules/twentyq/ai-client.test.js | 47 +++++- tests/modules/twentyq/handlers.test.js | 16 +- tests/modules/twentyq/seeds.test.js | 25 +-- 9 files changed, 266 insertions(+), 183 deletions(-) diff --git a/src/modules/twentyq/README.md b/src/modules/twentyq/README.md index 8dabec3..abf2b16 100644 --- a/src/modules/twentyq/README.md +++ b/src/modules/twentyq/README.md @@ -45,17 +45,18 @@ Hint: it uses wind through pipes to create sound. ## Data source -- **Secret pool:** `seeds.js` — ~60 hand-curated objects across 6 categories - (instrument, animal, food, vehicle, sport, household). Each entry ships with - a non-revealing initial hint. -- **Yes/no judging + hint generation:** Workers AI binding `env.AI` calling - `@cf/google/gemma-4-26b-a4b-it` with traditional function calling. The model - receives the secret + recent history each turn and emits a single - `submit_answer` tool call. +- **Secret pool:** `seeds.js` — flat array of keyword strings (~50+ nouns). + No hand-curated categories or hints — drop in any noun, the LLM figures out + the right category and produces a cryptic opening clue at round-start. +- **Round-start + per-turn responses:** Workers AI binding `env.AI` calling + `@cf/google/gemma-4-26b-a4b-it`. Prompts instruct the model to emit one-line + JSON that `parseJudgementJson` extracts even if wrapped in prose/fences. + - Round start: `generateRoundStart(env, target)` → `{category, initialHint}` + - Each turn: `judge(env, state, userInput)` → `{is_guess, answer, hint}` The Gemma 4 26B A4B model runs on the Workers Free plan (10k Neurons/day). -Pricing: $0.10/M input + $0.30/M output. Each turn is ~250 input + ~50 output -tokens, well under the cap for normal play volume. +Pricing: $0.10/M input + $0.30/M output. Each round is one start call (~1 +AI request) + one request per turn, well under the cap for normal play volume. ## Architecture diff --git a/src/modules/twentyq/ai-client.js b/src/modules/twentyq/ai-client.js index 66f4998..47df01d 100644 --- a/src/modules/twentyq/ai-client.js +++ b/src/modules/twentyq/ai-client.js @@ -11,7 +11,7 @@ * Rich logging on failure paths so wrangler tail surfaces the actual cause. */ -import { buildSystemPrompt } from "./prompts.js"; +import { buildStartRoundPrompt, buildSystemPrompt } from "./prompts.js"; export const MODEL_ID = "@cf/google/gemma-4-26b-a4b-it"; @@ -167,6 +167,7 @@ export async function judge(env, state, userInput) { JSON.stringify({ msg: "twentyq_ai_unparseable", model: MODEL_ID, + stage: "judge", preview: text.slice(0, 200), }), ); @@ -175,3 +176,60 @@ export async function judge(env, state, userInput) { judgement.hint = redactSecret(judgement.hint, state.target); return judgement; } + +/** + * Generate { category, initialHint } for a fresh round's target keyword. + * Uses the same JSON-in-content approach as judge(). On parse failure, + * returns a safe generic fallback so the round can still start. + * + * @param {{ AI: { run: (model: string, body: object) => Promise } }} env + * @param {string} target + * @returns {Promise<{ category: string, initialHint: string }>} + */ +export async function generateRoundStart(env, target) { + if (!env?.AI?.run) { + throw new UpstreamError("Workers AI binding not available"); + } + const messages = [ + { role: "system", content: buildStartRoundPrompt(target) }, + { role: "user", content: "begin" }, + ]; + let response; + try { + response = await env.AI.run(MODEL_ID, { messages }); + } catch (err) { + console.log( + JSON.stringify({ + msg: "twentyq_ai_throw", + model: MODEL_ID, + stage: "roundstart", + err: err instanceof Error ? { name: err.name, message: err.message } : String(err), + }), + ); + throw new UpstreamError("env.AI.run threw (roundstart)", { cause: err }); + } + + const text = extractText(response); + const payload = parseJudgementJson(text); + if (!payload || typeof payload.category !== "string" || typeof payload.initialHint !== "string") { + console.log( + JSON.stringify({ + msg: "twentyq_ai_unparseable", + model: MODEL_ID, + stage: "roundstart", + preview: text.slice(0, 200), + }), + ); + return { + category: "object", + initialHint: "it is something you might encounter in everyday life", + }; + } + const category = payload.category.trim() || "object"; + const rawHint = + payload.initialHint.trim() || "it is something you might encounter in everyday life"; + return { + category, + initialHint: redactSecret(rawHint, target), + }; +} diff --git a/src/modules/twentyq/handlers.js b/src/modules/twentyq/handlers.js index 6810bdc..1f7fd71 100644 --- a/src/modules/twentyq/handlers.js +++ b/src/modules/twentyq/handlers.js @@ -12,7 +12,7 @@ * /twentyq_stats → show per-subject stats */ -import { UpstreamError, judge } from "./ai-client.js"; +import { UpstreamError, generateRoundStart, judge } from "./ai-client.js"; import { formatBoard, formatGiveup, formatIntro, formatStats, formatTurnReply } from "./render.js"; import { getRandomSeed } from "./seeds.js"; import { clearGame, loadGame, loadStats, recordResult, saveGame } from "./state.js"; @@ -33,12 +33,13 @@ function argAfterCommand(text) { return idx === -1 ? "" : text.slice(idx + 1).trim(); } -function startFreshGame() { - const seed = getRandomSeed(); +async function startFreshGame(env) { + const target = getRandomSeed(); + const { category, initialHint } = await generateRoundStart(env, target); return { - category: seed.category, - target: seed.target, - initialHint: seed.initialHint, + category, + target, + initialHint, startedAt: Date.now(), solved: false, turns: [], @@ -71,7 +72,12 @@ export async function handleTwentyq(ctx, { db, env }) { } if (!game) { - game = startFreshGame(); + try { + game = await startFreshGame(env); + } catch (err) { + logFail("roundstart", err); + return ctx.reply(UPSTREAM_FAIL); + } await saveGame(db, subject, game); if (!arg) return ctx.reply(formatIntro(game), { parse_mode: "HTML" }); // Fresh round + immediate question — show intro then process turn. diff --git a/src/modules/twentyq/prompts.js b/src/modules/twentyq/prompts.js index 2b7b868..8e2432b 100644 --- a/src/modules/twentyq/prompts.js +++ b/src/modules/twentyq/prompts.js @@ -66,3 +66,43 @@ GOOD: {"is_guess": false, "answer": "no", "hint": "in its natural habitat you mi BAD (too direct): {"is_guess": false, "answer": "yes", "hint": "it uses pipes to produce sound"} BAD (names category synonym): {"is_guess": false, "answer": "yes", "hint": "it is a keyboard instrument"}`; } + +/** + * Build the round-start prompt. Given just a target keyword, we ask the + * model to emit { category, initialHint } so seeds don't need hand-curated + * metadata — drop in any noun, the model fills in the rest. + * + * @param {string} target + * @returns {string} + */ +export function buildStartRoundPrompt(target) { + return `You are opening a new round of the "20 questions" reverse-Akinator game. + +Secret object: "${target}" + +Your job: emit ONE line of JSON with these two fields: +{"category": "", "initialHint": ""} + +Category rules: +- A SHORT, COMMON category word a player can start narrowing from (e.g. "instrument", "animal", "food", "vehicle", "sport", "household item", "tool", "clothing", "plant"). Prefer single words. +- Broad enough that many objects fall under it — NOT a narrow sub-category (don't say "brass instrument", say "instrument"). +- Do not include the secret word in the category. + +Initial hint rules (same HINT STYLE as the main game): +- Max 120 characters. TRUE about the secret. Indirect, oblique, riddle-like. +- Never include the secret word, its plural, its base form, or an obvious category synonym. +- Hint at ONE lateral property — a cultural association, a habitat, a use context, a historical fact. +- Player should think "ok that narrows it a bit" NOT "oh that's obviously X". + +Examples (secret → output): +secret="organ" → {"category": "instrument", "initialHint": "it was once the largest mechanical object humans built"} +secret="elephant" → {"category": "animal", "initialHint": "the ancient Greeks believed it was terrified of mice"} +secret="pizza" → {"category": "food", "initialHint": "a popular version bears the colors of a national flag"} +secret="submarine" → {"category": "vehicle", "initialHint": "the first successful one was made of wood and powered by hand"} + +BAD examples (do NOT emit these): +secret="organ" → {"category": "keyboard instrument", "initialHint": "it has pipes"} // category too specific + hint too direct +secret="elephant" → {"category": "animal", "initialHint": "it has a long trunk"} // hint too direct + +Output ONLY the JSON line. No fences, no prose.`; +} diff --git a/src/modules/twentyq/seeds.js b/src/modules/twentyq/seeds.js index bc6fb06..0185d10 100644 --- a/src/modules/twentyq/seeds.js +++ b/src/modules/twentyq/seeds.js @@ -1,157 +1,79 @@ /** - * @file Seed list of secret objects for the twentyq game. + * @file Seed keywords for the twentyq game. * - * Each seed is a hand-curated `{ category, target, initialHint }` triple. - * The initial hint must NOT contain the target word or a close cognate — - * it should narrow the field without giving the answer. - * - * Add new entries freely; tests assert hint sanity (target not in hint). + * Just the target word. Category + initial hint are generated by the LLM at + * round start (see ai-client.generateRoundStart). Add any noun here — the + * model figures out the right category and produces a cryptic opening clue. */ -/** - * @typedef {object} Seed - * @property {string} category - * @property {string} target — lowercased - * @property {string} initialHint — short clue, no target substring - */ - -/** @type {Seed[]} */ +/** @type {string[]} */ export const SEEDS = [ - // instrument (8) - { category: "instrument", target: "guitar", initialHint: "it has strings you pluck or strum" }, - { category: "instrument", target: "piano", initialHint: "it has black and white keys" }, - { category: "instrument", target: "drum", initialHint: "you hit it to make sound" }, - { category: "instrument", target: "violin", initialHint: "you draw a bow across its strings" }, - { category: "instrument", target: "flute", initialHint: "it uses wind to create sound" }, - { - category: "instrument", - target: "trumpet", - initialHint: "brass family — buzz your lips into it", - }, - { - category: "instrument", - target: "organ", - initialHint: "it uses wind through pipes to create sound", - }, - { - category: "instrument", - target: "harmonica", - initialHint: "small enough to fit in one hand, played with the mouth", - }, - - // animal (10) - { category: "animal", target: "elephant", initialHint: "the largest land mammal" }, - { category: "animal", target: "dolphin", initialHint: "a highly intelligent marine creature" }, - { category: "animal", target: "eagle", initialHint: "a bird of prey known for sharp eyesight" }, - { - category: "animal", - target: "kangaroo", - initialHint: "this animal carries its young in a pouch", - }, - { category: "animal", target: "octopus", initialHint: "it has eight limbs and lives in the sea" }, - { - category: "animal", - target: "penguin", - initialHint: "a flightless bird that swims in cold water", - }, - { category: "animal", target: "tiger", initialHint: "a large striped predator from Asia" }, - { - category: "animal", - target: "horse", - initialHint: "domesticated for riding for thousands of years", - }, - { category: "animal", target: "snake", initialHint: "a legless reptile" }, - { - category: "animal", - target: "owl", - initialHint: "mostly nocturnal, can rotate its head far around", - }, - - // food (10) - { category: "food", target: "pizza", initialHint: "originated in Italy, often shared in slices" }, - { - category: "food", - target: "sushi", - initialHint: "a Japanese dish often featuring rice and seafood", - }, - { category: "food", target: "burger", initialHint: "a sandwich with a patty in the middle" }, - { category: "food", target: "ramen", initialHint: "noodles served in a hot broth" }, - { category: "food", target: "taco", initialHint: "a folded shell holding savory fillings" }, - { category: "food", target: "pho", initialHint: "a Vietnamese noodle soup" }, - { - category: "food", - target: "curry", - initialHint: "a heavily spiced sauce dish, popular in South Asia", - }, - { category: "food", target: "salad", initialHint: "usually cold, often leafy and raw" }, - { category: "food", target: "chocolate", initialHint: "made from cocoa, often eaten as a treat" }, - { category: "food", target: "cheese", initialHint: "a dairy product that can be soft or hard" }, - - // vehicle (8) - { category: "vehicle", target: "bicycle", initialHint: "two wheels, powered by you" }, - { category: "vehicle", target: "car", initialHint: "the most common personal road vehicle" }, - { - category: "vehicle", - target: "airplane", - initialHint: "it flies long distances carrying many people", - }, - { category: "vehicle", target: "boat", initialHint: "it travels on water" }, - { category: "vehicle", target: "train", initialHint: "it runs on fixed metal rails" }, - { - category: "vehicle", - target: "motorcycle", - initialHint: "two wheels, but powered by an engine", - }, - { category: "vehicle", target: "helicopter", initialHint: "it flies but can hover in place" }, - { category: "vehicle", target: "submarine", initialHint: "it travels underwater" }, - - // sport (8) - { category: "sport", target: "soccer", initialHint: "the world's most popular ball sport" }, - { - category: "sport", - target: "basketball", - initialHint: "you score by putting a ball through a hoop", - }, - { category: "sport", target: "tennis", initialHint: "two or four players, a net, and rackets" }, - { category: "sport", target: "swimming", initialHint: "competed in water" }, - { - category: "sport", - target: "boxing", - initialHint: "two opponents wear gloves and stand in a ring", - }, - { category: "sport", target: "golf", initialHint: "played on grass with clubs and a small ball" }, - { category: "sport", target: "chess", initialHint: "a turn-based game on a 64-square board" }, - { category: "sport", target: "skiing", initialHint: "performed on snow" }, - - // household (10) - { - category: "household", - target: "refrigerator", - initialHint: "it keeps things cold in the kitchen", - }, - { category: "household", target: "microwave", initialHint: "it heats food using radiation" }, - { category: "household", target: "vacuum", initialHint: "it cleans floors using suction" }, - { - category: "household", - target: "toaster", - initialHint: "small kitchen appliance for crisping bread", - }, - { category: "household", target: "kettle", initialHint: "it's used to boil water" }, - { - category: "household", - target: "blender", - initialHint: "it has fast-spinning blades for liquids", - }, - { category: "household", target: "lamp", initialHint: "provides light in a room" }, - { category: "household", target: "sofa", initialHint: "you sit on it, often in the living room" }, - { category: "household", target: "mirror", initialHint: "it reflects your image" }, - { category: "household", target: "broom", initialHint: "long handle, used for sweeping" }, + // instruments + "guitar", + "piano", + "drum", + "violin", + "flute", + "trumpet", + "organ", + "harmonica", + // animals + "elephant", + "dolphin", + "eagle", + "kangaroo", + "octopus", + "penguin", + "tiger", + "horse", + "snake", + "owl", + // food + "pizza", + "sushi", + "burger", + "ramen", + "taco", + "pho", + "curry", + "salad", + "chocolate", + "cheese", + // vehicles + "bicycle", + "car", + "airplane", + "boat", + "train", + "motorcycle", + "helicopter", + "submarine", + // sports + "soccer", + "basketball", + "tennis", + "swimming", + "boxing", + "golf", + "chess", + "skiing", + // household items + "refrigerator", + "microwave", + "vacuum", + "toaster", + "kettle", + "blender", + "lamp", + "sofa", + "mirror", + "broom", ]; /** - * Pick one seed at random. + * Pick one target at random. * @param {() => number} [rng] — defaults to Math.random; tests inject deterministic rng. - * @returns {Seed} + * @returns {string} */ export function getRandomSeed(rng = Math.random) { const i = Math.floor(rng() * SEEDS.length); diff --git a/tests/fakes/fake-ai.js b/tests/fakes/fake-ai.js index c1667fa..a7a0eeb 100644 --- a/tests/fakes/fake-ai.js +++ b/tests/fakes/fake-ai.js @@ -23,6 +23,16 @@ export function mockJudgement(ai, { is_guess = false, answer = "no", hint = "def }); } +/** + * Configure the next ai.run call to return a round-start response + * with { category, initialHint } JSON. + */ +export function mockRoundStart(ai, { category = "object", initialHint = "cryptic clue" } = {}) { + ai.run.mockResolvedValueOnce({ + response: JSON.stringify({ category, initialHint }), + }); +} + /** Configure the next call to throw (simulate Workers AI outage). */ export function mockFailure(ai, err = new Error("AI down")) { ai.run.mockRejectedValueOnce(err); diff --git a/tests/modules/twentyq/ai-client.test.js b/tests/modules/twentyq/ai-client.test.js index 56554ee..f36887e 100644 --- a/tests/modules/twentyq/ai-client.test.js +++ b/tests/modules/twentyq/ai-client.test.js @@ -3,12 +3,13 @@ import { MODEL_ID, UpstreamError, extractText, + generateRoundStart, judge, normalizeJudgement, parseJudgementJson, redactSecret, } from "../../../src/modules/twentyq/ai-client.js"; -import { makeFakeAi, mockFailure, mockJudgement } from "../../fakes/fake-ai.js"; +import { makeFakeAi, mockFailure, mockJudgement, mockRoundStart } from "../../fakes/fake-ai.js"; const baseState = () => ({ category: "instrument", @@ -168,4 +169,48 @@ describe("twentyq/ai-client", () => { expect(body.messages).toBeDefined(); }); }); + + describe("generateRoundStart", () => { + it("returns parsed { category, initialHint } on happy path", async () => { + const ai = makeFakeAi(); + mockRoundStart(ai, { category: "instrument", initialHint: "cryptic clue about organs" }); + const r = await generateRoundStart({ AI: ai }, "organ"); + expect(r.category).toBe("instrument"); + expect(r.initialHint).toBe("cryptic clue about organs"); + }); + + it("redacts the target word from the generated hint", async () => { + const ai = makeFakeAi(); + mockRoundStart(ai, { + category: "instrument", + initialHint: "an organ is what you should think of", + }); + const r = await generateRoundStart({ AI: ai }, "organ"); + expect(r.initialHint).not.toContain("organ"); + }); + + it("falls back to safe defaults when model output is unparseable", async () => { + const ai = makeFakeAi(); + ai.run.mockResolvedValueOnce({ response: "no json here" }); + const r = await generateRoundStart({ AI: ai }, "organ"); + expect(r.category).toBeTruthy(); + expect(r.initialHint).toBeTruthy(); + }); + + it("falls back when model returns partial payload", async () => { + const ai = makeFakeAi(); + ai.run.mockResolvedValueOnce({ + response: JSON.stringify({ category: "instrument" }), + }); + const r = await generateRoundStart({ AI: ai }, "organ"); + expect(r.category).toBeTruthy(); + expect(r.initialHint).toBeTruthy(); + }); + + it("wraps AI exception in UpstreamError", async () => { + const ai = makeFakeAi(); + mockFailure(ai, new Error("boom")); + await expect(generateRoundStart({ AI: ai }, "organ")).rejects.toBeInstanceOf(UpstreamError); + }); + }); }); diff --git a/tests/modules/twentyq/handlers.test.js b/tests/modules/twentyq/handlers.test.js index 386d2c3..d19f99f 100644 --- a/tests/modules/twentyq/handlers.test.js +++ b/tests/modules/twentyq/handlers.test.js @@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { createStore } from "../../../src/db/create-store.js"; import { handleGiveup, handleStats, handleTwentyq } from "../../../src/modules/twentyq/handlers.js"; import { loadGame, loadStats, saveGame } from "../../../src/modules/twentyq/state.js"; -import { makeFakeAi, mockFailure, mockJudgement } from "../../fakes/fake-ai.js"; +import { makeFakeAi, mockFailure, mockJudgement, mockRoundStart } from "../../fakes/fake-ai.js"; import { makeFakeKv } from "../../fakes/fake-kv-namespace.js"; function makeCtx(userId = 1, chatType = "private", msgText = "/twentyq") { @@ -43,6 +43,7 @@ describe("twentyq/handlers", () => { describe("handleTwentyq", () => { it("starts a fresh round and shows intro when no game + no arg", async () => { + mockRoundStart(ai, { category: "instrument", initialHint: "cryptic opener" }); const ctx = makeCtx(1, "private", "/twentyq"); await handleTwentyq(ctx, { db, env }); expect(ctx.reply).toHaveBeenCalledOnce(); @@ -50,6 +51,8 @@ describe("twentyq/handlers", () => { const game = await loadGame(db, 1); expect(game).not.toBeNull(); expect(game.turns).toEqual([]); + expect(game.category).toBe("instrument"); + expect(game.initialHint).toBe("cryptic opener"); }); it("shows board when game exists and no arg", async () => { @@ -123,12 +126,14 @@ describe("twentyq/handlers", () => { it("starts a fresh round transparently after a solved game", async () => { await saveGame(db, 1, sampleGame({ solved: true })); + mockRoundStart(ai); const ctx = makeCtx(1, "private", "/twentyq"); await handleTwentyq(ctx, { db, env }); expect(ctx.replies[0].text).toMatch(/I'm thinking/); }); it("starts round and processes turn when /twentyq with no prior game", async () => { + mockRoundStart(ai); mockJudgement(ai, { is_guess: false, answer: "yes", hint: "yes hint" }); const ctx = makeCtx(1, "private", "/twentyq is it big?"); await handleTwentyq(ctx, { db, env }); @@ -137,6 +142,14 @@ describe("twentyq/handlers", () => { expect(ctx.replies[1].text).toMatch(/Yes/); }); + it("surfaces roundstart UpstreamError as friendly message", async () => { + mockFailure(ai, new Error("roundstart down")); + const ctx = makeCtx(1, "private", "/twentyq"); + await handleTwentyq(ctx, { db, env }); + expect(ctx.replies[0].text).toMatch(/hiccup|try again/i); + expect(await loadGame(db, 1)).toBeNull(); + }); + it("surfaces UpstreamError as friendly message", async () => { await saveGame(db, 1, sampleGame()); mockFailure(ai, new Error("boom")); @@ -146,6 +159,7 @@ describe("twentyq/handlers", () => { }); it("uses chat id as subject in groups", async () => { + mockRoundStart(ai); mockJudgement(ai, { is_guess: false, answer: "no", hint: "h" }); const ctx = makeCtx(99, "group", "/twentyq is it big?"); ctx.chat.id = 12345; diff --git a/tests/modules/twentyq/seeds.test.js b/tests/modules/twentyq/seeds.test.js index 3f03f89..9404fe3 100644 --- a/tests/modules/twentyq/seeds.test.js +++ b/tests/modules/twentyq/seeds.test.js @@ -2,29 +2,16 @@ import { describe, expect, it } from "vitest"; import { SEEDS, getRandomSeed } from "../../../src/modules/twentyq/seeds.js"; describe("twentyq/seeds", () => { - it("every seed has non-empty category, target, initialHint", () => { + it("every seed is a non-empty lowercase string", () => { for (const s of SEEDS) { - expect(s.category).toBeTruthy(); - expect(s.target).toBeTruthy(); - expect(s.initialHint).toBeTruthy(); - expect(typeof s.category).toBe("string"); - expect(typeof s.target).toBe("string"); - expect(typeof s.initialHint).toBe("string"); + expect(typeof s).toBe("string"); + expect(s.length).toBeGreaterThan(0); + expect(s).toBe(s.toLowerCase()); } }); - it("all targets are lowercase", () => { - for (const s of SEEDS) { - expect(s.target).toBe(s.target.toLowerCase()); - } - }); - - it("initialHint never contains the target word", () => { - for (const s of SEEDS) { - const hintLower = s.initialHint.toLowerCase(); - const re = new RegExp(`\\b${s.target}\\b`, "i"); - expect(re.test(hintLower)).toBe(false); - } + it("no duplicate seeds", () => { + expect(new Set(SEEDS).size).toBe(SEEDS.length); }); it("getRandomSeed returns a member of SEEDS", () => {