From 62662d9cd07ddf9ed114b86dc6faced8971cd8ad Mon Sep 17 00:00:00 2001 From: Byntham <135178921+Byntham@users.noreply.github.com> Date: Thu, 5 Nov 2026 03:02:27 -0800 Subject: [PATCH] Require a fully correct prompt before Enter sends it A prompt now locks once every character matches. The text turns green and the composer shows "Done. Press Enter to send." Keys and backspace do nothing while it is locked, and Enter does nothing until the prompt is complete. The timer stops at the last key. Every sent prompt is now clean, so the agents never see a typo. This removes the typo diff, the literal replies, typosLiteral, the typo underline in sent messages, the end screen typo list, and the slack that let you type past the end. Co-Authored-By: Claude Opus 5.5 --- README.md | 8 +- src/components/EndSummary.tsx | 28 +-- src/components/chat/ChatComposer.tsx | 40 +++- src/components/chat/MessagesTimeline.tsx | 31 +-- src/game/reply.ts | 25 +++ src/game/store.ts | 57 ++--- src/game/typos.ts | 258 ----------------------- tests/game.test.ts | 39 +--- tests/play.mjs | 28 ++- tests/timing.test.ts | 51 +++-- 10 files changed, 145 insertions(+), 420 deletions(-) create mode 100644 src/game/reply.ts delete mode 100644 src/game/typos.ts diff --git a/README.md b/README.md index e1d7edb..23d6abe 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ # t3type A typing game that looks like T3 Code. You type prompts to coding agents as -Theo, Julius and Maria. The agents take every typo literally. +Theo, Julius and Maria. ```bash npm install @@ -25,11 +25,13 @@ every push and pull request. ## How to play -- The next prompt shows as gray ghost text in the composer. Type over it. Enter sends, typos included. +- The next prompt shows as gray ghost text in the composer. Type over it. Wrong keys show in red + until you backspace and fix them. +- When every character is right, the prompt turns green and locks. Press Enter to send it. - While an agent works, switch to another thread: Tab jumps to the next thread waiting on you, Shift+Tab goes back, and Ctrl+Shift+] / Ctrl+Shift+[ (Cmd on macOS) step through the sidebar like T3 Code's thread.next and thread.previous. -- Every prompt is its own WPM test, timed from its first key to Enter. Time spent in other +- Every prompt is its own WPM test, timed from its first key to its last. Time spent in other threads does not count. Each sent message shows its WPM and accuracy. - A session ends when every thread is done. diff --git a/src/components/EndSummary.tsx b/src/components/EndSummary.tsx index d771964..24af206 100644 --- a/src/components/EndSummary.tsx +++ b/src/components/EndSummary.tsx @@ -1,4 +1,4 @@ -import { CircleCheckIcon, ClockIcon, KeyboardIcon, RotateCcwIcon, SpellCheckIcon } from "lucide-react"; +import { CircleCheckIcon, ClockIcon, KeyboardIcon, RotateCcwIcon } from "lucide-react"; import { computeStats, restart, type GameState } from "~/game/store"; import { formatDuration } from "~/lib/time"; import { ChatMarkdown } from "./chat/MessagesTimeline"; @@ -6,16 +6,11 @@ import { Button } from "./ui/button"; function verdict(stats: ReturnType): string { if (stats.promptsSent === 0) return "No prompts sent. The agents enjoyed the quiet."; - if (stats.typosLiteral === 0) return "Every prompt was clean. The agents had nothing to misread, which they found disappointing."; - if (stats.typosLiteral <= 3) return `The agents took ${stats.typosLiteral === 1 ? "one typo" : `${stats.typosLiteral} typos`} literally. Nightly will be fine. Probably.`; - return `The agents took ${stats.typosLiteral} typos literally and shipped all of them.`; + return `You sent ${stats.promptsSent === 1 ? "one clean prompt" : `${stats.promptsSent} clean prompts`}.`; } export function EndSummary({ game }: { game: GameState }) { const stats = computeStats(game); - const literal = game.threads - .flatMap((thread) => thread.turns.flatMap((turn) => turn.diff.typos.map((typo) => ({ typo, thread })))) - .slice(0, 8); const rows = [ { label: "Prompts sent", value: String(stats.promptsSent), key: "prompts" }, { label: "Average speed", value: `${stats.wpm} wpm`, key: "wpm" }, @@ -60,29 +55,12 @@ export function EndSummary({ game }: { game: GameState }) { ))} - {literal.length > 0 ? ( -
-

What the agents read

- {literal.map(({ typo, thread }, index) => ( -
- - - - - {typo.typed} instead of{" "} - {typo.target} - ยท {thread.def.title} - -
- ))} -
- ) : null}

