Requirements Change
So, we were about to show you a demo of our tic-tac-toe masterpiece, when Exo Hashmark, the product manager, showed up and laid two new requirements on us.
- Include a teaching mode, where the human player is only allowed to choose from the optimal moves.
- Because the AI is unbeatable, make a "dumb" mode, so the user gets a chance to feel better about themselves.
Dumb Mode
Dumb mode is a pretty easy one, essentially just choosing between the effects. Really the only difference between smart and dumb mode is the AI, the rest of the effect code remains the same, and we'll just call the appropriate effect based on the value of the smart-ai
flag.
(def smart-ai false)
(defn ai-effect
[ai-fn session]
(async/go
(<! (async/timeout (/ (* ai-latency (+ 1 (rand))) 2)))
(when (not-empty (rules/query-partial session ::simple/move-request :?player :x))
(let [moves (map :?move (rules/query-partial session ::simple/move))
board (simple/squares->board moves)
next-move (ai-fn board)
{::simple/keys [position player] :as move-request} (rules/query-one :?request session ::simple/move-request :?position next-move :?player :x)]
(common/respond-to move-request #::simple{:position position :player player})))))
(def smart-ai-effect (partial ai-effect (fn [board] (ai/choose-move board :x 0))))
(def dumb-ai-effect (partial ai-effect (fn [board] (rand-nth (vec (set/difference (set (range 9)) (set (keys board))))))))
;;; HACK - is there a better way to call init-session and view/run only on load?
(defonce hackorama
(do
(add-watch session-atom :move-x
(fn [_ _ _ session]
(if smart-ai (smart-ai-effect session) (dumb-ai-effect session))))
(reset! session-atom (init-session simple/session))
;;; Comment out view/run to run tests headless
(view/run session-atom)))
Teaching Mode
The way the Teaching Mode requirement is phrased, it sounds like a UI problem. But the core concept of R³ is that such logic about what actions are allowed is encoded as rules, and surfaced in the form of requests. Let's then update the ::move-request!
rule.
[::move-request!
"If the game isn't over, request ::Moves from the ::CurrentPlayer for
eligible squares."
[:not [::GameOver]]
[::CurrentPlayer (= ?player player)]
[?moves <- (acc/all) :from [::Move]]
[::TeachingMode (= ?teaching-mode teaching-mode)]
[::common/ResponseFunction (= ?response-fn response-fn)]
=>
(let [all-positions (set (range 9))
empty-positions (set/difference all-positions (set (map ::position ?moves)))
requests (map #(common/request {::position % ::player ?player} ::MoveResponse ?response-fn) empty-positions)]
(if ?teaching-mode
(condp = ?player
:x (apply rules/insert! ::MoveRequest requests)
:o (when-let [optimal-moves (set (ai/optimal-moves (squares->board ?moves) :o 0))]
(apply rules/insert! ::MoveRequest (filter (fn [%] (optimal-moves (::position %))) requests))))
(apply rules/insert! ::MoveRequest requests)))]
In teaching mode, we only create ::MoveRequest
's for optimal moves, as evaluated by the AI. We also need to change the app/init-rules
function to initialize the ::TeachingMode
fact:
(def teaching-mode true)
(defn init-session
[session]
(reset! common/request-id 0)
(-> session
(rules/insert ::simple/CurrentPlayer {::simple/player :x})
(rules/insert ::simple/TeachingMode {::simple/teaching-mode teaching-mode})
(rules/insert ::common/ResponseFunction {::common/response-fn response-fn})
(rules/fire-rules)))
And we're done. The UI effect requires no changes. It already works in terms of whatever ::MoveRequest
's are made available by the rules, and so continues to work as is.