diff --git a/ui/src/tbd/ui/db.cljs b/ui/src/tbd/ui/db.cljs index 1cc37cb..62f55c4 100644 --- a/ui/src/tbd/ui/db.cljs +++ b/ui/src/tbd/ui/db.cljs @@ -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}}) diff --git a/ui/src/tbd/ui/events.cljs b/ui/src/tbd/ui/events.cljs index ce5b94f..3d618af 100644 --- a/ui/src/tbd/ui/events.cljs +++ b/ui/src/tbd/ui/events.cljs @@ -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)))) diff --git a/ui/src/tbd/ui/subs.cljs b/ui/src/tbd/ui/subs.cljs index 6dd1d92..66b338d 100644 --- a/ui/src/tbd/ui/subs.cljs +++ b/ui/src/tbd/ui/subs.cljs @@ -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)})) diff --git a/ui/src/tbd/ui/tictactoe.cljs b/ui/src/tbd/ui/tictactoe.cljs new file mode 100644 index 0000000..792822e --- /dev/null +++ b/ui/src/tbd/ui/tictactoe.cljs @@ -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))) diff --git a/ui/src/tbd/ui/views.cljs b/ui/src/tbd/ui/views.cljs index 22133ec..60bff8c 100644 --- a/ui/src/tbd/ui/views.cljs +++ b/ui/src/tbd/ui/views.cljs @@ -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"] diff --git a/ui/src/tbd/ui/webmcp.cljs b/ui/src/tbd/ui/webmcp.cljs index 1c0f738..0be9aa1 100644 --- a/ui/src/tbd/ui/webmcp.cljs +++ b/ui/src/tbd/ui/webmcp.cljs @@ -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 --- diff --git a/ui/test/tbd/ui/tictactoe_test.cljs b/ui/test/tbd/ui/tictactoe_test.cljs new file mode 100644 index 0000000..e76ab2b --- /dev/null +++ b/ui/test/tbd/ui/tictactoe_test.cljs @@ -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))))))) diff --git a/ui/test/tbd/ui/webmcp_test.cljs b/ui/test/tbd/ui/webmcp_test.cljs index 4ab9bc4..987e1e4 100644 --- a/ui/test/tbd/ui/webmcp_test.cljs +++ b/ui/test/tbd/ui/webmcp_test.cljs @@ -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))))))