3
\$\begingroup\$

A few days ago, I read something to the effect of "everyone knows orange tiles are the most frequently landed on of any property". I was skeptical of this claim, so I decided to test it!

This program simulates the movement of a player around the board. It records the number of times each tile is landed on.

I doesn't simulate money, other players, or anything else not directly able to move the player. There doesn't seem to be any way for money to move you. You can't buy your way to a certain tile, so I left it out. There also doesn't seem to be any way for one player to alter the movement of another player. The program simulates a single player going around the board, just moving.

I decided to break this program down into small chunks to keep everything organized, but I worry that I went too far. The files for miscellaneous and action tiles contain only a single definition to introduce some namespaced keywords.

There's also the problem that everything's hardcoded. I'm assuming a standard Monopoly board and cards. Some things like the cards couldn't easily be serialized anyways since they're just anonymous functions. Everything's kind of scattered around across a few different files too, so their placement isn't ideal.

I welcome any critique. This was just a weekend project, so it's certainly sloppy in parts, but I like how some parts turned out. (goto-tile state ::jail) reads nice if you squint a bit. Also, naming is hard. Any suggestions for better names is appreciated.

I don't expect a full review of all the code! There's a fair amount here to go through.

Note, I accidentally introduced a bug that I didn't notice until after posting. "Go" is being landed on less frequently than it should because unsafe-wrap is defective. I switched to it at the last moment because I noticed an unrelated issue with the other wrapping function I was using. I fixed the big for the sample output below, but I didn't update the code.

Sample run. The number beside each tile represents how many times that tile was landed on out of 10 million lands:

Luxury-tax: 205428
Park - Blue: 205567
Mediterranean - Brown: 206032
Baltic - Brown: 209752
Oriental - Light-blue: 215122
Connecticut - Light-blue: 219491
Vermont - Light-blue: 220448
Income-tax: 222681
States - Violet: 226570
Pennsylvania - Green: 232968
Virginia - Violet: 233792
Electric: 235006
Marvin - Yellow: 240935
North-carolina - Green: 243280
Boardwalk - Blue: 244009
Goto-jail: 244790
Pennsylvania-rr: 246596
Ventor - Yellow: 247294
Pacific - Green: 248671
Atlantic - Yellow: 250052
Indiana - Red: 250152
Short-rr: 250608
St-charles - Violet: 254313
Kentucky - Red: 258712
St-james - Orange: 259083
Free-parking: 263502
Tennessee - Orange: 269374
Water: 270865
Reading-rr: 276009
Bo-rr: 277053
New-york - Orange: 279739
Go: 283107
Illinois - Red: 287948
Jail: 501599
Chance: 689316
Community-chest: 730136

Helpers (Excerpt from my personal library):

(ns helpers.general-helpers
  (:import [java.util Random Collections Collection])

  (:refer-clojure :exclude [shuffle]))

(defn new-rand-gen
  ([seed] (Random. seed))
  ([] (Random.)))

(defn shuffle [^Collection coll ^Random rand-gen]
  (let [j-coll (java.util.ArrayList. coll)]
    (Collections/shuffle j-coll rand-gen)
    (into (empty coll) j-coll)))

(defn random-int
  "Returns a random integer (actually a long) between min (inclusive) and max (exclusive)."
  ^long [^long min ^long max ^Random rand-gen]
  (+ min (.nextInt rand-gen (- max min))))

(defn unsafe-wrap
  "Wraps a n so it's between min-n and max-n (inclusive)
  WARNING!: Breaks if (> n (- max-n min-n))"
  [n min-n max-n]
  (cond
    (< n min-n) (- max-n (- min-n n))
    (> n max-n) (+ min-n (- n max-n))
    :else n))

(defn parse-double
  "Returns nil on bad input"
  [str-n]
  (try
    (Double/parseDouble str-n)
    (catch NumberFormatException _
      nil)))

Board files:

(ns monopoly-movement-sim.tiles.action-tiles)

(def action-tiles
  #{::goto-jail ::chance ::community-chest})

(ns monopoly-movement-sim.tiles.misc-tiles)

(def misc-tiles
  #{::jail ::go ::free-parking
    ::income-tax ::luxury-tax})

(ns monopoly-movement-sim.tiles.property-tiles)

