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
{:calc {:x "" :y ""
@@ -20,4 +21,7 @@
:error nil}
;; The "Add this MCP server to Claude Code" section: :copied? is true right after the
;; 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]
[clojure.string :as str]
[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
;; `(conj on-response {:ok? bool :status int-or-nil :body string})`. A `body` is sent as EDN.
@@ -188,3 +189,19 @@
::mcp-copied-timeout
(fn [db _]
(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
(: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 ::result (fn [db _] (get-in db [:calc :result])))
(rf/reg-sub ::user (fn [db _] (:user db)))
(rf/reg-sub ::todos (fn [db _] (:todos db)))
(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
(:require [re-frame.core :as rf]
(:require [clojure.string :as str]
[re-frame.core :as rf]
[tbd.ui.events :as events]
[tbd.ui.subs :as subs]
[tbd.ui.webmcp :as webmcp]))
@@ -69,6 +70,35 @@
(when error [:p {:role "alert" :style {:color "crimson"}} error])
[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
"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)."
@@ -110,7 +140,7 @@
client to them. The token is pasted into the WebMCP widget in the corner of the page."
[]
[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"]
", and you see them work on the page. To connect:"]
[:ol
@@ -145,6 +175,7 @@
[calculator]
(when (= :logged-in (:status user))
[todos])
[tic-tac-toe]
[mcp-setup]
[browser-tools]
[: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]
[re-frame.core :as rf]
[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 ---
@@ -174,6 +175,48 @@
"the todo to go")))
(.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]
{:type "object" :properties properties :required required})
@@ -226,7 +269,31 @@
{:name "delete_todo"
:description "Presses a todo's Delete button"
: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 ---
+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 (empty? (get-in @rf-db/app-db [:todos :items])))
(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))))))