Showing posts with label clojure. Show all posts
Showing posts with label clojure. Show all posts

Monday, September 4, 2017

Your Random May Vary


At the beginning of the summer, we started hosting a semi-monthly Dungeons and Dragons game at our house. It's typically a lot of fun, involving much role-playing and dice rolling. We share Dungeon-Mastering duties; I think I've run most of the sessions, but at least two or three have been run by someone else. When I'm running the game, my character is typically an NPC. It's a bit of a shame, because my stats are pretty awesome.

I got to thinking this morning... I rolled my current character (as all my characters), using real dice and a "roll-4-keep-3" approach. I roll four 6-sided dice, discarding the lowest, and summing the other three. I do that six times in a row, and I've got the stats block for a new character.

I never use anything but real dice unless I happen not to have a set (and fyi: I carry a couple of sets around in my backpack just in case a spontaneous or random one-shot happens). Garrett used to roll great stats very often using his IPhone dice, and tended to roll challenges really highly as well. I started thinking about the difference in our results and decided to mess around with a rolling app this morning.

It's been a while since I posted anything technical. Bear with me.

I started by creating a little app that would roll a six-sided dice for me.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
(ns stats-generator.main
  (:gen-class))

(def d6 [1 2 3 4 5 6])

(defn roll-d6 []
  (rand-nth d6))

(defn -main
  "I don't do a whole lot ... yet."
  [& args]
  (println "Hello, World!"))

I quickly realized that I didn't quite know where to go. TDD to the rescue!

1
2
3
4
5
6
(ns stats-generator.main-test
  (:require [clojure.test :refer :all]
            [stats-generator.main :as main]))

(deftest test-roll-4-keep-3
  (testing "Given a roll of 4 6-sided dice, sums the highest 3"))

I started with a description of one of the end goals: a function that would roll four dice and keep the highest three. That... is probably too broad. Let's decompose for a moment. We know we'll need to roll four 6-sided dice and drop the lowest roll. Maybe that dropping function is easier to test and implement.

1
2
3
4
5
(deftest test-drop-lowest
  (testing "Given all different values, drops the lowest"
    (let [v [1 2 3 4]
          expected [2 3 4]]
      (is (= expected (main/drop-lowest v))))))

There's the test; we should stub a red (failing) function.

1
2
3
4
(defn drop-lowest
  "Given a vector, returns a vector sans its lowest value."
  [v]
  v)

Done! Run the tests!

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
(run-tests)

Testing stats-generator.main-test

FAIL in (test-drop-lowest) (main_test.clj:9)
Given all different values, drops the lowest
expected: [2 3 4]
  actual: [1 2 3 4]
    diff: - [2 3 4]
          + [1 2 3 4]

Ran 2 tests containing 1 assertions.
1 failures, 0 errors.
=> {:test 2, :pass 0, :fail 1, :error 0, :type :summary}

Failed as expected. What's a simple solution that will work?

1
2
3
4
(defn drop-lowest
  "Given a vector, returns a vector sans its lowest value."
  [v]
  (rest (sort v)))

Seems reasonable. Tests tell us...?

1
2
3
4
5
6
7
(run-tests)

Testing stats-generator.main-test

Ran 2 tests containing 1 assertions.
0 failures, 0 errors.
=> {:test 2, :pass 1, :fail 0, :error 0, :type :summary}

Yay! Green tests! Let's fill in some other assertions to make sure we're not fooling ourselves.

1
2
3
4
5
6
7
8
9
(deftest test-drop-lowest
  (testing "Given all different values in random order, drops the lowest"
    (is (= [2 3 4] (main/drop-lowest [1 2 3 4])))
    (is (= [2 3 4] (main/drop-lowest [4 2 3 1]))))
  (testing "Given identical values, drops one of them"
    (is (= [1 1 1] (main/drop-lowest [1 1 1 1]))))
  (testing "Given some duplicated values, drops whatever's lowest"
    (is (= [2 2 3] (main/drop-lowest [2 3 2 2])))
    (is (= [5 6 6] (main/drop-lowest [5 6 4 6])))))

That's good enough. A question I'm often asked is "how much automated test coverage should I implement?" The pedantic answer is "100%," but the pragmatic answer is "as much as you can that covers stuff you would do by hand anyway." That's my rule-of-thumb, in any case. Sometimes I do more, sometimes less. But if I find bugs, I try to reproduce them with tests and then they're covered into perpetuity. As such the test coverage grows organically.