(def rail-roads
  #{::reading-rr ::pennsylvania-rr ::bo-rr ::short-rr})

(def utilities
  #{::electric ::water})

(def colors
  #{::brown ::light-blue ::violet ::orange ::red ::yellow ::green ::blue})

; TODO: Make less repetitive. Duplicate color values.
(def property-colors
  {::mediterranean ::brown,
   ::baltic ::brown

   ::oriental ::light-blue,
   ::vermont ::light-blue,
   ::connecticut ::light-blue,

   ::st-charles ::violet,
   ::states ::violet,
   ::virginia ::violet,

   ::st-james ::orange,
   ::tennessee ::orange,
   ::new-york ::orange,

   ::kentucky ::red,
   ::indiana ::red,
   ::illinois, ::red,

   ::atlantic ::yellow,
   ::ventor ::yellow,
   ::marvin ::yellow,

   ::pacific ::green,
   ::north-carolina ::green,
   ::pennsylvania ::green,

   ::park ::blue,
   ::boardwalk ::blue})

(ns monopoly-movement-sim.board-layout
  (:require [monopoly-movement-sim.tiles.property-tiles :as pt]
            [monopoly-movement-sim.tiles.action-tiles :as at]
            [monopoly-movement-sim.tiles.misc-tiles :as mt]))

(def board-layout
  [::mt/go

   ::pt/mediterranean
   ::at/community-chest
   ::pt/baltic

   ::mt/income-tax
   ::pt/reading-rr

   ::pt/oriental
   ::at/chance
   ::pt/vermont
   ::pt/connecticut

   ::mt/jail

   ::pt/st-charles
   ::pt/electric
   ::pt/states
   ::pt/virginia

   ::pt/pennsylvania-rr

   ::pt/st-james
   ::at/community-chest
   ::pt/tennessee
   ::pt/new-york

   ::mt/free-parking

   ::pt/kentucky
   ::at/chance
   ::pt/indiana
   ::pt/illinois

   ::pt/bo-rr

   ::pt/atlantic
   ::pt/ventor
   ::pt/water
   ::pt/marvin

   ::at/goto-jail

   ::pt/pacific
   ::pt/north-carolina
   ::at/community-chest
   ::pt/pennsylvania

   ::pt/short-rr
   ::at/chance

   ::pt/park
   ::mt/luxury-tax
   ::pt/boardwalk])

(def enumerated-board
  (mapv vector (range) board-layout))

(def tile-indices
  "A mapping from tile types to the indices that they occupy."
  (reduce (fn [acc [i tile]]
            (update acc tile #(conj % i)))
          {}
          enumerated-board))

(defn indexes-satisfying
  "Returns a list of indices of tiles that satisfy the predicate."
  [pred]
  (map first
    (filter #(pred (second %)) enumerated-board)))

(defn closest-index
  "Finds the closest target-index to the current-index. In the case of a tie, it picks the first occurance in target-indices."
  [target-indices current-index]
  (first
    (reduce (fn [[acc-i acc-s-dist :as old] i]
              (let [raw-dist (- i current-index)
                    sqd-dist (* raw-dist raw-dist)]
                (if (< sqd-dist acc-s-dist)
                  [i sqd-dist]
                  old)))

            [nil Long/MAX_VALUE]

            target-indices)))

Game files:

(ns monopoly-movement-sim.game-state.game-state
  (:require [monopoly-movement-sim.board-layout :as bl]
            [monopoly-movement-sim.game-state.helpers :as sh]

            [monopoly-movement-sim.tiles.action-tiles :as at]
            [monopoly-movement-sim.tiles.misc-tiles :as mt]

            [monopoly-movement-sim.decks :as d]))

(defrecord Game-State [player-index consec-match-dice chance-deck community-deck])

(defn new-state [rand-gen]
  (->Game-State 0 0
                (d/shuffled-chance-deck rand-gen)
                (d/shuffled-community-deck rand-gen)))

(defn- draw-from
  "Returns the drawn card, and the state that was modified as a result of drawing the card.
  new-deck-f should be a function that accepts a random generator, and returns a new shuffled deck."
  [state deck-key new-deck-f]
  (let [[card r-deck] (d/draw (deck-key state))
        new-deck (if (empty? r-deck)
                   (new-deck-f)
                   r-deck)]

    [card (assoc state deck-key new-deck)]))

(defn- draw-from-chance-deck [state rand-gen]
  (draw-from state :chance-deck #(d/shuffled-chance-deck rand-gen)))

(defn- draw-from-community-deck [state rand-gen]
  (draw-from state :community-deck #(d/shuffled-community-deck rand-gen)))

(defn- draw-and-effect
  "Draws a card using the provided draw-f, and uses it to affect the state if it's non-nil.
  Draw-f should accept the current state and a random generator, and return the modified state."
  [state draw-f rand-gen]
  (let [[card drawn-state] (draw-f state rand-gen)]
    (if card
      (card drawn-state)
      drawn-state)))

(defn tile-action
  "Returns either an action that modifies a given state, or nil if the provided tile doesn't have an action associated with it."
  [tile rand-gen]
  (case tile
    ::at/chance #(draw-and-effect % draw-from-chance-deck rand-gen)
    ::at/community-chest #(draw-and-effect % draw-from-community-deck rand-gen)
    ::at/goto-jail #(sh/goto-tile % ::mt/jail)
    nil))

(ns monopoly-movement-sim.game-state.helpers
  (:require [monopoly-movement-sim.board-layout :as bl]
            [helpers.general-helpers :as g]))

(defn wrap-board-index [i]
  (g/unsafe-wrap i
          0 (dec (count bl/board-layout))))

(defn move-player-by [state n-tiles]
  (update state :player-index
          #(wrap-board-index
             (+ % n-tiles))))

(defn player-position [state]
  (get state :player-index))

(defn- goto-closest [state indices]
  (update state :player-index
          #(bl/closest-index indices %)))

(defn goto-tile [state tile-key]
  (goto-closest state (bl/tile-indices tile-key)))

(defn goto-closest-satisfying [state pred]
  (goto-closest state (bl/indexes-satisfying pred)))

(ns monopoly-movement-sim.decks
  (:require [helpers.general-helpers :as g]

            [monopoly-movement-sim.board-layout :as bl]
            [monopoly-movement-sim.game-state.helpers :as sh]

            [monopoly-movement-sim.tiles.misc-tiles :as mt]
            [monopoly-movement-sim.tiles.property-tiles :as pt]))

(def standard-deck-size 18)

(defn shuffle-deck [deck rand-gen]
  (g/shuffle deck rand-gen))

(defn filled-deck [legit-cards filler deck-size]
  (let [n-filler (- deck-size (count legit-cards))]
    (into
      legit-cards
      (repeat n-filler filler))))

(defn draw [deck]
  [(first deck) (rest deck)])

(def community-chest-move-cards
  [#(sh/goto-tile % ::mt/jail)
   #(sh/goto-tile % ::mt/go)])

; TODO: Currently doens't "activate" the tile that was landed on as the result of moving. If the "Go back 3 spaces" card causes the player to land on a community chest tile, a second card is not drawn. There's only a ~0.2% chance of this happening though, so the chance is low of it affecting results.
(def chance-move-cards
  (let [goto-rail #(sh/goto-closest-satisfying % pt/rail-roads)]
    [#(sh/goto-tile % ::mt/go)
     #(sh/goto-tile % ::pt/illinois)
     #(sh/goto-tile % ::pt/st-charles)
     #(sh/goto-closest-satisfying % pt/utilities)
     goto-rail goto-rail
     #(sh/move-player-by % -3)
     #(sh/goto-tile % ::pt/reading-rr)
     #(sh/goto-tile % ::pt/boardwalk)]))

(def community-chest-deck
  (filled-deck community-chest-move-cards nil standard-deck-size))

(def chance-deck
  (filled-deck chance-move-cards nil standard-deck-size))

(defn shuffled-chance-deck [rand-gen]
  (shuffle-deck chance-deck rand-gen))

(defn shuffled-community-deck [rand-gen]
  (shuffle-deck community-chest-deck rand-gen))

Simulation Files:

(ns monopoly-movement-sim.simulation
  (:require [monopoly-movement-sim.decks :as d]
            [monopoly-movement-sim.game-state.game-state :as s]
            [monopoly-movement-sim.game-state.helpers :as sh]
            [monopoly-movement-sim.tiles.action-tiles :as at]
            [monopoly-movement-sim.tiles.misc-tiles :as mt]
            [monopoly-movement-sim.board-layout :as bl]

            [helpers.general-helpers :as g]))


(def n-dice 2)
(def max-allowed-consec-multiples
  "The maximum number of \"doubles\" a player is allowed to roll before being sent to jail."
  3)

(defrecord Simulation-State [game-state tiles-visited])

(defn new-state [rand-gen]
  (->Simulation-State (s/new-state rand-gen) []))

(defn roll-dice [n-dice rand-gen]
  (mapv (fn [_]
          (g/random-int 1 7 rand-gen))
        (range n-dice)))

(defn react-to-roll
  "Moves the player according to the roll, and records the roll if all the dice are the same."
  [game-state dice-rolls]
  (let [sum (apply + dice-rolls)]
    (-> game-state
      (sh/move-player-by sum)
      (update :consec-match-dice #(if (apply = dice-rolls)
                                    (inc %)
                                    0)))))

(defn too-many-consec-multiples? [game-state]
  (>= (:consec-match-dice game-state)
      max-allowed-consec-multiples))

(defn handle-too-many-consec-multiples
  "Sends the player to jail if they've rolled max-allowed-consec-multiples many \"doubles\"."
  [game-state]
  (if (too-many-consec-multiples? game-state)
    (-> game-state
      (sh/goto-tile ::mt/jail)
      (assoc :consec-match-dice 0))

    game-state))

(defn tile-occupied-by-player [game-state]
  (bl/board-layout (sh/player-position game-state)))

(defn record-tile [state tile]
  (update state :tiles-visited #(conj % tile)))

(defn same-player-position? [& game-states]
  (apply =
    (map :player-index game-states)))

(defn move-player
  "Moves the player according to the roll, and deals with the concequences of landing on a tile."
  [state dice-rolls rand-gen]
  (let [{gs :game-state tv :tiles-visited} state
        moved-gs (react-to-roll gs dice-rolls)
        landed-tile (tile-occupied-by-player moved-gs)
        tile-action (s/tile-action landed-tile rand-gen)
        recorded-sim-state (record-tile state landed-tile)

        affected-game-state (if tile-action
                              (tile-action moved-gs)
                              moved-gs)

        recorded-altered-sim-state
        (if (same-player-position? moved-gs affected-game-state)
          recorded-sim-state
          (record-tile recorded-sim-state (tile-occupied-by-player affected-game-state)))]

    (assoc recorded-altered-sim-state :game-state affected-game-state)))

(defn sim-turn [state rand-gen]
  (let [rolls (roll-dice n-dice rand-gen)
        moved-state (move-player state rolls rand-gen)]

    moved-state))

(defn sim-turns-while [state pred rand-gen]
  (loop [sim-state state]
    (let [{gs :game-state tv :tiles-visited} state]

      (if (pred sim-state)
        (let [jailed-state (update sim-state :game-state
                                   handle-too-many-consec-multiples)
              advanced-sim-state (sim-turn jailed-state rand-gen)]
          (recur advanced-sim-state))

        sim-state))))

(ns monopoly-movement-sim.main
  (require [monopoly-movement-sim.simulation :as sim]
           [monopoly-movement-sim.tiles.property-tiles :as pt]

           [helpers.general-helpers :as g]

           [clojure.string :as s])

  (:gen-class))

(defn sorted-visited-freqs [sim-state]
  (sort-by second
           (frequencies (:tiles-visited sim-state))))

(defn pretty-tile [tile]
  (let [cap #(s/capitalize (name %))
        pname (cap tile)
        raw-color (pt/property-colors tile)
        pcolor (if raw-color (cap raw-color) nil)]

    (str pname
         (if pcolor
           (str " - " pcolor)
           ""))))

(defn pretty-tile-freqs [tile-freqs]
  (s/join "\n"
    (mapv (fn [[tile freq]]
            (str (pretty-tile tile) ": " freq))
          tile-freqs)))

(defn -main [n-tile-max]
  ; Recasting to string to allow easy use in the REPL
  (if-let [parsed-n-tiles (g/parse-double (str n-tile-max))]
    (let [r (g/new-rand-gen)
          s (sim/new-state r)
          end-state (sim/sim-turns-while s
                      #(< (count (:tiles-visited %)) parsed-n-tiles)
                      r)]

      (println
        (pretty-tile-freqs
          (sorted-visited-freqs end-state))))

    (println n-tile-max "isn't a valid number of tiles to visit.")))
\$\endgroup\$
9
  • \$\begingroup\$ I think you're ignoring one pretty important aspect... There is forced movement from the "stuff happens" cards... not sure what their name is in English. Either way: the layout I know only has one place where this can benefit other players. The orange streets. In all other cases the player is force-moved to a tile that can't be bought. \$\endgroup\$ – Vogel612 Jul 24 '17 at 22:09
  • \$\begingroup\$ @Vogel612 You mean the chance and community chest cards that "teleport" you? \$\endgroup\$ – Carcigenicate Jul 24 '17 at 22:10
  • \$\begingroup\$ yes, exactly those \$\endgroup\$ – Vogel612 Jul 24 '17 at 22:11
  • \$\begingroup\$ @Vogel612 See monopoly-movement-sim.decks; mainly community-chest-move-cards and chance-move-cards. \$\endgroup\$ – Carcigenicate Jul 24 '17 at 22:11
  • \$\begingroup\$ that's what I get for skimming this code... \$\endgroup\$ – Vogel612 Jul 24 '17 at 22:15
1
\$\begingroup\$

To repair unsafe-wrap, I suggest

(defn wrap
  "Wraps n so it's between min-n (inclusive) and max-n (exclusive)"
  [n min-n max-n]
  (let [limit (- max-n min-n)]
    (+ (mod n limit) min-n)))

This works like range and your random-int in that the top limit is exclusive. So we have to change its one use to

(defn wrap-board-index [i]
  (g/wrap i 0 (count bl/board-layout)))

... getting rid of the dec.

\$\endgroup\$
1
  • \$\begingroup\$ Thanks. For some reason I've always had a hard time with wrapping logic. \$\endgroup\$ – Carcigenicate Aug 15 '17 at 13:58

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.