play games with/against agents
CI / Test + coverage (push) Successful in 51s

This commit is contained in:
2026-09-19 15:48:06 -09:00
parent a76f326ae3
commit 7f0e58240d
8 changed files with 300 additions and 8 deletions
+6 -2
View File
@@ -1,4 +1,5 @@
(ns tbd.ui.db) (ns tbd.ui.db
(:require [tbd.ui.tictactoe :as tictactoe]))
(def default-db (def default-db
{:calc {:x "" :y "" {:calc {:x "" :y ""
@@ -20,4 +21,7 @@
:error nil} :error nil}
;; The "Add this MCP server to Claude Code" section: :copied? is true right after the ;; The "Add this MCP server to Claude Code" section: :copied? is true right after the
;; command was copied to the clipboard. ;; command was copied to the clipboard.
:mcp {:copied? false}}) :mcp {:copied? false}
;; Tic-tac-toe, entirely client side: the board (see tbd.ui.tictactoe) and whose turn it is.
:ttt {:board tictactoe/new-board
:turn :x}})
+18 -1
View File
@@ -2,7 +2,8 @@
(:require [cljs.reader :as reader] (:require [cljs.reader :as reader]
[clojure.string :as str] [clojure.string :as str]
[re-frame.core :as rf] [re-frame.core :as rf]
[tbd.ui.db :as db])) [tbd.ui.db :as db]
[tbd.ui.tictactoe :as tictactoe]))
;; Effect: request `uri` (GET unless `method` is given), then dispatch ;; Effect: request `uri` (GET unless `method` is given), then dispatch
;; `(conj on-response {:ok? bool :status int-or-nil :body string})`. A `body` is sent as EDN. ;; `(conj on-response {:ok? bool :status int-or-nil :body string})`. A `body` is sent as EDN.
@@ -188,3 +189,19 @@
::mcp-copied-timeout ::mcp-copied-timeout
(fn [db _] (fn [db _]
(assoc-in db [:mcp :copied?] false))) (assoc-in db [:mcp :copied?] false)))
;; --- Tic-tac-toe ---
;; An illegal move (taken cell, game over) changes nothing.
(rf/reg-event-db
::ttt-move
(fn [db [_ i]]
(let [{:keys [board turn]} (:ttt db)]
(if-let [board' (tictactoe/move board turn i)]
(assoc db :ttt {:board board' :turn (tictactoe/next-player turn)})
db))))
(rf/reg-event-db
::ttt-reset
(fn [db _]
(assoc db :ttt (:ttt db/default-db))))
+14 -1
View File
@@ -1,8 +1,21 @@
(ns tbd.ui.subs (ns tbd.ui.subs
(:require [re-frame.core :as rf])) (:require [re-frame.core :as rf]
[tbd.ui.tictactoe :as tictactoe]))
(rf/reg-sub ::operand (fn [db [_ k]] (get-in db [:calc k]))) (rf/reg-sub ::operand (fn [db [_ k]] (get-in db [:calc k])))
(rf/reg-sub ::result (fn [db _] (get-in db [:calc :result]))) (rf/reg-sub ::result (fn [db _] (get-in db [:calc :result])))
(rf/reg-sub ::user (fn [db _] (:user db))) (rf/reg-sub ::user (fn [db _] (:user db)))
(rf/reg-sub ::todos (fn [db _] (:todos db))) (rf/reg-sub ::todos (fn [db _] (:todos db)))
(rf/reg-sub ::mcp-copied? (fn [db _] (get-in db [:mcp :copied?]))) (rf/reg-sub ::mcp-copied? (fn [db _] (get-in db [:mcp :copied?])))
(rf/reg-sub ::ttt (fn [db _] (:ttt db)))
;; What the board view needs: the cells, whose turn, and how the game stands.
;; :outcome is nil while the game is on, or {:player :x-or-:o :line [..]} for a win, or :draw.
(rf/reg-sub
::ttt-game
:<- [::ttt]
(fn [{:keys [board turn]} _]
{:board board
:turn turn
:outcome (tictactoe/outcome board)}))
+39
View File
@@ -0,0 +1,39 @@
(ns tbd.ui.tictactoe
"Tic-tac-toe rules as plain functions of the board, called by the re-frame events and subs
(tbd.ui.events, tbd.ui.subs). The board is a vector of 9 cells, row by row, each nil, :x or :o.")
(def new-board (vec (repeat 9 nil)))
(def ^:private lines
[[0 1 2] [3 4 5] [6 7 8]
[0 3 6] [1 4 7] [2 5 8]
[0 4 8] [2 4 6]])
(defn winner
"{:player :x-or-:o :line [i j k]} when someone has three in a row, else nil."
[board]
(some (fn [[a b c :as line]]
(let [player (board a)]
(when (and player (= player (board b) (board c)))
{:player player :line line})))
lines))
(defn draw?
"The board is full and nobody won."
[board]
(and (every? some? board) (nil? (winner board))))
(defn outcome
"How the game stands: nil while it's on, :draw, or the winner (see `winner`)."
[board]
(or (winner board) (when (draw? board) :draw)))
(defn next-player [player]
(if (= :x player) :o :x))
(defn move
"`player` takes cell `i`. Returns the new board, or nil when the move isn't allowed (the cell
is taken or off the board, or the game is already over)."
[board player i]
(when (and (contains? board i) (nil? (board i)) (nil? (winner board)))
(assoc board i player)))
+33 -2
View File
@@ -1,5 +1,6 @@
(ns tbd.ui.views (ns tbd.ui.views
(:require [re-frame.core :as rf] (:require [clojure.string :as str]
[re-frame.core :as rf]
[tbd.ui.events :as events] [tbd.ui.events :as events]
[tbd.ui.subs :as subs] [tbd.ui.subs :as subs]
[tbd.ui.webmcp :as webmcp])) [tbd.ui.webmcp :as webmcp]))
@@ -69,6 +70,35 @@
(when error [:p {:role "alert" :style {:color "crimson"}} error]) (when error [:p {:role "alert" :style {:color "crimson"}} error])
[todo-list state]])) [todo-list state]]))
(defn- ttt-status [{:keys [turn outcome]}]
(cond
(= :draw outcome) "It's a draw."
outcome (str (str/upper-case (name (:player outcome))) " wins!")
:else (str (str/upper-case (name turn)) "'s turn")))
(defn- ttt-cell [i mark winning? over?]
[:button {:type "button"
:aria-label (str "Cell " (inc i) (when mark (str ", " (str/upper-case (name mark)))))
:disabled (or over? (some? mark))
:style {:aspect-ratio "1" :font-size "2.5rem" :font-weight "bold" :cursor "pointer"
:background (when winning? "gold") :color (when winning? "black")}
:on-click #(rf/dispatch [::events/ttt-move i])}
(some-> mark name str/upper-case)])
(defn tic-tac-toe
"Two players take turns on one board. Nothing goes to the server."
[]
(let [{:keys [board outcome] :as game} @(rf/subscribe [::subs/ttt-game])
winning (set (:line outcome))]
[:section
[:h2 "Tic-tac-toe"]
[:p {:role "status" :aria-live "polite"} (ttt-status game)]
[:div {:style {:display "grid" :grid-template-columns "repeat(3, 4.5rem)" :gap "0.25rem"
:width "fit-content"}}
(for [[i mark] (map-indexed vector board)]
^{:key i} [ttt-cell i mark (contains? winning i) (some? outcome)])]
[:p [:button {:type "button" :on-click #(rf/dispatch [::events/ttt-reset])} "New game"]]]))
(defn- mcp-command (defn- mcp-command
"The command that adds this server to Claude Code, for the address the page was loaded from "The command that adds this server to Claude Code, for the address the page was loaded from
(which is what BASE_URL has to be for the MCP login to work)." (which is what BASE_URL has to be for the MCP login to work)."
@@ -110,7 +140,7 @@
client to them. The token is pasted into the WebMCP widget in the corner of the page." client to them. The token is pasted into the WebMCP widget in the corner of the page."
[] []
[collapsible "Browser tools (WebMCP)" [collapsible "Browser tools (WebMCP)"
[:p "This page offers " [:strong (count webmcp/tools)] " tools of its own (the calculator and the todos) to " [:p "This page offers " [:strong (count webmcp/tools)] " tools of its own (the calculator, the todos and the tic-tac-toe game) to "
"your MCP client, over " [:a {:href "https://webmcp.dev" :target "_blank" :rel "noreferrer"} "WebMCP"] "your MCP client, over " [:a {:href "https://webmcp.dev" :target "_blank" :rel "noreferrer"} "WebMCP"]
", and you see them work on the page. To connect:"] ", and you see them work on the page. To connect:"]
[:ol [:ol
@@ -145,6 +175,7 @@
[calculator] [calculator]
(when (= :logged-in (:status user)) (when (= :logged-in (:status user))
[todos]) [todos])
[tic-tac-toe]
[mcp-setup] [mcp-setup]
[browser-tools] [browser-tools]
[:p [:a {:href "/api-docs"} "API docs"] " · " [:a {:href "/swagger.json"} "swagger.json"] [:p [:a {:href "/api-docs"} "API docs"] " · " [:a {:href "/swagger.json"} "swagger.json"]
+69 -2
View File
@@ -13,7 +13,8 @@
(:require ["@jason.today/webmcp/src/webmcp.js" :as WebMCP] (:require ["@jason.today/webmcp/src/webmcp.js" :as WebMCP]
[re-frame.core :as rf] [re-frame.core :as rf]
[re-frame.db :as rf-db] [re-frame.db :as rf-db]
[tbd.ui.events :as events])) [tbd.ui.events :as events]
[tbd.ui.tictactoe :as tictactoe]))
;; --- Reading and waiting for the app-db --- ;; --- Reading and waiting for the app-db ---
@@ -174,6 +175,48 @@
"the todo to go"))) "the todo to go")))
(.then (fn [_] "deleted")))) (.then (fn [_] "deleted"))))
;; Tic-tac-toe is moves taken in turns: X, then O, on the same board. The agent can move for
;; either side, so every result says whose turn it is.
(defn- tic-tac-toe-state
"The game as the page shows it: the 9 cells (row by row, cell 0 is the top left; null when
empty, else \"x\" or \"o\"), `turn` (whose move it is, while the game is on), and `status`
(in_progress, won or draw; with `winner` and `line` when won)."
[]
(let [{:keys [board turn]} (:ttt (db))
{:keys [player line] :as outcome} (tictactoe/outcome board)]
(cond-> {:board board}
(nil? outcome) (assoc :status "in_progress" :turn turn)
(= :draw outcome) (assoc :status "draw")
player (assoc :status "won" :winner player :line line))))
(defn- get-tic-tac-toe [_]
(tic-tac-toe-state))
(defn- move-tic-tac-toe
"Presses cell `cell`, which moves for whoever's turn it is. With `player`, only if it is that
player's turn: a guard against moving for the wrong side."
[{:keys [cell player]}]
(when-not (and (number? cell) (js/Number.isInteger cell) (<= 0 cell 8))
(fail! "invalid arguments: cell must be an integer from 0 to 8"))
(when-not (contains? #{nil "x" "o"} player)
(fail! "invalid arguments: player must be \"x\" or \"o\""))
(let [{:keys [board turn]} (:ttt (db))]
(when (tictactoe/outcome board)
(fail! "the game is over: start a new game"))
(when (and player (not= player (name turn)))
(fail! (str "it is " (name turn) "'s turn, not " player "'s")))
(when (board cell)
(fail! (str "cell " cell " is taken")))
(rf/dispatch-sync [::events/ttt-move cell])
(tic-tac-toe-state)))
(defn- new-tic-tac-toe-game
"Presses New game."
[_]
(rf/dispatch-sync [::events/ttt-reset])
(tic-tac-toe-state))
(defn- object-schema [properties required] (defn- object-schema [properties required]
{:type "object" :properties properties :required required}) {:type "object" :properties properties :required required})
@@ -226,7 +269,31 @@
{:name "delete_todo" {:name "delete_todo"
:description "Presses a todo's Delete button" :description "Presses a todo's Delete button"
:input-schema (object-schema {:id todo-id} ["id"]) :input-schema (object-schema {:id todo-id} ["id"])
:handler delete-todo}]) :handler delete-todo}
{:name "get_tic_tac_toe"
:description (str "The tic-tac-toe game on this page (JSON): board (9 cells, row by row, cell 0 the top left "
"and cell 8 the bottom right; null when empty, else \"x\" or \"o\"), turn (whose move it "
"is, \"x\" or \"o\": X goes first, and they alternate) and status (in_progress, won or draw; "
"when won, also winner and line). A person moves on the same board, so check the turn "
"before you move")
:input-schema (object-schema {} []) :handler get-tic-tac-toe}
{:name "move_tic_tac_toe"
:description (str "Presses a cell of the tic-tac-toe board, which moves for whoever's turn it is, and returns "
"the game as get_tic_tac_toe does (including whose turn is next). Fails if the cell is "
"taken or the game is over")
:input-schema (object-schema {:cell {:type "integer" :minimum 0 :maximum 8
:description "0 is the top left, 2 the top right, 8 the bottom right"}
:player {:type "string" :enum ["x" "o"]
:description (str "Optional: the side you are moving for. The move is "
"refused if it is the other side's turn")}}
["cell"])
:handler move-tic-tac-toe}
{:name "new_tic_tac_toe_game"
:description "Presses New game: clears the tic-tac-toe board, X to move (JSON, as get_tic_tac_toe)"
:input-schema (object-schema {} []) :handler new-tic-tac-toe-game}])
;; --- Registering them with WebMCP --- ;; --- Registering them with WebMCP ---
+72
View File
@@ -0,0 +1,72 @@
(ns tbd.ui.tictactoe-test
(:require [cljs.test :refer [deftest is testing]]
[re-frame.core :as rf]
[re-frame.db :as rf-db]
[tbd.ui.events :as events]
[tbd.ui.tictactoe :as ttt]))
(defn- board
"A board from a string of 9 characters: x, o or anything else for an empty cell."
[s]
(mapv {\x :x \o :o} s))
(deftest winner-test
(testing "every row, column and diagonal"
(doseq [line [[0 1 2] [3 4 5] [6 7 8] [0 3 6] [1 4 7] [2 5 8] [0 4 8] [2 4 6]]
player [:x :o]]
(let [b (reduce #(assoc %1 %2 player) ttt/new-board line)]
(is (= {:player player :line line} (ttt/winner b))))))
(testing "no winner"
(is (nil? (ttt/winner ttt/new-board)))
(is (nil? (ttt/winner (board "xo.ox.xo."))))
(is (nil? (ttt/winner (board "xxo.oxxox"))))))
(deftest draw-test
(is (ttt/draw? (board "xoxxoooxx")))
(is (not (ttt/draw? (board "xxxooxoxo"))) "a full board with a winner is not a draw")
(is (not (ttt/draw? (board "xoxxooox.")))))
(deftest outcome-test
(is (nil? (ttt/outcome ttt/new-board)))
(is (= :draw (ttt/outcome (board "xoxxoooxx"))))
(is (= {:player :o :line [0 4 8]} (ttt/outcome (board "oxxxoxxxo")))))
(deftest move-test
(is (= :x (get (ttt/move ttt/new-board :x 4) 4)))
(is (nil? (ttt/move (board "x........") :o 0)) "taken cell")
(is (nil? (ttt/move ttt/new-board :x 9)) "off the board")
(is (nil? (ttt/move ttt/new-board :x -1)) "off the board")
(is (nil? (ttt/move (board "xxxoo....") :o 8)) "game already won"))
(defn- move! [& cells]
(doseq [i cells] (rf/dispatch-sync [::events/ttt-move i])))
(deftest game-flow-test
(rf/dispatch-sync [::events/initialize])
(let [game #(let [{:keys [board turn]} (:ttt @rf-db/app-db)]
{:board board :turn turn :outcome (ttt/outcome board)})]
(testing "a new game"
(is (= :x (:turn (game))))
(is (nil? (:outcome (game)))))
(testing "players alternate"
(move! 0)
(is (= :o (:turn (game))))
(move! 3)
(is (= [:x nil nil :o] (subvec (:board (game)) 0 4))))
(testing "a taken cell is ignored and doesn't pass the turn"
(move! 0)
(is (= :x (:turn (game))))
(is (= :o (get (:board (game)) 3))))
(testing "X wins on the top row; the game then ignores moves"
(move! 1 4 2)
(is (= {:player :x :line [0 1 2]} (:outcome (game))))
(let [before (:board (game))]
(move! 8)
(is (= before (:board (game))))))
(testing "new game"
(rf/dispatch-sync [::events/ttt-reset])
(is (= ttt/new-board (:board (game))))
(is (= :x (:turn (game)))))
(testing "a draw"
(move! 0 1 2 4 3 5 7 6 8)
(is (= :draw (:outcome (game)))))))
+49
View File
@@ -228,3 +228,52 @@
(is (re-find #"Invalid body" (:text r))) (is (re-find #"Invalid body" (:text r)))
(is (empty? (get-in @rf-db/app-db [:todos :items]))) (is (empty? (get-in @rf-db/app-db [:todos :items])))
(done)))))) (done))))))
(deftest tic-tac-toe
(open-page!)
(async done
(-> (call "get_tic_tac_toe" {})
(.then (fn [r]
(is (false? (:error? r)))
(is (= {:board (vec (repeat 9 nil)) :status "in_progress" :turn "x"} (json (:text r))))
(call "move_tic_tac_toe" {:cell 4})))
(.then (fn [r]
;; It pressed the cell, as a user would, and says it is O's turn now.
(is (= :x (get-in @rf-db/app-db [:ttt :board 4])))
(is (= {:board [nil nil nil nil "x" nil nil nil nil] :status "in_progress" :turn "o"}
(json (:text r))))
(call "move_tic_tac_toe" {:cell 4})))
(.then (fn [r]
(is (= {:text "cell 4 is taken" :error? true} r))
(call "move_tic_tac_toe" {:cell 0 :player "x"})))
(.then (fn [r]
(is (= {:text "it is o's turn, not x's" :error? true} r))
(is (nil? (get-in @rf-db/app-db [:ttt :board 0])) "nothing was moved")
(call "move_tic_tac_toe" {:cell 9})))
(.then (fn [r]
(is (:error? r))
(is (re-find #"0 to 8" (:text r)))
(call "move_tic_tac_toe" {:cell 1.5})))
(.then (fn [r]
(is (:error? r))
(call "move_tic_tac_toe" {:cell 0 :player "z"})))
(.then (fn [r]
(is (:error? r))
(call "move_tic_tac_toe" {:cell 0 :player "o"})))
(.then (fn [r]
(is (= "x" (:turn (json (:text r)))))
;; The person moves X to 3 and O to 1; the agent's X then completes the middle row.
(rf/dispatch-sync [::events/ttt-move 3])
(rf/dispatch-sync [::events/ttt-move 1])
(call "move_tic_tac_toe" {:cell 5 :player "x"})))
(.then (fn [r]
(is (= {:board ["o" "o" nil "x" "x" "x" nil nil nil]
:status "won" :winner "x" :line [3 4 5]}
(json (:text r))))
(call "move_tic_tac_toe" {:cell 8})))
(.then (fn [r]
(is (= {:text "the game is over: start a new game" :error? true} r))
(call "new_tic_tac_toe_game" {})))
(.then (fn [r]
(is (= {:board (vec (repeat 9 nil)) :status "in_progress" :turn "x"} (json (:text r))))
(done))))))