Alright, let's fill in some more of the decomposed functions and see where we wind up.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
(ns stats-generator.main
  (:gen-class))

(def d6 [1 2 3 4 5 6])

(defn roll-d6 [_]
  (rand-nth d6))

(defn roll-4
  "Rolls a d6 4 times"
  []
  (map roll-d6 (range 4)))

(defn drop-lowest
  "Given a vector, returns a vector sans its lowest value."
  [v]
  (rest (sort v)))

(defn sum [v]
  (apply + v))

(defn roll-4-keep-3
  "Rolls a 6-sided die 4 times, summing the highest 3 values"
  []
  (-> (roll-4)
      (drop-lowest)
      (sum)))

(defn -main
  "I don't do a whole lot ... yet."
  [& args]
  (println "Hello, World!"))

That seems reasonable. Test coverage is minimal, but enough for me to know that I'm not doing something overtly silly.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
(ns stats-generator.main-test
  (:require [clojure.test :refer :all]
            [stats-generator.main :as main]))

(deftest test-roll-4
  (let [roll (main/roll-4)]
    (is (= 4 (count roll)))
    (is (every? #(<= 1 % 6) roll))))

(deftest test-drop-lowest
  (testing "Given all different values in random order, drops the lowest"
    (is (= [2 3 4] (main/drop-lowest [1 2 3 4])))
    (is (= [2 3 4] (main/drop-lowest [4 2 3 1]))))
  (testing "Given identical values, drops one of them"
    (is (= [1 1 1] (main/drop-lowest [1 1 1 1]))))
  (testing "Given some duplicated values, drops whatever's lowest"
    (is (= [2 2 3] (main/drop-lowest [2 3 2 2])))
    (is (= [5 6 6] (main/drop-lowest [5 6 4 6])))))

(deftest test-sum
  (is (= 18 (main/sum [6 6 6])))
  (is (= 3 (main/sum [1 1 1])))
  (is (= 12 (main/sum [3 4 5]))))

(deftest test-roll-4-keep-3
  (testing "Given a roll of 4 6-sided dice, sums the highest 3"
    (is (<= 3 (main/roll-4-keep-3) 18))))

At this point, I'm ready to do the REAL implementation. We'll set up a little recursion to see if we can generate a set of six 18s. I'm not good at math, but the likelihood seems pretty low that I'll actually do it. Let's compromise and try 100 MILLION times. We'll also squawk if we get close.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
(defn roll-all-18s []
 (let [counter (atom 0)]
   (println "STARTED AT:" (now))
    (loop [current-roll []]
      (swap! counter inc)
      (cond
        (= 100000000 @counter)
        (do
          (println "Gave up after ONE HUNDRED MILLION sets...")
          (println "ENDED AT:" (now)))

        (= [18 18 18 18 18 18] current-roll)
        (do
          (println "All 18s took" @counter "attempt(s).")
          (println "ENDED AT:" (now)))

        :default
        (do
          (when (<= 5 (count (filter #(= 18 %) current-roll)))
            (println "close call!" current-roll))
          (recur (roll-stats)))))))

Alrighty! We can run this in the REPL. The first try's results follow.

1
2
3
4
5
(roll-all-18s)
STARTED AT: #inst "2017-09-04T17:26:34.981-00:00"
close call! (5 18 18 18 18 18)
Gave up after ONE HUNDRED MILLION sets...
ENDED AT: #inst "2017-09-04T17:44:07.983-00:00"

It took us 22 minutes to try 100 MILLION times, and in all those tries, we only got close once. The frequency lines up with my (admittedly bad-at-math-and-fuzzy) expectations.

So how in the world did Garrett's IPhone roll so well for him so often? I suspect they weren't using the same approach as I was (roll-4-keep-3). After a little more research, I found that another approach that simply took a random integer between 3 and 18. This seemed like a pretty simple and probably more efficient implementation, but I suspected that the level of randomness in that picking was LOWER than that of the roll-4-take-3 approach. Again, I don't have the math or computer science to tell you why -- it was just my instinct.

So, I implemented a couple more rolling functions...

1
2
3
4
(def range-3-18 (range 3 19))

(defn roll-d3-18 [_]
  (rand-nth range-3-18))

...and refactored the roll-stats function into a multi-method that dispatches on the roll style you wanted to use.

1
2
3
4
5
6
7
8
9
(defmulti roll-stats identity)

(defmethod roll-stats :roll-4-keep-3
  [_]
  (map roll-4-keep-3 (range 6)))

(defmethod roll-stats :range-3-to-18
  [_]
  (map roll-d3-18 (range 6)))

No tests were added. I wanted to get to the bottom of this silly thing. Let's do some more REPLing!

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
(roll-all-18s :range-3-to-18)
STARTED AT: #inst "2017-09-04T21:56:32.650-00:00"
close call! (18 12 18 18 18 18)
close call! (18 18 17 18 18 18)
close call! (18 18 18 18 18 10)
close call! (18 18 18 18 17 18)
close call! (18 18 18 18 12 18)
close call! (18 18 18 18 18 7)
close call! (18 18 18 9 18 18)
close call! (18 18 18 17 18 18)
close call! (18 18 18 18 8 18)
close call! (18 13 18 18 18 18)
close call! (18 9 18 18 18 18)
close call! (18 18 14 18 18 18)
close call! (18 18 18 4 18 18)
close call! (18 18 18 18 12 18)
close call! (18 18 18 18 10 18)
close call! (18 18 18 18 18 3)
close call! (18 3 18 18 18 18)
close call! (18 18 18 18 10 18)
close call! (18 18 10 18 18 18)
close call! (18 18 18 18 18 3)
close call! (18 18 18 18 15 18)
close call! (18 18 15 18 18 18)
close call! (18 18 18 18 12 18)
close call! (18 18 18 11 18 18)
close call! (3 18 18 18 18 18)
close call! (18 18 15 18 18 18)
close call! (18 18 18 9 18 18)
close call! (18 18 18 18 18 7)
close call! (17 18 18 18 18 18)
close call! (18 18 18 18 15 18)
close call! (18 18 18 18 11 18)
close call! (18 17 18 18 18 18)
close call! (18 4 18 18 18 18)
close call! (18 18 16 18 18 18)
close call! (18 18 18 8 18 18)
close call! (18 18 18 18 9 18)
close call! (18 18 15 18 18 18)
close call! (9 18 18 18 18 18)
close call! (18 18 18 18 7 18)
close call! (18 11 18 18 18 18)
close call! (18 18 18 6 18 18)
close call! (18 18 18 18 8 18)
close call! (18 18 18 15 18 18)
close call! (18 18 18 18 18 9)
close call! (18 18 9 18 18 18)
close call! (18 18 18 18 4 18)
close call! (18 18 18 18 11 18)
close call! (7 18 18 18 18 18)
close call! (18 18 18 18 18 3)
close call! (18 11 18 18 18 18)
close call! (18 18 12 18 18 18)
close call! (18 18 18 5 18 18)
close call! (18 18 18 18 18 6)
close call! (18 17 18 18 18 18)
close call! (18 6 18 18 18 18)
close call! (18 18 18 9 18 18)
close call! (18 18 18 11 18 18)
close call! (18 11 18 18 18 18)
close call! (18 16 18 18 18 18)
close call! (14 18 18 18 18 18)
close call! (18 18 10 18 18 18)
close call! (18 18 18 18 18 12)
close call! (18 18 18 18 18 16)
close call! (18 18 18 4 18 18)
close call! (18 18 18 18 12 18)
close call! (18 11 18 18 18 18)
close call! (18 18 18 18 16 18)
close call! (18 18 5 18 18 18)
close call! (18 18 18 18 18 16)
close call! (18 18 18 17 18 18)
close call! (5 18 18 18 18 18)
close call! (18 18 18 11 18 18)
close call! (18 18 18 18 18 12)
close call! (18 18 18 18 18 7)
close call! (18 18 18 3 18 18)
close call! (18 18 18 18 8 18)
close call! (18 18 18 5 18 18)
close call! (15 18 18 18 18 18)
close call! (18 5 18 18 18 18)
close call! (18 18 18 16 18 18)
close call! (18 18 18 18 4 18)
close call! (18 18 17 18 18 18)
close call! (18 14 18 18 18 18)
close call! (18 18 18 18 18 7)
close call! (18 18 18 4 18 18)
close call! (18 18 18 18 4 18)
All 18s took 13880334 attempt(s).
ENDED AT: #inst "2017-09-04T21:57:12.554-00:00"

WHOA! There were a TON more close calls, and an actual hit about 14 milllion rolls in.

I decided to re-run the roll-4-take-3 strategy to verify its performance.

1
2
3
4
5
6
7
(roll-all-18s :roll-4-keep-3)
STARTED AT: #inst "2017-09-04T22:51:37.468-00:00"
close call! (18 18 18 18 14 18)
close call! (13 18 18 18 18 18)
close call! (18 18 18 18 18 9)
Gave up after ONE HUNDRED MILLION sets...
ENDED AT: #inst "2017-09-04T23:09:02.571-00:00"

A few more close calls were had, but roughly the same result. I re-ran the range-3-to-18 strategy:

1
2
3
4
5
6
(roll-all-18s :range-3-to-18)
STARTED AT: #inst "2017-09-04T23:24:08.765-00:00"
close call! (18 18 8 18 18 18)
close call! (18 18 18 5 18 18)
All 18s took 321349 attempt(s).
ENDED AT: #inst "2017-09-04T23:24:09.925-00:00"

Wowzers. SO MUCH MORE LIKELY to roll 18s using that approach. The second run only took a second and slightly more than 321,000 tries.

So, what's the moral of the story? I think it's that the amount of randomness you get from computers may vary, based on what the underlying strategy is. Random numbers aren't truly random in the machine. Always keep that in mind when they rise up and become our overlords.

Also: rolling 321,000 times by hand, at one set of rolls per second, would still take you almost 4 straight days of rolling to get that all 18s set. So for you DMs out there... if a player comes to you with a character they said they rolled, and it's got six 18s, you give them a knowing wink while handing them your dice and telling them "roll again..."

Sunday, March 6, 2016

Cartagena, part 5

When last we spoke, we had managed to draw a board with spaces and icons.  Now it's time to fill in the players and finish up the web implementation.

Recall that we had left the server code in a state that if we called the new-game route without a :players keyword, we would default the new game to return a two-player game featuring tanya as orange and rusty as black.  I decided to work forward from here, because it let me defer the decision about how to capture the player data up front.  As it turns out, this was a great decision, because I'm terrible at user experience.  :-)

Calling the endpoint resulted in the server giving us a set of game data with two players. The players start in jail.  Now I needed to add the code that would actually display the players in that spot.

(defn jail []
  (when-let [jail (get-in @app-state [:board 0])]
    (apply concat
           (let [pirate-frequencies (frequencies (:pirates jail))
                 pirate-colors (vec (keys pirate-frequencies))]
             (for [player-index (range (count pirate-frequencies))]
               (let [pirate-color (get pirate-colors player-index)
                     pirate-count (pirate-color pirate-frequencies)
                     color-name (name pirate-color)
                     x (to-scale (+ 5 (* 10 player-index)))]
                 (for [pirate-index (range pirate-count)]
                   (let [y (to-scale (+ 35 (* 10 pirate-index)))]
                     [:circle
                      {:cx x
                       :cy y
                       :r (to-scale 4)
                       :fill color-name}]))))))))

I also needed to add this bit of rendering code to the main-view function, which was easy.

(defn main-view []
  [:center
   [:h1 "CARTAGENA"]
   [:div
    [:button
     {:class "btn btn-primary"
      :on-click (fn button-click [e]
                  (select-players!))}
     "New Game"]]

   [:div
    {:class "row"
     :style {:float "left" :margin-left 10 :margin-right 10}}
    [:div
     {:class "col-md-5"}
     (-> [:svg
          {:view-box (str "0 0 " (to-scale 501) " " (to-scale 301))
           :width (to-scale 501)
           :height (to-scale 301)}]
         (into (static-board))
         (into (jail))
         )]]])

The result was marvelously exciting!

Alright, I might've oversold that a little.  But the fact of the matter is, I had a set of players on the board, in the right position!  SO CLOSE TO BEING FINISHED!

...except that I really wasn't.  How in the world was I going to handle moving the pieces around?  Recall that the rules state that you can take three actions on a turn: playing a card to move a pirate forward, selecting a pirate to move backward in order to draw cards, and passing.  At this point, I can't play any cards because I can't tell who all has what.  I can't move backward, because there's nowhere to move backward to.  I can't even pass, because there's no action button or keystroke that will allow me to do that.

It looks like I'm going to have to break down and build a player area.  <insert ominous music here>

As I've already stated, I don't consider myself to be much of a user experience expert.  This became doubly apparent as I iterated through several different versions of the player area.  I finally landed on a design such that the player area is to the right of the game board.  Here's the code.  Notice that I baked it right into the main-view function, which makes that function needlessly cluttered.  I'm unapologetic.  It's not time to refactor yet...  :-D

(defn main-view []
  [:center
   [:h1 "CARTAGENA"]
   [:div
    [:button
     {:class "btn btn-primary"
      :on-click (fn button-click [e]
                  (select-players!))}
     "New Game"]]

   [:div
    {:class "row"
     :style {:float "left" :margin-left 10 :margin-right 10}}
    [:div
     {:class "col-md-5"}
     (-> [:svg
          {:view-box (str "0 0 " (to-scale 501) " " (to-scale 301))
           :width (to-scale 501)
           :height (to-scale 301)}]
         (into (static-board))
         (into (jail))
         )]]
   (when-let [active-player (active-player @app-state)]
     (let [{:keys [color cards] player-name :name} active-player
           card-groups (frequencies cards)]
       [:div
        {:class "col-md-4"}
        [:div {:style {:margin-right 10}}
         [:table {:class "table table-bordered table-responsive"}
          [:tbody
           [:tr
            [:td "Current Player"]
            [:td player-name]]]
          [:tr
           [:td "Color"]
           [:td
            [:svg
             {:width (to-scale 20)
              :height (to-scale 20)}
             [:circle
              {:cx (to-scale 10)
               :cy (to-scale 10)
               :r (to-scale 7)
               :fill (name color)}]]
            [:span {:style {:color (name color)}} (name color)]]]
          [:tr
           [:td "Actions Remaining"]
           [:td (:actions-remaining @app-state)]]
          [:tr
           [:td "Cards"]
           [:td (for [[card num] card-groups]
                  ^{:key card}
                  [:span {:style {:float "left"}}
                   [:figure
                    [:img
                     {:src (card icon-images)
                      :width (to-scale 30)
                      :height (to-scale 30)
                      :on-click (fn img-click [e]
                                  (select-card! card))}]
                    [:center [:figcaption num]]]])]]
          [:tr
           [:td "Selected Card"]
           [:td [:span {:style {:float "left"}}
                 (when-let [selected-card (:selected-card @app-state)]
                   [:img
                    {:src (selected-card icon-images)
                     :width (to-scale 30)
                     :height (to-scale 30)
                     :on-click (fn img-click [e]
                                 (unselect-card!))}])]]]
          [:tr
           [:td {:colSpan 2}
            [:center
             [:button
              {:class "btn"
               :on-click (fn btn-click [e]
                                   (update-active-player! @app-state))} "Pass"]]]]]]
        [:div
         [:p "To move forward, click a card, then click the target pirate.  To undo card selection, click the selected card."]
         [:p "To move backward, click the target pirate."]]]))])

This also required a couple of handler functions: select-card!, unselect-card! and update-active-player!  The update-active-player! function is actually a server request, since all of that logic is built into the game engine.

(defn update-active-player [state response]
  (assoc state :actions-remaining (:actions-remaining response)
               :current-player (:current-player response)))

(defn on-update-active-player [response]
  (swap! app-state update-active-player response))

(defn update-active-player! [{:keys [actions-remaining current-player player-order]}]
  (ajax/POST "http://localhost:3000/update-active-player"
             {:params {:actions-remaining actions-remaining
                       :current-player current-player
                       :player-order player-order}
              :handler on-update-active-player
              :error on-error}))

(defn select-card! [card]
  (reset! app-state (assoc @app-state :selected-card card)))

(defn unselect-card! []
  (reset! app-state (dissoc @app-state :selected-card)))

This set of code led to a usable UI, if not an aesthetically pleasing one.


Now we have the ability to choose cards (and unchoose them, if we accidentally clicked one) and pass, but we don't have the ability to choose a specific pirate to move forward or backward.  We also don't have any code to display pirates on any board space except the jail.  Time to add those bits to the appropriate spots.

;; TODO: this looks almost exactly like jail
(defn ship []
  (when-let [ship (get-in @app-state [:board 37])]
    (apply concat
           (let [pirate-frequencies (frequencies (:pirates ship))
                 pirate-colors (vec (keys pirate-frequencies))]
             (for [player-index (range (count pirate-frequencies))]
               (let [pirate-color (get pirate-colors player-index)
                     pirate-count (pirate-color pirate-frequencies)
                     color-name (name pirate-color)
                     x (to-scale (+ 445 (* 10 player-index)))]
                 (for [pirate-index (range pirate-count)]
                   (let [y (to-scale (+ 245 (* 10 pirate-index)))]
                     [:circle
                      {:cx x
                       :cy y
                       :r (to-scale 4)
                       :fill color-name
                       :on-click (fn ship-click [e]
                                   (pirate-click pirate-color 37))}]))))))))
                                   
(defn circles-for [space-index x y colors]
  (for [color-index (range (count colors))]
    (let [color (get colors color-index)
          color-name (name color)
          cx (to-scale (+ 35 x))
          cy (to-scale (+ y 5 (* 10 color-index)))]
      ^{:key color-index}
      [:circle
       {:cx cx
        :cy cy
        :r (to-scale 4)
        :fill color-name
        :on-click (fn circle-click [e]
                    (pirate-click color space-index))}])))

(defn normal-spaces []
  (apply concat
         (for [i (range 1 37)]
           (when-let [space-data (get-in @app-state [:board i])]
             (let [position (get piece-positions i)
                   x (:x position)
                   y (:y position)
                   space (normal-space x y)
                   image (space-image x y (:icon space-data))
                   pirates (circles-for i x y (:pirates space-data))]
               (conj [space image] pirates))))))

Notice the prolific pirate-click! function.  This is the function from whence the remaining server calls initiate.  It and its associated functions are a decent chunk of code, but it all feels very similar to the code I did for the console version.

;; TODO: figure out a way to make this work elegantly with callbacks
;; swiped from server code
(defn game-over?
  "Returns truthy if a player has 6 pirates on the ship; otherwise nil."
  [board]
  (let [ship (first (filter #(= :ship (:icon %)) board))
        pirate-counts-by-color (frequencies (:pirates ship))]
    (some #(>= (second %) 6) pirate-counts-by-color)))

(defn end-game [state]
  (assoc state :game-over true))

(defn end-game! []
  (swap! app-state end-game))

(defn play-card [state response]
  (-> state
      (assoc :board (:board response)
             :discard-pile (:discard-pile response)
             :players (conj (remove #{(active-player state)} (:players state)) (:player response)))
      (dissoc state :selected-card)))

(defn on-play-card [response]
  (let [board (:board response)]
    (swap! app-state play-card response)
    (if (game-over? board)
      (end-game!)
      (update-active-player! @app-state))))

(defn play-card! [player card from-space board discard-pile]
  (ajax/POST "http://localhost:3000/play-card"
             {:params {:player player
                       :icon card
                       :from-space from-space
                       :board board
                       :discard-pile discard-pile}
              :handler on-play-card
              :error-handler on-error}))

(defn move-back [state board response]
  (assoc state :board board
         :draw-pile (:draw-pile response)
         :discard-pile (:discard-pile response)
         :players (conj (remove #{(active-player state)} (:players state)) (:player response))))

(defn on-move-back [response]
  (when-let [board (:board response)]
    (swap! app-state move-back board response)
    (update-active-player! @app-state)))

(defn move-back! [player from-space board draw-pile discard-pile]
  (ajax/POST "http://localhost:3000/move-back"
             {:params {:player player
                       :from-space from-space
                       :board board
                       :draw-pile draw-pile
                       :discard-pile discard-pile}
              :handler on-move-back
              :error-handler on-error}))

(defn pirate-click [color from-space-index]
  (when (= color (:color (active-player @app-state)))
    (let [player (active-player @app-state)
          board (:board @app-state)
          from-space (get board from-space-index)
          discard-pile (:discard-pile @app-state)]
      (if-let [selected-card (:selected-card @app-state)]
        (play-card! player selected-card from-space board discard-pile)
        (move-back! player from-space board (:draw-pile @app-state) discard-pile)))))

Now, FINALLY, I can click pirates and expect them to render properly.  Every click of a pirate calls the server, passing the current game state, and receiving an updated board from the engine.  I'm not completely satisfied with it, since I wound up duplicating some server code in the web implementation to check for the end game condition.  However, given the amount of typing I'd already done and the fact that I want to make it multi-player next, I decided it wasn't worth the effort to clean up.

All that's left is gathering the initial player data.  As you might've guessed, there was more than a little give-up in me at this point.  I decided the simplest thing that could work would be to create a silly little set of inputs, one for each color.  I won't bother showing you the hiccup for that, but the form looks like:


Are you impressed yet?!

As you might imagine, filling in names and pressing the Start button creates a players map that gets sent in the request to the new-game endpoint.  The server initializes a game state given the player data, and away we go!

So this is the end.  We've finally slogged through the initial web implementation for Cartagena.  If you've followed me thus far, you deserve a medal for your determination. I certainly learned a lot along the way, and hope that you have too (or at least that you were entertained by my bumbling through it).

I do intend to implement the multi-player non-local version of this at some point in the future, but it might be a while.  If you have a burning desire to see it done, let me know in the comments.  Otherwise, thanks for sharing my adventures through this series, and stay tuned for non-Cartagena posts coming your way soon!  :-D