This commit is contained in:
@@ -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}})
|
||||
|
||||
@@ -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
@@ -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)}))
|
||||
|
||||
@@ -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)))
|
||||
@@ -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"]
|
||||
|
||||
@@ -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 ---
|
||||
|
||||
|
||||
@@ -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)))))))
|
||||
@@ -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))))))
|
||||
|
||||
Reference in New Issue
Block a user