mirror of
https://github.com/tiennm99/tiennm99bot.git
synced 2026-10-14 14:17:58 +00:00
feat(twentyq): LLM-generated category + initial hint from bare keyword seeds
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).
This commit is contained in:
1 parent
1d41d27b2b
commit
1412166ad7
9 files changed
+266
-183
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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<any> } }} 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),
|
||||
};
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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": "<short broad category>", "initialHint": "<cryptic opening clue>"}
|
||||
|
||||
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.`;
|
||||
}
|
||||
+67
-145
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 <text> 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;
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in new issue
Block a user