;;;; Connect 4 game by David Christiansen
(defpackage connect4
  (:nicknames :connect4)
  (:use :common-lisp-user :clim :clim-lisp)
  (:export run-connect4))

(in-package :connect4)

;;; Establish various options prior to evaluating forms that depend on
;;; them.  Think of this section as being analogous to a C program's
;;; config.h file.
(eval-when (:compile-toplevel :load-toplevel :execute)
  (declaim (optimize (speed 3) (safety 0) (debug 0)))
  (defparameter *cols* 7)
  (defparameter *rows* 6)
  ;; I have empirically determined that searches to a depth of seven
  ;; always take less that 30 seconds on my computer.  This number can
  ;; be modified to adjust the difficulty of the game.
  (defparameter *default-depth* 7)
  (proclaim '(type (integer 0 30) *rows* *cols* *default-depth*))
  (defconstant infinity 5000)
  (defparameter *piece-radius* 20)
  (proclaim '(type (integer 0 50) *piece-radius*))
  (defparameter *player-colors* (list +BLUE+ +RED+ +BLACK+)))


;;;; Implementing basic data structures.

;;; Basic error to act as a superclass.
(define-condition game-error () ()
  (:documentation "Superclass not meant to be instantiated.")
  (:report (lambda (condition stream)
             (declare (ignore condition))
             (format stream "An error has occurred with the game."))))

;;; Error object representing an illegal move.
(define-condition invalid-move (game-error)
  ((where :initarg :at :reader invalid-move-at))
  (:documentation "Condition to represent an illegal move.")
  (:report (lambda (condition stream)
             (format stream "~A is an invalid move."
                     (invalid-move-at condition)))))


;;; Structure representing a board.  Basically an array of pieces with
;;; some book keeping stuff for efficiency reasons.
(defstruct board
  "Connect Four board structure"
  (pieces (make-array `(,*rows* ,*cols* )  ;`(,*rows* ,*cols*)
                                :initial-element 0
                                :element-type '(integer 0 2)))
          ;:type (array fixnum `(,*rows* ,*cols*)))
  (remaining-moves (* *rows* *cols*) :type (integer 0 100))
  (tops (make-array *cols* :initial-element 0 :element-type '(integer 0 50))
        :type (array (integer 0 50))))

;;; Convenience function to make later code more readable.
(declaim (inline col-top))
(defun col-top (board col)
  "Return the index of the top piece in a column."
  (aref (board-tops board) col))


(defun move (board col player)
  "Drop a piece into the specified column.  If the column is
  full, generate an instance of INVALID-MOVE."
  (let ((row (aref (board-tops board) col)))
    (if (>= row *rows*)
        (error 'invalid-move :at col)
        (progn (setf (aref (board-pieces board) row col) player)
               (incf (aref (board-tops board) col))
               (decf (board-remaining-moves board))
               (1- (aref (board-tops board) col))))))


(defun unmove (board col)
  "Back out the top move in a given column."
  (with-slots (pieces tops remaining-moves) board
    (when (/= (aref tops col) 0)
      (decf (aref tops col))
      (incf remaining-moves)
      (setf (aref pieces (aref tops col) col) 0))))


(defun print-board (board &optional (stream t))
  "Nicely table-formats a board configuration and dumps it to a stream."
  (loop for i from (1- *rows*) downto 0
       do (progn (loop for j from 0 to (1- *cols*)
                      do (format stream " ~A " (aref (board-pieces board) i j)))
                 (format stream "~%"))))


;;; Function checks to see if column is full.
(defun valid-move-p (board col)
  "Returns t if a column has spaces open, nil otherwise."
  (< (aref (board-tops board) col) *rows*))
(declaim (inline valid-move-p))


;;;; Functions related to game rules and semantics

(defun drawp (board)
  "Checks a board for a draw"
  (loop for c from 0 to (1- *cols*)
       always (/= (aref (board-pieces board) (1- *rows*) c) 0)))


(defun winning-piece-p (board row col)
  "Checks whether the piece at a particular index into a board is part
  of a winner.  Checking the whole board is usually not required."
  (let ((num-seen 0)
        (num-seen-d1 0)
        (num-seen-d2 0)
        (piece (aref (board-pieces board) row col)))
    ;;Check vertically
    (loop for r from 0 to (1- *rows*)
       do (progn (if (= (aref (board-pieces board) r col) piece)
                     (incf num-seen)
                     (setq num-seen 0))
                 (when (>= num-seen 4) (return-from winning-piece-p t))))
    ;;Check diagonals and horizontally.  Row nums are functions of col nums.
    (setq num-seen 0)
    (loop for c from 0 to (1- *cols*)
       for r1 = (+ c (- row col))
       for r2 = (- (+ row col) c)
       do (progn (if (and (<= 0 r1 (1- *rows*))
                          (= (aref (board-pieces board) r1 c) piece))
                     (incf num-seen-d1)
                     (setq num-seen-d1 0))
                 (if (and (<= 0 r2 (1- *rows*))
                          (= (aref (board-pieces board) r2 c) piece))
                     (incf num-seen-d2)
                     (setq num-seen-d2 0))
                 (if (= (aref (board-pieces board) row c) piece)
                     (incf num-seen)
                     (setq num-seen 0))
                 (when (or (>= num-seen-d1 4) (>= num-seen-d2 4)
                           (>= num-seen 4))
                   (return-from winning-piece-p t)))))
  ;;If none succeed, eval to nil
  nil)



;;;; Searches and such.  The agent.


;;; I noticed that the goodness function contained a lot of
;;; nearly-identical loops and decided to factor them out into this
;;; macro.  The first parameter is a list of things that initialize
;;; the looping (like for and such), the second is the form to
;;; evaluate to get the current one (presumably based on the looping
;;; keywords from the first arg), and the optional third argument is a
;;; condition that is tested before binding current and, if true,
;;; causes the loop to terminate and return the current score.  THIS
;;; MACRO IS NOT GENERAL UTILITY!  EXPANSIONS CONTAIN NASTY FREE
;;; VARIABLES!
(defmacro goodness-loop ((&rest loop-keywords-for-init)
                         current-form
                         &optional bail-form)
  `(loop
      ,@loop-keywords-for-init
      with score = 0.0
      ,@(if bail-form `(when ,bail-form return score) nil)
      do (let ((current ,current-form))
           (cond ((= current player) (incf score))
                 ((= current 0) (return (+ score .25)))
                 (t (return score))))
      finally (return score)))


;;; The score is the sum of the scores for horizonal, vertical, and
;;; diagonal directions.  The score for a direction is the number of
;;; pieces in a row belonging to the player in a direction and 3/4 the
;;; number of empty pieces at each end of the row.  Four pieces in a
;;; row has the arbitrary score of 10, and so does three in a row with
;;; a blank on each end, as these are guaranteed wins.  This function
;;; is long and repetetive, and would be much worse if not for the
;;; above helper macro.
(defun goodness (board row col)
  "Return the utility of a particular piece for its owner."
  (when (winning-piece-p board row col) (return-from goodness infinity))
  (let* ((player (aref (board-pieces board) row col))
         (score-h
          (+ (goodness-loop (for c from (1- col) downto 0)
                            (aref (board-pieces board) row c))
              1 ;the current piece
             (goodness-loop (for c from (1+ col) to (1- *cols*))
                            (aref (board-pieces board) row c))))
         (score-v
          (+ (goodness-loop (for r from (1- row) downto 0)
                            (aref (board-pieces board) r col))
             1 ;current piece
             (goodness-loop (for r from (1+ row) to (1- *rows*))
                            (aref (board-pieces board) r col))))
         (score-d1
          (+ (goodness-loop (for c from (1- col) downto 0
                             for r = (+ c (- row col)))
                            (aref (board-pieces board) r c)
                            (< r 0))
             1
             (goodness-loop (for c from (1+ col) to (1- *cols*)
                             for r = (+ c (- row col)))      ;row as function of col
                            (aref (board-pieces board) r c)
                            (>= r *rows*))))
         (score-d2
          (+ (goodness-loop (for c from (1- col) downto 0
                             for r = (- (+ row col) c))      ;row a func of col again
                            (aref (board-pieces board) r c)
                            (>= r *rows*))
             1
             (goodness-loop (for c from (1+ col) to (1- *cols*)
                             for r = (- (+ row col) c))
                            (aref (board-pieces board) r c)
                            (< r 0)))))
         (flet ((eval-score (score)
                  (if (>= score 4.0) infinity score)))
           (+ (eval-score score-h)
              (eval-score score-v)
              (eval-score score-d1)
              (eval-score score-d2)))))



;;; Random opponent.  DEPTH argument included to allow for a future
;;; generic agent interface.
(defun random-move (board player depth)
  "Return a random move."
  (declare (ignore depth))
  (let ((move (random (1- *cols*))))
    (if (valid-move-p board move)
        (progn (move board move player) move)
        (loop for c from 0 to *cols*
           when (valid-move-p board c)
           do (progn (move board c player) c)
           finally (error "No moves found.")))))


(declaim (inline other-player))
(defun other-player (p)
  "When passed a player number, other-player returns the opposite."
  (if (= p 1) 2 1))


(defun state-goodness (board player)
  "Loop across a state considering all possible moves for PLAYER.
  Return the score of the best one found and the location of it."
  (let ((best-score 0) (best-move 3))
    (loop for c from 0 to (1- *cols*)
       when (valid-move-p board c)
       do (let ((this-score ;score for this column
                 ;; The unwind-protect form makes sure that unmove gets called even
                 ;; if there is a nonlocal exit
                 (unwind-protect
                      (let ((r (move board c player)))
                        (goodness board r c)) ;this value returned from unwind-protect
                   (unmove board c))))
            (when (> this-score best-score)
              (setq best-score this-score
                    best-move c))))
    (values best-score best-move)))


;;; Minmax algorithm.  Because good for one player is bad for the
;;; other, minmax can be transformed into a single function that
;;; negates the result of the recursive call.  An optional depth
;;; argument may be supplied. The keyword :bail-out-time causes the
;;; search to terminate at a particular time, returning a bogus, tiny
;;; little value.
(defun minmax (board player &optional (depth *default-depth*)
               (alpha (- infinity)) (beta infinity) (bail-out-time 100 bail-p))
  "Perform alpha-beta minmax search on BOARD starting with PLAYER.  If
  the BAIL-OUT-TIME is given, get out quickly when that time is
  exceeded."
  ;;If max time exceeded, bail out.  Note that bail-p is true iff the
  ;;final optional argument was provided. Callers using bail-out-time
  ;;must establish a catch for the 'too-long tag.  This feature is not
  ;;currently used, but can be used to implement time-limited bail-out.
  (when (and bail-p (> (get-internal-real-time) bail-out-time)
             (throw 'too-long (values nil nil))))
  ;; If a leaf in the search tree, return score and location of
  ;; best move,
  (if (or (<= depth 0)
          (= (board-remaining-moves board) 2))
      (state-goodness board player)
      ;; Otherwise, recurse.
      (loop for m from 0 to (1- *cols*)
         with where = 0
         ;; For each valid move, make the move, evaluate it, then unmake it
         when (valid-move-p board m)
         do (let ((score (unwind-protect
                              (progn (move board m player)
                                     ;; If a win, prune tree.  Otherwise, recurse.
                                     (if (winning-piece-p board (1- (col-top board m)) m)
                                         (values (- infinity 1) m)
                                         (- (minmax board (other-player player)
                                                    (1- depth)
                                                    (- beta) (- alpha)))))
                           (unmove board m))))
              (when (> score alpha) (setq alpha score
                                      where m))
              (when (>= alpha beta) (return-from minmax (values alpha where))))
         finally (return (values alpha where)))))


;;;; Graphical Object Definitions & Methods

;;; This class' usefulness is solely in being different in identity from
;;; the default view.
(defclass board-view (view)
  ()
  (:documentation "Simple view class used to distinguish board panes from others."))
(defparameter *board-view* (make-instance 'board-view)
  "Instance of BOARD-VIEW used in drawing functions.")


;;; This is the major window for the application.  It has slots representing
;;; the state of the game, such as the board and whose turn it is.  It also
;;; contains the panes that represent this state to the user.
(define-application-frame connect4-frame ()
  ;;Slots:
  ((turn :accessor turn :initform 1) ;Whose turn is it?
   ;;Holds the array representing the board state
   (board :initform (make-board)
         :accessor connect4-frame-board)
   ;;String holding the status message
   (status-msg :initform "Player 1." :accessor status-msg)
   ;;Boolean value that is false when a game is in progress and the number
   ;; of the winning player when it has been won.  If a draw has occurred,
   ;; it is set to the symbol DRAW.
   (game-won :accessor game-won :initform nil)
   ;;Cons cell holding players one and two, identified either by a function
   ;; returning two values: a score and a 0-indexed column to move to, or
   ;; the symbol PLAYER denoting that a human is playing that role.
   (players :accessor players :initform (cons 'human 'human)))
  (:panes
   (player1-menu (make-pane 'option-pane
                            :id :p1-menu
                            :value "Human"
                            :mode :one-of
                            :items '("Human" "Random" "Search")
                            :test 'string=))
   (player2-menu (make-pane 'option-pane
                            :id :p2-menu
                            :mode :one-of
                            :value "Human"
                            :items '("Human" "Random" "Search")))
   (go-button (make-pane 'push-button-pane
                         :id :go-button
                         :label "Go!"
                         :show-as-default t
                         :activate-callback 'next-move))
   ;;The graphical display of the board
   (board-pane :application
               :default-view '+board-view+
               :background +yellow+
               :scroll-bars nil
               :display-function 'board-pane-redisplay
               :incremental-redisplay t)
   ;;The line telling players state such as invalid moves and whose turn
   ;; it is
   (status-pane :stream
                :scroll-bars nil
                :incremental-redisplay t
                :display-function 'status-pane-redisplay)
   ;;The context-sensitive help menu at the bottom
   (pointer-help :pointer-documentation))
  ;;Stack panes vertically
  (:layouts (default
                (vertically (:width 310)
                  (25 (horizontally (:x-spacing 3 :y-spacing 3)
                        (2/5 player1-menu)
                        (2/5 player2-menu)
                        (1/5 go-button)))
                  (275 board-pane)
                  (25 status-pane)
                  (50 pointer-help)))))


;;; These are the callbacks that cause the change in application state
;;; when menu options are selected.  Unfortunately, I needed to write
;;; two nearly identical functions because specialization occurs with
;;; an EQL specializer and writing a combined gadget is overkill.
(defmethod value-cHanged-callback :after
    (option-pane client (id (eql :p1-menu)) val)
  (declare (ignore client option-pane))
  (with-slots (players) *application-frame*
    (setf (car players)
          (cdr (assoc val
                      '(("Human" . human)
                        ("Random" . random)
                        ("Search" . search))
                      :test #'string=)))))
(defmethod value-changed-callback :after
    (option-pane client (id (eql :p2-menu)) val)
  (declare (ignore client option-pane))
  (with-slots (players) *application-frame*
    (setf (cdr players)
          (cdr (assoc val
                      '(("Human" . human)
                        ("Random" . random)
                        ("Search" . search))
                      :test #'string=)))))


;;; This is the callback function for the "Go!" button. It selects the
;;; appropriate action to take based on the current state.
(defun next-move (button)
  "Callback for `Go!' button."
  (declare (ignore button))
  (with-slots (players turn) *application-frame*
    (let ((who (if (= turn 1) (car players) (cdr players))))
      (case who
        ((human)
         (update-status-msg *application-frame*
                            (format nil "Player ~A: Click a column to move."
                                    turn)))
        ((random)
         (com-move (+ (random (1- *cols*)) 1)))
        ((search)
         (with-slots (board turn) *application-frame*
           (multiple-value-bind (score where)
               (minmax board turn)
             (declare (ignore score))
             (com-move (+ where 1)))))
        (t (update-status-msg *application-frame*
                              "unimplemented")))
      ;;Redisplay is called because normally status is updated only by
      ;; the command loop.
      (redisplay-frame-panes *application-frame*))))


;;; A presentation type for columns, allowing them to be accepted properly
;;; by commands.
(define-presentation-type column ()
  :inherit-from `(integer 1 ,*cols*))


;;; How to draw columns to a text stream
(define-presentation-method present
    (object (type column) stream (view textual-view)
            &key acceptably for-context-type)
  (declare (ignore acceptably for-context-type))
  (format stream "Column ~A" object))


;;; How to draw columns to the boad pane
(define-presentation-method present
    (object (type column) stream (view board-view)
            &key acceptably for-context-type)
  (declare (ignore acceptably for-context-type))
  (draw-column *application-frame*
               stream
               object))


;;; This is the function that draws the board into the window.  It is
;;; called automatically when needed.
(defmethod board-pane-redisplay ((frame connect4-frame) pane)
  (loop for x from 1 to *cols*
     do (present x 'column :stream pane :view *board-view*)))


;;; This is the function that writes the status to the status line.
;;; It is called when needed.
(defmethod status-pane-redisplay ((frame connect4-frame) pane)
  (format pane (status-msg frame)))


;;; This function resets the state held in the application frame.
(defmethod initialize-frame ((frame connect4-frame))
  (update-status-msg frame "Player 1")
  (setf (turn frame) 1
        (connect4-frame-board frame) (make-board)
        (game-won frame) nil
        (pane-needs-redisplay (get-frame-pane frame 'board-pane)) t))


;;; Convenience function to set the status message and tell the pane
;;; to redraw.
(defmethod update-status-msg ((frame connect4-frame) message)
  (setf (status-msg frame)
        message
        (pane-needs-redisplay (get-frame-pane frame 'status-pane))
        t))


;;; Draws the specified column into the pane.
(defmethod draw-column ((frame connect4-frame) pane col)
  ;;Calculate column position and size based on column number.
  (let* ((pieces (board-pieces (connect4-frame-board frame)))
         (col-width (ceiling (* *piece-radius*
                                2.2)))
         (middle-x (ceiling (+ (/ col-width 2) (* (1- col) col-width)))))
    ;;Iterate over pieces, drawing them at the appropriate y position.
    (loop
       for row from (1- *rows*) downto 0
       for y-mult from 0 to (1- *rows*)
       do (draw-piece frame pane
                      middle-x
                      (ceiling (+ (/ col-width 2)
                                  (* y-mult col-width)))
                      (aref pieces row (1- col))))))


;;; Draw a piece at specified x and y postion with a color indexed by the
;;; player number.
(defmethod draw-piece ((frame connect4-frame) pane x y player)
  (declare (ignore frame))
  (let ((color (nth player *player-colors*)))
    (draw-circle* pane x y *piece-radius* :filled t :ink color)))


;;;; CLIM command definitions

;;; Place piece.
(define-connect4-frame-command (com-move :name t :menu "Move")
    ((col 'column))
  ;;If game is already won, do nothing
  (unless (game-won *application-frame*)
    ;;Make the move, remembering where the piece landed
    (handler-case
        (let ((player (turn *application-frame*))
              (row (move (connect4-frame-board *application-frame*)
                         (1- col)
                         (turn *application-frame*))))
          ;;Update the turn counter
          (setf (turn *application-frame*)
                (if (= (turn *application-frame*) 1) 2 1))
          ;;Update the status message
          (update-status-msg *application-frame*
                             (format nil "Player ~A."
                                     (turn *application-frame*)))
          ;;Check for a draw
          (when (drawp (connect4-frame-board *application-frame*))
            (update-status-msg *application-frame* "A draw has occurred.")
            (setf (game-won *application-frame*) 'draw))
          ;;Use remembered location to determine whether there was a win
          (when (winning-piece-p (connect4-frame-board *application-frame*)
                                 row
                                 (1- col))
            (update-status-msg *application-frame*
                               (format nil "Player ~A has won." player))
            (setf (game-won *application-frame*) (turn *application-frame*))))
      (invalid-move (condition)
        (declare (ignore condition))
        (update-status-msg *application-frame* (format nil "~A is invalid." col))))
    ;;Redisplay the output panes
    (setf (pane-needs-redisplay (get-frame-pane *application-frame*
                                                'board-pane))
          t)))


;;; Re-initialize the state of the game.
(define-connect4-frame-command (com-reset-game :name t :menu "Reset")
    ()
  ;;Reset state
  (initialize-frame *application-frame*)
  ;;Redraw window
  (setf (pane-needs-redisplay (get-frame-pane *application-frame*
                                                'board-pane))
        t
        (pane-needs-redisplay (get-frame-pane *application-frame*
                                              'status-pane))
        t))


;;; Quit command.
(define-connect4-frame-command (com-quit-frame :name t :menu "Quit")
    ()
  (frame-exit *application-frame*))


;;;Enable clicking on columns to move in them.  The column
;;;presentation type is mapped to a command on that type.
(define-presentation-to-command-translator column-move-translator
    (column com-move connect4-frame
             :gesture :select
             :menu t
             :documentation "Move in column"
             :pointer-documentation "Move here")
  (object)
  (list object))


;;;Convenience function to run the toplevel.
(defun run-connect4 ()
  (run-frame-top-level
   (make-application-frame 'connect4-frame :pretty-name "Connect 4")))
