From 1862977f38ee0175f1c495b95f7131b0f70e3a71 Mon Sep 17 00:00:00 2001 From: Naseem AlNaji Date: Thu, 5 Mar 2026 16:59:21 -0500 Subject: [PATCH] feat(playground): replace demo with interactive Wordle game Rewrite the playground as a fully playable Wordle clone that showcases webmcp-react hooks. Tools dynamically register/unregister across game phases (idle, playing, won/lost), and guesses can be made via keyboard or through the DevPanel/MCP bridge. Includes game engine with tests, extension bridge detection banner, and easy-mode toggle for the hint tool. --- .gitignore | 3 +- README.md | 4 +- examples/playground/index.html | 2 +- examples/playground/src/App.css | 221 +++++++++- examples/playground/src/App.tsx | 384 ++++++++++++------ examples/playground/src/components/Board.css | 67 +++ examples/playground/src/components/Board.tsx | 66 +++ .../src/components/ExtensionBanner.css | 8 +- .../src/components/ExtensionBanner.tsx | 4 +- .../playground/src/components/Keyboard.css | 53 +++ .../playground/src/components/Keyboard.tsx | 48 +++ examples/playground/src/data/words.ts | 73 ++++ examples/playground/src/game/engine.test.ts | 311 ++++++++++++++ examples/playground/src/game/engine.ts | 181 +++++++++ .../playground/src/tools/GameStatusTool.tsx | 42 ++ .../playground/src/tools/GuessWordTool.tsx | 69 ++++ examples/playground/src/tools/HintTool.tsx | 42 ++ .../playground/src/tools/StartGameTool.tsx | 25 ++ examples/playground/tsconfig.json | 1 + vitest.config.ts | 2 +- 20 files changed, 1453 insertions(+), 153 deletions(-) create mode 100644 examples/playground/src/components/Board.css create mode 100644 examples/playground/src/components/Board.tsx create mode 100644 examples/playground/src/components/Keyboard.css create mode 100644 examples/playground/src/components/Keyboard.tsx create mode 100644 examples/playground/src/data/words.ts create mode 100644 examples/playground/src/game/engine.test.ts create mode 100644 examples/playground/src/game/engine.ts create mode 100644 examples/playground/src/tools/GameStatusTool.tsx create mode 100644 examples/playground/src/tools/GuessWordTool.tsx create mode 100644 examples/playground/src/tools/HintTool.tsx create mode 100644 examples/playground/src/tools/StartGameTool.tsx diff --git a/.gitignore b/.gitignore index b6bcb42..f4a9f04 100644 --- a/.gitignore +++ b/.gitignore @@ -12,4 +12,5 @@ examples/*/dist/ examples/nextjs/.next/ *.tsbuildinfo src/**/*.js -examples/*/src/**/*.js \ No newline at end of file +examples/*/src/**/*.js +docs/plans/ diff --git a/README.md b/README.md index 31e7a3e..81a7a7e 100644 --- a/README.md +++ b/README.md @@ -23,9 +23,9 @@ npm install webmcp-react zod ## Playground -Try it live: [**webmcp-react playground**](https://mcpcat.github.io/webmcp-react/playground/) +Try it live: [**WebMCP Wordle Demo**](https://mcpcat.github.io/webmcp-react/playground/) -The playground registers several example tools and includes a DevPanel for testing tool execution. Install the Chrome extension to bridge tools to AI clients like Claude and Cursor. +A fully playable Wordle clone that showcases `webmcp-react` hooks. Tools dynamically register and unregister as the game moves through phases (idle, playing, won/lost), and guesses can be made via keyboard or through a connected MCP agent. Includes a DevPanel for inspecting tool state and an easy-mode toggle that enables a hint tool. Install the Chrome extension to bridge tools to AI clients like Claude and Cursor. ## Quick start diff --git a/examples/playground/index.html b/examples/playground/index.html index 8ffdf56..399b748 100644 --- a/examples/playground/index.html +++ b/examples/playground/index.html @@ -3,7 +3,7 @@ - webmcp-react Playground + WebMCP Wordle
diff --git a/examples/playground/src/App.css b/examples/playground/src/App.css index f94277e..4608f9d 100644 --- a/examples/playground/src/App.css +++ b/examples/playground/src/App.css @@ -1,3 +1,5 @@ +/* ── Reset & Base ──────────────────────────────────── */ + * { margin: 0; padding: 0; @@ -6,30 +8,229 @@ body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; - background: #0a0a0a; + background: #121213; color: #e0e0e0; } -.app { - max-width: 600px; - padding: 2rem; +/* ── Layout ───────────────────────────────────────── */ + +.app-shell { + display: flex; + flex-direction: column; + height: 100vh; + overflow: hidden; +} + +.app-layout { + display: flex; + flex-direction: row; + min-height: 0; + flex: 1; +} + +.game-area { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + padding: 1.5rem 1rem; + margin-right: 380px; } -.app-header h1 { +/* ── Header ───────────────────────────────────────── */ + +.game-header { + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-between; + width: 100%; + max-width: 400px; + padding-bottom: 0.75rem; + margin-bottom: 1.25rem; + border-bottom: 1px solid #3a3a3c; +} + +.game-header h1 { font-size: 1.5rem; + font-weight: 700; + letter-spacing: 0.04em; +} + +/* ── Status Badge ─────────────────────────────────── */ + +.status-badge { + font-size: 0.7rem; font-weight: 600; - margin-bottom: 0.5rem; + padding: 0.2em 0.65em; + border-radius: 999px; + background: #3a3a3c; + color: #888; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.status-badge.online { + background: #1b3a1b; + color: #538d4e; } -.app-header p { +/* ── Start Screen ─────────────────────────────────── */ + +.start-screen { + display: flex; + flex-direction: column; + align-items: center; + gap: 1.25rem; + max-width: 400px; + text-align: center; + margin-top: 2rem; +} + +.tagline { color: #888; - font-size: 0.875rem; - line-height: 1.5; + font-size: 0.95rem; + line-height: 1.6; } -.app-header code { +.tagline code { background: #1a1a2e; padding: 0.15em 0.4em; border-radius: 4px; + font-size: 0.85rem; + color: #a0a0d0; +} + +.hint-text { + color: #666; + font-size: 0.85rem; + line-height: 1.5; +} + +.hint-text strong { + color: #888; +} + +.mode-descriptions { + color: #888; font-size: 0.8rem; + line-height: 1.5; + text-align: center; + max-width: 340px; +} + +/* ── Buttons ──────────────────────────────────────── */ + +.start-buttons { + display: flex; + gap: 0.75rem; +} + +.btn { + font-size: 0.9rem; + font-weight: 600; + padding: 0.6em 1.4em; + border: none; + border-radius: 6px; + cursor: pointer; + transition: opacity 0.15s; +} + +.btn:hover { + opacity: 0.85; +} + +.btn-primary { + background: #538d4e; + color: #fff; +} + +.btn-secondary { + background: #3a3a3c; + color: #e0e0e0; +} + +/* ── Game Info Bar ────────────────────────────────── */ + +.game-info { + display: flex; + flex-direction: row; + align-items: center; + gap: 0.75rem; + margin-bottom: 0.75rem; + font-size: 0.85rem; + color: #888; +} + +.guess-counter { + font-variant-numeric: tabular-nums; +} + +.hard-badge { + font-size: 0.65rem; + font-weight: 700; + padding: 0.15em 0.55em; + border-radius: 999px; + background: rgba(181, 159, 59, 0.2); + color: #b59f3b; + text-transform: uppercase; + letter-spacing: 0.06em; +} + +/* ── Game Message ─────────────────────────────────── */ + +.game-message { + margin-top: 0.75rem; + padding: 0.5em 1.2em; + border-radius: 6px; + background: #1a1a1c; + color: #e0e0e0; + font-size: 0.85rem; + text-align: center; +} + +/* ── Game Over ────────────────────────────────────── */ + +.game-over { + display: flex; + flex-direction: column; + align-items: center; + gap: 1rem; + margin-top: 1.25rem; +} + +.game-over-text { + font-size: 1.1rem; + font-weight: 600; +} + +.game-over-text.win { + color: #538d4e; +} + +.game-over-text.lose { + color: #888; +} + +.game-over-text strong { + color: #e0e0e0; + letter-spacing: 0.08em; +} + +/* ── Easy Mode Toggle ─────────────────────────────── */ + +.easy-mode-toggle { + display: flex; + align-items: center; + gap: 0.4rem; + margin-top: auto; + padding-top: 1.5rem; + font-size: 0.75rem; + color: #666; + cursor: pointer; + user-select: none; +} + +.easy-mode-toggle input[type="checkbox"] { + cursor: pointer; } diff --git a/examples/playground/src/App.tsx b/examples/playground/src/App.tsx index 4b26a80..12ccf1f 100644 --- a/examples/playground/src/App.tsx +++ b/examples/playground/src/App.tsx @@ -1,155 +1,275 @@ -import { useState } from "react"; -import { WebMCPProvider, useMcpTool, useWebMCPStatus } from "webmcp-react"; -import { z } from "zod"; +import { useState, useCallback, useEffect } from "react"; +import { WebMCPProvider, useWebMCPStatus } from "webmcp-react"; import { DevPanel } from "./components/DevPanel"; import { ExtensionBanner } from "./components/ExtensionBanner"; +import { Board } from "./components/Board"; +import Keyboard from "./components/Keyboard"; +import { StartGameTool } from "./tools/StartGameTool"; +import { GuessWordTool } from "./tools/GuessWordTool"; +import { GameStatusTool } from "./tools/GameStatusTool"; +import { HintTool } from "./tools/HintTool"; +import { + createInitialState, + getLetterStatuses, + evaluateGuess, + validateHardMode, + MAX_GUESSES, + WORD_LENGTH, +} from "./game/engine"; +import type { GameState, LetterResult, Difficulty } from "./game/engine"; +import { getRandomAnswer, isValidWord } from "./data/words"; import "./App.css"; -function SearchTool() { - useMcpTool({ - name: "search", - description: "Search the catalog", - input: z.object({ query: z.string() }), - handler: async ({ query }) => ({ - content: [{ type: "text", text: `Results for: ${query}` }], - }), - }); - return null; -} - -function TranslateTool() { - const { state, execute } = useMcpTool({ - name: "translate", - description: "Translate text to Spanish", - input: z.object({ text: z.string() }), - handler: async ({ text }) => { - await new Promise((r) => setTimeout(r, 500)); // simulate latency - const translations: Record = { - Hello: "Hola", - Goodbye: "Adiós", - "Thank you": "Gracias", - }; - const result = translations[text] ?? `[translated] ${text}`; - return { content: [{ type: "text", text: result }] }; - }, - }); - +function StatusBadge() { + const { available } = useWebMCPStatus(); return ( -
-

Translate (execution state)

- - {state.lastResult && state.lastResult.content[0].type === "text" && ( -

{state.lastResult.content[0].text}

- )} - {state.error &&

{state.error.message}

} -
+ + {available ? "WebMCP Active" : "Loading..."} + ); } -function DeleteUserTool() { - useMcpTool({ - name: "delete_user", - description: "Permanently delete a user account", - input: z.object({ userId: z.string() }), - annotations: { - destructiveHint: true, - idempotentHint: true, - }, - handler: async ({ userId }) => ({ - content: [{ type: "text", text: `Deleted user ${userId}` }], - }), - }); - return null; -} +function WordleGame() { + const [gameState, setGameState] = useState(createInitialState); + const [currentInput, setCurrentInput] = useState(""); + const [message, setMessage] = useState(""); + const [easyMode, setEasyMode] = useState(false); -function CheckoutTool() { - useMcpTool({ - name: "checkout", - description: "Complete a purchase", - input: z.object({ cartId: z.string() }), - handler: async ({ cartId }) => { - await new Promise((r) => setTimeout(r, 300)); - return { content: [{ type: "text", text: `Order placed for cart ${cartId}` }] }; - }, - onSuccess: (result) => console.log("[onSuccess]", result), - onError: (error) => console.error("[onError]", error), - }); - return null; -} + const handleStart = useCallback((difficulty: Difficulty) => { + const answer = getRandomAnswer(); + setGameState({ + phase: "playing", + targetWord: answer, + guesses: [], + difficulty, + easyMode: false, + }); + setCurrentInput(""); + setMessage(""); + }, []); -function CalculateTool() { - useMcpTool({ - name: "calculate", - description: "Basic arithmetic", - inputSchema: { - type: "object", - properties: { - a: { type: "number" }, - b: { type: "number" }, - op: { type: "string", enum: ["add", "subtract", "multiply", "divide"] }, - }, - required: ["a", "b", "op"], + const handleGuess = useCallback( + (result: LetterResult[], won: boolean, lost: boolean) => { + setGameState((prev) => ({ + ...prev, + guesses: [...prev.guesses, result], + phase: won ? "won" : lost ? "lost" : prev.phase, + })); + setCurrentInput(""); + if (won) { + setMessage("You won!"); + } else if (lost) { + setMessage(`Game over! The word was ${gameState.targetWord}.`); + } else { + setMessage(""); + } }, - handler: async (args) => { - const { a, b, op } = args as { a: number; b: number; op: string }; - const result = { add: a + b, subtract: a - b, multiply: a * b, divide: a / b }[op]; - return { content: [{ type: "text", text: String(result) }] }; + [gameState.targetWord], + ); + + const submitGuess = useCallback(() => { + if (currentInput.length !== WORD_LENGTH) { + setMessage("Not enough letters"); + return; + } + + if (!isValidWord(currentInput)) { + setMessage("Not in word list"); + return; + } + + if (gameState.difficulty === "hard") { + const hardModeError = validateHardMode(currentInput, gameState.guesses); + if (hardModeError) { + setMessage(hardModeError); + return; + } + } + + const result = evaluateGuess(currentInput, gameState.targetWord); + const won = result.every((r) => r.status === "correct"); + const lost = !won && gameState.guesses.length + 1 >= MAX_GUESSES; + handleGuess(result, won, lost); + }, [currentInput, gameState, handleGuess]); + + const handleKey = useCallback( + (key: string) => { + if (gameState.phase !== "playing") return; + + if (key === "ENTER") { + submitGuess(); + return; + } + + if (key === "BACK") { + setCurrentInput((prev) => prev.slice(0, -1)); + return; + } + + if (/^[A-Z]$/.test(key) && currentInput.length < WORD_LENGTH) { + setCurrentInput((prev) => prev + key); + } }, - }); - return null; -} + [gameState.phase, currentInput.length, submitGuess], + ); -function AdminTools() { - useMcpTool({ - name: "admin_reset", - description: "Reset all user sessions (admin only)", - input: z.object({}), - handler: async () => ({ - content: [{ type: "text", text: "All sessions reset" }], - }), - }); - return null; -} + // Physical keyboard listener + useEffect(() => { + const onKeyDown = (e: KeyboardEvent) => { + if (e.ctrlKey || e.metaKey || e.altKey) return; + + if (e.key === "Enter") { + handleKey("ENTER"); + } else if (e.key === "Backspace") { + handleKey("BACK"); + } else if (/^[a-zA-Z]$/.test(e.key)) { + handleKey(e.key.toUpperCase()); + } + }; + + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, [handleKey]); + + const letterStatuses = getLetterStatuses(gameState.guesses); + + const showStartTool = gameState.phase !== "playing"; + const showPlayingTools = gameState.phase === "playing"; + const showHintTool = showPlayingTools && easyMode; -function StatusBar() { - const { available } = useWebMCPStatus(); return ( -

- Status: {available ? "available" : "loading..."} -

+
+ {/* Conditional tool registration — the key WebMCP demo */} + {showStartTool && } + {showPlayingTools && ( + + )} + {showPlayingTools && } + {showHintTool && } + +
+

WebMCP Wordle

+ +
+ + {gameState.phase === "idle" && ( +
+

+ A Wordle clone powered by{" "} + webmcp-react — every game action is an MCP tool. +

+

+ Open the DevPanel on the right to see tools + register and unregister as game state changes. +

+
+ + +
+

+ Normal: Guess any valid 5-letter word each turn. +
+ Hard: Green letters must stay in the same position and yellow letters must be reused in every subsequent guess. +

+
+ )} + + {gameState.phase === "playing" && ( + <> +
+ + {gameState.guesses.length} / {MAX_GUESSES} + + {gameState.difficulty === "hard" && ( + HARD + )} +
+ + {message &&
{message}
} + + + )} + + {(gameState.phase === "won" || gameState.phase === "lost") && ( + <> + +
+ {gameState.phase === "won" ? ( +

+ You guessed it in {gameState.guesses.length} attempt + {gameState.guesses.length !== 1 ? "s" : ""}! +

+ ) : ( +

+ The word was {gameState.targetWord}. +

+ )} +
+ + +
+

+ Normal: Guess any valid 5-letter word each turn. +
+ Hard: Green letters must stay in the same position and yellow letters must be reused in every subsequent guess. +

+
+ + )} + + +
); } export default function App() { - const [isAdmin, setIsAdmin] = useState(false); - return ( - - -
-
-

webmcp-react playground

- -
- - - - - - - -
-

Dynamic tools

- -
- {isAdmin && } - - + +
+ +
+ + +
); diff --git a/examples/playground/src/components/Board.css b/examples/playground/src/components/Board.css new file mode 100644 index 0000000..00e4081 --- /dev/null +++ b/examples/playground/src/components/Board.css @@ -0,0 +1,67 @@ +.board { + display: flex; + flex-direction: column; + align-items: center; + gap: 6px; + margin: 1.5rem 0; +} + +.board-row { + display: flex; + flex-direction: row; + gap: 6px; +} + +.board-cell { + width: 56px; + height: 56px; + display: flex; + align-items: center; + justify-content: center; + font-size: 1.5rem; + font-weight: bold; + border-radius: 4px; + transition: background-color 0.3s, border-color 0.3s; + text-transform: uppercase; +} + +.board-cell.empty { + border: 2px solid #333; + background-color: transparent; +} + +.board-cell.filled { + border: 2px solid #555; + background-color: transparent; + animation: pop 0.1s ease-in-out; +} + +.board-cell.correct { + background-color: #538d4e; + border: 2px solid #538d4e; + color: white; +} + +.board-cell.present { + background-color: #b59f3b; + border: 2px solid #b59f3b; + color: white; +} + +.board-cell.absent { + background-color: #3a3a3c; + border: 2px solid #3a3a3c; + color: white; +} + +@keyframes pop { + 0% { + transform: scale(1); + } + 50% { + transform: scale(1.1); + } + 100% { + transform: scale(1); + } +} diff --git a/examples/playground/src/components/Board.tsx b/examples/playground/src/components/Board.tsx new file mode 100644 index 0000000..5af05a1 --- /dev/null +++ b/examples/playground/src/components/Board.tsx @@ -0,0 +1,66 @@ +import React from "react"; +import { LetterResult, MAX_GUESSES, WORD_LENGTH } from "../game/engine"; +import "./Board.css"; + +interface BoardProps { + guesses: LetterResult[][]; + currentInput: string; + phase: "idle" | "playing" | "won" | "lost"; +} + +export const Board: React.FC = ({ guesses, currentInput, phase }) => { + const rows: React.ReactNode[] = []; + + // Completed guess rows + for (let r = 0; r < guesses.length; r++) { + const guess = guesses[r]; + rows.push( +
+ {guess.map((lr, c) => ( +
+ {lr.letter} +
+ ))} +
, + ); + } + + // Current input row (only while playing) + if (phase === "playing" && guesses.length < MAX_GUESSES) { + const cells: React.ReactNode[] = []; + for (let c = 0; c < WORD_LENGTH; c++) { + if (c < currentInput.length) { + cells.push( +
+ {currentInput[c]} +
, + ); + } else { + cells.push( +
, + ); + } + } + rows.push( +
+ {cells} +
, + ); + } + + // Empty rows to fill the rest of the grid + const filledRows = guesses.length + (phase === "playing" && guesses.length < MAX_GUESSES ? 1 : 0); + for (let r = filledRows; r < MAX_GUESSES; r++) { + rows.push( +
+ {Array.from({ length: WORD_LENGTH }, (_, c) => ( +
+ ))} +
, + ); + } + + return
{rows}
; +}; + +export default Board; diff --git a/examples/playground/src/components/ExtensionBanner.css b/examples/playground/src/components/ExtensionBanner.css index 456510d..b20c830 100644 --- a/examples/playground/src/components/ExtensionBanner.css +++ b/examples/playground/src/components/ExtensionBanner.css @@ -14,9 +14,9 @@ } .extension-banner--not-detected { - background: #2d1f00; - border-bottom: 1px solid #4d3800; - color: #fbbf24; + background: #2d0a0a; + border-bottom: 1px solid #5c1a1a; + color: #f87171; } .extension-banner__content { @@ -37,7 +37,7 @@ } .extension-banner--not-detected .extension-banner__dot { - background: #fbbf24; + background: #f87171; } .extension-banner a { diff --git a/examples/playground/src/components/ExtensionBanner.tsx b/examples/playground/src/components/ExtensionBanner.tsx index 5b26a3d..4d7a093 100644 --- a/examples/playground/src/components/ExtensionBanner.tsx +++ b/examples/playground/src/components/ExtensionBanner.tsx @@ -57,11 +57,11 @@ export function ExtensionBanner() {
- Install the{" "} + Not connected — the{" "} WebMCP Bridge extension {" "} - to connect your tools to AI clients + is not detected. Install it to expose your tools to AI clients.
+ ); + })} +
+ ))} +
+ ); +} diff --git a/examples/playground/src/data/words.ts b/examples/playground/src/data/words.ts new file mode 100644 index 0000000..0a4ff60 --- /dev/null +++ b/examples/playground/src/data/words.ts @@ -0,0 +1,73 @@ +import allWords from "../words.json"; + +/** + * Word bank for the Wordle game. + * + * ANSWERS contains ~200 common 5-letter words used as target answers. + * Guess validation uses the full word list from words.json. + */ + +const VALID_WORDS: Set = new Set( + Object.keys(allWords).map((w) => w.toUpperCase()) +); + +/** Words that can be chosen as the answer. Stored lowercase. */ +export const ANSWERS: string[] = [ + "about", "above", "abuse", "actor", "acute", + "admit", "adopt", "adult", "after", "again", + "agent", "agree", "ahead", "alarm", "album", + "alert", "alien", "align", "alive", "alley", + "allow", "alone", "along", "alter", "among", + "angel", "anger", "angle", "angry", "ankle", + "apart", "apple", "apply", "arena", "argue", + "arise", "armor", "array", "arrow", "aside", + "asset", "audio", "avoid", "award", "aware", + "badge", "badly", "baker", "basic", "basin", + "basis", "beach", "began", "begin", "being", + "belly", "below", "bench", "berry", "birth", + "black", "blade", "blame", "bland", "blank", + "blast", "blaze", "bleed", "blend", "bless", + "blind", "block", "blood", "bloom", "blown", + "board", "bonus", "boost", "bound", "brain", + "brand", "brave", "bread", "break", "breed", + "brick", "bride", "brief", "bring", "broad", + "broke", "brook", "brown", "brush", "build", + "burst", "buyer", "cabin", "candy", "carry", + "catch", "cause", "cedar", "chain", "chair", + "charm", "chart", "chase", "cheap", "check", + "cheek", "cheer", "chest", "chief", "child", + "china", "claim", "class", "clean", "clear", + "climb", "cling", "clock", "close", "cloud", + "coach", "coast", "color", "couch", "could", + "count", "court", "cover", "crack", "craft", + "crane", "crash", "crazy", "cream", "crime", + "cross", "crowd", "crown", "crush", "curve", + "cycle", "daily", "dance", "debut", "decay", + "delay", "depth", "derby", "devil", "diary", + "dirty", "doubt", "dough", "draft", "drain", + "drama", "drank", "drawn", "dream", "dress", + "dried", "drift", "drill", "drink", "drive", + "drown", "dying", "eager", "early", "earth", + "eight", "elder", "elect", "elite", "empty", + "enemy", "enjoy", "enter", "entry", "equal", + "error", "essay", "event", "every", "exact", + "exist", "extra", "faint", "faith", "fault", + "feast", "fence", "ferry", "fever", "fiber", + "field", "fifth", "fifty", "fight", "final", + "first", "flame", "flash", "flesh", "float", + "flood", "floor", "flour", "fluid", "flush", + "focus", "force", "forge", "forth", "forum", + "found", "frame", "frank", "fraud", "fresh", + "front", "frost", "fruit", "fully", "funny", +]; + +/** Checks if a word exists in the full word list. */ +export function isValidWord(word: string): boolean { + return VALID_WORDS.has(word.toUpperCase()); +} + +/** Returns a random answer from the ANSWERS list, uppercased. */ +export function getRandomAnswer(): string { + const index = Math.floor(Math.random() * ANSWERS.length); + return ANSWERS[index].toUpperCase(); +} diff --git a/examples/playground/src/game/engine.test.ts b/examples/playground/src/game/engine.test.ts new file mode 100644 index 0000000..6b67e16 --- /dev/null +++ b/examples/playground/src/game/engine.test.ts @@ -0,0 +1,311 @@ +import { describe, it, expect } from "vitest"; +import { + evaluateGuess, + getLetterStatuses, + getUnusedLetters, + getKnownPattern, + validateHardMode, + type LetterResult, +} from "./engine"; + +describe("evaluateGuess", () => { + it("marks all letters correct when guess matches target", () => { + const result = evaluateGuess("CRANE", "CRANE"); + expect(result).toEqual([ + { letter: "c", status: "correct" }, + { letter: "r", status: "correct" }, + { letter: "a", status: "correct" }, + { letter: "n", status: "correct" }, + { letter: "e", status: "correct" }, + ]); + }); + + it("marks all letters absent when no letters match", () => { + const result = evaluateGuess("PLUMB", "NIGHT"); + expect(result).toEqual([ + { letter: "p", status: "absent" }, + { letter: "l", status: "absent" }, + { letter: "u", status: "absent" }, + { letter: "m", status: "absent" }, + { letter: "b", status: "absent" }, + ]); + }); + + it("marks letters as present when in wrong position", () => { + const result = evaluateGuess("RENAL", "CRANE"); + // R is in target (pos 1) but guessed at pos 0 -> present + // E is in target (pos 4) but guessed at pos 1 -> present + // N is in target (pos 3) but guessed at pos 2 -> present + // A is in target (pos 2) but guessed at pos 3 -> present + // L is not in target -> absent + expect(result).toEqual([ + { letter: "r", status: "present" }, + { letter: "e", status: "present" }, + { letter: "n", status: "present" }, + { letter: "a", status: "present" }, + { letter: "l", status: "absent" }, + ]); + }); + + it("green consumes before yellow with duplicate letters (GOOSE vs MOOSE)", () => { + const result = evaluateGuess("GOOSE", "MOOSE"); + // G vs M -> absent + // O vs O -> correct + // O vs O -> correct + // S vs S -> correct + // E vs E -> correct + expect(result).toEqual([ + { letter: "g", status: "absent" }, + { letter: "o", status: "correct" }, + { letter: "o", status: "correct" }, + { letter: "s", status: "correct" }, + { letter: "e", status: "correct" }, + ]); + }); + + it("only awards one yellow when target has one instance of that letter", () => { + // Guess ALLOY against PLUMB: + // Target has one L at position 1. + // Guess has L at positions 1 (correct) and 2 (should be absent since the single L is consumed by the green). + const result = evaluateGuess("ALLOY", "PLUMB"); + // A vs P -> absent + // L vs L -> correct + // L: only one L in target, already consumed -> absent + // O vs M -> absent + // Y vs B -> absent + expect(result).toEqual([ + { letter: "a", status: "absent" }, + { letter: "l", status: "correct" }, + { letter: "l", status: "absent" }, + { letter: "o", status: "absent" }, + { letter: "y", status: "absent" }, + ]); + }); + + it("gives only one yellow for duplicate guess letters when target has one instance", () => { + // Guess HELLO against ANGER: no letters overlap -> all absent except... + // Actually let's pick a clearer example: + // Guess LLAMA against CLASP: target has one L at position 1 + // L at position 0: not exact match, but L is in pool at index 1 -> present (consumes pool[1]) + // L at position 1: target[1] is 'l' -> correct... wait let me think about this. + // target = CLASP -> c, l, a, s, p + // guess = LLAMA -> l, l, a, m, a + // Pass 1: position 1: guess l vs target l -> correct, pool[1] = "" + // position 2: guess a vs target a -> correct, pool[2] = "" + // Pool after pass 1: ["c", "", "", "s", "p"] + // Pass 2: position 0: guess l, pool has no 'l' -> absent + // position 3: guess m, pool has no 'm' -> absent + // position 4: guess a, pool has no 'a' -> absent + const result = evaluateGuess("LLAMA", "CLASP"); + expect(result).toEqual([ + { letter: "l", status: "absent" }, + { letter: "l", status: "correct" }, + { letter: "a", status: "correct" }, + { letter: "m", status: "absent" }, + { letter: "a", status: "absent" }, + ]); + }); +}); + +describe("getLetterStatuses", () => { + it("returns highest priority status per letter across multiple guesses", () => { + const guesses: LetterResult[][] = [ + // First guess: 'a' is absent, 'b' is present + [ + { letter: "a", status: "absent" }, + { letter: "b", status: "present" }, + { letter: "c", status: "absent" }, + { letter: "d", status: "absent" }, + { letter: "e", status: "absent" }, + ], + // Second guess: 'a' is present (upgrades from absent), 'b' is correct (upgrades from present) + [ + { letter: "a", status: "present" }, + { letter: "b", status: "correct" }, + { letter: "f", status: "absent" }, + { letter: "g", status: "absent" }, + { letter: "h", status: "absent" }, + ], + ]; + + const statuses = getLetterStatuses(guesses); + + expect(statuses.get("a")).toBe("present"); + expect(statuses.get("b")).toBe("correct"); + expect(statuses.get("c")).toBe("absent"); + expect(statuses.get("f")).toBe("absent"); + }); + + it("does not downgrade a status with a lower-priority one", () => { + const guesses: LetterResult[][] = [ + [ + { letter: "x", status: "correct" }, + { letter: "y", status: "absent" }, + { letter: "z", status: "absent" }, + { letter: "w", status: "absent" }, + { letter: "v", status: "absent" }, + ], + [ + { letter: "x", status: "absent" }, + { letter: "y", status: "absent" }, + { letter: "z", status: "absent" }, + { letter: "w", status: "absent" }, + { letter: "v", status: "absent" }, + ], + ]; + + const statuses = getLetterStatuses(guesses); + expect(statuses.get("x")).toBe("correct"); + }); +}); + +describe("getUnusedLetters", () => { + it("excludes guessed letters and includes unguessed ones", () => { + const guesses: LetterResult[][] = [ + [ + { letter: "a", status: "absent" }, + { letter: "b", status: "absent" }, + { letter: "c", status: "absent" }, + { letter: "d", status: "absent" }, + { letter: "e", status: "absent" }, + ], + ]; + + const unused = getUnusedLetters(guesses); + + // a-e should be excluded + expect(unused).not.toContain("A"); + expect(unused).not.toContain("B"); + expect(unused).not.toContain("C"); + expect(unused).not.toContain("D"); + expect(unused).not.toContain("E"); + + // f-z should be included (21 letters) + expect(unused).toHaveLength(21); + expect(unused).toContain("F"); + expect(unused).toContain("Z"); + }); + + it("returns all 26 letters when no guesses have been made", () => { + const unused = getUnusedLetters([]); + expect(unused).toHaveLength(26); + expect(unused[0]).toBe("A"); + expect(unused[25]).toBe("Z"); + }); + + it("returns uppercase letters", () => { + const unused = getUnusedLetters([]); + for (const letter of unused) { + expect(letter).toBe(letter.toUpperCase()); + } + }); +}); + +describe("getKnownPattern", () => { + it("shows correct letters in position and underscores elsewhere", () => { + const guesses: LetterResult[][] = [ + [ + { letter: "c", status: "correct" }, + { letter: "r", status: "absent" }, + { letter: "a", status: "present" }, + { letter: "n", status: "absent" }, + { letter: "e", status: "correct" }, + ], + ]; + + const pattern = getKnownPattern(guesses); + expect(pattern).toBe("C___E"); + }); + + it("accumulates correct letters across multiple guesses", () => { + const guesses: LetterResult[][] = [ + [ + { letter: "c", status: "correct" }, + { letter: "r", status: "absent" }, + { letter: "a", status: "absent" }, + { letter: "n", status: "absent" }, + { letter: "e", status: "absent" }, + ], + [ + { letter: "c", status: "correct" }, + { letter: "l", status: "absent" }, + { letter: "i", status: "absent" }, + { letter: "m", status: "correct" }, + { letter: "b", status: "absent" }, + ], + ]; + + const pattern = getKnownPattern(guesses); + expect(pattern).toBe("C__M_"); + }); + + it("returns all underscores when no letters are correct", () => { + const guesses: LetterResult[][] = [ + [ + { letter: "x", status: "absent" }, + { letter: "y", status: "absent" }, + { letter: "z", status: "present" }, + { letter: "w", status: "absent" }, + { letter: "v", status: "absent" }, + ], + ]; + + const pattern = getKnownPattern(guesses); + expect(pattern).toBe("_____"); + }); +}); + +describe("validateHardMode", () => { + it("returns null for a valid guess", () => { + const previousGuesses: LetterResult[][] = [ + [ + { letter: "c", status: "correct" }, + { letter: "r", status: "absent" }, + { letter: "a", status: "present" }, + { letter: "n", status: "absent" }, + { letter: "e", status: "absent" }, + ], + ]; + + // Guess must have 'c' at position 0 and 'a' somewhere + const result = validateHardMode("CHAOS", previousGuesses); + expect(result).toBeNull(); + }); + + it("returns error when missing a correct-position letter", () => { + const previousGuesses: LetterResult[][] = [ + [ + { letter: "c", status: "correct" }, + { letter: "r", status: "absent" }, + { letter: "a", status: "absent" }, + { letter: "n", status: "absent" }, + { letter: "e", status: "absent" }, + ], + ]; + + // Guess does not have 'c' at position 0 + const result = validateHardMode("PLUMB", previousGuesses); + expect(result).toBe("Position 1 must be C"); + }); + + it("returns error when missing a present letter", () => { + const previousGuesses: LetterResult[][] = [ + [ + { letter: "c", status: "correct" }, + { letter: "r", status: "absent" }, + { letter: "a", status: "present" }, + { letter: "n", status: "absent" }, + { letter: "e", status: "absent" }, + ], + ]; + + // Guess has 'c' at position 0 but is missing 'a' + const result = validateHardMode("CLIMB", previousGuesses); + expect(result).toBe("Guess must contain A"); + }); + + it("returns null when no previous guesses exist", () => { + const result = validateHardMode("CRANE", []); + expect(result).toBeNull(); + }); +}); diff --git a/examples/playground/src/game/engine.ts b/examples/playground/src/game/engine.ts new file mode 100644 index 0000000..13e39bc --- /dev/null +++ b/examples/playground/src/game/engine.ts @@ -0,0 +1,181 @@ +export type LetterStatus = "correct" | "present" | "absent"; + +export interface LetterResult { + letter: string; + status: LetterStatus; +} + +export type GamePhase = "idle" | "playing" | "won" | "lost"; + +export type Difficulty = "normal" | "hard"; + +export interface GameState { + phase: GamePhase; + targetWord: string; + guesses: LetterResult[][]; + difficulty: Difficulty; + easyMode: boolean; +} + +export const MAX_GUESSES = 6; +export const WORD_LENGTH = 5; + +/** + * Returns a fresh idle game state with empty/default values. + */ +export function createInitialState(): GameState { + return { + phase: "idle", + targetWord: "", + guesses: [], + difficulty: "normal", + easyMode: false, + }; +} + +/** + * Evaluate a 5-letter guess against the target word. + * + * Uses two passes to handle duplicate letters correctly: + * Pass 1 — mark exact positional matches as "correct" and remove + * those letters from the remaining target pool. + * Pass 2 — for each non-correct letter, mark "present" if it still + * exists in the pool (consuming it), otherwise "absent". + */ +export function evaluateGuess(guess: string, target: string): LetterResult[] { + const guessLower = guess.toLowerCase(); + const targetLower = target.toLowerCase(); + + const results: LetterResult[] = Array.from({ length: WORD_LENGTH }, (_, i) => ({ + letter: guessLower[i], + status: "absent" as LetterStatus, + })); + + // Remaining target letters available for "present" matching. + const pool = targetLower.split(""); + + // Pass 1: exact matches + for (let i = 0; i < WORD_LENGTH; i++) { + if (guessLower[i] === targetLower[i]) { + results[i].status = "correct"; + pool[i] = ""; // consume this target letter + } + } + + // Pass 2: present / absent + for (let i = 0; i < WORD_LENGTH; i++) { + if (results[i].status === "correct") continue; + + const poolIndex = pool.indexOf(guessLower[i]); + if (poolIndex !== -1) { + results[i].status = "present"; + pool[poolIndex] = ""; // consume from pool + } + // otherwise stays "absent" + } + + return results; +} + +/** + * Build a map of letter -> best status across all guesses. + * Priority: correct > present > absent. + */ +export function getLetterStatuses(guesses: LetterResult[][]): Map { + const statusPriority: Record = { + correct: 2, + present: 1, + absent: 0, + }; + + const map = new Map(); + + for (const guess of guesses) { + for (const { letter, status } of guess) { + const current = map.get(letter); + if (current === undefined || statusPriority[status] > statusPriority[current]) { + map.set(letter, status); + } + } + } + + return map; +} + +/** + * Returns all A-Z letters that have not appeared in any guess. + */ +export function getUnusedLetters(guesses: LetterResult[][]): string[] { + const used = new Set(); + for (const guess of guesses) { + for (const { letter } of guess) { + used.add(letter.toLowerCase()); + } + } + + const unused: string[] = []; + for (let code = 97; code <= 122; code++) { + const ch = String.fromCharCode(code); + if (!used.has(ch)) { + unused.push(ch.toUpperCase()); + } + } + + return unused; +} + +/** + * Returns a pattern string showing confirmed (green) letter positions, + * e.g. "_E_L_" for a word where E is confirmed at index 1 and L at index 3. + */ +export function getKnownPattern(guesses: LetterResult[][]): string { + const pattern = Array.from({ length: WORD_LENGTH }, () => "_"); + + for (const guess of guesses) { + for (let i = 0; i < guess.length; i++) { + if (guess[i].status === "correct") { + pattern[i] = guess[i].letter.toUpperCase(); + } + } + } + + return pattern.join(""); +} + +/** + * Validate a guess under hard-mode rules against all previous guesses. + * + * Hard mode requires: + * - Every green (correct) letter must be reused in the same position. + * - Every yellow (present) letter must appear somewhere in the guess. + * + * Returns null if the guess is valid, or a human-readable error message. + */ +export function validateHardMode( + guess: string, + previousGuesses: LetterResult[][], +): string | null { + const guessLower = guess.toLowerCase(); + + for (const prev of previousGuesses) { + // Check green letters — must appear in the same position + for (let i = 0; i < prev.length; i++) { + if (prev[i].status === "correct") { + if (guessLower[i] !== prev[i].letter.toLowerCase()) { + return `Position ${i + 1} must be ${prev[i].letter.toUpperCase()}`; + } + } + } + + // Check yellow letters — must appear somewhere in the guess + for (const { letter, status } of prev) { + if (status === "present") { + if (!guessLower.includes(letter.toLowerCase())) { + return `Guess must contain ${letter.toUpperCase()}`; + } + } + } + } + + return null; +} diff --git a/examples/playground/src/tools/GameStatusTool.tsx b/examples/playground/src/tools/GameStatusTool.tsx new file mode 100644 index 0000000..aa84546 --- /dev/null +++ b/examples/playground/src/tools/GameStatusTool.tsx @@ -0,0 +1,42 @@ +import { useMcpTool } from "webmcp-react"; +import { z } from "zod"; +import type { GameState } from "../game/engine"; +import { MAX_GUESSES, getKnownPattern } from "../game/engine"; + +interface Props { + gameState: GameState; +} + +export function GameStatusTool({ gameState }: Props) { + useMcpTool({ + name: "get_game_status", + description: "Get the current status of the Wordle game, including guesses made, remaining attempts, and known letter positions.", + input: z.object({}), + annotations: { readOnlyHint: true, idempotentHint: true }, + handler: async () => { + const guessCount = gameState.guesses.length; + const remaining = MAX_GUESSES - guessCount; + const pattern = getKnownPattern(gameState.guesses); + const previousWords = gameState.guesses + .map((g) => g.map((r) => r.letter).join("").toUpperCase()) + .join(", "); + + const lines = [ + `Phase: ${gameState.phase}`, + `Difficulty: ${gameState.difficulty}`, + `Guesses: ${guessCount} / ${MAX_GUESSES}`, + `Remaining attempts: ${remaining}`, + `Known pattern: ${pattern}`, + ]; + + if (previousWords) { + lines.push(`Previous guesses: ${previousWords}`); + } + + return { + content: [{ type: "text", text: lines.join("\n") }], + }; + }, + }); + return null; +} diff --git a/examples/playground/src/tools/GuessWordTool.tsx b/examples/playground/src/tools/GuessWordTool.tsx new file mode 100644 index 0000000..2cf1c4f --- /dev/null +++ b/examples/playground/src/tools/GuessWordTool.tsx @@ -0,0 +1,69 @@ +import { useMcpTool } from "webmcp-react"; +import { z } from "zod"; +import type { GameState, LetterResult } from "../game/engine"; +import { evaluateGuess, validateHardMode, MAX_GUESSES } from "../game/engine"; +import { isValidWord } from "../data/words"; + +interface Props { + gameState: GameState; + onGuess: (result: LetterResult[], won: boolean, lost: boolean) => void; +} + +const STATUS_EMOJI: Record = { + correct: "🟩", + present: "🟨", + absent: "⬛", +}; + +export function GuessWordTool({ gameState, onGuess }: Props) { + useMcpTool({ + name: "guess_word", + description: "Guess a 5-letter word in the current Wordle game.", + input: z.object({ + word: z.string().length(5).describe("A 5-letter word guess."), + }), + handler: async ({ word }) => { + const guess = word.toUpperCase(); + + if (!isValidWord(guess)) { + return { + content: [{ type: "text", text: `"${guess}" is not a valid 5-letter word.` }], + isError: true, + }; + } + + if (gameState.difficulty === "hard") { + const hardModeError = validateHardMode(guess, gameState.guesses); + if (hardModeError) { + return { + content: [{ type: "text", text: `Hard mode violation: ${hardModeError}` }], + isError: true, + }; + } + } + + const result = evaluateGuess(guess, gameState.targetWord); + const won = result.every((r) => r.status === "correct"); + const lost = !won && gameState.guesses.length + 1 >= MAX_GUESSES; + + onGuess(result, won, lost); + + const emojiRow = result.map((r) => STATUS_EMOJI[r.status]).join(""); + const remaining = MAX_GUESSES - (gameState.guesses.length + 1); + + let feedback = `${guess}: ${emojiRow}`; + if (won) { + feedback += `\nCongratulations! You guessed the word in ${gameState.guesses.length + 1} attempt(s)!`; + } else if (lost) { + feedback += `\nGame over! The word was ${gameState.targetWord}.`; + } else { + feedback += `\n${remaining} attempt(s) remaining.`; + } + + return { + content: [{ type: "text", text: feedback }], + }; + }, + }); + return null; +} diff --git a/examples/playground/src/tools/HintTool.tsx b/examples/playground/src/tools/HintTool.tsx new file mode 100644 index 0000000..7501463 --- /dev/null +++ b/examples/playground/src/tools/HintTool.tsx @@ -0,0 +1,42 @@ +import { useMcpTool } from "webmcp-react"; +import type { GameState } from "../game/engine"; +import { getUnusedLetters, getKnownPattern } from "../game/engine"; + +interface Props { + gameState: GameState; +} + +export function HintTool({ gameState }: Props) { + useMcpTool({ + name: "get_hint", + description: "Get a hint for the current Wordle game. Choose between seeing unused letters or the known letter pattern.", + inputSchema: { + type: "object", + properties: { + hint_type: { + type: "string", + enum: ["unused_letters", "pattern"], + description: "unused_letters shows letters not yet tried, pattern shows known positions", + }, + }, + required: ["hint_type"], + }, + annotations: { readOnlyHint: true }, + handler: async (args) => { + const { hint_type } = args as { hint_type: "unused_letters" | "pattern" }; + + if (hint_type === "unused_letters") { + const unused = getUnusedLetters(gameState.guesses); + return { + content: [{ type: "text", text: `Unused letters: ${unused.join(", ")}` }], + }; + } + + const pattern = getKnownPattern(gameState.guesses); + return { + content: [{ type: "text", text: `Known pattern: ${pattern}` }], + }; + }, + }); + return null; +} diff --git a/examples/playground/src/tools/StartGameTool.tsx b/examples/playground/src/tools/StartGameTool.tsx new file mode 100644 index 0000000..eaeed72 --- /dev/null +++ b/examples/playground/src/tools/StartGameTool.tsx @@ -0,0 +1,25 @@ +import { useMcpTool } from "webmcp-react"; +import { z } from "zod"; +import type { Difficulty } from "../game/engine"; + +interface Props { + onStart: (difficulty: Difficulty) => void; +} + +export function StartGameTool({ onStart }: Props) { + useMcpTool({ + name: "start_game", + description: "Start a new Wordle game. Normal mode: guess any valid 5-letter word each turn. Hard mode: green letters must stay in the same position and yellow letters must be reused in every subsequent guess.", + input: z.object({ + difficulty: z.enum(["normal", "hard"]).describe("Normal: any valid word is accepted each turn. Hard: confirmed letters (green in same position, yellow somewhere) must appear in all future guesses."), + }), + annotations: { idempotentHint: true }, + handler: async ({ difficulty }) => { + onStart(difficulty); + return { + content: [{ type: "text", text: `New game started on ${difficulty} mode. Guess the 5-letter word in 6 attempts. Use the guess_word tool to make guesses.` }], + }; + }, + }); + return null; +} diff --git a/examples/playground/tsconfig.json b/examples/playground/tsconfig.json index d081e4e..e6c759c 100644 --- a/examples/playground/tsconfig.json +++ b/examples/playground/tsconfig.json @@ -9,6 +9,7 @@ "noEmit": true, "esModuleInterop": true, "skipLibCheck": true, + "resolveJsonModule": true, "isolatedModules": true, "paths": { "webmcp-react": ["../../src/index.ts"] diff --git a/vitest.config.ts b/vitest.config.ts index d69914c..4ef8ae4 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -4,7 +4,7 @@ export default defineConfig({ test: { environment: "jsdom", globals: true, - include: ["src/**/__tests__/**/*.test.{ts,tsx}"], + include: ["src/**/__tests__/**/*.test.{ts,tsx}", "examples/**/src/**/*.test.{ts,tsx}"], setupFiles: ["src/__tests__/setup.ts"], passWithNoTests: true, },