
#lang racket

(require games/cards racket/gui racket/class racket/unit racket/file
         racket/async-channel)

;; Player record
(define-struct player (r hand-r  ; region
                         hand) #:mutable)    ; cards

;; Messages
(define YOUR-NAME "You")
(define OPPONENT-X-NAME "Opponent ~a")
(define OPPONENT-NAME "Opponent")
(define YOUR-TURN-MESSAGE "Your turn - discard a ~a or crazy 8, or else ~a")
(define PICK-A-SUIT "Pick a suit")
(define GAME-OVER-YOU-WIN "Game over - you win!")
(define GAME-OVER-STUCK "Game over - no one wins")
(define GAME-OVER "Game over - opponent wins")
(define NEW-GAME "New Game")

;; Region layout constants
(define MARGIN 10)
(define SUBMARGIN 10)
(define LABEL-H 15)
(define BUTTON-HEIGHT 18)
(define PASS-W 40)
(define NEW-GAME-W 80)
(define SEL-WIDTH 32)
(define SEL-HEIGHT 32)

(provide game@)

(define-signature configuration^
  (opponents-count init-hand-size drag-mode? new-game))

;; This unit drives multiple Crazy 8 instances:
(define game@
  (unit (import) (export)

    ;; Configuration
    (define opponents-count (get-preference 'crazy8s:num-opponents (lambda () 1)))
    (define init-hand-size (get-preference 'crazy8s:hand-size (lambda () 7)))
    (define drag-mode? (get-preference 'crazy8s:drag-mode (lambda () #f)))

    (define (start-new-game opponents-count init-hand-size drag-mode?)
      (define orig-eventspace (current-eventspace))
      ;; Procedure for a game to use to start a sibling game
      (define (new-game oc ihs dm?)
        (parameterize ([current-eventspace orig-eventspace])
          (queue-callback
           (lambda ()
             (start-new-game oc ihs dm?)))))
      ;; Start a new game as a child process:
      (parameterize* ([current-custodian (make-custodian)]
                      [exit-handler
                       (lambda (v)
                         (custodian-shutdown-all (current-custodian)))]
                      [current-eventspace (make-eventspace)])
        (queue-callback
         (lambda () (invoke-unit configured-game@ (import configuration^))))))

    ;; Start the initial child game:
    (start-new-game opponents-count init-hand-size drag-mode?)))

;; This unit is for a particular Crazy 8 instance:
(define configured-game@
  (unit (import configuration^) (export)

    ;; Randomize
    (random-seed (modulo (current-milliseconds) 10000))

    ;; ========== GUI ========================================

    ;; Set up the table
    (define t (make-table "Crazy 8s" 8 5.5))

    ;; Add status line and buttons:
    (define status-pane (send t create-status-pane))
    (new button%
         [parent status-pane]
         [label "Options..."]
         [callback (lambda (b e) (configure-dialog))])
    (send t add-scribble-button status-pane
          '(lib "games/scribblings/games.scrbl") "crazy8s")

    ;; The "Options.." button opens a configuration dialog that
    ;;  starts a new game:
    (define (configure-dialog)
      (define d (new dialog%
                     [parent t]
                     [label "Crazy 8 Options"]))
      (define kinds (new radio-box%
                         [label #f]
                         [choices '("1 opponent, 10 cards"
                                    "1 opponent, 7 cards"
                                    "2 opponents, 7 cards"
                                    "3 opponents, 7 cards")]
                         [parent (new group-box-panel%
                                      [parent d]
                                      [label "Players and Cards"])]
                         [callback void]))
      (define drag-mode-check
        (new check-box%
             [parent d]
             [label "Drag cards instead of single-click"]
             [callback void]))
      (define button-panel (new horizontal-pane%
                                [parent d]
                                [alignment '(right center)]))
      (new button%
           [label "Close"]
           [parent button-panel]
           [callback (lambda (b e) (send d show #f))])
      (new button%
           [label "New Game"]
           [parent button-panel]
           [callback (lambda (b e)
                       (let-values ([(oc ihs)
                                     (case (send kinds get-selection)
                                       [(0) (values 1 10)]
                                       [(1) (values 1 7)]
                                       [(2) (values 2 7)]
                                       [(3) (values 3 7)])]
                                    [(dm?) (send drag-mode-check get-value)])
                         (put-preferences
                          '(crazy8s:num-opponents crazy8s:hand-size crazy8s:drag-mode)
                          (list oc ihs dm?)
                          void)
                         (new-game oc ihs dm?)
                         (send d show #f)))]
           [style '(border)])
      (send kinds set-selection
            (case opponents-count
              [(1) (if (= init-hand-size 7) 1 0)]
              [(2) 2]
              [(3) 3]))
      (send drag-mode-check set-value drag-mode?)
      (send d show #t))

    ;; Show the table
    (send t show #t)

    ;; ========== Cards setup ========================================

    ;; Set the default card actions:
    (send t set-double-click-action #f)
    (send t set-button-action 'left 'drag-raise/one)
    (send t set-button-action 'right 'drag/above)

    ;; Get table width & height
    (define w (send t table-width))
    (define h (send t table-height))

    ;; Set up the cards
    (define all-cards (shuffle-list (make-deck) 7))
    (define deck all-cards)
    (define discards null)
    (for-each (lambda (card) (send card user-can-flip #f)) deck)

    ;; We'll need an 8 of each suit for substitutions later
    (define (find-8 suit)
      (ormap (lambda (c)
               (and (= 8 (send c get-value))
                    (eq? suit (send c get-suit))
                    (send c copy)))
             deck))
    (define 8-hearts (find-8 'hearts))
    (define 8-spades (find-8 'spades))
    (define 8-clubs (find-8 'clubs))
    (define 8-diamonds (find-8 'diamonds))

    ;; Function for dealing or drawing cards
    (define (deal n)
      (let loop ([n n][d deck])
        (if (zero? n)
          (begin (set! deck d) null)
          (cons (car d) (loop (sub1 n) (cdr d))))))

    ;; Card width & height
    (define cw (send (car deck) card-width))
    (define ch (send (car deck) card-height))

    ;; Make regions for deck and descard
    (define deck-region
      (make-region (- (/ (- w cw) 2) (/ (+ cw MARGIN) 2))
                   (/ (- h ch) 2)
                   cw ch
                   #f #f))
    (define discard-region
      (make-region (+ (region-x deck-region) cw MARGIN)
                   (region-y deck-region)
                   cw ch
                   #f #f))
    (define discard-target-region
      (make-region (- (region-x discard-region) (/ MARGIN 2))
                   (- (region-y discard-region) (/ MARGIN 2))
                   (+ cw MARGIN) (+ ch MARGIN)
                   "" #f))
    (when drag-mode?
      (send t add-region discard-target-region))

    ;; Put the cards on the table
    (send t add-cards-to-region deck deck-region)

    ;; Make regions for choosing a suit
    (define (make-suit-region x y label card)
      (let ([bm (make-object bitmap%
                             (build-path (collection-path "games" "crazy8s")
                                         "images"
                                         (format "~a.png" label))
                             'unknown/mask)])
        (make-button-region x y
                            SEL-WIDTH SEL-HEIGHT
                            bm
                            ;; The callback for the region sends
                            ;;  a clonable card to the game driver
                            (lambda () (async-channel-put msg card)))))
    (define hearts-region
      (make-suit-region (+ (region-x discard-target-region) cw (* 2 MARGIN))
                        (+ (region-y discard-target-region)
                           (/ (- ch (* 2 SEL-HEIGHT) (/ MARGIN 2)) 2))
                        "heart" 8-hearts))
    (define spades-region
      (make-suit-region (+ (region-x hearts-region) SEL-WIDTH (/ MARGIN 2))
                        (region-y hearts-region)
                        "spade" 8-spades))
    (define clubs-region
      (make-suit-region (region-x hearts-region)
                        (+ (region-y hearts-region) SEL-HEIGHT (/ MARGIN 2))
                        "club" 8-clubs))
    (define diamonds-region
      (make-suit-region (region-x spades-region)
                        (region-y clubs-region)
                        "diamond" 8-diamonds))

    ;; Make the "Pass" button:
    (define pass-button
      (make-button-region (+ (region-x deck-region)
                             (/ (- cw PASS-W) 2))
                          (+ (region-y deck-region)
                             (/ (- ch BUTTON-HEIGHT) 2))
                          PASS-W BUTTON-HEIGHT
                          "Pass" (lambda ()
                                   (async-channel-put msg 'pass))))

    ;; Player region size
    (define pw (/ (- w (* opponents-count MARGIN)) opponents-count))
    (define ph (- (/ (- h ch) 2) (* 2 MARGIN)))

    ;; Define the players with their regions
    (define (make-a-player x y w h lbl)
      (let ([r (make-region x y w h lbl #f)])
        (send t add-region r)
        (make-player
         r
         (make-region (+ x SUBMARGIN) (+ y SUBMARGIN LABEL-H)
                      (- w (* 2 SUBMARGIN))
                      (- h (* 2 SUBMARGIN) LABEL-H)
                      #f #f)
         null)))
    (define players
      (cons
       ;; You
       (make-a-player
        (/ MARGIN 2) (- h ph (/ MARGIN 2))
        (- w MARGIN) ph
        YOUR-NAME)
       (build-list
        opponents-count
        (lambda (delta)
          (make-a-player
           (+ (* (+ pw MARGIN) delta) (/ MARGIN 2)) (/ MARGIN 2)
           pw ph
           (if (= opponents-count 1)
             OPPONENT-NAME
             (format OPPONENT-X-NAME (+ 1 delta))))))))
    (define you (car players))
    (define opponents (cdr players))

    ;; Add the "Clean" and "Sort" buttons:
    (define (sort-hand! card<)
      (let ([sorted (sort (player-hand you) card<)])
        (set-player-hand! you sorted)
        (send t stack-cards sorted)
        (send t move-cards-to-region sorted (player-hand-r you))))
    (define clean-button
      (make-button-region
       (region-x (player-r you))
       (- (region-y (player-r you))
          (+ BUTTON-HEIGHT MARGIN))
       PASS-W BUTTON-HEIGHT
       "Clean" (lambda ()
                 (sort-hand!
                  (lambda (a b)
                    (let-values ([(ax ay) (send t card-location a)]
                                 [(bx by) (send t card-location b)])
                      (> ax bx)))))))
    (send t add-region clean-button)
    (define (remap v)
      ;; So that black and red suits are interleaved
      (case v [(2) 1][(1) 2][else v]))
    (define (card< a b)
      (cond
        [(= 8 (send a get-value))
         (or (not (= 8 (send b get-value)))
             (< (remap (send a get-suit-id)) (remap (send b get-suit-id))))]
        [(= 8 (send b get-value))
         #f]
        [(= (send a get-suit-id) (send b get-suit-id))
         (< (send a get-value) (send b get-value))]
        [else
         (< (remap (send a get-suit-id)) (remap (send b get-suit-id)))]))
    (when drag-mode?
      (send t add-region
            (make-button-region (+ (region-x clean-button) PASS-W MARGIN)
                                (region-y clean-button)
                                PASS-W BUTTON-HEIGHT
                                "Sort" (lambda () (sort-hand! card<)))))

    ;; ========== Game engine ========================================

    ;; Callbacks communicate back to the main loop
    (define msg (make-async-channel))

    ;; Utility: Determine whether a list of cards corresponds to a
    ;; valid discard; return one card or #f
    (define (get-discard-card cs)
      (and (= 1 (length cs))
           (let ([c (car cs)])
             (and (memq c (player-hand you))
                  (or (= (send (car discards) get-value) (send c get-value))
                      (= (send (car discards) get-suit-id) (send c get-suit-id))
                      (= (send c get-value) 8))
                  c))))

    ;; Utility: detect a stuck game
    (define (stuck-game?)
      (and (null? deck)
           (not (ormap (lambda (p)
                         (and (pair? (player-hand p))
                              (ormap (lambda (c) (get-discard-card (list c)))
                                     (player-hand p))))
                       players))))

    ;; Auto-player strategy: Choose which valid card to discard
    (define (pick-to-discard cards)
      (let ([non-8s (filter (lambda (c) (not (= 8 (send c get-value)))) cards)])
        (car (if (null? non-8s) cards non-8s))))

    ;; Auto-player: take a turn
    (define (play-opponent p)
      (let ([suit-id (send (car discards) get-suit-id)]
            [value (send (car discards) get-value)])
        ;; Which cards can we discard?
        (let ([matches (filter (lambda (c)
                                 (or (= suit-id (send c get-suit-id))
                                     (= value (send c get-value))
                                     (= 8 (send c get-value))))
                               (player-hand p))])
          (if (null? matches)
            ;; Can't discard, so draw or pass
            (if (pair? deck)
              ;; Draw
              (begin
                (send t card-to-front (car deck))
                (set-player-hand! p (append (deal 1) (player-hand p)))
                (send t move-cards-to-region (player-hand p) (player-hand-r p))
                (play-opponent p))
              ;; Pass
              (begin
                (send t hilite-region (player-r p))
                (send t pause 0.25)
                (send t unhilite-region (player-r p))
                #t))
            ;; Discard
            (let ([c (pick-to-discard matches)])
              (set-player-hand! p (remq c (player-hand p)))
              (send t flip-card c)
              (send t card-to-front c)
              (send t move-cards-to-region (list c) discard-region)
              (send t move-cards-to-region (player-hand p) (player-hand-r p))
              (set! discards (cons c discards))
              ;; Did we just discard an 8? (And we still have cards?)
              (when (and (= 8 (send (car discards) get-value))
                         (pair? (player-hand p)))
                ;; Pick a suit based on our hand
                (let ([counts
                       (map (lambda (v)
                              (cons v
                                    (length
                                     (filter
                                      (lambda (c)
                                        (and (= v (send c get-suit-id))
                                             (not (= 8 (send c get-value)))))
                                      (player-hand p)))))
                            '(1 2 3 4))])
                  (let ([suit-id
                         ;; Sort based on counts, then pick the first one:
                         (sub1 (caar (sort counts (lambda (a b)
                                                    (> (cdr a) (cdr b))))))])
                    ;; Find the clonable 8 for the chosen suit, and
                    ;; reset the discard
                    (reset-8
                     (list-ref
                      (list 8-clubs 8-diamonds 8-hearts 8-spades)
                      suit-id)))))
              ;; Return #f if this player has just won:
              (pair? (player-hand p)))))))

    ;; Utility: disables cards for "you"
    (define (allow-cards on?)
      (when (pair? deck)
        (send (car deck) user-can-move (and drag-mode? on?)))
      (for-each (lambda (c) (send c user-can-move (and drag-mode? on?)))
                (player-hand you))
      (send t set-single-click-action
            (cond [(and on? (not drag-mode?)) click-card]
                  [drag-mode? void]
                  [else (lambda (x) (bell))]))
      (when (null? deck)
        (if on?
          (send t add-region pass-button)
          (send t remove-region pass-button))))

    ;; Utility: replaces the top discard, which is an 8, with an 8
    ;; of a particular suit (possibly the same).
    (define (reset-8 got-8)
      (unless (eq? (send (car discards) get-suit) (send got-8 get-suit))
        (let ([c (send got-8 copy)])
          (send c user-can-move #f)
          (send t flip-card (car discards))
          (send t add-cards-to-region (list c) discard-region)
          (send t card-to-front c)
          (send t remove-card (car discards))
          (set! discards (cons c (cdr discards)))
          (send t flip-card c))))

    ;; Sub-game: the user just discarded an 8, so pick a suit:
    (define (pick-suit)
      (allow-cards #f)
      (send t add-region hearts-region)
      (send t add-region spades-region)
      (send t add-region clubs-region)
      (send t add-region diamonds-region)
      (send t set-status PICK-A-SUIT)
      ;; Clicking one of these regions returns a clonable 8 card:
      (let ([got-8 (yield msg)]) (reset-8 got-8))
      (send t remove-region hearts-region)
      (send t remove-region spades-region)
      (send t remove-region clubs-region)
      (send t remove-region diamonds-region)
      (allow-cards #t))

    ;; Install interactive callback for discard: accept the card
    ;; (from the player's hand) and release it from its home:
    (set-region-interactive-callback!
     discard-target-region
     (lambda (in? cs)
       (let ([c (get-discard-card cs)])
         (when c
           (send c home-region (if in? #f (player-r you)))))))

    ;; Install final callback for discard: perform the discard
    (set-region-callback!
     discard-target-region
     (lambda (cs)
       (let ([c (get-discard-card cs)])
         (when c (you-discard c)))))

    (define (you-discard c)
      (send c home-region #f)
      (set! discards (cons c discards))
      (set-player-hand! you (remq c (player-hand you)))
      (send t card-to-front c)
      (send t move-cards-to-region (list c) discard-region)
      (send c user-can-move #f)
      (async-channel-put msg 'discard))

    ;; Install interactive callback for hand: accept the card
    ;; (from the deck) and release it from its home:
    (set-region-interactive-callback!
     (player-r you)
     (lambda (in? cs)
       (send (car cs) home-region (if in? (player-r you) deck-region))))

    ;; Install final callback for hand: draw the card:
    (set-region-callback!
     (player-r you)
     (lambda (cs) (let ([c (car cs)]) (you-draw c))))

    (define (you-draw c)
      (send t flip-card c)
      (send c home-region (player-r you))
      (set-player-hand! you (let loop ([l (player-hand you)])
                              (cond [(null? l) (list c)]
                                    [(card< c (car l)) (cons c l)]
                                    [else (cons (car l) (loop (cdr l)))])))
      (deal 1)
      (unless drag-mode?
        (send t stack-cards (player-hand you))
        (send t move-cards-to-region (player-hand you) (player-hand-r you)))
      (async-channel-put msg 'draw))

    (define (click-card c)
      (cond [(memq c deck) (you-draw c)]
            [(memq c (player-hand you))
             (if (get-discard-card (list c)) (you-discard c) (bell))]
            [else (bell)]))

    (unless drag-mode?
      (send t set-single-click-action click-card))

    ;; Run a loop for multiple games
    (let gloop ()

      ;; Card setup: Deal the cards
      (for-each (lambda (player)
                  (set-player-hand! player (sort (deal init-hand-size) card<))
                  (send t stack-cards (player-hand player))
                  (send t move-cards-to-region
                        (player-hand player)
                        (player-hand-r player)))
                players)

      ;; Opponents's cards and deck initially can't be moved
      (for-each (lambda (card) (send card user-can-move #f))
                (append
                 (apply append
                        (map player-hand (if drag-mode? opponents players)))
                 deck))
      ;; Your cards stay home:
      (for-each (lambda (c)
                  (send c home-region (player-r you))
                  (send c user-can-move drag-mode?))
                (player-hand you))

      ;; Initial discard
      ;;  If it's an eight, then shuffle and try again
      (let loop ()
        (when (= 8 (send (car deck) get-value))
          (set! deck (shuffle-list deck 1))
          (send t stack-cards deck)
          (loop)))
      (set! discards (deal 1))
      (send t flip-cards discards)
      (send t move-cards-to-region discards discard-region)

      ;; Show your cards
      (send t flip-cards (player-hand you))

      ;; Run a single-game loop
      (let loop ()
        ;; Ready deck and/or pass button:
        (when (pair? deck)
          (when drag-mode? (send (car deck) user-can-move #t))
          (send (car deck) home-region deck-region))
        (when (null? deck) (send t add-region pass-button))
        ;; Tell the player what to do:
        (send t set-status
              (format YOUR-TURN-MESSAGE
                      (let ([v (send (car discards) get-value)]
                            [suit (case (send (car discards) get-suit)
                                    [(hearts) "heart"]
                                    [(spades) "spade"]
                                    [(diamonds) "diamond"]
                                    [(clubs) "club"])])
                        (if (= v 8)
                          suit
                          (format "~a, ~a,"
                                  suit
                                  (case v
                                    [(1) "ace"]
                                    [(11) "jack"]
                                    [(12) "queen"]
                                    [(13) "king"]
                                    [else v]))))
                      (if (null? deck) "pass" "draw")))
        ;; What for something to happen:
        (let ([what (yield msg)])
          ;; Discarded a crazy 8? (And not as our last card?)
          (when (and (eq? what 'discard)
                     (= 8 (send (car discards) get-value))
                     (pair? (player-hand you)))
            ;; Yes, so pick suit before continuing
            (pick-suit))
          ;; What did we do?
          (case what
            [(draw)
             ;; Go again
             (loop)]
            [(discard pass)
             ;; Hide pass button...
             (when (null? deck) (send t remove-region pass-button))
             ;; ... and run opponents
             (send t set-status "Opponent's turn...")
             (unless (null? (player-hand you))
               (let oloop ([l opponents])
                 (if (null? l)
                   ;; Check for a stuck game here:
                   (unless (stuck-game?) (loop))
                   (when (play-opponent (car l)) (oloop (cdr l))))))])))

      ;; Game over: disable player:
      (allow-cards #f)

      ;; Report result:
      (send t set-status (cond [(null? (player-hand you)) GAME-OVER-YOU-WIN]
                               [(stuck-game?) GAME-OVER-STUCK]
                               [else GAME-OVER]))

      (let ([button
             (make-button-region
              (+ (region-x discard-region) cw (* 2 MARGIN))
              (+ (region-y discard-region) (/ (- ch LABEL-H) 2))
              NEW-GAME-W LABEL-H
              NEW-GAME (lambda () (async-channel-put msg 'new-game)))])
        (send t add-region button)
        (yield msg)
        (send t remove-region button))

      (let ([all (send t all-cards)])
        ;; Gather up cards, with animation
        (let ([flip (filter (lambda (c) (not (send c face-down?))) all)])
          (send t flip-cards flip)
          (send t move-cards-to-region all deck-region))
        ;; Reset all cards (no animation)
        (send t begin-card-sequence)
        (send t remove-cards all)
        (send t add-cards-to-region all-cards deck-region)
        (set! deck (shuffle-list all-cards 7))
        (for-each (lambda (c) (unless (send c face-down?) (send c flip))) deck)
        (send t stack-cards deck)
        (send t end-card-sequence))

      ;; Re-enable player:
      (allow-cards #t)

      (gloop))))