Enter plays again - Each prompt is timed from its first key to Enter + Each prompt is timed from its first key to its last

diff --git a/src/components/chat/ChatComposer.tsx b/src/components/chat/ChatComposer.tsx index f5b1d1e..6c6d97c 100644 --- a/src/components/chat/ChatComposer.tsx +++ b/src/components/chat/ChatComposer.tsx @@ -1,7 +1,7 @@ -import { AArrowDownIcon, AArrowUpIcon, FolderIcon, GitBranchIcon, PaperclipIcon } from "lucide-react"; +import { AArrowDownIcon, AArrowUpIcon, CircleCheckIcon, FolderIcon, GitBranchIcon, PaperclipIcon } from "lucide-react"; import type { ComponentProps } from "react"; import { personOf, sourceAuthor, threadBranch, type PromptDef } from "~/content/prompts"; -import { isWorking, nextPrompt, type GameState, type ThreadState } from "~/game/store"; +import { isDraftComplete, isWorking, nextPrompt, type GameState, type ThreadState } from "~/game/store"; import { COMPOSER_FONT_MAX, COMPOSER_FONT_MIN, @@ -56,7 +56,17 @@ function Main({ className, ...props }: ComponentProps<"div">) { ); } -function GhostEditor({ draft, target, locked }: { draft: string; target: string; locked: boolean }) { +function GhostEditor({ + draft, + target, + locked, + complete, +}: { + draft: string; + target: string; + locked: boolean; + complete: boolean; +}) { const typed = Array.from(draft); const goal = Array.from(target); const fontPx = useComposerFont(); @@ -65,6 +75,7 @@ function GhostEditor({ draft, target, locked }: { draft: string; target: string; className="relative flow-root font-sans" style={{ fontSize: fontPx }} data-testid="composer-editor-root" + data-complete={complete || undefined} >
{typed.map((ch, index) => { const ok = goal[index] === ch; @@ -94,7 +108,7 @@ function GhostEditor({ draft, target, locked }: { draft: string; target: string; ); })} - {locked ? null : } + {locked || complete ? null : } {goal.slice(typed.length).join("")} @@ -131,6 +145,15 @@ function PromptKindLabel({ thread, prompt }: { thread: ThreadState; prompt: Prom ); } +function DoneLabel() { + return ( + + + Done. Press Enter to send. + + ); +} + function FontSizeControl() { const fontPx = useComposerFont(); return ( @@ -238,6 +261,7 @@ export function ChatComposer({ game, thread }: { game: GameState; thread: Thread const prompt = nextPrompt(thread); const ended = game.session.status === "ended"; const locked = working || !prompt || ended; + const complete = !locked && isDraftComplete(thread); const placeholder = ended ? "Session over. Press Enter to play again." : working @@ -259,7 +283,7 @@ export function ChatComposer({ game, thread }: { game: GameState; thread: Thread
- +
- {prompt && !locked ? : null} + {complete ? : prompt && !locked ? : null}
- {working ? : 0} />} + {working ? : }
diff --git a/src/components/chat/MessagesTimeline.tsx b/src/components/chat/MessagesTimeline.tsx index 8334e0b..a8660c7 100644 --- a/src/components/chat/MessagesTimeline.tsx +++ b/src/components/chat/MessagesTimeline.tsx @@ -52,29 +52,6 @@ export function ChatMarkdown({ text, className }: { text: string; className?: st ); } -function TypedWithTypos({ turn }: { turn: Turn }) { - const typoWords = new Set(turn.diff.typos.map((typo) => typo.typed.toLowerCase())); - const pieces = turn.typed.split(/(\s+)/); - return ( - <> - {pieces.map((piece, index) => { - const bare = piece.replace(/^[^\p{L}\p{N}@`$/._-]+|[^\p{L}\p{N}`/_-]+$/gu, "").toLowerCase(); - return typoWords.has(bare) && bare.length > 0 ? ( - - {piece} - - ) : ( - {piece} - ); - })} - - ); -} - function PromptSource({ turn, thread }: { turn: Turn; thread: ThreadState }) { const prompt = turn.prompt; const person = personOf(thread.def.persona); @@ -114,9 +91,7 @@ function UserMessageRow({ turn, thread }: { turn: Turn; thread: ThreadState }) {

You

-

- -

+

{turn.prompt.text}

@@ -479,7 +454,7 @@ function TurnRows({ ) : null}
) : null} -
+

T3 Code

@@ -501,7 +476,7 @@ export function MessagesTimeline({ thread, now }: { thread: ThreadState; now: nu

Send a message to start the conversation.

- Type the gray prompt below. Enter sends it, typos included. + Type the gray prompt below. Fix every red letter, then press Enter.

); diff --git a/src/game/reply.ts b/src/game/reply.ts new file mode 100644 index 0000000..6252677 --- /dev/null +++ b/src/game/reply.ts @@ -0,0 +1,25 @@ +import type { LineCounts, ScriptedReply, ToolRow } from "~/content/prompts"; + +export interface AgentReply extends ScriptedReply { + files: { path: string; additions: number; deletions: number }[]; +} + +function filesFor(tools: ToolRow[], diff: LineCounts | null) { + if (!diff) return []; + const paths = [...new Set(tools.filter(([type]) => type === "edit").map(([, label]) => label))]; + if (paths.length === 0) return []; + let addLeft = diff[0]; + let delLeft = diff[1]; + return paths.map((path, index) => { + const last = index === paths.length - 1; + const additions = last ? addLeft : Math.ceil(diff[0] / paths.length); + const deletions = last ? delLeft : Math.ceil(diff[1] / paths.length); + addLeft -= additions; + delLeft -= deletions; + return { path, additions: Math.max(0, additions), deletions: Math.max(0, deletions) }; + }); +} + +export function buildReply(scripted: ScriptedReply): AgentReply { + return { ...scripted, files: filesFor(scripted.tools, scripted.diff) }; +} diff --git a/src/game/store.ts b/src/game/store.ts index 783bb36..bb5c339 100644 --- a/src/game/store.ts +++ b/src/game/store.ts @@ -1,6 +1,6 @@ import { useSyncExternalStore } from "react"; import { drawThreads, type PromptDef, type ThreadDef } from "~/content/prompts"; -import { buildReply, diffPrompt, type AgentReply, type PromptDiff } from "./typos"; +import { buildReply, type AgentReply } from "./reply"; export type GameMs = number; export type RealMs = number; @@ -17,8 +17,6 @@ export interface Turn { result: PromptResult; promptIndex: number; prompt: PromptDef; - typed: string; - diff: PromptDiff; reply: AgentReply; startedAt: GameMs; durationMs: GameMs; @@ -30,7 +28,6 @@ export interface ThreadState { turns: Turn[]; draft: string; draftMs: RealMs; - draftWordsMs: RealMs; draftLastKeyAt: RealMs | null; draftKeys: number; draftCorrectKeys: number; @@ -47,7 +44,6 @@ export interface Session { keystrokes: number; correctKeystrokes: number; promptsSent: number; - typosLiteral: number; } export interface GameState { @@ -59,7 +55,6 @@ export interface GameState { const params = new URLSearchParams(typeof location === "undefined" ? "" : location.search); const SPEED = Math.max(0.1, Number(params.get("speed") ?? 1) || 1); -const DRAFT_SLACK = 24; const SETTLE_DELAY_MS = 1800; const seedParam = params.get("seed"); @@ -77,7 +72,6 @@ function seededThreads(now: number, defs: ThreadDef[]): ThreadState[] { turns: [], draft: "", draftMs: 0, - draftWordsMs: 0, draftLastKeyAt: null, draftKeys: 0, draftCorrectKeys: 0, @@ -96,7 +90,6 @@ function freshSession(): Session { keystrokes: 0, correctKeystrokes: 0, promptsSent: 0, - typosLiteral: 0, }; } @@ -140,6 +133,11 @@ export function isComplete(thread: ThreadState): boolean { return nextPrompt(thread) === null && !isWorking(thread); } +export function isDraftComplete(thread: ThreadState): boolean { + const prompt = nextPrompt(thread); + return prompt !== null && thread.draft === prompt.text; +} + export function awaitingInput(thread: ThreadState): boolean { return !isWorking(thread) && nextPrompt(thread) !== null; } @@ -156,10 +154,9 @@ export interface Stats { wpm: number; liveWpm: number | null; bestWpm: number; - best: { wpm: number; text: string; thread: string } | null; + best: { wpm: number; thread: string } | null; accuracy: number; promptsSent: number; - typosLiteral: number; elapsedMs: number; threadsDone: number; } @@ -184,13 +181,13 @@ export function computeStats(s: GameState = state): Stats { const wpm = turns.length > 0 ? wpmOf(totalChars, totalMs) : 0; let best: Stats["best"] = null; for (const { turn, thread } of turns) { - if (!best || turn.result.wpm > best.wpm) best = { wpm: turn.result.wpm, text: turn.typed, thread: thread.def.title }; + if (!best || turn.result.wpm > best.wpm) best = { wpm: turn.result.wpm, thread: thread.def.title }; } const open = activeThread(s); const prompt = nextPrompt(open); let liveWpm: number | null = null; - if (open.draft.length > 0 && prompt && open.draftWordsMs >= 1000) { - liveWpm = Math.round(wpmOf(correctCharsOf(open.draft.trimEnd(), prompt.text), open.draftWordsMs)); + if (open.draft.length > 0 && prompt && open.draftMs >= 1000) { + liveWpm = Math.round(wpmOf(correctCharsOf(open.draft, prompt.text), open.draftMs)); } else if (open.draft.length === 0 && open.turns.length > 0) { liveWpm = open.turns.at(-1)!.result.wpm; } @@ -204,7 +201,6 @@ export function computeStats(s: GameState = state): Stats { best, accuracy, promptsSent: session.promptsSent, - typosLiteral: session.typosLiteral, elapsedMs, threadsDone: s.threads.filter(isComplete).length, }; @@ -275,13 +271,11 @@ function addTimeSinceLastKey(thread: ThreadState, real: RealMs): ThreadState { } function editDraft(thread: ThreadState, draft: string, real: RealMs): ThreadState { - const timed = addTimeSinceLastKey(thread, real); - const wordsChanged = draft.trimEnd() !== thread.draft.trimEnd(); - return { ...timed, draft, draftWordsMs: wordsChanged ? timed.draftMs : thread.draftWordsMs }; + return { ...addTimeSinceLastKey(thread, real), draft }; } function canType(thread: ThreadState): boolean { - return state.session.status !== "ended" && awaitingInput(thread); + return state.session.status !== "ended" && awaitingInput(thread) && !isDraftComplete(thread); } function startIfReady(s: GameState, now: GameMs): GameState { @@ -293,7 +287,7 @@ export function typeChar(ch: string) { const thread = activeThread(); const target = nextPrompt(thread); if (!target || !canType(thread)) return; - if (thread.draft.length >= target.text.length + DRAFT_SLACK) return; + if (thread.draft.length >= target.text.length) return; const now = clock(); const real = Date.now(); let s = startIfReady(advance(state, now), now); @@ -323,44 +317,33 @@ export function backspace(word = false) { export function send() { const thread = activeThread(); const prompt = nextPrompt(thread); - if (!prompt || !canType(thread) || thread.draft.trim().length === 0) return; + if (!prompt || state.session.status === "ended" || !isDraftComplete(thread)) return; const now = clock(); - let s = startIfReady(advance(state, now), now); - const correctChars = correctCharsOf(thread.draft.trim(), prompt.text); + const s0 = advance(state, now); + const correctChars = prompt.text.length; const result: PromptResult = { - wpm: Math.round(wpmOf(correctChars, thread.draftWordsMs)), + wpm: Math.round(wpmOf(correctChars, thread.draftMs)), accuracy: thread.draftKeys > 0 ? thread.draftCorrectKeys / thread.draftKeys : 1, - ms: Math.max(thread.draftWordsMs, MIN_TEST_MS), + ms: Math.max(thread.draftMs, MIN_TEST_MS), correctChars, }; - const diff = diffPrompt(thread.draft, prompt.text); - const reply = buildReply(prompt.reply, diff, thread.def.persona); + const reply = buildReply(prompt.reply); const turn: Turn = { id: `${thread.def.id}:${thread.turns.length}`, result, promptIndex: thread.turns.length, prompt, - typed: thread.draft.trim(), - diff, reply, startedAt: now, durationMs: reply.seconds * 1000, done: false, }; - s = { - ...s, - session: { - ...s.session, - promptsSent: s.session.promptsSent + 1, - typosLiteral: s.session.typosLiteral + diff.typos.length, - }, - }; + let s: GameState = { ...s0, session: { ...s0.session, promptsSent: s0.session.promptsSent + 1 } }; s = updateThread(s, thread.def.id, (t) => ({ ...t, turns: [...t.turns, turn], draft: "", draftMs: 0, - draftWordsMs: 0, draftLastKeyAt: null, draftKeys: 0, draftCorrectKeys: 0, diff --git a/src/game/typos.ts b/src/game/typos.ts deleted file mode 100644 index be3ab55..0000000 --- a/src/game/typos.ts +++ /dev/null @@ -1,258 +0,0 @@ -import type { LineCounts, PersonaKey, ScriptedReply, ToolRow, ToolType } from "~/content/prompts"; - -export interface WordTypo { - target: string; - typed: string; -} - -export interface PromptDiff { - typos: WordTypo[]; - missing: string[]; - extra: string[]; -} - -const strip = (word: string) => word.replace(/^[^\p{L}\p{N}@`$/._-]+|[^\p{L}\p{N}`/_-]+$/gu, ""); -const norm = (word: string) => strip(word).toLowerCase(); - -export function tokenize(text: string): string[] { - return text.split(/\s+/).filter(Boolean); -} - -const NEAR_MISS_MAX_CHAR_EDITS = 3; -const NEAR_MISS_COST = 1; -const UNRELATED_WORD_COST = 1.6; - -function substitutionCost(targetWord: string, typedWord: string): number { - if (targetWord === typedWord) return 0; - return charDistance(targetWord, typedWord) <= NEAR_MISS_MAX_CHAR_EDITS ? NEAR_MISS_COST : UNRELATED_WORD_COST; -} - -export function diffPrompt(typedText: string, targetText: string): PromptDiff { - const a = tokenize(targetText); - const b = tokenize(typedText); - const n = a.length; - const m = b.length; - const cost: number[][] = Array.from({ length: n + 1 }, (_, i) => - Array.from({ length: m + 1 }, (_, j) => (i === 0 ? j : j === 0 ? i : 0)), - ); - for (let i = 1; i <= n; i++) { - for (let j = 1; j <= m; j++) { - const sub = substitutionCost(norm(a[i - 1]!), norm(b[j - 1]!)); - cost[i]![j] = Math.min( - cost[i - 1]![j - 1]! + sub, - cost[i - 1]![j]! + 1, - cost[i]![j - 1]! + 1, - ); - } - } - const typos: WordTypo[] = []; - const missing: string[] = []; - const extra: string[] = []; - let i = n; - let j = m; - while (i > 0 || j > 0) { - if (i > 0 && j > 0) { - const sub = substitutionCost(norm(a[i - 1]!), norm(b[j - 1]!)); - const same = sub === 0; - if (cost[i]![j] === cost[i - 1]![j - 1]! + sub) { - if (!same) typos.unshift({ target: strip(a[i - 1]!), typed: strip(b[j - 1]!) || b[j - 1]! }); - i--; - j--; - continue; - } - } - if (i > 0 && cost[i]![j] === cost[i - 1]![j]! + 1) { - missing.unshift(strip(a[i - 1]!)); - i--; - } else { - extra.unshift(strip(b[j - 1]!)); - j--; - } - } - return { typos, missing: missing.filter(Boolean), extra: extra.filter(Boolean) }; -} - -export function charDistance(x: string, y: string): number { - const row = Array.from({ length: y.length + 1 }, (_, k) => k); - for (let i = 1; i <= x.length; i++) { - let prev = row[0]!; - row[0] = i; - for (let j = 1; j <= y.length; j++) { - const tmp = row[j]!; - row[j] = Math.min(row[j]! + 1, row[j - 1]! + 1, prev + (x[i - 1] === y[j - 1] ? 0 : 1)); - prev = tmp; - } - } - return row[y.length]!; -} - -function hash(text: string): number { - let h = 2166136261; - for (let k = 0; k < text.length; k++) { - h ^= text.charCodeAt(k); - h = Math.imul(h, 16777619); - } - return h >>> 0; -} - -const VERBS = new Set( - "fix add ship sort split hide measure trim batch open merge draft list find rerun put make tell inject get use tag back sync keep figure insane measure design".split( - " ", - ), -); - -const pascal = (word: string) => - word - .replace(/[^\p{L}\p{N}]+/gu, " ") - .trim() - .split(" ") - .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) - .join("") || "Thing"; - -const looksLikeCode = (word: string) => /[./_`$]|[a-z][A-Z]|\d/.test(word); - -function headlineTypo(typos: WordTypo[]): WordTypo { - return [...typos].sort( - (x, y) => - Number(looksLikeCode(y.target)) - Number(looksLikeCode(x.target)) || - y.target.length - x.target.length, - )[0]!; -} - -const SIGNOFF: Record = { - theo: ["Shipping it to nightly anyway.", "This is fine. Probably."], - julius: ["Left a note in the PR so nobody merges it on Saturday.", "Marked it intentional."], - maria: ["Added before and after screenshots, both of them confused.", "No regressions, technically."], -}; - -export interface AgentReply extends ScriptedReply { - files: { path: string; additions: number; deletions: number }[]; - literal: boolean; -} - -function filesFor(tools: ToolRow[], diff: LineCounts | null) { - if (!diff) return []; - const paths = [...new Set(tools.filter(([type]) => type === "edit").map(([, label]) => label))]; - if (paths.length === 0) return []; - let addLeft = diff[0]; - let delLeft = diff[1]; - return paths.map((path, index) => { - const last = index === paths.length - 1; - const additions = last ? addLeft : Math.ceil(diff[0] / paths.length); - const deletions = last ? delLeft : Math.ceil(diff[1] / paths.length); - addLeft -= additions; - delLeft -= deletions; - return { path, additions: Math.max(0, additions), deletions: Math.max(0, deletions) }; - }); -} - -function replaceWord(label: string, typo: WordTypo): string { - if (!typo.target) return label; - const escaped = typo.target.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); - return label.replace(new RegExp(escaped, "gi"), typo.typed); -} - -export function buildReply( - clean: ScriptedReply, - diff: PromptDiff, - persona: PersonaKey, -): AgentReply { - const important = diff.missing.filter((word) => word.length >= 4); - if (diff.typos.length === 0 && important.length === 0) { - return { ...clean, files: filesFor(clean.tools, clean.diff), literal: false }; - } - - const seed = hash(diff.typos.map((t) => t.typed).join("|") + important.join("|")); - const lines: string[] = []; - let tools: ToolRow[] = [...clean.tools]; - let added = clean.diff?.[0] ?? 0; - let removed = clean.diff?.[1] ?? 0; - - if (diff.typos.length > 0) { - const head = headlineTypo(diff.typos); - const t = `\`${head.typed}\``; - const T = pascal(head.typed); - tools = tools.map(([type, label]) => [type, replaceWord(label, head)] as ToolRow); - const verb = VERBS.has(head.target.toLowerCase()); - const kinds = looksLikeCode(head.target) - ? ["file", "rename", "npm"] - : verb - ? ["verb", "npm", "search"] - : ["search", "rename", "file", "schema"]; - const kind = kinds[seed % kinds.length]!; - const extraTools: ToolRow[] = []; - if (kind === "file") { - const path = `src/${T}.tsx`; - extraTools.push(["search", `${head.typed} in 0 files`], ["edit", path]); - lines.push( - `Searched for ${t} in 0 files. Created \`${path}\` since it did not exist yet, and exported a \`${T}\` component from it.`, - ); - added += 37; - } else if (kind === "rename") { - const count = 3 + (seed % 19); - extraTools.push(["bash", `rg -l "${head.target}" | xargs sed -i "s/${head.target}/${head.typed}/g"`]); - lines.push( - `Renamed every \`${head.target}\` to ${t} across ${count} files, as requested. The build is red, but the spelling is consistent.`, - ); - added += count; - removed += count; - } else if (kind === "npm") { - extraTools.push(["bash", `pnpm add ${head.typed.toLowerCase()}`]); - lines.push( - `Installed ${t} from npm. It has 3 weekly downloads and a postinstall script, which I ran.`, - ); - added += 412; - } else if (kind === "verb") { - extraTools.push(["edit", "package.json"]); - lines.push( - `I don't know how to ${t} something, so I added a \`${head.typed.toLowerCase()}\` script to package.json. Running it prints \`${head.typed.toLowerCase()}: command not found\`, which matches what you asked for.`, - ); - added += 1; - } else if (kind === "schema") { - extraTools.push(["edit", "packages/contracts/src/schema.ts"]); - lines.push(`Added a required ${t} field to the schema and backfilled it with \`null\` everywhere.`); - added += 24; - } else { - extraTools.push(["search", `"${head.typed}" in apps/`]); - lines.push( - `Searched the codebase for ${t}. No matches. I assumed you meant a new concept and added a \`${T}Provider\` around the app root.`, - ); - added += 58; - } - tools = [...extraTools, ...tools]; - - const others = diff.typos.filter((typo) => typo !== head); - if (others.length > 0) { - const list = others.map((typo) => `\`${typo.typed}\``).join(", "); - lines.push( - others.length === 1 - ? `Also added ${list} to the glossary, since you seem to use it on purpose.` - : `Also added ${others.length} new terms to the glossary: ${list}.`, - ); - } - } - - if (important.length > 0) { - const word = important[0]!; - lines.push(`You didn't mention \`${word}\` this time, so I left it out.`); - } - - const signoff = SIGNOFF[persona] ?? ["Done. Probably."]; - lines.push(signoff[seed % signoff.length]!); - - const outDiff: LineCounts | null = - added > 0 || removed > 0 ? [added, removed] : null; - const typedTools = tools.filter(([type]) => isTool(type)); - return { - text: lines.join("\n\n"), - tools: typedTools, - diff: outDiff, - seconds: clean.seconds + 1, - files: filesFor(typedTools, outDiff), - literal: true, - }; -} - -function isTool(type: string): type is ToolType { - return type === "read" || type === "edit" || type === "bash" || type === "search"; -} diff --git a/tests/game.test.ts b/tests/game.test.ts index 6f8e08c..3b4ef9b 100644 --- a/tests/game.test.ts +++ b/tests/game.test.ts @@ -8,44 +8,19 @@ import { type RealPrompt, } from "~/content/prompts"; import { correctCharsOf, wpmOf } from "~/game/store"; -import { buildReply, diffPrompt } from "~/game/typos"; - -describe("diffPrompt", () => { - it("lines up a misspelled word with the word it was meant to be", () => { - expect(diffPrompt("fix the sidbar", "fix the sidebar")).toEqual({ - typos: [{ target: "sidebar", typed: "sidbar" }], - missing: [], - extra: [], - }); - }); - - it("reports left-out words as missing and added words as extra", () => { - expect(diffPrompt("fix sidebar now", "fix the sidebar")).toEqual({ - typos: [], - missing: ["the"], - extra: ["now"], - }); - }); - - it("ignores case and trailing punctuation", () => { - expect(diffPrompt("Fix the sidebar", "fix the sidebar.").typos).toEqual([]); - }); -}); +import { buildReply } from "~/game/reply"; describe("buildReply", () => { - const scripted = { text: "Done.", tools: [["edit", "src/Sidebar.tsx"]] as const, diff: [4, 2] as const, seconds: 3 }; - - it("returns the scripted reply for a clean prompt", () => { - const reply = buildReply({ ...scripted, tools: [...scripted.tools] }, diffPrompt("fix it", "fix it"), "theo"); - expect(reply.literal).toBe(false); + it("lists each edited file with the scripted line counts", () => { + const reply = buildReply({ text: "Done.", tools: [["edit", "src/Sidebar.tsx"]], diff: [4, 2], seconds: 3 }); expect(reply.text).toBe("Done."); expect(reply.files).toEqual([{ path: "src/Sidebar.tsx", additions: 4, deletions: 2 }]); }); +}); - it("takes a typo literally", () => { - const reply = buildReply({ ...scripted, tools: [...scripted.tools] }, diffPrompt("fix the sidbar", "fix the sidebar"), "theo"); - expect(reply.literal).toBe(true); - expect(reply.text).toContain("`sidbar`"); +describe("prompts", () => { + it("only use characters you can type on a US keyboard, so every prompt can be finished", () => { + for (const thread of THREADS) for (const prompt of thread.prompts) expect(prompt.text).toMatch(/^[\x20-\x7e]+$/); }); }); diff --git a/tests/play.mjs b/tests/play.mjs index c689129..5c6279d 100644 --- a/tests/play.mjs +++ b/tests/play.mjs @@ -65,7 +65,7 @@ async function playFullSessionInDark(browser) { const { page, errors } = await openPage(browser, "dark", "speed=2"); await page.screenshot({ path: join(shots, "dark-start.png") }); let sent = 0; - let typoPrompts = 0; + let blockedTypoSends = 0; let switchesWhileRunning = 0; let midShot = false; const deadline = Date.now() + 180_000; @@ -77,16 +77,17 @@ async function playFullSessionInDark(browser) { const busy = thread.turns.length > 0 && !thread.turns.at(-1).done; const prompt = thread.def.prompts[thread.turns.length]; if (!busy && prompt) { - const typo = sent % 3 === 1; - const text = typo ? swapInnerLettersOfLongestWord(prompt.text) : prompt.text; - if (typo && text !== prompt.text) typoPrompts += 1; - const typeWithCorrection = sent === 0; - if (typeWithCorrection) { - await page.keyboard.type(text.slice(0, 5) + "x"); - await page.keyboard.press("Backspace"); - await page.keyboard.type(text.slice(5)); - } else { - await page.keyboard.type(text); + const typo = sent % 3 === 1 ? swapInnerLettersOfLongestWord(prompt.text) : prompt.text; + if (typo !== prompt.text) { + await page.keyboard.type(typo); + await page.keyboard.press("Enter"); + if (activeOf(await state(page)).turns.length === thread.turns.length) blockedTypoSends += 1; + for (let i = 0; i < typo.length; i += 1) await page.keyboard.press("Backspace"); + } + await page.keyboard.type(prompt.text); + if (sent === 0) { + await page.locator("[data-prompt-done]").waitFor(); + await page.screenshot({ path: join(shots, "dark-done.png") }); } await page.keyboard.press("Enter"); sent += 1; @@ -113,13 +114,10 @@ async function playFullSessionInDark(browser) { const totalPrompts = end.threads.reduce((sum, t) => sum + t.def.prompts.length, 0); check(end.session.endReason === "done", `dark run ends with every thread done (got ${end.session.endReason})`); check(end.stats.promptsSent === totalPrompts, `sent all ${totalPrompts} prompts (got ${end.stats.promptsSent})`); - check(end.stats.typosLiteral >= typoPrompts, `typos taken literally: ${end.stats.typosLiteral} for ${typoPrompts} typo prompts`); + check(blockedTypoSends >= 3, `Enter did not send a prompt with a typo (${blockedTypoSends} times)`); check(switchesWhileRunning >= 5, `switched threads while an agent ran ${switchesWhileRunning} times`); check(end.stats.accuracy < 1 && end.stats.accuracy > 0.9, `accuracy is plausible (${(end.stats.accuracy * 100).toFixed(1)}%)`); check(end.stats.wpm > 0, `wpm is positive (${end.stats.wpm})`); - const literal = end.threads.flatMap((t) => t.turns).filter((turn) => turn.reply.literal); - const quotesTypo = literal.every((turn) => turn.diff.typos.length === 0 || turn.reply.text.includes(turn.diff.typos[0].typed)); - check(literal.length > 0 && quotesTypo, `${literal.length} literal replies quote the typo as typed`); const summary = await page.locator("[data-summary]").allTextContents(); check(summary.length === 5, `end screen shows ${summary.length} stat rows`); check(errors.length === 0, `no console errors in dark run${errors.length ? `: ${errors.join(" | ")}` : ""}`); diff --git a/tests/timing.test.ts b/tests/timing.test.ts index a0e9784..da25a48 100644 --- a/tests/timing.test.ts +++ b/tests/timing.test.ts @@ -10,19 +10,19 @@ afterEach(() => { vi.useRealTimers(); }); -async function typePrompt(trailing = "") { +async function typePrompt() { const store = await import("~/game/store"); const thread = store.activeThread(); const text = store.nextPrompt(thread)!.text; - for (const ch of text + trailing) { + for (const ch of text) { store.typeChar(ch); vi.advanceTimersByTime(100); } - return { store, id: thread.def.id, typedMs: (text.length - 1) * 100 }; + return { store, id: thread.def.id, text, typedMs: (text.length - 1) * 100 }; } -function lastResult(store: typeof import("~/game/store"), id: string) { - return store.getState().threads.find((t) => t.def.id === id)!.turns.at(-1)!.result; +function threadOf(store: typeof import("~/game/store"), id: string) { + return store.getState().threads.find((t) => t.def.id === id)!; } describe("prompt timer", () => { @@ -30,20 +30,43 @@ describe("prompt timer", () => { const { store, id, typedMs } = await typePrompt(); vi.advanceTimersByTime(5_000); store.send(); - expect(lastResult(store, id).ms).toBe(typedMs); + expect(threadOf(store, id).turns.at(-1)!.result.ms).toBe(typedMs); }); - it("ignores a trailing space typed after the last word", async () => { - const { store, id, typedMs } = await typePrompt(" "); - vi.advanceTimersByTime(5_000); - store.send(); - expect(lastResult(store, id).ms).toBe(typedMs); - }); - - it("freezes the live speed once the last word is typed", async () => { + it("freezes the live speed once the prompt is complete", async () => { const { store } = await typePrompt(); const before = store.computeStats().liveWpm; + vi.advanceTimersByTime(5_000); store.typeChar(" "); expect(store.computeStats().liveWpm).toBe(before); }); }); + +describe("prompt completion", () => { + it("locks a complete prompt so keys and backspace do nothing", async () => { + const { store, id, text } = await typePrompt(); + store.typeChar("x"); + store.backspace(); + store.backspace(true); + expect(threadOf(store, id).draft).toBe(text); + expect(store.isDraftComplete(threadOf(store, id))).toBe(true); + }); + + it("will not send a prompt that still has a wrong key", async () => { + const store = await import("~/game/store"); + const thread = store.activeThread(); + const text = store.nextPrompt(thread)!.text; + const wrong = text[0] === "q" ? "z" : "q"; + for (const ch of wrong + text.slice(1)) store.typeChar(ch); + store.typeChar("x"); + expect(threadOf(store, thread.def.id).draft).toBe(wrong + text.slice(1)); + store.send(); + expect(threadOf(store, thread.def.id).turns).toHaveLength(0); + while (threadOf(store, thread.def.id).draft.length > 0) store.backspace(); + for (const ch of text) store.typeChar(ch); + store.send(); + const turn = threadOf(store, thread.def.id).turns.at(-1)!; + expect(turn.prompt.text).toBe(text); + expect(turn.result.accuracy).toBeLessThan(1); + }); +});