From 8ef694996f2d83d06b7f51481a056bb781a19dfa Mon Sep 17 00:00:00 2001 From: Chris Reuter Date: Sat, 13 Mar 2021 14:58:08 -0500 Subject: [PATCH] Added ruby implemention of Checkers. Also added my annotated version of the BASIC source code on the off chance that it would be useful. --- 23 Checkers/README.md | 5 +- 23 Checkers/checkers.annotated.bas | 315 ++++++++++++++ 23 Checkers/ruby/README.md | 5 + 23 Checkers/ruby/checkers.rb | 651 +++++++++++++++++++++++++++++ 4 files changed, 975 insertions(+), 1 deletion(-) create mode 100644 23 Checkers/checkers.annotated.bas create mode 100644 23 Checkers/ruby/checkers.rb diff --git a/23 Checkers/README.md b/23 Checkers/README.md index 05681815..0a3c57a2 100644 --- a/23 Checkers/README.md +++ b/23 Checkers/README.md @@ -6,10 +6,13 @@ https://www.atariarchives.org/basicgames/showpage.php?page=40 Downloaded from Vintage Basic at http://www.vintage-basic.net/games.html +The file `checkers.annotated.bas` contains an indented and annotated +version of the source code. This is no longer valid BASIC code but +should be more readable. ## Known Issues In the Original BASIC Code - If the computer moves a checker to the bottom row, it promotes, but leaves the original checker in place. (See line 1240) - Human players may move non-kings as if they were kings. (See lines 1590 to 1810) + - Human players are not required to jump if it is possible. - Curious writing to "I" variable without ever reading it. (See lines 1700 and 1806) - \ No newline at end of file diff --git a/23 Checkers/checkers.annotated.bas b/23 Checkers/checkers.annotated.bas new file mode 100644 index 00000000..af8ffabc --- /dev/null +++ b/23 Checkers/checkers.annotated.bas @@ -0,0 +1,315 @@ + # Annotated version of CHECKERS.BAS, modified to improve readability. + # + # I've made the following changes: + # + # 1. Added many comments and blank lines. + # 2. Separated each statement into its own line. + # 3. Indented loops, conditionals and subroutines. + # 4. Turned *SOME* conditionals and loops into + # structured-BASIC-style if/endif and loop/endloop blocks. + # 5. Switched to using '#' to delimit comments. + # 6. Subroutines now begin with "Sub_Start" + # 7. All non-string text has been converted to lower-case + # 8. All line numbers that are not jump destinations have been removed. + # + # This has helped me make sense of the code. I hope it will also help you. + # + + # Print the banner + print tab(32);"CHECKERS" + print tab(15);"CREATIVE COMPUTING MORRISTOWN, NEW JERSEY" + print + print + print + print "THIS IS THE GAME OF CHECKERS. THE COMPUTER IS X," + print "AND YOU ARE O. THE COMPUTER WILL MOVE FIRST." + print "SQUARES ARE REFERRED TO BY A COORDINATE SYSTEM." + print "(0,0) IS THE LOWER LEFT CORNER" + print "(0,7) IS THE UPPER LEFT CORNER" + print "(7,0) IS THE LOWER RIGHT CORNER" + print "(7,7) IS THE UPPER RIGHT CORNER" + print "THE COMPUTER WILL TYPE '+TO' WHEN YOU HAVE ANOTHER" + print "JUMP. TYPE TWO NEGATIVE NUMBERS IF YOU CANNOT JUMP." + print + print + print + + # Declare the "globals": + + # The current move: (rating, current x, current y, new x, new y) + # 'rating' represents how good the move is; higher is better. + dim r(4) + r(0)=-99 # Start with minimum score + + # The board. Pieces are represented by numeric values: + # + # - 0 = empty square + # - -1,-2 = X (-1 for regular piece, -2 for king) + # - 1,2 = O (1 for regular piece, 2 for king) + # + # This program's player ("me") plays X. + dim s(7,7) + + g=-1 # constant holding -1 + + # Initialize the board. Data is 2 length-wise strips repeated. + data 1,0,1,0,0,0,-1,0,0,1,0,0,0,-1,0,-1,15 + for x=0 to 7 + for y=0 to 7 + read j + if j=15 then 180 + s(x,y)=j + goto 200 +180 restore + read s(x,y) +200 next y,x + +230 # Start of game loop. First, my turn. + + # For each square on the board, search for one of my pieces + # and if it can make the best move so far, store that move in 'r' + for x=0 to 7 + for y=0 to 7 + + # Skip if this is empty or an opponent's piece + if s(x,y) > -1 then 350 + + # If this is one of my ordinary pieces, analyze possible + # forward moves. + if s(x,y) = -1 then + for a=-1 to 1 step 2 + b=g + gosub 650 + next a + endif + + # If this is one of my kings, analyze possible forward + # and backward moves. + if s(x,y) = -2 then + for a=-1 to 1 step 2 + for b=-1 to 1 step 2 + gosub 650 + next b,a + endif + +350 next y,x + goto 1140 # Skip the subs + + + # Analyze a move from (x,y) to (x+a, y+b) and schedule it if it's + # the best candidate so far. +650 Sub_Start + u=x+a + v=y+b + + # Done if it's off the board + if u<0 or u>7 or v<0 or v>7 then 870 + + # Consider the destination if it's empty + if s(u,v) = 0 then + gosub 910 + goto 870 + endif + + # If it's got an opponent's piece, jump it instead + if s(u,v) > 0 + + # Restore u and v, then return if it's off the board + u=u+a + v=v+b + if u<0 or v<0 or u>7 or v>7 then 870 + + # Otherwise, consider u,v + if s(u,v)=0 then gosub 910 + endif +870 return + + # Evaluate jumping (x,y) to (u,v). + # + # Computes a score for the proposed move and if it's higher + # than the best-so-far move, uses that instead by storing it + # and its score in array 'r'. +910 Sub_Start + + # q is the score; it starts at 0 + + # +2 if it promotes this piece + if v=0 and s(x,y)=-1 then q=q+2 + + # +5 if it takes an opponent's piece + if abs(y-v)=2 then q=q+5 + + # -2 if the piece is moving away from the top boundary + if y=7 then q=q-2 + + # +1 for putting the piece against a vertical boundary + if u=0 or u=7 then q=q+1 + + for c=-1 to 1 step 2 + if u+c < 0 or u+c > 7 or v+g < 0 then 1080 + + # +1 for each adjacent friendly piece + if s(u+c, v+g) < 0 then + q=q+1 + goto 1080 + endif + + # Prevent out-of-bounds testing + if u-c < 0 or u-c > 7 or v-g > 7 then 1080 + + # -2 for each opponent piece that can now take this piece here + if s(u+c,v+g) > 0 and(s(u-c,v-g)=0 or(u-c=x and v-g=y))then q=q-2 +1080 next c + + # Use this move if it's better than the previous best + if q>r(0) then + r(0)=q + r(1)=x + r(2)=y + r(3)=u + r(4)=v + endif + + q=0 # reset the score + return + +1140 if r(0)=-99 then 1880 # Game is lost if no move could be found. + + # Print the computer's move. (Note: chr$(30) is an ASCII RS + # (record separator) code; probably no longer relevant.) + print chr$(30)"FROM"r(1);r(2)"TO"r(3);r(4); + r(0)=-99 + + # Make the computer's move. If the piece finds its way to the + # end of the board, crown it. +1240 if r(4)=0 then + s(r(3),r(4))=-2 + goto 1420 + endif + s(r(3),r(4))=s(r(1),r(2)) + s(r(1),r(2))=0 + + # If the piece has jumped 2 squares, it means the computer has + # taken an opponents' piece. + if abs(r(1)-r(3)) == 2 then + s((r(1)+r(3))/2,(r(2)+r(4))/2)=0 # Delete the opponent's piece + + # See if we can jump again. Evaluate all possible moves. + x=r(3) + y=r(4) + for a=-2 to 2 step 4 + if s(x,y)=-1 then + b=-2 + gosub 1370 + endif + if s(x,y)=-2 then + for b=-2 to 2 step 4 + gosub 1370 + next b + endif + next a + + # If we've found a move, go back and make that one as well + if r(0) <> -99 then + print "TO" r(3); r(4); + r(0)=-99 + goto 1240 + endif + + goto 1420 # Skip the sub + + # If (u,v) is in the bounds, evaluate it as a move using + # the sub at 910 +1370 Sub_Start + u=x+a + v=y+b + if u<0 or u>7 or v<0 or v>7 then 1400 + if s(u,v)=0 and s(x+a/2,y+b/2)>0 then gosub 910 +1400 return + +1420 endif + + # Now, print the board + print + print + print + for y=7 to 0 step-1 + for x=0 to 7 + i=5*x + print tab(i); + if s(x,y)=0 then print"."; + if s(x,y)=1 then print"O"; + if s(x,y)=-1 then print"X"; + if s(x,y)=-2 then print"X*"; + if s(x,y)=2 then print"O*"; + next x + print" " + print + next y + print + + # Check if either player is out of pieces. If so, announce the + # winner. + for l=0 to 7 + for m=0 to 7 + if s(l,m)=1 or s(l,m)=2 then z=1 + if s(l,m)=-1 or s(l,m)=-2 then t=1 + next m + next l + if z<>1 then 1885 + if t<>1 then 1880 + + # Prompt the player for their move. + z=0 + t=0 +1590 input "FROM";e,h + x=e + y=h + if s(x,y)<=0 then 1590 +1670 input "TO";a,b + x=a + y=b + if s(x,y)=0 and abs(a-e)<=2 and abs(a-e)=abs(b-h)then 1700 + print chr$(7)chr$(11); # bell, vertical tab; invalid move + goto 1670 + +1700 i=46 # Not used; probably a bug +1750 loop + # Make the move and stop unless it might be a jump. + s(a,b) = s(e,h) + s(e,h) = 0 + if abs(e-a) <> 2 then break + + # Remove the piece jumped over + s((e+a)/2,(h+b)/2) = 0 + + # Prompt for another move; -1 means player can't, so I've won. + # Keep prompting until there's a valid move or the player gives + # up. +1802 input "+TO";a1,b1 + if a1 < 0 then break + if s(a1,b1) <> 0 or abs(a1-a) <>2 or abs(b1-b) <> 2 then 1802 + + # Update the move variables to correspond to the next jump + e=a + h=b + a=a1 + b=b1 + + i=i+15 # Not used; probably a bug + endloop + + # If the player has reached the end of the board, crown this piece +1810 if b=7 then s(a,b)=2 + + # And play the next turn. + goto 230 + + # Endgame: +1880 print + print "YOU WIN." + end + +1885 print + print "I WIN." + end diff --git a/23 Checkers/ruby/README.md b/23 Checkers/ruby/README.md index fb32811e..3b53879b 100644 --- a/23 Checkers/ruby/README.md +++ b/23 Checkers/ruby/README.md @@ -1,3 +1,8 @@ Original source downloaded [from Vintage Basic](http://www.vintage-basic.net/games.html) Conversion to [Ruby](https://www.ruby-lang.org/en/) + +This version preserves the underlying algorithms and functionality of +the original while using more modern programming constructs +(functions, classes, symbols) and providing much more detailed +comments. It also fixes some (but not all) of the bugs. diff --git a/23 Checkers/ruby/checkers.rb b/23 Checkers/ruby/checkers.rb new file mode 100644 index 00000000..cac0ab06 --- /dev/null +++ b/23 Checkers/ruby/checkers.rb @@ -0,0 +1,651 @@ +#!/usr/bin/env ruby + +# Checkers in Ruby, Version 1 +# +# This version of the game attempts to preserve the underlying +# algorithm(s) and feel of the BASIC version while using more modern +# coding techniques. Specifically: +# +# 1. The major data structures (the board and current move, known as S +# and R in the BASIC version) have been turned into classes. In +# addition, I made a class for coordinates so that you don't always +# have to deal with pairs of numbers. +# +# 2. Much of the functionality associated with this data has been moved +# into methods of these classes in line with the philosophy that objects +# are smart data. +# +# 3. While I've kept the board as a single object (not global, though), +# this program will create many Move objects (i.e. copies of the move +# under consideration) rather than operating on a single global +# instance. +# +# 4. The rest of the code has been extracted into Ruby functions with +# all variables as local as reasonably possible. +# +# 5. Pieces are now represented with Symbols instead of integers; this +# made it *much* easier to understand what was going on. +# +# 6. There are various internal sanity checks. They fail by throwing +# a string as an exception. (This is generally frowned upon if +# you're going to catch the exception later but we never do that; +# an exception here means a bug in the software and the way to fix +# that is to fix the program.) +# +# And probably other stuff. +# + + +# Note: I've ordered the various major definitions here from (roughly) +# general to specific so that if you read the code starting from the +# beginning, you'll (hopefully) get a big-picture view first and then +# get into details. Normally, I'd order things by topic and define +# things before using them, which is a better ordering. So in this +# case, do what I say and not what I do. + + +# +# Some global constants +# + +BOARD_TEXT_INDENT = 30 # Number of spaces to indent the board when printing + +# Various constants related to the game of Checkers. +# +# (Yes, they're obvious but if you see BOARD_WIDTH, you know this is +# related to board dimensions in a way that you wouldn't if you saw +# '8'.) +BOARD_WIDTH = 8 +KING_ROW_X = 0 +KING_ROW_Y = BOARD_WIDTH - 1 + + + +# This is the mainline routine of the program. Ruby doesn't require +# that you put this in a function but this way, your local variables +# are contained here. It's also neater, IMO. +# +# The name 'main' isn't special; it's just a function. The last line +# of this program is a call to it. +def main + print < move.from.y + return false if !is_me && move.to.y < move.from.y + end + + # If jumping, that there's an opponent's piece between the start + # and end. + return false if + move.jump? && + (empty_at?(move.midpoint) || opponent_at?(move.midpoint) != is_me) + + # Otherwise, it's legal + return true + end + + # Perform 'move' on the board. 'move' must be legal; the player + # performing it is determined by the move's starting ('from') + # position. + def make_move!(move) + piece = self[move.from] + + # Sanity check + raise "Illegal move: #{move}" unless legal_move?(piece.downcase == :x,move) + + # Promote the piece if it's reached the end row + piece = piece.upcase if + (piece == :x && move.to.y == KING_ROW_X) || + (piece == :o && move.to.y == KING_ROW_Y) + + # And do the move + self[move.to] = piece + self[move.from] = :_ + + # Remove the piece jumped over if this is a jump + self[move.midpoint] = :_ if move.jump? + end + + + # Return the best (i.e. likely to win) move possible for the + # piece (mine) at 'coord'. + def bestMoveFrom(coord, mustJump) + so_far = Move.invalid + return so_far unless coord.valid? + + offsets = [ [-1, -1], [1, -1]] + offsets += [ [-1, 1], [1, 1]] if king_at?(coord) + + for ofx, ofy in offsets + new_coord = coord.by(ofx, ofy) + + if opponent_at?(new_coord) + new_coord = new_coord.by(ofx, ofy) + elsif mustJump + next + end + + next unless new_coord.valid? + + so_far = so_far.betterOf( ratedMove(coord, new_coord) ) + end + + return so_far + end + + + # Create and return a move for *me* from Coords 'from' to 'to' with + # its 'rating' set to how good the move looks according to criteria + # used by the BASIC version of this program. If the move is + # illegal, returns an invalid Move object. + def ratedMove(from, to) + return Move.invalid unless legal_move?(true, Move.new(from, to)) + + rating = 0 + + # +2 if it promotes this piece + rating += 2 if to.y == 0 + + # +50 if it takes the opponent's piece. (Captures are mandatory + # so we ensure that a capture will always outrank a non-capture.) + rating += 4 if (from.y - to.y).abs == 2 + + # -2 if we're moving away from the king row + rating -= 2 if from.y == BOARD_WIDTH - 1 + + # +1 for putting the piece against a vertical boundary + rating += 1 if to.x == 0 || to.x == BOARD_WIDTH - 1 + + # +1 for each friendly piece behind this one + [-1, 1].each {|c| + rating += 1 if mine_at?( to.by(c, -1) ) + } + + # -2 for each opponent's piece that can now capture this one. + # (This includes a piece that may be captured when moving here; + # this is a bug.) + [ -1, 1].each {|c| + there = to.by(c, -1) + opposite = to.by(-c, 1) + rating -= 2 if + opponent_at?(there) && (empty_at?(opposite) || opposite == from) + } + + return Move.new(from, to, rating) + end +end + + +# Class to hold the X and Y coordinates of a position on the board. +# +# Coord objects are immutable--that is, they never change after +# creation. Instead, you will always get a modified copy back. +class Coord + + # Coordinates are readable + attr_reader :x, :y + + # Initialize + def initialize(x, y) + @x, @y = [x,y] + end + + # Test if this move is on the board. + def valid? + return x >= 0 && y >= 0 && x < BOARD_WIDTH && y < BOARD_WIDTH + end + + # Test if this Coord is equal to another Coord. + def ==(other) + return other.class == self.class && other.x == @x && other.y == y + end + + # Return a string that describes this Coord in a human-friendly way. + def to_s + return "(#{@x},#{@y})" + end + + # Return a new Coord whose x and y coordinates have been adjusted by + # arguments 'x' and 'y'. + def by(x, y) + return Coord.new(@x + x, @y + y) + end +end + + +# Class to represent a move by a player between two positions, +# possibly with a rating that can be used to select the best of a +# collection of moves. +# +# An (intentionally) invalid move will have a value of nil for both +# 'from' and 'to'. Most methods other than 'valid?' assume the Move +# is valid. +class Move + # Readable fields: + attr_reader :from, :to, :rating + + # The initializer; -99 is the lowest rating from the BASIC version + # so we use that here as well. + def initialize(from, to, rating = -99) + @from, @to, @rating = [from, to, rating] + + # Sanity check; the only invalid Move tolerated is the official + # one (i.e. with nil for each endpoint.) + raise "Malformed Move: #{self}" if @from && @to && !valid? + end + + # Return an invalid Move object. + def self.invalid + return self.new(nil, nil, -99) + end + + # Return true if this is a valid move (i.e. as close to legal as we + # can determine without seeing the board.) + def valid? + # Not valid if @from or @to is nil + return false unless @from && @to + + # Not valid unless both endpoints are on the board + return false unless @from.valid? && @to.valid? + + # Not valid unless it's a diagonal move by 1 or 2 squares + dx, dy = delta + return false if dx.abs != dy.abs || (dx.abs != 1 && dx.abs != 2) + + # Otherwise, valid + return true + end + + # Return true if this move is a jump, false otherwise + def jump? + return valid? && magnitude() == 2 + end + + # Return the coordinates of the piece being jumped over by this + # move. + def midpoint + raise "Called 'midpoint' on a non-jump move!" unless jump? + dx, dy = delta + return @from.by(dx / dx.abs, dy / dy.abs) + end + + # Return the better-rated of self or otherMove. + def betterOf(otherMove) + return otherMove if !valid? + return rating > otherMove.rating ? self : otherMove + end + + # Return a human-friendly string representing this move. + def to_s + return "[NOMOVE]" if !@from && !@to # Well-known invalid move + + jumpover = jump? ? + "-> #{midpoint} ->" + : "->" + + return "#{@from} #{jumpover} #{to}#{valid? ? '' : ' [INVALID]'}" + end + + private + + # Return the distance (x, y) between the 'from' and 'to' locations. + def delta + return [to.x - from.x, to.y - from.y] + end + + # Return the number of squares this move will take the piece (either + # 1 or 2). + def magnitude + # Note: we assume that this move is a legal move (and therefore + # diagonal); otherwise, this may not be correct. + return (to.x - from.x).abs + end +end + + + +# Start the game +main()