board-ultimatum

0.1.0-SNAPSHOT


Noir front-end to the board-ultimatum board game recomendation engine.

dependencies

org.clojure/clojure
1.3.0
noir
1.3.0-beta10
enclog
0.5.8-SNAPSHOT
org.clojure/core.logic
0.7.5
com.novemberain/monger
1.3.4
org.clojure/math.combinatorics
0.0.3
incanter
1.2.4
org.clojure/data.json
0.2.0



(this space intentionally left almost blank)
 
(ns board-ultimatum.engine
    (:refer-clojure :exclude [==])
    (:use [clojure.core.logic]))

I don't do a whole lot.

(defn foo
  [x]
  (println x "Hello, World!"))
(defrel num-players game n)
(facts num-players [['Checkers 2]
                    ['Chess 2]
                    ['Solitaire 1]
                    ['Sorry 4]
                    ['Yahtzee 6]
                    ['Nothing 0]])
(defrel property* game p)
(facts property* [['Solitaire 'has-cards]
                  ['Sorry 'has-dice]
                  ['Yahtzee 'has-dice]
                  ['Nothing 'has-everything]])
(defrel category game type)
(facts category [['Checkers 'strategy]
                 ['Chess 'strategy]
                 ['Solitaire 'card]
                 ['Sorry 'chance]
                 ['Yahtzee 'chance]])
(defn query []
    "Returns the number of players of Checkers"
    (run* [q]
        (num-players 'Checkers q)))
(defn query2 []
    "Returns all games with 2 players"
    (run* [q]
        (num-players q 2)))
(defn query3 [value tolerance]
    "Returns all games with 'value' players with +/- 'tolerance'"
    (run* [q]
      (fresh [n]
        (conde [(membero n (range (- value tolerance) (+ value tolerance 1)))])
        (num-players q n))))
(defn query4 []
    "Returns all games with the property 'has-dice'"
    (run* [q]
        (property* q 'has-dice)))
(defn query5 []
    "Return all games with 4 players that have dice and are 
    also are in the category 'chance' games"
    (run* [q]
        (num-players q 4)
        (property* q 'has-dice)
        (category q 'chance)))
 
(ns board-ultimatum.engine.config
  (:require [clojure.string :as string]
            [clojure.java.io :as io]))

Load the given resource path as a clojure file and return its content.

(defn load-map-from-resource
  [resource-path]
  (if-let [cres (io/resource resource-path)]
    (-> cres (.getPath) (load-file))
    {}))

Define a map read from config.clj on the resource path.

(def ^:dynamic config-from-file (load-map-from-resource "config.clj"))

Reads config by first accessing the map from the config file and falling back on environment variables. This is heroku "friendly".

(defn read-config
  [config-var]
  (get config-from-file
       (keyword (string/replace (string/lower-case config-var) "_" "-"))
       (System/getenv config-var)))

These values are meant for setting up mongo.

(def storage {:uri (read-config "MONGOHQ_URL")
              :db-name (read-config "MONGO_DB_NAME")
              :username (read-config "MONGO_USERNAME")
              :password (read-config "MONGO_PASSWORD")})
 
(ns board-ultimatum.engine.model
  (:require [monger.core :as mg]
            [monger.collection :as mc]
            [board-ultimatum.engine.tag :as tag])
  (:use [monger.operators]
        [clojure.pprint]
        [clojure.string :only [blank?]]))

This namespace contains all functions related to manipulating the applications "model" (which is mostly mongo).

Ensures the existence of several indexes to use mongo efficiently.

(defn ensure-indexes
  []
  (mc/ensure-index "experts" {:identifier 1})
  (mc/ensure-index "board_games" {:bgg_id 1} {:unique 1 :dropDups 1})
  (mc/ensure-index "board_games" {:name 1})
  (mc/ensure-index "board_games" {:bgg_id 1, :random "2d"})
  (mc/ensure-index "network_data" {:id 1})
  (mc/ensure-index "network_output" {:game_a 1}))

Connect to mongo based on the given connection information.

(defn connect
  [connection-info]
  (if (:uri connection-info)
    (mg/connect-via-uri! (:uri connection-info))
    (let [db-name (:db-name connection-info)]
      (mg/connect!)
      (when-not (nil? (:username connection-info))
        (mg/authenticate db-name
                         (:username connection-info)
                         (into-array Character/TYPE
                                     (:password connection-info))))
      (mg/set-db! (mg/get-db db-name))))
  ; Set up the indexes necessary for decent performance.
  (ensure-indexes))
(def time-map
  {20 [10 15 20]
   30 [25 30 35]
   45 [40 45 50]
   60 [45 50 60 70 75]
   90 [75 80 90 100]
   120 [100 120 135]
   180 [150 180 200]
   240 [210 240]
   300 [300]
   360 [420 480 600 720 1200 6000]})
(defn times [selected]
  "Turns user inputted time approx. ranges into database queries matching the
  actual game lengths in the database."
  ^{:test (fn [] (assert (= (times [30 45]) '(25 30 35 40 45 50))))}
  (mapcat time-map selected))
(defn find-all []
  "Queries mongo for all games."
  (mc/find-maps "board_games"))
(defn tag-values-by-subtype [game subtype]
  (set (map :value (filter #(= subtype (:subtype %)) (:tags game)))))
(defn mechanics [game]
  (tag-values-by-subtype game "mechanic"))
(defn categories [game]
  (tag-values-by-subtype game "category"))
(def basic-score-weight 100)
(defn score-attr [subtype [attr-name influence-sign]]
  (let [tag (mc/find-one-as-map "tags" {:subtype (tag/singular-subtype subtype) :value attr-name})]
    (if (pos? influence-sign)
      {:score (:pos-influence tag) :reason (:value tag)}
      {:score (:neg-influence tag) :reason (:value tag)})))
(defn rank-score [game]
  {:reason "BGG Rank"
   :score (- basic-score-weight (/ (float (:rank game)) 10))})

player num

(defn convert-player-num [num-key]
  (if (neg? (.indexOf (name num-key) "+"))
    (Integer/parseInt (name num-key))
    100))
(defn recommended-num-player-votes [hsh [player-num poll-result]]
  (assoc hsh (convert-player-num player-num)
    (+
     (* 2 (poll-result :Best))
     (* 1 (poll-result :Recommended))
     (* -1 (poll-result (keyword "Not Recommended"))))))
(defn tally-player-poll [game]
  (reduce recommended-num-player-votes {} (game :suggested_players)))
(defn max-kv-by-value [hsh]
  (reduce (fn [[maxk maxv] [k v]]
            (if (> v maxv)
              [k v]
              [maxk maxv]))
          [-1 -100]
          hsh))
(defn normalize-votes [optimal-num votes]
  (reduce (fn [hsh [p v]]
            (assoc hsh p (/ (float v) (get votes optimal-num))))
          {}
          votes))
(defn optimal-player-num [game]
  (let [player-votes (tally-player-poll game)]
    (first (max-kv-by-value player-votes))))
(defn num-players-score [players game]
  (let [min-pl (apply min players)
        max-pl (apply max players)
        player-votes (tally-player-poll game)
        optimal-num (first (max-kv-by-value player-votes))
        norm-votes (normalize-votes optimal-num player-votes)]
    (cond
     (contains? (set players) optimal-num) basic-score-weight
     (> optimal-num max-pl) (if (contains? (set (keys norm-votes)) max-pl)
                              (* (get norm-votes max-pl) basic-score-weight)
                              (* -1 basic-score-weight))
     (< optimal-num min-pl) (if (contains? (set (keys norm-votes)) min-pl)
                              (* (get norm-votes min-pl) basic-score-weight)
                              (* -1 basic-score-weight))
     :else 0)))
(defn num-players-factors [game attrs]
  (let [players (:num-players attrs)]
    (if (pos? (count players))
      {:reason "Optimal Player Number"
       :score (num-players-score players game)})))
(defn weight-factor [game attrs]
  (if (not (clojure.string/blank? (:weight attrs)))
    (let [w (:weight_average game)
          x (. Float parseFloat (:weight attrs))
          d (- x w)
          score (- basic-score-weight (* 16 d d))]
      {:reason
        (cond
          (> score 80.0) "Close Weight"
          (pos? score) "Acceptable Weight"
          (> w x) "Weight Too High"
          :else "Weight Too Low")
       :score score})))

returns [ [attr-name value] ]

(defn score-factor [attr-type game relevant-attrs]
  (let [game-attrs (tag-values-by-subtype game (tag/singular-subtype attr-type))]
    (remove nil?
            (map #(if (contains? game-attrs (str (first %)))
                    (score-attr attr-type %))
                 relevant-attrs))))
(defn collect-score-factors [game query-attrs]
  (flatten
   (remove empty?
           (list
            (score-factor "mechanics" game (:mechanics query-attrs))
            (score-factor "categories" game (:categories query-attrs))
            (rank-score game)
            (num-players-factors game query-attrs)
            (weight-factor game query-attrs)))))
(defn sum-score [factors]
  (apply + (map :score factors)))
(defn total-score [game]
  (sum-score (:factors game)))
(defn sorted-ranked-games [games query-attrs]
  (sort-by
   #(* -1 (:score %))
   (filter #(> (:score %) 0)
           (map #(let [factors (collect-score-factors % query-attrs)]
                   (assoc % :factors factors
                          :score (sum-score factors)))
                games))))
(defn filter-on-times [attrs games]
  (let [selected-times (:length attrs)]
    (if (pos? (count selected-times))
      (filter #(boolean (some #{(:length %)} (times selected-times))) games)
      games)))
(defn filter-on-num-players [attrs games]
  (let [selected-num-players (:num-players attrs)]
    (if (pos? (count selected-num-players))
      (let [min-pl (apply min selected-num-players)
            max-pl (apply max selected-num-players)]
        (filter #(not (or (> min-pl (:max_players %))
                          (< max-pl (:min_players %)))) games))
      games)))
(defn find-games [query-attrs]
    "Queries mongo for games matching selected inputs."
    (let [collection "board_games"
          games (mc/find-maps collection)]
      (sorted-ranked-games
       (->> games
            (filter-on-times query-attrs)
            (filter-on-num-players query-attrs))
       query-attrs)))

Get an expert from the database by id.

(defn get-game-by-id
  ([id fields] (mc/find-one-as-map "board_games"
                                   {:bgg_id id}
                                   fields))
  ([id] (get-game-by-id id [])))

This function should only be run as a cron task. It adds/update a random field on each game in the board-games collection.

NOTE: This may be improved by using mongo's built-in map-reduce functionality. Also, there is a race condition between when each game document is fetched and updates start.

(defn add-random-field-to-games
  []
  (map (fn [{obj-id :_id}]
         (mc/update-by-id "board_games" obj-id {$set {:random [(rand) 0]}}))
       (mc/find-maps "board_games" {} [:_id])))

functions for getting similar game results

(defn get-game-by-name [name]
  "Get a game from the db by name"
  (mc/find-one-as-map "board_games" {:name name}))
(defn get-id-by-name [name]
  "Returns the id of the game with the provided name"
  (:bgg_id (get-game-by-name name)))
(defn get-similar [id]
  "Get the ids of all games similar to that provided"
  (mc/find-maps "network_output" {:game_a id}))
 

A namespace for maniuplating the experts as part of the datastore

(ns board-ultimatum.engine.model.expert
  (:refer-clojure :exclude [sort find])
  (:require [clojure.string :as string]
            [board-ultimatum.engine.model :as model]
            [monger.core :as mg]
            [monger.collection :as mc])
  (:use [monger operators query]))

The name of the collection on mongo containing experts.

(def coll
  "experts")

An expert with the given id exists in the database.

(defn exists?
  [id] (boolean (mc/find-one coll
                             {:identifier (string/lower-case id)}
                             [:identifier])))

Get an expert from the database by id.

(defn from-id
  ([id fields] (mc/find-one-as-map coll
                                   {:identifier (string/lower-case id)}
                                   fields))
  ([id] (from-id id [])))
(defn name-from-id [id]
  "Get the name of the expert with the given id in its pretty form if it
  exists."
  (let [expert (from-id id [:identifier :pretty-id])]
       (get expert :pretty-id
            (get expert :identifier))))

For the given expert specify

(defn add-unfamiliar-games
  [id games]
  (mc/update coll {:identifier (string/lower-case id)} {$pushAll {:unfamiliar-games games}}))

For the given expert get the games that they are unfamiliar with.

(defn unfamiliar-games-for
  [id]
  (:unfamiliar-games (mc/find-one-as-map coll
                                         {:identifier (string/lower-case id)}
                                         [:unfamiliar-games])
                     []))

Get num-games games for the given expert to compare.

(defn games-for
  [id num-games]
  (with-collection "board_games"
    (find {:bgg_id {$nin (unfamiliar-games-for id)}
           :random {"$near" [(rand) 0]}})
    (limit num-games)))

Add an expert with the given id to the datastore.

(defn add
  [id]
  (mc/insert coll {:pretty-id id
                   :identifier (string/lower-case id)}))
 

A namespace for maniuplating the experts as part of the datastore

(ns board-ultimatum.engine.model.relationship
  (:require [monger.core :as mg]
            [monger.collection :as mc]
            [board-ultimatum.engine.model.expert :as expert])
  (:use [monger.operators]))

The name of the collection on mongo containing relationships.

(def coll
  "relationships")

The difference in the span of possible rating values.

(def rating-span
  4)

The value at which the lowest rating starts.

(def rating-offset
  1)

An relationship between the two games exists in the database.

(defn exists?
  [games] (boolean (mc/find-one coll {:games {$all games}} [:_id])))

Get an expert from the database by id.

(defn from-games
  ([games fields] (mc/find-one-as-map coll {:games {$all games}} fields))
  ([games] (from-games games [])))

Run aggregate to calculate the average rating for each relationship.

(defn average-ratings
  []
  (mc/aggregate coll [{$group {:_id "$games" :rating {$avg "$rating"}}}]))

Convert the given relationship to an object to be stored in the datastore. The first argument must be an expert map contain an object id.

(defn convert-to-object
  [{expert-obj-id :_id} [games rating]]
  {:expert expert-obj-id
   ;; Always sort games on insert so we do not have [a b] and [b a] entries.
   :games (sort games)
   :rating (float (/ (- rating rating-offset) rating-span))})

Add the given relationships to the datastore and keep track for the expert.

(defn add-many
  [game-relationships expert-id]
  (mc/insert-batch coll (map (partial convert-to-object
                                      (expert/from-id expert-id [:_id]))
                             game-relationships)))
 
(ns board-ultimatum.engine.neural
  (:require [monger.core :as mg]
            [monger.collection :as mc]
            [board-ultimatum.engine.model :as model]
            [board-ultimatum.engine.model.relationship :as relationship]
            [board-ultimatum.engine.config :as config])
  (:use clojure.pprint)
  (use [enclog nnets training])
  (:use [monger.operators]))
(let [connection-info (if (nil? (:db-name config/storage))
     (assoc config/storage :db-name "board_ultimatum") config/storage)]

  (try
    (model/connect connection-info)
    (catch java.io.IOException e
      (println "ERROR: Could not connect to MongoDB."))
    (catch java.lang.NullPointerException e
      (println "ERROR: Could not authenticate with Mongo. See config: \n\t"
               (str (assoc connection-info :password "********"))))))

network functions

(def net
  (network  (neural-pattern :feed-forward)
    :activation :sigmoid
    :input   20
    :output  1
    :hidden [20 10]))

for each id, join each id with every other id and calculate output

(def game-ids
  (into [] 
    (map 
      (fn [game] (:bgg_id game))
      (model/find-all))))
(defn get-vector [game-id] 
  (:data (mc/find-one-as-map "network_data" {:id game-id})))
(defn join-vector [id-A id-B] (into [] (concat (get-vector id-A) (get-vector id-B))))
(defn to-dataset [id-A id-B] (data :basic-dataset [(join-vector id-A id-B)]
                                [[-1.0]]))
(defn output-pair [id-A id-B] (map 
  (fn [pair] (. (. net compute (. pair getInput)) getData 0))
  (to-dataset id-A id-B)))

set up the training data

(def training-set 
  (data :basic-dataset 
    (into [] 
      (map 
        (fn [rel] 
          (join-vector (nth (:_id rel) 0) (nth (:_id rel) 1)))
        (relationship/average-ratings)))
    (into [] 
      (map 
        (fn [rel] 
          [(:rating rel)])
        (relationship/average-ratings)))))
(def prop-train (trainer :resilient-prop :network net :training-set training-set)) 

use this function to train the network

(defn train-network []
  (train prop-train 0.01 500 []))

iterate and add top 50 games to DB

(defn network-eval [] 
  (dorun 
    (mc/remove "network_output")
    (doseq [id-A game-ids] 
      (doseq [game-record (take 50 
        (sort-by :rating >
          (map 
            (fn [id-B] 
              { :rating (nth (output-pair id-A id-B) 0) :game_a id-A :game_b id-B })
            game-ids)))]
        (mc/insert "network_output" game-record)))))
 
(ns board-ultimatum.engine.tag
  (:require [monger.core :as mg]
            [monger.collection :as mc])
  (:use [monger.operators]
        [clojure.pprint]))
(defn all-raw-tags []
  (flatten
   (map (fn [g] (:tags g))
        (mc/find-maps "board_games"))))
(defn subtype? [subtype tag]
  (= (:subtype tag) subtype))
(defn raw-tags-by-subtype [subtype]
  (filter
   (partial subtype? subtype)
   (all-raw-tags)))
(defn raw-freq-tags-by-subtype [subtype]
  (sort-by (fn [tag] (* -1 (:frequency tag)))
           (map (fn [[tag freq]] (assoc tag :frequency freq))
                (frequencies (raw-tags-by-subtype subtype)))))
(defn uniq-tags-by-subtype [subtype]
  (distinct
   (raw-tags-by-subtype subtype)))
(defn create-tags-db-by-subtype [subtype]
  (mc/insert-batch "tags"
                   (map #(assoc % :pos-influence 100 :neg-influence -200 :group "Uncategorized")
                        (raw-freq-tags-by-subtype subtype))))
(defn reset-tags-table! []
  (do
    (mc/drop "tags")
    (mc/create "tags" {})
    (create-tags-db-by-subtype "mechanic")
    (create-tags-db-by-subtype "category")
    (create-tags-db-by-subtype "designer")
    (create-tags-db-by-subtype "publisher")))
(defn singular-subtype [attr]
  (cond
   (= attr "mechanics") "mechanic"
   (= attr "categories") "category"
   (= attr "designers") "designer"
   (= attr "publishers") "publisher"
   :else attr))
(defn tags-by-subtype [subtype]
  (sort-by :value (mc/find-maps "tags" {:subtype subtype})))
(defn mechanics []
  (tags-by-subtype "mechanic"))
(defn categories []
  (tags-by-subtype "category"))
(defn designers []
    (tags-by-subtype "designer"))
(defn publishers []
  (tags-by-subtype "publisher"))
(defn all-tags []
  (mc/find-maps "tags" {}))
(defn to-i [in]
  (cond
   (integer? in) in
   (string? in) (Integer/parseInt in)
   :else (int in)))
(defn update [subtype bgg-id new-data]
  (let [data (reduce #(update-in %1 [%2] to-i)
                     new-data
                     [:pos-influence :neg-influence])]
    (mc/update "tags" {:subtype subtype :bgg_id bgg-id} {$set data})))
 
(ns board-ultimatum.engine.vector-convert
  (:require [monger.core :as mg]
            [monger.collection :as mc]
            [board-ultimatum.engine.model :as model]
            [board-ultimatum.engine.config :as config])
  (:use clojure.pprint)
  (:use clojure.set)
  (:use incanter.core incanter.stats incanter.charts))
(let [connection-info (if (nil? (:db-name config/storage))
     (assoc config/storage :db-name "board_ultimatum") config/storage)]

  (try
    (model/connect connection-info)
    (catch java.io.IOException e
      (println "ERROR: Could not connect to MongoDB."))
    (catch java.lang.NullPointerException e
      (println "ERROR: Could not authenticate with Mongo. See config: \n\t"
               (str (assoc connection-info :password "********"))))))
(defn has-tag [game subtype value]
  (cond
    (some #(= value %) 
      (remove nil? 
        (map (fn [tag] 
          (cond (= (:subtype tag) subtype) (:value tag))) (:tags game)))) 1.0
     :else 0.0))
(defn game-categories [game]
  "Return all categories for some game"
  (into #{} (remove nil? 
    (map (fn [tag] 
      (cond (= (:subtype tag) "category") (:value tag))) (:tags game)))))
(defn game-mechanics [game]
  "Return all game mechanics for some game"
  (into #{} (remove nil? 
    (map (fn [tag] 
      (cond (= (:subtype tag) "mechanic") (:value tag))) (:tags game)))))
(defn all-categories []
  "Return all possible categories"
  (seq (apply union (map game-categories
    (model/find-all)))))
(defn all-mechanics []
  "Return all possible mechanics"
  (seq (apply union (map game-mechanics
    (model/find-all)))))
(defn to-vector [game]
  "Convert Mongo game record to normalized numeric vector"
  { :id (:bgg_id game)
    :data [ (/ (:length game) 6000.0)
            (/ (:min_players game) 8.0)
            (/ (:max_players game) 8.0)
            (/ (:min_age game) 18.0)
            (/ (:rank game) 1000.0)
            (/ (:weight_average game) 5.0)
            (/ (:rating_average game) 10.0)
            (/ (:rank game) 1000.0)
            (has-tag game "category" "Video Game Theme")
            (has-tag game "category" "Vietnam War")
            (has-tag game "category" "Aviation / Flight")
            (has-tag game "category" "Print & Play")
            (has-tag game "category" "Memory")
            (has-tag game "category" "Negotiation")
            (has-tag game "category" "Novel-based")
            (has-tag game "category" "Card Game")
            (has-tag game "category" "Prehistoric")
            (has-tag game "category" "Movies / TV / Radio theme")
            (has-tag game "category" "Exploration")
            (has-tag game "category" "Trivia")
            (has-tag game "category" "World War I")
            (has-tag game "category" "Humor")
            (has-tag game "category" "Arabian")
            (has-tag game "category" "Mythology")
            (has-tag game "category" "Napoleonic")
            (has-tag game "category" "Deduction")
            (has-tag game "category" "World War II")
            (has-tag game "category" "Fantasy")
            (has-tag game "category" "American Revolutionary War")
            (has-tag game "category" "Children's Game")
            (has-tag game "category" "Dice")
            (has-tag game "category" "Space Exploration")
            (has-tag game "category" "City Building")
            (has-tag game "category" "Action / Dexterity")
            (has-tag game "category" "Book")
            (has-tag game "category" "Spies/Secret Agents")
            (has-tag game "category" "Horror")
            (has-tag game "category" "Mafia")
            (has-tag game "category" "Word")
            (has-tag game "category" "Industry / Manufacturing")
            (has-tag game "category" "Trains")
            (has-tag game "category" "Party Game")
            (has-tag game "category" "Transportation")
            (has-tag game "category" "Korean War")
            (has-tag game "category" "Farming")
            (has-tag game "category" "Zombies")
            (has-tag game "category" "Racing")
            (has-tag game "category" "American West")
            (has-tag game "category" "Adventure")
            (has-tag game "category" "Abstract Strategy")
            (has-tag game "category" "Mature / Adult")
            (has-tag game "category" "Medical")
            (has-tag game "category" "Civilization")
            (has-tag game "category" "Fighting")
            (has-tag game "category" "Renaissance")
            (has-tag game "category" "Educational")
            (has-tag game "category" "American Indian Wars")
            (has-tag game "category" "Economic")
            (has-tag game "category" "Miniatures")
            (has-tag game "category" "Modern Warfare")
            (has-tag game "category" "Comic Book / Strip")
            (has-tag game "category" "Bluffing")
            (has-tag game "category" "Nautical")
            (has-tag game "category" "Animals")
            (has-tag game "category" "Murder/Mystery")
            (has-tag game "category" "Science Fiction")
            (has-tag game "category" "Ancient")
            (has-tag game "category" "Medieval")
            (has-tag game "category" "Territory Building")
            (has-tag game "category" "Pirates")
            (has-tag game "category" "Collectible Components")
            (has-tag game "category" "Wargame")
            (has-tag game "category" "Travel")
            (has-tag game "category" "Religious")
            (has-tag game "category" "Civil War")
            (has-tag game "category" "American Civil War")
            (has-tag game "category" "Real-time")
            (has-tag game "category" "Electronic")
            (has-tag game "category" "Game System")
            (has-tag game "category" "Political")
            (has-tag game "category" "Puzzle")
            (has-tag game "category" "Environmental")
            (has-tag game "category" "Sports")
            (has-tag game "category" "Maze")
            (has-tag game "mechanic" "Rock-Paper-Scissors")
            (has-tag game "mechanic" "Trading")
            (has-tag game "mechanic" "Memory")
            (has-tag game "mechanic" "Press Your Luck")
            (has-tag game "mechanic" "Area Movement")
            (has-tag game "mechanic" "Worker Placement")
            (has-tag game "mechanic" "Pick-up and Deliver")
            (has-tag game "mechanic" "Crayon Rail System")
            (has-tag game "mechanic" "Paper-and-Pencil")
            (has-tag game "mechanic" "Pattern Building")
            (has-tag game "mechanic" "Role Playing")
            (has-tag game "mechanic" "Roll / Spin and Move")
            (has-tag game "mechanic" "Variable Phase Order")
            (has-tag game "mechanic" "Voting")
            (has-tag game "mechanic" "Simulation")
            (has-tag game "mechanic" "Pattern Recognition")
            (has-tag game "mechanic" "Simultaneous Action Selection")
            (has-tag game "mechanic" "Betting/Wagering")
            (has-tag game "mechanic" "Grid Movement")
            (has-tag game "mechanic" "Variable Player Powers")
            (has-tag game "mechanic" "Area Control / Area Influence")
            (has-tag game "mechanic" "Dice Rolling")
            (has-tag game "mechanic" "Route/Network Building")
            (has-tag game "mechanic" "Campaign / Battle Card Driven")
            (has-tag game "mechanic" "Chit-Pull System")
            (has-tag game "mechanic" "Partnerships")
            (has-tag game "mechanic" "Auction/Bidding")
            (has-tag game "mechanic" "Commodity Speculation")
            (has-tag game "mechanic" "Modular Board")
            (has-tag game "mechanic" "Acting")
            (has-tag game "mechanic" "Deck / Pool Building")
            (has-tag game "mechanic" "Action Point Allowance System")
            (has-tag game "mechanic" "Secret Unit Deployment")
            (has-tag game "mechanic" "Card Drafting")
            (has-tag game "mechanic" "Line Drawing")
            (has-tag game "mechanic" "Tile Placement")
            (has-tag game "mechanic" "Hex-and-Counter")
            (has-tag game "mechanic" "Hand Management")
            (has-tag game "mechanic" "Point to Point Movement")
            (has-tag game "mechanic" "Area-Impulse")
            (has-tag game "mechanic" "Time Track")
            (has-tag game "mechanic" "Stock Holding")
            (has-tag game "mechanic" "Storytelling")
            (has-tag game "mechanic" "Set Collection")
            (has-tag game "mechanic" "Co-operative Play")
            (has-tag game "mechanic" "Trick-taking")
            (has-tag game "mechanic" "Area Enclosure")
          ]})

compile full vector data into a matrix

(def game-ids
  (into [] 
    (map 
      (fn [game] (:bgg_id game))
      (model/find-all))))
(def full-data (matrix (into [] (map 
  (fn [game] (:data game)) 
  (map to-vector
    (model/find-all))))))

perform pca on the data

(def pca (principal-components full-data))
(def components (:rotation pca))
(def pc (into [] (map 
    (fn [i] 
      (sel components :cols i))
    (range 10))))
(def x (into [] (map 
    (fn [i] 
      (mmult full-data (nth pc i)))
    (range 10))))
(def data-2d (dataset 
  ["id" "x1" "x2"]
  (trans (matrix [game-ids (nth x 0) (nth x 1)]))))
(defn data-convert [] 
  (dorun 
    ;; add the data to the mongo db
    (mc/remove "network_data")
    (dorun (map 
        (fn [id data] 
          (mc/insert "network_data" { :id id :data (into [] data) }))
        game-ids 
        (trans (matrix x))))
    ;; plot the data in 2D
    ;(view (scatter-plot (nth x 0) (nth x 1) 
    ;                    :x-label "PC1" 
    ;                    :y-label "PC2" 
    ;                    :title "Game Data"))
    ;; view a table of the dataset
    ;(view ($order [:x1 :x2] :desc data-2d))))
 

Wrap noir's flash functionality with some helper functions to simplify the default use case of an alert flash.

(ns board-ultimatum.flash
  (:refer-clojure :exclude [get])
  (:require [noir.session :as sess]))

Store a flash value that will persist for this request only.

(defn- flash-now!
  [k v]
  (swap! sess/*noir-flash* assoc-in [:incoming k] v))

Most basic flash put the can either do an immediate or slow push using a custom format on the :alert key.

(defn- base-put!
  [flasher t m] (flasher :alert {:type t :message (apply str m)}))

A wrapper around base-put! that allows it to be used with a variable number of arguments. The second argument is treated specially if it is a keyword.

(defn- put-partial
  [flasher]
  (fn [flash-type & message]
    (if (keyword? flash-type)
      (base-put! flasher flash-type message)
      (base-put! flasher :info (cons flash-type message)))))

Does an immediate flash.

(def now!
  (put-partial flash-now!))

Does a slow flash.

(def put!
  (put-partial sess/flash-put!))

Alias this namespace's get to noir's flash-get.

(def get (partial sess/flash-get :alert))
 
(ns board-ultimatum.form-validators
  (:require [noir.session :as sess])
  (:use noir.validation))

Returns whether the given expert attempt is valid or not.

(def ^:private attempt-actions #{"Register" "Log In"})
(defn attempt?
  [{:keys [action identity]}]
  (rule (min-length? identity 3)
        [:identity "identity must be at least 5 characters long."])
  (rule (max-length? identity 32)
        [:identity "identity must be no more than 32 characters long."])
  (rule (re-find #"^[a-zA-z][\-_\w]*( [\-_\w]+)*\w$" identity)
        [:identity (str "identity must match this regex "
                        "/^[a-zA-z][\\-_\\w]*( [\\-_\\w]+)*\\w$/")])
  (rule (contains? attempt-actions action)
        [:identity (str "Given action (\"" action "\") is not a valid action.")])
  (not (errors? :identity)))
 
(ns board-ultimatum.server
  (:require [noir.server :as server]
            [board-ultimatum.engine.model :as model]
            [board-ultimatum.engine.config :as config]))
(server/load-views-ns 'board-ultimatum.views)
(defn -main [& m]
  (let [mode (keyword (or (first m) :dev))
        port (Integer. (get (System/getenv) "PORT" "8080"))
        connection-info (if (nil? (:db-name config/storage))
                          (assoc config/storage :db-name "board_ultimatum")
                          config/storage)]
    (try
      (model/connect connection-info)
      (server/start port {:mode mode
                          :ns 'board-ultimatum})
      (catch java.io.IOException e
        (println "ERROR: Could not connect to MongoDB."))
      (catch java.lang.NullPointerException e
        (println "ERROR: Could not authenticate with Mongo. See config: \n\t"
                 (str (assoc connection-info :password "********")))))))
 

Functions for manipulating the session. Specifally, login and logout.

(ns board-ultimatum.session
  (:require [noir.session :as sess]
            [noir.validation :as vali]
            [board-ultimatum.engine.model.expert :as expert]
            [board-ultimatum.flash :as flash]))

Get the logged in expert's id from the session.

(defn current-expert-id
  [] (sess/get :expert-id))

Determine if there is an expert logged in right now.

(defn expert-logged-in?
  [] (boolean (current-expert-id)))

Logout the currently logged in expert.

(defn expert-logout
  [] (sess/remove! :expert-id))

Login the given expert.

(defn- expert-login
  [id]
  (if (expert/exists? id)
    (let [pretty-id (expert/name-from-id id)]
      (sess/put! :expert-id pretty-id)
      (flash/put! :success "Hello, " pretty-id ". You have logged in successfully!"))
    (vali/set-error :identity "No user with that identity exists. Try
                         registering.")))

Attempt to add the expert to the datastore if it does not already exist.

(defn- expert-register
  [id]
  (if (expert/exists? id)
    (vali/set-error :identity "Another expert is already using that identity.")
    (do
      (expert/add id)
      (flash/put! :success "Thanks for registering " id ". Try logging in." ))))

A mapping of attempt actions to functions. The sign up action maps to adding an expert to the database and the Log In action simply logs in the given id.

(def ^:private attempt-action-to-function
  {"Register" expert-register "Log In" expert-login})

Process the given attempt by either creating a new user or logging them in.

(defn process-login-attempt
  [{:keys [action identity]}]
  ((get attempt-action-to-function action) identity))
 
(ns board-ultimatum.views.attr-display
  (:use [hiccup.element]
        [hiccup.form]
        [noir.core]))
(defn pretty-hours [length]
  (let [hours (/ (float length) 60)]
    (if (== hours (int hours))
      (str (int hours) " hours")
      (str hours " hours"))))
(defn game-length [length]
  (cond
   (>= length 120) (pretty-hours length)
   :else (str length " minutes")))
(defn num-players [min-players max-players]
  (cond
   (= min-players max-players) (str max-players " player")
   :else (str min-players "-" max-players " players")))
(defn format-score [score]
  (format "%+.1f" (float score)))
(defpartial colored-attr [value freq color]
  [:span value
   [:span {:style (str "color: " color ";")}
    " (" freq ")"]])
(defn format-freq [value freq]
  (cond
   (> freq 150) (colored-attr value freq "#007FCF")
   (> freq 100) (colored-attr value freq "#B751C2")
   (> freq 60)  (colored-attr value freq "#D97C75")
   :else        (colored-attr value freq "#CFA176")))

Build 3-state preference selection buttons

(defpartial build-tri-state [{:keys [value frequency description]} attr]
  [:div {:style "float:left;margin:10px 20px 0px 0px;"}
   [:div {:class (clojure.string/join " " ["btn-group" "tri-state" value])}
    [:button {:type "button" :class "btn btn-mini btn-danger"} [:i {:class "icon-thumbs-down"}]]
    [:button {:type "button" :data-title "More Info" :data-content description :class "btn btn-mini option"} (format-freq value frequency )]
    [:button {:type "button" :class "btn btn-mini btn-success"} [:i {:class "icon-thumbs-up"}]]]
   [:input {:type "hidden" :name (str attr "[" value "]") :value "0"}]])

Build radio preference selection buttons

(defpartial build-radio-buttons [name-value form-name]
  [:div
    [:div {:class "btn-group radio-buttons" :data-toggle "buttons-radio"}
      (map 
        #(identity [:button {:type "button" :value (val %) :class "btn"} (key %)])
        name-value)]
    [:input {:type "hidden" :name form-name :value }]])
(defpartial player-checkboxes [num]
  [:div.selection
   [:label.checkbox
    [:div.icon.player]
    (check-box "num-players[]" false num)
    [:div.bottom-label (str num " Players")]]])
(defpartial time-checkboxes [num]
  [:div.selection
   [:label.checkbox
    [:div.icon.time]
    (check-box "length[]" false num)
    [:div.bottom-label (game-length num)]]])
 
(ns board-ultimatum.views.common
  (:require [clojure.string :as string]
            [board-ultimatum.flash :as flash])
  (:use [noir.core :only [defpartial]]
        [hiccup.core]
        [hiccup.element]
        [hiccup.page :only [include-js include-css html5]]))

Macros

Set a dynamicly bindable default site title.

(def site-title "Board Ultimatum")
(def ^:dynamic *site-title* site-title)

Specify what site title to use dynamically.

(defmacro with-title
  [title & body]
  `(binding [*site-titel ~title]
     ~@body))

Set a dynamicly bindable default vector of js files to include.

(def ^:dynamic *javascripts* ["/js/bootstrap.min.js"])

Specify what javascripts to use dynamically.

(defmacro with-javascripts
  [js-paths & body]
  `(binding [*javascripts* ~js-paths]
     ~@body))

Helper Functions

Displays an alert box.

(defn alert
  ([class type message show-close?]
   [:div#flash {:class (str "alert fade in alert-" (name class))}
    (when show-close?
      [:a.close {:data-dismiss "alert"} "&times;"])
    [:strong (if (keyword? type)
               (string/capitalize (name type))
               type) " "] message])
  ([class type message] (alert class type message true))
  ([type message] (alert type type message true)))

Takes a collection of error messages and formats it into html.

(defpartial format-errors
  [errs]
  (when (seq errs) [:div.help-block [:ul (map #(html [:li %]) errs)]]))

Layouts

Base layout used by the web app.

(defpartial base-layout [& content]
  (html5
    [:head
     [:title *site-title*]
     ; Meta Tag Necessary for Twitter Boostrap
     [:meta {:name "viewport"
             :content "width=device-width, initial-scale=1.0"}]
     [:link {:rel "shortcut icon" :type "image/x-icon" :href "/favicon.ico"}]
     (include-css "/css/bootstrap.min.css")
     ; Get jQuery
     (include-js "/js/jquery-1.7.2.min.js")]
    [:body
     content
     (html (map include-js *javascripts*))]))

Standard layout used by most pages on the website.

(defpartial layout [& content]
  (base-layout
    [:div#navbar.navbar.navbar-fixed-top
     [:div.navbar-inner
      [:div.container-fluid
       [:a.btn.btn-navbar
        {:data-toggle "collapse" :data-target ".nav-collapse"}
        [:span.icon-bar] [:span.icon-bar] [:span.icon-bar]]
       [:a.brand.dropdown-toggle {:href "/"} site-title]
       [:div.nav-collapse
        [:ul.nav
         [:li (link-to "/recommend" "Recommend")]
         [:li (link-to "/similar" "Similar Games")]]
        [:ul.nav.pull-right
         [:li (link-to "/expert" "Experts")]
         [:li (link-to "/tags" "Edit Tags")]
         [:li.divider-vertical]
         [:li [:a
               {:href "http://drsnjm.github.com/board-ultimatum"}
               "Documentation"]]
         [:li [:a
               {:href "https://github.com/DRSNJM/board-ultimatum"}
               "Source"]]]]]]]
    [:div#main-wrapper
     [:div#main.container-fluid
      (when-let [{t :type c :class m :message} (flash/get)]
        (alert (if (nil? c) t c) t m))
      content]
     [:footer#footer.container-fluid
      [:a.label.label-success {:href "http://drsnjm.github.com/about/"} "About"]
      " &copy; 2012 " (link-to "http://drsnjm.github.com/" "DRSNJM")]]))
 

Namespace containing all expert views including login, logout, selection and rating.

(ns board-ultimatum.views.expert
  (:require [board-ultimatum.views.common :as common]
            [board-ultimatum.flash :as flash]
            [board-ultimatum.form-validators :as valid]
            (board-ultimatum.engine.model [relationship :as relationship]
                                          [expert :as expert])
            [board-ultimatum.engine.model :as model]
            [clojure.math.combinatorics :as combo]
            [clojure.string :as string]
            [noir.session :as sess]
            [noir.validation :as vali]
            [noir.response :as resp])
  (:use [noir.core :only [defpage defpartial pre-route render]]
        [board-ultimatum.session]
        [hiccup core element]
        [clojure.walk :only [keywordize-keys]]
        [hiccup.form :only [select-options form-to label text-field submit-button]]))
(pre-route [:any "/expert/*"] {:as req}
           (when-not (expert-logged-in?)
             (flash/put! :warning "You must log in before accessing this
                                  functionality.")
             (resp/redirect "/expert")))

Used by the GET /expert route when there is an expert logged in.

(defpartial expert-logged-in
  []
  [:div.page-header
   [:h1 "You have two choices"]]
  [:ol#expert-choices.row-fluid
   [:li.span6
    [:div.hero-unit.small
     [:h2 "Select games you know "
      [:small "and tell us how good of a recommendation they are for
              eachother."]]
     (link-to {:class "btn btn-primary btn-large"}
              "/expert/select" [:strong "Do some work!"])]]
   [:li.span6
    [:h2 "&hellip;or be boring" [:small " and log out."]]
    (link-to {:class "btn btn-large"}
             "/expert/logout" "Log out")]])

Used by the GET /expert route when there is not an expert logged in.

(defpartial expert-not-logged-in
  []
  [:div.page-header
   [:h1 "Welcome, Board Game Expert!"]]
  [:p "Since this is not a security required application there is no
      strong authentication. However, to keep track of your history of
      recommendations we still need to identify you."]
  [:p "The resulting system is very simple. As an expert you will use the
      same identifier each time you use the application. Your identifier
      can be anything you want including your name or random string of
      numbers."]
  [:div.well
   (form-to {:id "expert-login" :class "form-inline"} [:post "/expert"]
            (text-field {:id "identity" :placeholder "Your identifier"}
                        "identity") " "
            (submit-button {:name "action" :class "btn btn-primary"}
                           "Log In") " "
            (submit-button {:name "action" :class "btn"}
                           "Register"))])

The route from which an expert should start at. If they are not logged in they can here. If they are then they are redirected to select.

(defpage "/expert" []
  (common/layout
    (if (expert-logged-in?)
      (expert-logged-in)
      (expert-not-logged-in))))

POST version of the /expert route. This route processes the login/register attempt and redirects back to the GET page.

(defpage [:post "/expert"] {:as attempt}
  (when-not (expert-logged-in?)
    (when (valid/attempt? attempt)
      (process-login-attempt attempt))
    (if-let [errors (vali/get-errors :identity)]
      (flash/put! :error (common/format-errors errors))))
  (resp/redirect "/expert"))

Logout the currently logged in expert.

(defpage "/expert/logout" []
  (expert-logout)
  (resp/redirect "/expert"))

grid-cols must divide 12 for use with CSS grid system.

(def grid-cols 4)
(def grid-rows 3)
(def grid-size (* grid-cols grid-rows))

Takes a collection of games and returns a 2-D vector of games where each row is of length grid-cols.

(defn games-to-grid
  [games]
  (let [num-games (count games)
        num-rows (int (Math/ceil (/ num-games grid-cols)))
        remainder (mod num-games grid-cols)
        last-row-size (if (zero? remainder) grid-cols remainder)]
    (for [y (range num-rows)]
      (for [x (range (if (= (dec num-rows) y) last-row-size grid-cols))]
        (nth games (+ (* y grid-cols) x))))))

Takes a map representing a game and returns markup for a game including its title and thumbnail.

(defpartial game-thumb
  [{:keys [bgg_id name thumbnail]}]
  [:div {:id (str "game-" bgg_id)
         :class (str "game-container span" (/ 12 grid-cols))
         :data-toggle "button"}
   [:div.game
    [:input {:type "hidden" :name (str "games[" bgg_id "]") :value "false"}]
    [:div.image-wrapper
     [:img.img-rounded {:src thumbnail}]]
    [:div.title-wrapper
     [:h5 name]]]])

Create a fluid row with the content being game-thumb mapped over the given coll.

(defpartial grid-row
  [coll]
  [:div.row-fluid
   (map game-thumb coll)])

A page show to the expert

(defpage "/expert/select" []
  (common/with-javascripts (cons "/js/expert.js" common/*javascripts*)
    (common/layout
      [:div.page-header
       [:h1 "Select all of the games you are familiar with"]]
      [:form#expert-select {:method "post"}
       (map grid-row (games-to-grid (expert/games-for (current-expert-id)
                                                      grid-size)))
       [:div.form-actions
        [:div.row-fluid
         [:button#main-button.btn.btn-large.span8
          [:strong "I am unfamiliar with all of these games. Next!"]]
         [:a.btn.btn-large.span4 {:href "/expert"}
          "I'm done with this for today."]]]])))

Similar to game-thumb but for the compare page. Taks a bgg_id and returns markup containing a thumbnail and title of the referenced game.

(defpartial compare-game
  [bgg-id]
  (let [{:keys [name thumbnail]} (model/get-game-by-id bgg-id
                                                       [:name :thumbnail])]
    [:div.game.span4
     [:div.image-wrapper
      [:img.img-rounded {:src thumbnail}]]
     [:div.title-wrapper
      [:h5 name]]]))

Take an index and pair of games returning markup an expert can use to rate the recommendation quality of the pair.

(defpartial compare-games
  [index [game-a game-b]]
  [:div.row-fluid {:id (str "rate-games" index)}
   [:div.rate-games
    (compare-game game-a)
    [:div.rating.span4 {:id (str "rating" index)}
     [:div.rating-slider {:id (str "rating-slider" index)}
      [:select {:name (str game-a "-" game-b)}
       (select-options
         [["Bad" 1] ["" 1.5] ["Poor" 2] ["" 2.5] ["OK" 3] ["" 3.5] ["Good" 4]
          ["" 4.5] ["Great" 5]] 3)]]]
    (compare-game game-b)]])

The main body of /expert/compare when ids is greater than 1. Provides an interface for rating the quality of each game pair combination.

(defpartial expert-compare
  [ids]
  (common/with-javascripts (concat common/*javascripts*
                                   ["/js/jquery-ui-slider.min.js"
                                    "/js/selectToUISlider.jQuery.min.js"
                                    "/js/expert-compare.js"])
    (common/layout
      [:div.page-header
       [:h1 "Rate these recommendations"]
       [:div.instructions
        [:p "For each pair below please rate how good of a recommendation each
            game is given the other."]]
       [:div#expert-compare
        (form-to [:post "/expert/compare"]
          (map-indexed compare-games
                       (shuffle (map shuffle (combo/combinations ids 2))))
          [:div.form-actions
           [:div.row-fluid
            [:button#main-button.btn.btn-large.span6.btn-primary
             [:strong "I'm done rating these games."]]
            [:a.btn.btn-large.span6 {:href "/expert/select"}
             "I can't rate these games."]]])]])))

For a given map entry return whether the value is the string "true".

(defn- input-game-filter
  {:test (fn []
           (assert (input-game-filter ["123" "true"]))
           (assert (not (input-game-filter ["123" "false"]))))}
  [[_ selected?]]
  (= selected? "true"))

For a given two element return the first element parsed as an integer.

(defn- input-game-mapper
  {:test (fn []
           (assert (= 123 (input-game-mapper ["123" "true"]))))}
  [[bgg-id _]]
  (Integer/parseInt bgg-id))

Convert input from the from to an easy to handle two element vector where the first element is a vector of selected ids and the second is a vector of.

(defn- convert-input-games
  {:test (fn [] (assert (= (convert-input-games
                             {"123" "true" "234" "false" "567" "true"})
                           [[123 567] [234]])))}
  [games]
  [(map input-game-mapper (filter input-game-filter games))
   (map input-game-mapper (remove input-game-filter games))])

Take selected games from an expert and if they selected 2 or more render an interface for comparing them.

(defpage [:post "/expert/select"] {:keys [games]}
  (let [[selected-ids unfamiliar-ids] (convert-input-games games)]
    (expert/add-unfamiliar-games (current-expert-id) unfamiliar-ids)
    (if (<= (count selected-ids) 1)
      (resp/redirect "/expert/select")
      (expert-compare selected-ids))))

This route specifies how to take the results of recommendation quality ratings provided by an expert.

(defpage [:post "/expert/compare"] {:as relationships}
  (when (seq relationships)
    (relationship/add-many
      (into {} (map (fn [[pair-str value]]
                      [(map #(Integer/parseInt %)
                            (string/split pair-str (re-pattern "-")))
                       (Integer/parseInt value)])
                    relationships))
      (current-expert-id)))
  (resp/redirect "/expert/select"))
 
(ns board-ultimatum.views.index
  (:require [board-ultimatum.views.common :as common])
  (:use [noir.core :only [defpage]]
        [hiccup.element]))
(defpage "/" []
         (common/layout
           [:h1 "Welcome to board-ultimatum"]
           [:p "The board-ultimatum is a board game recommendation system" 
               [:br] 
            "This system is implemented in Clojure"]

           [:h3 "Pages"]
           [:p (link-to "/recommend" "Recommend V1")]
           [:p (link-to "/tags" "Tag Administration")]
           [:p (link-to "/expert" "Expert Interface")]))
 
(ns board-ultimatum.views.recommend
  (:require [board-ultimatum.views.common :as common]
            [board-ultimatum.engine.model :as model]
            [board-ultimatum.engine.tag :as tag]
            [board-ultimatum.views.attr-display :as attr-display]
            [board-ultimatum.views.results :as results]
            [clojure.string :as string])
  (:use [noir.core :only [defpage defpartial]]
        [clojure.pprint]))

Page for querying the logic based recommendation engine.

(defpage "/recommend" []
    (common/with-javascripts (cons "/js/recommend.js" common/*javascripts*)
      (common/layout
        [:h1 "Want a game recommendation?"]
        [:h2 "Fill in the inputs below with your preferences"]
        [:div#recommend.row-fluid
         [:div#sidebar.span2
          [:ul#select.nav.nav-pills.nav-stacked.affix
            [:li#length [:a "Game Length"]]
            [:li#num-players [:a "Number of Players"]]
            [:li#mechanics [:a "Mechanics"]]
            [:li#categories [:a "Categories"]]
            [:li#weight [:a "Weight"]]]]

         [:div.span9
          [:form#game-params {:action "/recommend" :method "post"}

            [:div {:id "input-length" :class "param well well-small"}
              [:input {:type "hidden" :name "length-active" :value "false"}]
              [:h3 "Game Length"]
              [:p "This is a description of this field"]
              (map attr-display/time-checkboxes [20 30 45 60 90 120 180 240 300])]

            [:div {:id "input-num-players" :class "param well well-small"}
              [:input {:type "hidden" :name "num-players-active" :value "false"}]
              [:h3 "Number of Players"]
              [:p "This is a description of this field"]
              (map attr-display/player-checkboxes ["1" "2" "3" "4" "5" "6" "7+"])]

            [:div {:id "input-mechanics" :class "param well well-small"}
              [:input {:type "hidden" :name "mechanics-active" :value "false"}]
              [:h3 "Mechanics"]
              [:p "Select gameplay mechanics that you like or dislike"]
              (map #(attr-display/build-tri-state % "mechanics")
                   (tag/mechanics))]

            [:div {:id "input-categories" :class "param well well-small"}
              [:input {:type "hidden" :name "categories-active" :value "false"}]
              [:h3 "Categories"]
              [:p "Select gameplay categories that you like or dislike"]
              (map #(attr-display/build-tri-state % "categories")
                   (tag/categories))]

           
            [:div {:id "input-weight" :class "param well well-small"}
              [:input {:type "hidden" :name "weight-active" :value "false"}]
              [:h3 "Weight"]
              [:p "This is a description of this field"]
              [:div {:class "btn-group" :data-toggle "buttons-radio"}
              (attr-display/build-radio-buttons 
                (array-map :Light "1" :Medium-Light "2"
                           :Medium "3" :Medium-Heavy "4"
                           :Heavy "5") 
                "weight")]]    
            [:button {:type "submit" :class "btn btn-submit"} "Submit"]]]])))

Should probaby do this filtering in js

(defn sanitize-query-params [attrs]
  (->> {}
       ((fn [hsh]
          (if (Boolean/valueOf (:mechanics-active attrs))
            (assoc hsh :mechanics
                   (map #(vector (name (first %)) (Integer/parseInt (second %)))
                        (filter #(not= "0" (second %)) (:mechanics attrs))))
            hsh)))
       ((fn [hsh]
          (if (Boolean/valueOf (:categories-active attrs))
            (assoc hsh :categories
                   (map #(vector (name (first %)) (Integer/parseInt (second %)))
                        (filter #(not= "0" (second %)) (:categories attrs))))
            hsh)))
       ((fn [hsh]
           (if (Boolean/valueOf (:num-players-active attrs))
             (assoc hsh :num-players
                    (flatten
                     (map #(if (= % "7+")
                             (range 7 100)
                             (Integer/parseInt %))
                          (:num-players attrs))))
           hsh)))
       ((fn [hsh]
           (if (Boolean/valueOf (:length-active attrs))
             (assoc hsh :length
                    (map #(Integer/parseInt %) (:length attrs)))
           hsh)))
       ((fn [hsh]
           (if (Boolean/valueOf (:weight-active attrs))
             (assoc hsh :weight (:weight attrs))
             hsh)))))

messy, but just debug info, so who cares?

(defpartial display-query-params [[attr-type values]]
  [:li attr-type ": "
   (if (vector? (first values))
     [:ul
      (map (fn [[k v]]
             (if (= 1 v)
               [:li.positive k]
               [:li.negative k]))
           values)]
     (string/join ", " values))])
(defpage [:post "/recommend"] {:as params}
  (common/with-javascripts
    (concat common/*javascripts* ["/js/bootstrap.js" "/js/results.js"])
    (common/layout
     [:h1 "Have fun playing!"]
     [:h3 "Query Params"]
     [:div.well {:style "overflow:hidden;"}
      [:ul.query-params
       (map display-query-params (sanitize-query-params params))]]
     (results/build-results-list
      (take 30 (model/find-games
                (sanitize-query-params params)))
      true
      true
      false))))
 

Namespace of html generating code for displaying games on a results page. External users of this namespace should only need to call build-results-list.

(ns board-ultimatum.views.results
  (:require [board-ultimatum.engine.model :as model]
            [board-ultimatum.views.attr-display :as attr-display]
            [clojure.string :as string])
  (:use [hiccup.element]
        [noir.core]))
(defn game-weight-text [weight]
  (cond
    (< weight 1.5) "Light"
    (< weight 2.5) "Medium Light"
    (< weight 3.5) "Medium"
    (< weight 4.5) "Medium Heavy"
    :else "Heavy"))
(defpartial pp-factor [factor]
  [:div {:style "clear:both;"}
    [:div {:style "float:left;"} (:reason factor)]
    [:div {:style "float:right;"} (attr-display/format-score (:score factor))]])
(defpartial pp-factors [game]
  (map pp-factor (:factors game))
  [:div {:style "clear:left;border-top: 1px solid #666666;padding: 5px 0px;"}
    [:b
      [:div {:style "float:left;"} "Total score"]
      [:div {:style "float:right;"} (attr-display/format-score (:score game))]]])
(defpartial display-game [i game disp-recom disp-explanation rating]
  [:div.well.game {:style "height:150px;position:relative;"}
    (if (and disp-explanation (not= (.size (:factors game)) 0))
      [:div
        [:div.pop-trigger {:style "position:absolute;right:5px;top:2px;float:left;display:none;"
             :rel "popover" :data-placement "left" :data-trigger "hover"
             :data-title "How did this game match up to your preferences?"}
          [:i.icon-question-sign {:style "margin-left:10px;"}]]
        [:div.pop-content {:style "display:none;"} (pp-factors game)]])
    [:div {:style "width:200px;float:left;"}
      [:img {:src (:thumbnail game) :style "margin: 0px auto;display: block;"}]]
    [:table {:style "float:left;margin-left:20px;width:75%;line-height:normal;"}
      [:tr {:style "height:50px;border-bottom:1px solid black;"}
        [:td {:colspan "5"}
          [:div {:style "font-size:34px;float:left;"}
            (:name game)
            (when-not (nil? rating) (str " - " (format "%.1f" (* 100 rating)) "% Match"))]
          [:div {:style "float:right;"} 
            (link-to
              (str "http://boardgamegeek.com/boardgame/" (:bgg_id game) "/")
              "BGG Rank: " (:rank game) " "
              [:i {:class "icon-share"}])]]]
      [:tr {:style "height:80px;"}
        [:td {:style "width:50%;"}
          (map #(identity [:span {:style "width:50%;float:left;margin-bottom:2px;"} "&#149; " %])
            (concat (model/mechanics game)
              (model/categories game)))]
        [:td {:style "width:12%;"}
          (let [length-disp
            (string/split
              (attr-display/game-length (:length game))
              #"\s+")]
            [:div {:style "text-align:center;border-left:1px solid black;"}
              [:div {:style "font-size:30px;"} (first length-disp)]
              [:div (second length-disp)]])]
        [:td {:style "width:12%;"}
          (let [num-pl-disp
            (string/split 
              (attr-display/num-players (:min_players game) (:max_players game))
              #"\s+")]
            [:div {:style "text-align:center;border-left:1px solid black;"}
              [:div {:style "font-size:30px;"} (first num-pl-disp)]
              [:div (second num-pl-disp)]])]
        [:td {:style "width:12%;"}
          [:div {:style "text-align:center;border-left:1px solid black;"}
            [:div {:style "font-size:30px;"} (:min_age game) "+"]
            [:div "years old"]]]
        [:td {:style "width:14%;"}
          [:div {:style "text-align:center;border-left:1px solid black;"}
            (let [text (string/split (game-weight-text (:weight_average game)) #"\s+")]
              (if
                (< 1 (.size text))
                [:b (map #(identity [:div {:style "font-size:16px;"} %]) text)]
                (identity [:div {:style "font-size:30px;"} (first text)])))
            [:div "weight"]]]]]
    [:div.open-recom {:style "height:150px;width:20px;float:right;display:none;"}
      [:i.icon-chevron-right {:style "position:absolute;top:45%;"}]]])

Send true for disp-recom, disp-explanation if you wish to display recommendations and explanations on games. Ratings should be nil if no ratings are to be displayed.

(defpartial build-results-list [games disp-recom disp-explanation ratings]
    (map display-game
      (iterate inc 1)
      games
      (iterate identity disp-recom)
      (iterate identity disp-explanation)
      (if ratings
        ratings
        (cycle [nil]))))
 
(ns board-ultimatum.views.similar
  (:require [board-ultimatum.views.common :as common]
            [board-ultimatum.engine.model :as model]
            [board-ultimatum.views.results :as results]
            [board-ultimatum.flash :as flash]
            [clojure.string :as string]
            [clojure.data.json :as json])
  (:use [noir.core :only [defpage defpartial]]
        [noir.response :only [redirect]]
        [hiccup.element]
        [hiccup.form]
        [clojure.pprint]))
(defn names-to-json []
  (json/write-str (map (fn [game] (:name game)) (model/find-all))))

Page for searching for "similar" games

(defpage "/similar" []
    (common/with-javascripts (cons "/js/similar.js" common/*javascripts*)
      (common/layout
        [:script {:type "text/javascript"} (str "var taValues = " (names-to-json) ";")]
        [:h1 "Liked a game?"]
        [:h2 "Enter the name below to find more that you will enjoy!"]
        [:div#recommend.row-fluid
          [:form#game-params {:action "/similar" :method "post"}
            [:div.input-append
              [:input#game-name {:type "text" :data-provide "typeahead" :name "game-name"}]
              [:button {:type "submit" :class "btn"} "Search"]]]])))
(defpage [:post "/similar"] {:as params}
    (if
      (nil? (model/get-id-by-name (:game-name params)))
        (do (flash/put! :error (str (:game-name params) " not found in database")) 
            (redirect "/similar"))
      (common/with-javascripts (cons "/js/similar.js" common/*javascripts*)
        (common/layout      
          [:h1 "Game Results"]
          [:h2 "Based on \"" (:game-name params) "\""]
          (let [game-ids
            (sort-by :rating >
              (model/get-similar 
                (model/get-id-by-name 
                  (:game-name params))))]
            (results/build-results-list
              (map #(model/get-game-by-id (:game_b %)) game-ids)
              false
              false
              (map :rating game-ids)))
          [:h4 [:a {:href "/similar"} "Search again"]]))))
 
(ns board-ultimatum.views.tags
  (:require [board-ultimatum.views.common :as common]
            [board-ultimatum.engine.tag :as tag])
  (:use [noir.core]
        [hiccup.core]
        [hiccup.element]
        [hiccup.form]
        [clojure.pprint]
        [monger.result]))
(defpartial tag-item [{:keys [value bgg_id description group subtype pos-influence neg-influence frequency]}]
    (with-group subtype
      (with-group bgg_id
        [:tr
         [:td value]
         [:td (text-area :description description)]
         [:td (text-field :group group)]
         [:td (text-field :pos-influence pos-influence)]
         [:td (text-field :neg-influence neg-influence)]
         [:td frequency]])))
(defpartial subtype-table [subtype]
  [:h3 (clojure.string/capitalize subtype)]
  [:table.table.table-striped.table-condensed
   [:thead
    [:th "Name"]
    [:th "Description"]
    [:th "Group"]
    [:th "Positive Influence"]
    [:th "Negative Influence"]
    [:th "Frequency"]]
   [:tbody
    (map tag-item (tag/tags-by-subtype subtype))]])
(defpage "/tags" []
  (common/layout
   [:h1 "Tag Administration"]
   (form-to [:post "/tags"]
            (map subtype-table ["mechanic" "category"])
            (submit-button "Save Corrections"))))
(defn update-tag [subtype params]
  (doall (map (fn [[bgg-id data]]
                (tag/update subtype (Integer/parseInt bgg-id) data))
              (subtype params)))  )
(defpage [:post "/tags"] [:as params]
  (do
    (update-tag :mechanic params)
    (update-tag :category params)
    (render "/tags")))