#| src/web/builder.lisp "The Deck Builder" I figure we can also put YDK stuff here since they sorta do the same thing. Since CSV import comes from Crystal Commerce I've left that in the crystal-commerce file. "The old one used to just have a list of cards, and you would click it, and add it to the deck." - Yuki, ~July 2023 TODO I think some of these modules are going to have conflicting names, e.g. RENDER-SAVED-DECK-LIST and SAVED-DECK-LIST ... Maybe I ought to start packaging this up. |# (in-package #:cl-deck-builder2.web) ;; TODO Not in use yet. ;; I really should be using https://github.com/40ants/reblocks (defclass builder-session () ((current-deck :accessor builder-session-current-deck :initform (make-instance 'ydk) :initarg :deck)) (:documentation "A BUILDER-SESSION encapsulates a deck building session.")) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (defparameter *current-deck* (make-instance 'ydk) "An instance of YDK that the builder uses as a working region.") ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (defun render-current-deck-list () "Helper function. Render the current deck list from *CURRENT-DECK*. The HTMX JS in the builder is what this gets output to." (let ((deck-info (ydk-query *current-deck*))) ;; No Deck info? Just return an empty list. (render-with-env #P"builder/current-deck-list.html" `(:active "/builder" :deck-id ,(ignore-errors (ydk-id-of *current-deck*)) :main-deck ,(ignore-errors (getf deck-info :main-deck)) :extra-deck ,(ignore-errors (getf deck-info :extra-deck)) :side-deck ,(ignore-errors (getf deck-info :side-deck)))))) (defun render-saved-deck-list (&optional id) "Helper function. Render the saved deck list from SAVED-DECK-LIST." (render-with-env #P"builder/saved-deck-list.html" `(:active "/builder" :current-deck-id ,(ignore-errors (ydk-id-of *current-deck*)) :id ,id :saved-deck-list ,(select-ydk-deck)))) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; Index route (defroute ("/builder" :method :GET) (&key _parsed) "The builder home page. The *CURRENT-DECK* is used as a temporary workspace to create a YDK style deck listing." (v:info :builder "GET /builder => ~a" _parsed) (with-logged-in-user (render-results :active "/builder" :class 'ygo-info :params _parsed :tpl #P"builder/index.html"))) (defroute ("/builder/current-deck-list" :method :GET) () "The endpoint for RENDER-CURRENT-DECK-LIST. TODO This should be re-written as a Redblocks Widget." ;; Haha, I moved the hack somewhere else. Now it's somebody elses ;; problem. I actually think I fixed the hack. This comment stays ;; for now. (with-logged-in-user (render-current-deck-list))) (defroute ("/builder/saved-deck-list" :method :GET) () "The endpoint for RENDER-SAVED-DECK-LIST. TODO This should be re-written as a Redblocks Widget." (with-logged-in-user (render-saved-deck-list))) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; Search route (defroute ("/builder/search" :method :POST) (&key _parsed) "Main deck builder card search route. Will return a list of data from YGOProDeck. Now souped up and using an ALIST to pass parameters around." (v:info :builder "POST /builder/search => ~a~%" _parsed) (with-logged-in-user (render-results :active "/builder" :class 'ygo-info :params _parsed :tpl #P"builder/search-results.html"))) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; POST ROUTES ;; ;; TODO Review Logic ;; ;; Always Extra, then Side ;; "fusion" ;; "link" ;; "ritual" ;; "synchro" ;; "xyz" ;; "fusion_pendulum" ;; "ritual_pendulum" ;; "synchro_pendulum" ;; "xyz_pendulum" ;; ;; TODO Pretty Gnarly! (defroute ("/builder/add/:passcode" :method :POST) (&key passcode _parsed) "Try to add PASSCODE to *CURRENT-DECK*. TODO Parameters: PASSCODE DECK" (v:info :builder "POST /builder/add/~d => ~a" passcode _parsed) ;; Clumsily enforce login (unless (logged-in-p) (_ "Oops")) (let ((passcode (ignore-errors (parse-integer passcode))) (target-deck (alexandria:make-keyword (string-upcase (or (query-param "deck" _parsed) "MAIN"))))) (when passcode (let* ((card-info (ygo-select-info-by-passcode passcode)) (frame-type (ygo-frame-type-of card-info))) (labels ((is-special (frame-type) (or ;; First check what kind of monster it is (string= "fusion" frame-type) (string= "link" frame-type) ;; Ritual cards go in the main deck... ;; (string= "ritual" frame-type) (string= "synchro" frame-type) (string= "xyz" frame-type) (string= "fusion_pendulum" frame-type) ;; (string= "ritual_pendulum" frame-type) (string= "synchro_pendulum" frame-type) (string= "xyz_pendulum" frame-type))) (try-add (frame-type passcode target-deck) (v:info :builder "TRY-ADD ~a ~a ~a" frame-type passcode target-deck) (cond ((is-special frame-type) ;; It's a Special Summon - try to add it to the Extra Deck, then Side Deck. (cond ((< (length (ydk-extra-deck-of *current-deck*)) +extra-deck-card-limit+) ;; Add to extra deck (ydk-add *current-deck* :extra passcode)) ((< (length (ydk-side-deck-of *current-deck*)) +extra-deck-card-limit+) ;; Add to side deck (ydk-add *current-deck* :side passcode)) (t (v:info :builder (_ "Deck ~a has too many cards~%") (ydk-name-of *current-deck*))))) ;; So if we get here theoretically the card shouldn't be a "Special Summon" ((and (eq target-deck :main) (< (length (ydk-main-deck-of *current-deck*)) +main-deck-card-limit+)) ;; Add to main deck (ydk-add *current-deck* target-deck passcode)) ((and (eq target-deck :extra) (< (length (ydk-extra-deck-of *current-deck*)) +extra-deck-card-limit+) (not (equal frame-type "ritual"))) ;; Add to extra deck (ydk-add *current-deck* target-deck passcode)) ((and (eq target-deck :side) (< (length (ydk-side-deck-of *current-deck*)) +extra-deck-card-limit+) (not (equal frame-type "ritual"))) ;; Add to side deck (ydk-add *current-deck* target-deck passcode))))) ;; TODO Where should we validate deck constrains? Here or ADD? ;; For now we'll do it here. (if (< (count passcode (ydk-concatenate *current-deck*)) 3) ;; There *are* fewer than three of this card in the deck... (try-add frame-type passcode target-deck) (flash-error (format nil (_ "Deck ~a has too many cards~%") (ydk-name-of *current-deck*)))))) (setf (getf (response-headers *response*) :HX-Trigger) "deck-list-changed") (render-current-deck-list)))) (defroute ("/builder/remove" :method :POST) (&key _parsed) "Try to remove PASSCODE from *CURRENT-DECK*. TODO Parameters: PASSCODE DECK" (v:info :builder "POST /builder/remove => ~a" _parsed) (let ((index (query-param "index" _parsed)) (deck (query-param "deck" _parsed))) (handler-case (ratify:with-parsed-forms ((:integer index) (:string deck)) (ydk-delete-index *current-deck* (alexandria:make-keyword (string-upcase deck)) index) (setf (getf (response-headers *response*) :HX-Trigger) "deck-list-changed")) (ratify:combined-error (e) (flash-error (princ-to-string e)) (redirect "/builder" 302)))) (render-current-deck-list)) (defroute ("/builder/clear" :method :POST) () "Clear the *CURRENT-DECK* of any cards using YDK-CLEAR." (ydk-clear *current-deck*) (render-current-deck-list)) (defroute ("/builder/create" :method :POST) (&key _parsed) "Create a new deck. This will preserve some information from *CURRENT-DECK*, giving it a new name, and immediately synchronising it to the database. You may use the CLEAR route to erase the deck." (v:info :builder "POST /builder/create => ~a" _parsed) (let ((name (query-param "deck-create-name" _parsed))) (handler-case (ratify:with-parsed-forms ((:string name)) (let ((found (ydk-deck-by-name name))) (if found ;; The deck already exists (progn (flash-message (format nil (_ "Deck ~a already exists in database; loading...") name)) (setf *current-deck* (ydk-sync found))) (progn (flash-message (format nil (_ "Creating deck ~a into database.") name)) (setf *current-deck* (make-instance 'ydk :name name :created-by (user-name) :main-deck (ydk-main-deck-of *current-deck*) :extra-deck (ydk-extra-deck-of *current-deck*) :side-deck (ydk-side-deck-of *current-deck*))) ;; Sync it to the database to save it to the ;; names list - doesn't matter if it has no ;; cards in it. (let ((new (ydk-sync *current-deck*))) (when new (setf (ydk-id-of *current-deck*) (mito:object-id new)))))))) (ratify:combined-error (e) (flash-error e)))) (render-saved-deck-list)) (defroute ("/builder/delete" :method :DELETE) (&key _parsed) "Delete the deck specified by DECK-CREATE-NAME in the HTML. TODO Parameters: NAME The name of the deck to use YDK-DECK-DELETE-BY-NAME on." (v:info :builder "DELETE /builder/delete => ~a" _parsed) (let ((name (query-param "deck-create-name" _parsed))) (handler-case (ratify:with-parsed-forms ((:string name)) (ydk-deck-delete-by-name name)) (ratify:combined-error (e) (flash-error e)))) (render-saved-deck-list)) (defroute ("/builder/save" :method :POST) () "Save *CURRENT-DECK* to the database using YDK-SYNC. Return an updated deck listing with RENDER-CURRENT-DECK-LIST." (v:info :builder "POST /builder/save") (v:info :builder "~a" (ydk-sync *current-deck*)) (render-current-deck-list)) (defroute ("/builder/load" :method :POST) (&key _parsed) "Load Deck POST Route. Load ID into *CURRENT-DECK*." (v:info :builder "POST /builder/load => ~a" _parsed) (let ((id (query-param "deck-load-id" _parsed))) (handler-case (ratify:with-parsed-forms ((:integer id)) (let ((found (ydk-deck-by-id id))) (when found (setf *current-deck* (ydk-sorted (ydk-sync found))) (flash-message (format nil (_ "Found entry ~A (~d); loading...~%") (ydk-name-of found) (mito:object-id found)))))) (ratify:combined-error (e) (flash-error e)))) (render-current-deck-list)) (defroute ("/builder/rename" :method :POST) (&key _parsed) "Try to rename a deck. Rename Deck ID to DECK-CREATE-NAME. TODO There is other code to rename, isn't there? It looks like that code is inteded to be used from the Deck Overview page. This is just in the builder." (v:info :builder "POST /builder/rename => ~a" _parsed) (let ((create-name (query-param "deck-create-name" _parsed)) (id (query-param "deck-load-id" _parsed))) ;; We want to change the name of LOAD-NAME to CREATE-NAME. (handler-case (ratify:with-parsed-forms ((:integer id)) (let ((found (ydk-deck-by-id id))) (ydk-rename-deck found create-name) (flash-message (format nil (_ "Found entry ~A (~d); renaming to ~a.~%") (ydk-name-of found) id create-name)))) (ratify:combined-error (e) (flash-error e)))) (render-saved-deck-list)) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; Since we're doing this in a builder context I thought it would be ;; better to do this here than at /category/saved-category-list as ;; that seemed a bit redundant. Surely there's a better way to ;; organize all this stuff! (defroute ("/builder/saved-category-list" :method :GET) (&key _parsed) "Route for rendering the saved category list. This is a \"widget\" that we include with Djula, and render separately via HTMX AJAX." (let ((id (query-param "id" _parsed))) (handler-case (ratify:with-parsed-forms ((:integer id)) (let ((found (find-dao 'category :id id))) (if found (render-category found :tpl #P"builder/saved-category-list.html")))) (ratify:combined-error (e) (flash-error e) (render-category (find-dao 'category :id (max-dao 'category)) :tpl #P"builder/saved-category-list.html"))))) ;; TODO This has got to be better! (defroute ("/builder/decks-by-cat-id" :method :GET) (&key |id|) "Saved Deck List helper. Display the saved deck list, and if an |ID| is provided, send that as the current ID." (with-logged-in-user (render-with-env #P"builder/saved-deck-list.html" `(:active "/builder" :current-deck-id ,(ignore-errors (ydk-id-of *current-deck*)) :id ,|id| :saved-deck-list ,(select-ydk-deck (if |id| (sxql:where (:= :category-id |id|)))))))) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (defroute ("/builder/move-to-category" :method :POST) (&key _parsed) "Move *CURRENT-DECK* into CATEGORY ID. TODO Implement this?" (v:info :builder "GET /builder/move-to-category => ~a" _parsed))