Bike Bingo in Clojure

So I’m admittedly a total newb in Clojure, but in the spirit of expanding my repertoire, I like to tackle problems in it now and again. So here’s what I want from you rare folks who somehow find these posts: enlighten me. Here’s my attempt at solving a trivial problem in what is surely not idiomatic Clojure. So why not shine a little light my way and tell me how you’d do it.

The Challenge

I’m in a biking-for-beer competition and the side game this month is bike bingo. All bikers know the potential tiles (which come in such flavors as “Bike at least 30 miles in one day” or “Pass someone on a tall bike”), but we don’t know the board layout. We spend the month completing as many of the tiles as possible, and the person with the most bingos at the end of the month wins beer. And sweet, sweet, arrogant pride.

So now we need a board. I figure we only need one board, because we’ll all have earned different tiles (opposite of standard bingo where everyone plays with the same numbers but different boards). The board should be somewhat random, but we’d also like to reward those who completed the hardest tiles.

The Solution

Here’s my solution. Tear it apart.

(ns bike-bingo
  (:require
   ;; We'll use cl-format to draw the board.
   [clojure.contrib.pprint :as pp]))

;; Here's our 5x5 bingo board. We're counting horizontal, vertical,
;; diagonal, four corners, and blackout.  The numbers in each square
;; represent how many bingos that square is involved in (excluding
;; blackout).
;;
;; It's been predetermined that the middle square, [2 2], will be "Ride
;; more than 1 mile", so I'm leaving it out.
;;
;;    | 0 | 1 | 2 | 3 | 4 |
;; ---+---+---+---+---+---|
;;  0 | 4 | 2 | 2 | 2 | 4 |
;; ---+---+---+---+---+---|
;;  1 | 2 | 3 | 2 | 3 | 2 |
;; ---+---+---+---+---+---|
;;  2 | 2 | 2 | x | 2 | 2 |
;; ---+---+---+---+---+---|
;;  3 | 2 | 3 | 2 | 3 | 2 |
;; ---+---+---+---+---+---|
;;  4 | 4 | 2 | 2 | 2 | 4 |
;;    +---+---+---+---+---|
;;
;; Given the illustration above, we're going to fill the most valuable
;; slots first using the most difficult tiles. Here's our board
;; filling order:

(def *board-priority*
     [[0 0] [0 4] [4 0] [4 4]
      [1 1] [1 3] [3 1] [3 3]
      [0 1] [0 2] [0 3] [1 0]
      [1 2] [1 4] [2 0] [2 1]
      [2 3] [2 4] [3 0] [3 2]
      [3 4] [4 1] [4 2] [4 3]])

;; To determine which tiles are the hardest, we'll take a look at how
;; many people earned them. Not perfect, but it'll do. Here's a map of
;; tiles to times completed.

(def *freq-map*
     {"Ride more than 10 miles"                                7
      "Ride more than 20 miles"                                3
      "Ride more than 30 miles"                                3
      "Ride more than 40 miles"                                2
      "Ride more than 50 miles"                                4
      "Ride more than 100 miles"                               1
      "Ride between 12:00AM and 5:00AM"                        2
      "Ride in inclement weather"                              5
      "Get yelled at by a stranger"                            5
      "Hit 30mph (downhill OK)"                                8
      "Ride to work every day one week"                        5
      "Go mountain biking"                                     2
      "Bike home from the bar"                                 5
      "Bike up Summit (Ramsey) hill"                           1
      "Bike on the Greenway"                                   5
      "Ride the Minneapolis Grand Rounds"                      2
      "Ride from DT Minneapolis to DT St. Paul or vice versa"  1
      "Ride outside the 494-694 loop"                          5
      "Follow all traffic laws (yes, even stop signs)"         8
      "Get a flat tire"                                        5
      "Change a flat tire"                                     3
      "Get honked at by a stranger"                            5
      "Bike in a group of 5 or more people"                    4
      "Pass someone on a Segway"                               2
      "Pass someone on a tall bike"                            2
      "Participate in a public bike event"                     4
      "Watch a public bike event"                              1
      "Track stand for 30 seconds"                             2})

;; Here's a simple HTML board template with some `cl-format'
;; directives in it. `~{' and `~}' start and end loops, `~a' is
;; replaced with some value. (Todo: It might be interesting to use
;; Enlive for this instead (http://github.com/cgrand/enlive).)

(def *template*
"<html xmlns=\"http://www.w3.org/1999/xhtml\" xml:lang=\"en\" lang=\"en\">
  <head>
    <style type=\"text/css\" media=\"screen\">
      td {
      border: 2px solid black;
      padding: 10px;
      width: 150px;
      height: 150px;
      text-align: center;
      font-weight: bold;
      }
    </style>
  </head>
  <body>
    <table>
      ~{<tr>
        ~{<td>~a</td>
        ~}
      </tr>
      ~}
    </table>
  </body>
</html>
")

;; Each tile will be selected at random, but I'd really like to give
;; the hardest tiles a good chance to hit the most valuable
;; squares. This function will transform their weight into the number
;; of entries they'll be getting in the hat.
;;
;; If someone has a more mathematically clever solution for this
;; transformation, I'm all ears.

(defn weight-transform [x]
  (* x x x))

;; Weight is inversely proportional to frequency from our freq-map, so
;; we subtract the times seen from 1 + most frequent to determine
;; it.
;;
;; Though, would it make more sense to make this an inverse of the
;; actually frequency divided by the number of people in the
;; contest (greatest possible frequency)?

(defn freq-to-weight [freq-map]
  (let [biggest (inc (apply max (vals freq-map)))]
    (apply hash-map (flatten
                     (for [pair freq-map] (list (pair 0) (- biggest (pair 1))))))))

;; Given the weight map and our transforming function, fill the hat
;; with an appropriate number of entries for each tile.

(defn fill-hat [weight-map weight-transform]
  (flatten (for [pair weight-map] (repeat (weight-transform (pair 1)) (pair 0)))))

;; Now we build up our board in order of priority.

(defn build-board [hat priority]
  (loop [board {} hat hat priority priority]
    ;; Once we've filled all our tiles from the priority list, we'll
    ;; place "Bike more than 1 mile" at the center.
    (if (= (count priority) 0)
      (assoc board [2 2] "Bike more than 1 mile")
      ;; Pull a random item from the hat. More heavily rated items
      ;; have more entries in the hat and are more likely to be drawn.
      (let [value (nth hat (rand-int (count hat)))]
        (recur
         ;; Place the tile on the board.
         (assoc board (first priority) value)
         ;; Remove all entries for that tile from the hat. (Good thing
         ;; this isn't a real hat.)
         (remove #(= % value) hat)
         ;; Continue with the remaining priority list.
         (rest priority))))))

;; `cl-format' will really like our board if it's nested vectors. Hash
;; table, not so much.

(defn board-to-nested-vector [board]
  (vec (for [row (range 5)]
         (vec (for [col (range 5)]
                (board [row col]))))))

;; A simple bit of business

(defn print-board [board template]
  (pp/cl-format true template (board-to-nested-vector board)))

;; And finally, we do the work.

(-> *freq-map*
    (freq-to-weight)
    (fill-hat weight-transform)
    (build-board *board-priority*)
    (print-board *template*))

Here’s the result.

About selah

Programmer at Prominence Advisors. Also dabbling musician. Also cycling commuter.
This entry was posted in Clojure, Programming and tagged , , , . Bookmark the permalink.

One Response to Bike Bingo in Clojure

Leave a Reply

Your email address will not be published. Required fields are marked *