diff --git a/resources/migrations/20250312151923-create-game.up.sql b/resources/migrations/20250312151923-create-game.up.sql index b8f4fb1..ff042c1 100644 --- a/resources/migrations/20250312151923-create-game.up.sql +++ b/resources/migrations/20250312151923-create-game.up.sql @@ -1,8 +1,5 @@ CREATE TABLE IF NOT EXISTS game ( - id text PRIMARY KEY, - code text UNIQUE, - display_session text NOT NULL, - control_session text, + code text PRIMARY KEY, current_player integer, - active boolean not null + player_count integer NOT NULL ); diff --git a/resources/migrations/20250312151928-create-players.up.sql b/resources/migrations/20250312151928-create-players.up.sql index 452e068..b4b77d8 100644 --- a/resources/migrations/20250312151928-create-players.up.sql +++ b/resources/migrations/20250312151928-create-players.up.sql @@ -5,5 +5,6 @@ CREATE TABLE IF NOT EXISTS player ( name text NOT NULL, game_code text NOT NULL, play_order integer NOT NULL, + last_move integer, FOREIGN KEY (game_code) REFERENCES game(code) ); diff --git a/src/com/biffweb/my_project.clj b/src/com/biffweb/my_project.clj index 73114f3..420ee8c 100644 --- a/src/com/biffweb/my_project.clj +++ b/src/com/biffweb/my_project.clj @@ -1,25 +1,19 @@ (ns com.biffweb.my-project (:require - [migratus.core :as migratus] - [clojure.test :as test] - [ring.adapter.jetty9 :as jetty] - ;; [clojure.tools.logging :as log] + [clojure.test :as test] ;; [clojure.tools.logging :as log] [clojure.tools.namespace.repl :as tn-repl] [com.biffweb :as biff] [com.biffweb.my-project.app :as app] [com.biffweb.my-project.auth-module :as auth-module] - [rum.core :as rum] - [honey.sql :as sql] [com.biffweb.my-project.email :as email] [com.biffweb.my-project.home :as home] [com.biffweb.my-project.middleware :as mid] [com.biffweb.my-project.ui :as ui] [com.biffweb.my-project.worker :as worker] - [taoensso.telemere :as t] - [taoensso.telemere.tools-logging :as tlog] - [taoensso.telemere.timbre :as log] + [migratus.core :as migratus] [next.jdbc :as jdbc] - [nrepl.cmdline :as nrepl-cmd]) + [nrepl.cmdline :as nrepl-cmd] + [taoensso.telemere.timbre :as log]) (:gen-class)) (def modules diff --git a/src/com/biffweb/my_project/app.clj b/src/com/biffweb/my_project/app.clj index bac1cef..5b0ca91 100644 --- a/src/com/biffweb/my_project/app.clj +++ b/src/com/biffweb/my_project/app.clj @@ -1,9 +1,10 @@ (ns com.biffweb.my-project.app (:require - [clojure.pprint :as pp] [clojure.string :as str] + [rum.core :as rum] [com.biffweb :as biff] - [com.biffweb.my-project.middleware :refer [wrap-session]] + [ring.adapter.jetty9 :as jetty] + [com.biffweb.my-project.middleware :refer [wrap-clean-up-param-vals]] [com.biffweb.my-project.settings :as settings] [com.biffweb.my-project.ui :as ui] [honey.sql :as sql] @@ -24,25 +25,20 @@ (defn error-style [s] [:h4.pico-color-red-500 s]) -(defn create-game [{:keys [example/ds session params] :as _ctx}] +(defn create-game [{:keys [example/ds params]}] (let [players (map str/trim (-> params :players str/split-lines))] (if (> 2 (count players)) (error-style "Need at least two players") - (let [id (-> session :id) - code (game-code) + (let [code (game-code) random-order? (= "on" (-> params :random-player-order)) player-order (if random-order? (shuffle (range 0 (count players))) (range 0 (count players)))] (jdbc/execute! ds (sql/format {:insert-into :game - :values [{:id id - :code code - :display_session id + :values [{:code code :current_player 0 - :active false}] - :on-conflict :id - :do-update-set [:code :display_session :active]})) + :player_count (count players)}]})) (jdbc/execute! ds (sql/format {:insert-into :player :values (for [[p o] (partition 2 (interleave players player-order))] @@ -53,14 +49,10 @@ :game_code code})})) {:status 200 - :headers {"HX-Redirect" (str/join "/" ["" "game" code "display"])} - :session {:id id}})))) + :headers {"HX-Redirect" (str/join "/" ["" "game" code "display"])}})))) -(defn display-game [{:keys [session params path-params] - :example/keys [ds] - :as _ctx}] - (let [code (:code path-params) - players (into [] +(defn player-summary [code ds] + (let [players (into [] (sort-by :player/play_order (jdbc/execute! ds (sql/format {:select :* @@ -70,7 +62,47 @@ :from :game :where [:= :code code]})) current-player (:game/current_player game)] - (println code game) + [:div#player-summary + {:hx-ext "ws,multi-swap" + :ws-connect (str "/game/" (:game/code game) "/connect")} + [:h4 "Game code is " (:game/code game)] + [:table + {:style {:table-layout :fixed}} + [:thead + [:tr + [:th {:scope "col"} "Player"] + [:th {:scope "col"} "Total Score"]]] + + (into [:tbody] + (for [p players + :let [{:player/keys [play_order game_score]} p]] + + [:tr + [:th {:scope "row"} [:div + (:player/name p) + (when + (= (:player/play_order p) current-player) + [:span.pico-background-jade-600 + {:style {:text-align "center" + :padding "4px 8px" + :width "8px" + :margin-left "20px" + :border-radius "5px" + :align-items "center"}} + "now playing"])]] + + [:td [:div + {:id (str "player" "-" play_order "-game-score")} + game_score]]]))]])) + +(defn display-game [{:keys [path-params] + :example/keys [ds] + :as _ctx}] + (let [code (:code path-params) + + game (jdbc/execute-one! ds (sql/format {:select :* + :from :game + :where [:= :code code]}))] (if-not (and code game) {:status 200 @@ -83,69 +115,23 @@ [:ul [:li (biff/form {:id "reset" :hx-get "/reset"} [:button.secondary "Reset"])]]] - [:h4 (:game/id game)] - (let [player-elements-swap-str (->> players - (map (fn [{:player/keys [id play_order]}] - (str "#player" id "-" play_order))) - (str/join ",") - (str "multi:"))] - [:div - [:h4 "Game code is " (:game/code game)] - [:table - [:thead - [:tr - [:th {:scope "col"} "Player"] - [:th {:scope "col"} "Total Score"]]] + (player-summary code ds)])))) - (into [:tbody - {:hx-ext "ws,multi-swap" - :ws-connect (str "/game/" (:game/code game) "/connect") - :hx-swap player-elements-swap-str}] - (for [p players - :let [{:player/keys [id play_order game_score]} p]] - - [:tr - [:th {:scope "row"} [:div - (:player/name p) - (when (= (:player/play_order p) current-player) - [:span.pico-background-jade-600 - {:style {:text-align "center" - :padding "4px 8px" - :width "8px" - :margin-left "20px" - :border-radius "5px" - :align-items "center"}} - "now playing"])]] - - [:td [:div - {:id (str "player" id "-" play_order "-game-score")} - game_score]]]))]])])))) - -(defn connect-ws [{:keys [session params] - :example/keys [chat-clients ds] :as ctx}] - (let [{:game/keys [code]} - (jdbc/execute-one! ds (sql/format {:select :* - :from :game - :where [:= :id (:id session)]}))] +(defn connect-ws [{:keys [path-params] + :example/keys [chat-clients]}] + (let [code (:code path-params)] {:status 101 :headers {"upgrade" "websocket" "connection" "upgrade"} :ws {:on-connect (fn [ws] (swap! chat-clients assoc code ws)) - :on-close (fn [ws status-code reason] + :on-close (fn [ws _status-code _reason] (swap! chat-clients (fn [chat-clients] - (let [chat-clients (update chat-clients code disj ws)] - (cond-> chat-clients - (empty? (get chat-clients code)) (dissoc code))))))}})) -(defn checkbox [n] - [:label - [:input {:type "checkbox", :name n}] - n]) (def pig-position ["jowler" @@ -155,46 +141,196 @@ "no dot" "razorback"]) -(defn control-view [{:keys [session params path-params] +(defn now-playing [current-player code ds] + (let [player (jdbc/execute-one! ds (sql/format {:select :* + :from :player + :where [:and + [:= :game_code code] + [:= :play_order current-player]]}))] + [:div#now-playing + [:h4 "Now playing: " [:b.pico-color-jade-600 (:player/name player)]] + [:h5 "Total score: " [:b.pico-color-jade-600 (:player/game_score player)]] + [:h5 "Score this round: " [:b.pico-color-jade-600 (:player/round_score player)]] + + (biff/form {:id ::score-form + :hx-post "/score-hand" + :hx-target "#now-playing"} + [:input {:hidden true + :name "game-code" + :value code}] + [:input {:hidden true + :name ::round-option + :id ::round-option + :value ::score-pigs}] + [:div;; .grid + {:id ::pig-options + :style {:display :flex + :flex-direction :row}} + + (into [:fieldset + {:flex "50%"} + [:legend "Pig 1:"]] + + (for [p pig-position] + [:label + [:input {:type :radio + :value p + :name ::pig-1} + + p]])) + + (into [:fieldset + {:flex "50%"} + [:legend "Pig 2:"]] + + (for [p pig-position] + [:label + [:input {:type :radio + :value p + :name ::pig-2} + + p]]))] + + [:fieldset;; .grid + [:button {:type "submit" + :_ "on click set #round-option.value to 'score-pigs'"} "Score pigs"] + [:button.secondary {:type "submit" + :_ "on click set #round-option.value to 'pass-the-pigs'"} "Pass the pigs"] + [:button.contrast {:type "submit" + :_ "on click set #round-option.value to 'oinker'"} "Pigs are touching! (lose all points)"] + [:button.pico-background-red-500 {:type "submit" + :_ "on click set #round-option.value to 'undo'" + :disabled (= 0 (:player/round_score player))} "undo last move"]])])) + +(defn control-view [{:keys [path-params] :example/keys [ds] :as _ctx}] (let [code (:code path-params) - players (into [] - (sort-by - :player/play_order - (jdbc/execute! ds (sql/format {:select :* - :from :player - :where [:= :game_code code]})))) + game (jdbc/execute-one! ds (sql/format {:select :* :from :game :where [:= :code code]})) current-player (:game/current_player game)] + (ui/page {} [:div [:nav [:ul [:li [:strong (str "Score the pigs - " code)]]] [:ul [:li (biff/form {:id "reset" :hx-get "/reset"} [:button.secondary "Reset"])]]] - [:div.grid - [:h4 "Now playing: " [:b.pico-color-jade-600 (-> (get players current-player) :player/name)]]] + (now-playing current-player code ds)]))) - (biff/form {:id ::score-form} - [:table - [:thead - [:tr - [:th {:scope "col"} "Pig 1"] - [:th {:scope "col"} "Pig 2"]]] +(def double-score + {:jowler 60 + :snouter 40 + :trotter 20 + :razorback 20 + :black-dot 1 + :no-dot 1}) - [:tbody - (for [p pig-position] - [:tr - [:td - (checkbox p)] +(def single-score + {:jowler 15 + :snouter 10 + :trotter 5 + :razorback 5 + :black-dot 0 + :no-dot 0}) - [:td (checkbox p)]])]] - [:button {:type "submit"} "Score pigs"] - [:button.contrast {:type "submit"} "Pigs are touching! (Lose all points)"])]))) +(defn score-hand [{:keys [params] + :example/keys [ds chat-clients]}] + (let [{:keys [game-code round-option]} params + + game (jdbc/execute-one! ds (sql/format {:select :* + :from :game + :where [:= :code game-code]})) + + current-player (:game/current_player game) + + {:keys [pig-1 pig-2]} params + score (cond + (= pig-1 pig-2) + (get double-score pig-1) + + (= #{:black-dot :no-dot} (set [pig-1 pig-2])) + ::pig-out + + :else + (+ (single-score pig-1) (single-score pig-2))) + + next-player (mod (inc current-player) (:game/player_count game)) + next-player-query (sql/format + {:update :game + :set {:current_player next-player} + :where [:= :code game-code]}) + player-round-score (fn [] + (:player/round_score + (jdbc/execute-one! ds (sql/format {:select :round_score + :from :player + :where [:and + [:= :game_code game-code] + [:= :play_order current-player]]})))) + update-display-table (delay (jetty/send! (get @chat-clients game-code) + (rum/render-static-markup + (player-summary game-code ds))))] + + (cond + (= score ::pig-out) + (do + (jdbc/with-transaction [tx ds] + (jdbc/execute! tx (sql/format + {:update :player + :set {:round_score 0} + :where [:and + [:= :game_code game-code] + [:= :play_order current-player]]})) + (jdbc/execute! tx next-player-query)) + @update-display-table + (now-playing next-player game-code ds)) + + (= round-option :pass-the-pigs) + (if (zero? (player-round-score)) + {:status 200 + :headers {"HX-Retarget" (str "#" (name ::pig-options)) + "HX-Reswap" "afterend"} + :body + (rum/render-static-markup (error-style "Can't pass the pigs without playing!"))} + (do + (jdbc/with-transaction [tx ds] + (jdbc/execute! tx (sql/format + {:update :player + :set {:game_score :round_score + :round_score 0}})) + (jdbc/execute! tx next-player-query)) + @update-display-table + (now-playing next-player game-code ds))) + + (= round-option :oinker) + (do + (jdbc/with-transaction [tx ds] + (jdbc/execute! tx (sql/format + {:update :player + :set {:game_score 0 + :round_score 0}})) + (jdbc/execute! tx next-player-query)) + @update-display-table + (now-playing next-player game-code ds)) + + (= round-option :score-pigs) + (if (every? nil? [pig-1 pig-2]) + {:status 200 + :headers {"HX-Retarget" (str "#" (name ::pig-options)) + "HX-Reswap" "afterend"} + :body + (rum/render-static-markup (error-style "Need some pigs to score some pigs!"))} + + (do (jdbc/execute! ds (sql/format + {:update :player + :set {:round_score [:+ :round_score score]} + :where [:and + [:= :game_code game-code] + [:= :play_order current-player]]})) + (now-playing current-player game-code ds)))))) (def game-code-input-attrs {:name "game-code", @@ -204,7 +340,7 @@ :type "text"}) (defn route-to-game-view - [{:keys [session params] + [{:keys [params] :example/keys [ds] :as _ctx}] (let [code (:game-code params) @@ -214,76 +350,70 @@ (not (and code @game)) (error-style "Couldn't find that game.") - (= view-type "show-scoreboard") + (= view-type :show-scoreboard) {:status 200 :headers {"HX-Redirect" (str/join "/" ["/game" code "display"])}} - (= view-type "show-game-remote") + (= view-type :show-game-remote) {:status 200 :headers {"HX-Redirect" (str/join "/" ["/game" code "control"])}}))) -(defn app [{:keys [session params] - :example/keys [ds] - :as _ctx}] - (pp/pprint session) - (let [id (:id session)] +(defn app [_ctx] + (ui/page + {} + [:div + [:nav + [:ul [:li [:strong "Score the pigs"]]] + [:ul [:li (biff/form {:id "reset" + :hx-get "/reset"} + [:button.secondary "Reset"])]]] - (ui/page - {} + [:section + [:button {:_ "on click toggle the *display of #new-game-form"} "New game"]] + + (biff/form + {:hx-post "/game/create" + :style {:display :none} + :hx-swap "afterend" + :id ::new-game-form} [:div - [:nav - [:ul [:li [:strong "Score the pigs"]]] - [:ul [:li (biff/form {:id "reset" - :hx-get "/reset"} - [:button.secondary "Reset"])]]] - [:h4 id] - - [:section - [:button {:_ "on click toggle the *display of #new-game-form"} "New game"]] - - (biff/form - {:hx-post "/game/create" - :style {:display :none} - :hx-swap "afterend" - :id ::new-game-form} - [:div - [:textarea#players {:type "textarea" :rows "8" :name "players"}] - [:fieldset - [:legend "Game options:"] - [:label - [:input {:type "checkbox", :name "random-player-order", :checked ""}] - "Random player order"] + [:textarea#players {:type "textarea" :rows "8" :name "players"}] + [:fieldset + [:legend "Game options:"] + [:label + [:input {:type "checkbox", :name "random-player-order", :checked ""}] + "Random player order"] ;; [:label ;; [:input {:type "checkbox", :name "french", :checked ""}] ;; "French"] - ] - [:button {:type "submit"} "Start"]]) + ] + [:button {:type "submit"} "Start"]]) - (biff/form - {:id ::display-existing-game - :hx-post "/game/route-to-view" - :hx-swap "afterend"} - [:fieldset.grid - [:input - {:hidden :true - :name ::view-type - :value ::show-scoreboard}] - [:input game-code-input-attrs] + (biff/form + {:id ::display-existing-game + :hx-post "/game/route-to-view" + :hx-swap "afterend"} + [:fieldset.grid + [:input + {:hidden :true + :name ::view-type + :value ::show-scoreboard}] + [:input game-code-input-attrs] - [:input {:type :submit :value "Show scoreboard"}]]) + [:input {:type :submit :value "Show scoreboard"}]]) - (biff/form - {:id ::control-existing-game - :hx-post "/game/route-to-view" - :hx-swap "afterend"} - [:fieldset.grid - [:input - {:hidden :true - :name ::view-type - :value ::show-game-remote}] - [:input game-code-input-attrs] + (biff/form + {:id ::control-existing-game + :hx-post "/game/route-to-view" + :hx-swap "afterend"} + [:fieldset.grid + [:input + {:hidden :true + :name ::view-type + :value ::show-game-remote}] + [:input game-code-input-attrs] - [:input.secondary {:type :submit :value "Control an existing game"}]])]))) + [:input.secondary {:type :submit :value "Control an existing game"}]])])) (def about-page (ui/page @@ -298,7 +428,7 @@ (def module {:static {"/about/" about-page} - :routes ["/" {:middleware [wrap-session]} + :routes ["/" {:middleware [wrap-clean-up-param-vals]} ["" {:get app}] ["game/" ["create" {:post create-game}] @@ -307,5 +437,6 @@ ["/display" {:get display-game}] ["/control" {:get control-view}] ["/connect" {:get connect-ws}]]] + ["score-hand" {:post score-hand}] ["reset" {:get reset}]] :api-routes [["/api/echo" {:post echo}]]}) diff --git a/src/com/biffweb/my_project/middleware.clj b/src/com/biffweb/my_project/middleware.clj index ae2360a..350d868 100644 --- a/src/com/biffweb/my_project/middleware.clj +++ b/src/com/biffweb/my_project/middleware.clj @@ -1,36 +1,24 @@ (ns com.biffweb.my-project.middleware - (:require [com.biffweb :as biff] - [muuntaja.middleware :as muuntaja] - [org.sqids.clojure :as sqids] - [ring.middleware.anti-forgery :as csrf] - [ring.middleware.defaults :as rd] - [clojure.string :as str])) + (:require + [camel-snake-kebab.core :as csk] + [clojure.pprint :as pp] + [clojure.string :as str] + [com.biffweb :as biff] + [muuntaja.middleware :as muuntaja] + [ring.middleware.anti-forgery :as csrf] + [ring.middleware.defaults :as rd])) -(defn wrap-redirect-signed-in [handler] - (fn [{:keys [session] :as ctx}] - (if (some? (:uid session)) - {:status 303 - :headers {"location" "/app"}} - (handler ctx)))) - -(defn wrap-signed-in [handler] - (fn [{:keys [session] :as ctx}] - (if (some? (:uid session)) - (handler ctx) - {:status 303 - :headers {"location" "/signin?error=not-signed-in"}}))) - -(defn wrap-session [handler] - (fn [{:keys [session] :as req}] - (let [req (update-in req [:path-params :code] #(when % (str/lower-case %)))] - (if (some? (:id session)) - (do - (println "found session id" (:id session)) - (handler req)) - - (let [new-id (random-uuid)] - (println "no session id, adding new one:" new-id) - (handler (assoc-in req [:session :id] new-id))))))) +(defn wrap-clean-up-param-vals [handler] + (fn [req] + (pp/pprint (req :params)) + (let [unknown-params (-> req + :params + (dissoc :__anti-forgery-token :game-code :players) + (update-vals csk/->kebab-case-keyword)) + req (-> req + (update-in [:path-params :code] #(when % (str/lower-case %))) + (update :params merge unknown-params))] + (handler req)))) ;; Stick this function somewhere in your middleware stack below if you want to ;; inspect what things look like before/after certain middleware fns run. @@ -74,6 +62,5 @@ biff/wrap-resource biff/wrap-internal-error biff/wrap-ssl - ;; biff/wrap-log-requests ))