When I was young, I was in a programming club that often had us build 
working programs from simple games.  Blackjack, Yahtzee, etc.  There 
was usually a twist though, like a time limit or having to create the 
game on your TI calculator.  In that club, I built Yahtzee in just 
under one hour.

That code (in Pascal) was ugly and more than a little hostile to the 
user.  As a penance, I tried to make this one a little more 
professional looking, but that took me more than an hour...

James Edward Gray II

#!/usr/local/bin/ruby -w

#  yahtzee.rb
#
#  Created by James Edward Gray II on 2005-02-15.
#  Copyright (c) 2005 Gray Productions. All rights reserved.

# Namespace for all things Yahtzee.
module Yahtzee
	# An object for managing the rolls of a Yahtzee game.
	class Roll
		# Create an instance of Roll.  Methods can then be used the examine 
the
		# results of the roll and reroll dice.
		def initialize(  )
			@dice = Array.new(5) { rand(6) + 1 }
		end
		
		# Examine the individual dice of a Roll.
		def []( index )
			@dice[index]
		end
	
		# Count occurrences of a set of pips.
		def count( *pips )
			@dice.inject(0) do |total, die|
				if pips.include?(die) then total + 1 else total end
			end
		end
	
		# Add all occurrences of a set of pips, or all the dice.
		def sum( *pips )
			if pips.size == 0
				@dice.inject(0) { |total, die| total + die }
			else
				@dice.inject(0) do |total, die|
					if pips.include?(die) then total + die else total end
				end
			end
		end
	
		#
		# Examines Roll for a pattern of dice, returning true if found.
		# Patterns can be of the form:
		#
		#     roll.matches?(1, 2, 3, 4)
		#
		# Which validates a sequence, regardless of the actual pips on the 
dice.
		#
		# You can also use the form:
		#
		#     roll.matches?(*%w{x x x y y})
		#
		# To validate repititions.
		#
		# The two forms can be mixed in any combination and when they are, 
both
		# must match completely.
		#
		def matches?( *pattern )
			digits, letters = pattern.partition { |e| e.is_a?(Integer) }
			matches_digits?(digits) and matches_letters?(letters)
		end
	
		# Reroll selected _dice_.
		def reroll( *dice )
			if dice.size == 0
				@dice = Array.new(5) { rand(6) + 1 }
			else
				indices = [ ]
				pool    = @dice.dup
				dice.each do |d|
					i = pool.index(d) or raise ArgumentError, "Dice not found."
					indices << i
					pool[i] = -1
				end
			
				indices.each { |i| @dice[i] = rand(6) + 1 }
			end
		end
	
		# To make printing out rolls easier.
		def to_s(  )
			"#{@dice[0..-2].join(',')} and #{@dice[-1]}"
		end
	
		private
	
		# Verifies matching of sequence patterns.
		def matches_digits?( digits )
			return true if digits.size < 2
		
			digits.sort!
			test = @dice.uniq.sort
			loop do
				(0..(@dice.length - digits.length)).each do |index|
					return true if test[index, digits.length] == digits
				end
		
				digits.collect! { |d| d + 1 }
				break if digits.last > 6	
			end
		
			false
		end
	
		# Verifies matching of repitition patterns.
		def matches_letters?( letters )
			return true if letters.size < 2
		
			counts = Hash.new(0)
			letters.each { |l| counts[l] += 1 }
			counts = counts.values.sort.reverse
		
			pips = @dice.uniq
			counts.each do |c|
				return false unless match = pips.find { |p| count(p) >= c }
				pips.delete(match)
			end
		
			true
		end
	end

	# A basic score tracking object.
	class Scorecard
		# Create an instance of Scorecard.  Add categories and totals, track
		# score and display results as needed.
		def initialize(  )
			@categories = [ ]
		end
	
		# Add one or more categories to this Scorecard.  Order is maintained.
		def add_categories( *categories )
			categories.each do |cat|
				@categories << [cat, 0]
			end
		end
	
		#
		# Add a total, with a block to calculate it from passed a
		# categories Hash.
		#
		def add_total( name, &calculator )
			@categories << [name, calculator]
		end
		
		#
		# The primary score action method.  Adds _count_ points to the 
category
		# at _index_.
		#
		def count( index, count )
			@categories.assoc(category(index))[1] += count
		end
	
		# Lookup the score of a given category.
		def []( name )
			@categories.assoc(name)[1]
		end
		
		# Lookup a category name, by _index.
		def category( index )
			id = 0
			@categories.each_with_index do |(name, count_or_calc), i|
				next unless count_or_calc.is_a?(Numeric)
				id += 1
				return @categories[i][0] if id == index
			end

			raise ArgumentError, "Invalid category."
		end
		
		# Support for easy printing.
		def to_s(  )
			id = 0
			@categories.inject("") do |disp, (name, count_or_calc)|
				if count_or_calc.is_a?(Numeric)
					id += 1
					disp + "%3d: %-20s %4d\n" % [id, name, count_or_calc]
				else
					disp + "     %-20s %4d\n" %
						[name, count_or_calc.call(to_hash)]
				end
			end
		end
	
		# Convert category listing to a Hash.
		def to_hash(  )
			@categories.inject(Hash.new) do |hash, (name, count_or_calc)|
				hash[name] = count_or_calc if count_or_calc.is_a?(Numeric)
				hash
			end
		end
	end
end

# Console game interface.
if __FILE__ == $0
	# Assemble Scorecard.
	score = Yahtzee::Scorecard.new()
	UPPER = %w{Ones Twos Threes Fours Fives Sixes}
	UPPER_TOTAL = lambda do |cats|
		cats.inject(0) do |total, (cat, count)|
			if UPPER.include?(cat) then total + count else total end
		end
	end
	score.add_categories(*UPPER)
	score.add_total("Bonus") do |cats|
		upper = UPPER_TOTAL.call(cats)
		if upper >= 63 then 35 else 0 end
	end
	score.add_total("Upper Total") do |cats|
		upper = UPPER_TOTAL.call(cats)
		if upper >= 63 then upper + 35 else upper end
	end
	LOWER = [ "Three of a Kind", "Four of a Kind", "Full House",
			  "Small Straight", "Large Straight", "Yahtzee", "Chance" ]
	bonus_yahtzees = 0
	LOWER_TOTAL = lambda do |cats|
		cats.inject(bonus_yahtzees) do |total, (cat, count)|
			if LOWER.include?(cat) then total + count else total end
		end
	end
	score.add_categories(*LOWER[0..-2])
	score.add_total("Bonus Yahtzees") { bonus_yahtzees }
	score.add_categories(LOWER[-1])
	score.add_total("Lower Total", &LOWER_TOTAL)
	score.add_total("Overall Total") do |cats|
		upper = UPPER_TOTAL.call(cats)
		if upper >= 63
			upper + 35 + LOWER_TOTAL.call(cats)
		else
			upper + LOWER_TOTAL.call(cats)
		end
	end
	
	# Game.
	puts "\nWelcome to Yahtzee!"
	scratches = (1..13).to_a
	13.times do
		# Rolling...
		roll = Yahtzee::Roll.new
		rolls = 2
		while rolls > 0
			puts "\nYou rolled #{roll}."
			print "Action:  (c)heck score, (s)core, (q)uit or #s to reroll?  "
			choice = STDIN.gets.chomp
			case choice
			when /^c/i
				puts "\nScore:\n#{score}"
			when /^s/i
				break
			when /^q/i
				exit
			else
				begin
					pips = choice.gsub(/\s+/, "").split(//).map do |n|
						Integer(n)
					end
					roll.reroll(*pips)
					rolls -= 1
				rescue
					puts "Error:  That not a valid reroll."
				end
			end
		end
		
		# Scoring...
		loop do
			if roll.matches?(*%w{x x x x x}) and score["Yahtzee"] == 50
				bonus_yahtzees += 100
				
				if scratches.include?(roll[0])
					score.count(roll[0], roll.sum(roll[0]))
					scratches.delete(choice)
					puts "Bonus Yahtzee scored in #{score.category(roll[0])}."
					break
				end
				
				puts "Bonus Yahtzee!  100 points added.  " +
				     "Score in lower section as a wildcard."
				bonus_yahtzee = true
			else
				bonus_yahtzee = false
			end
			
			print "\nScore:\n#{score}\n" +
			      "Where would you like to count your #{roll} " +
			      "(# of category)?  "
			begin
				choice = Integer(STDIN.gets.chomp)
				raise "Already scored." unless scratches.include?(choice)
				case choice
				when 1..6
					score.count(choice, roll.sum(choice))
				when 7
					if roll.matches?(*%w{x x x}) or bonus_yahtzee
						score.count(choice, roll.sum())
					end
				when 8
					if roll.matches?(*%w{x x x x}) or bonus_yahtzee
						score.count(choice, roll.sum())
					end
				when 9
					if roll.matches?(*%w{x x x y y}) or bonus_yahtzee
						score.count(choice, 25)
					end
				when 10
					if roll.matches?(1, 2, 3, 4) or bonus_yahtzee
						score.count(choice, 30)
					end
				when 11
					if roll.matches?(1, 2, 3, 4, 5) or bonus_yahtzee
						score.count(choice, 40)
					end
				when 12
					if roll.matches?(*%w{x x x x x})
						score.count(choice, 50)
					end
				when 13
					score.count(choice, roll.sum)
				end
				scratches.delete(choice)
				break
			rescue
				puts "Error:  Invalid category choice."
			end
		end
	end
	
	print "\nFinal Score:\n#{score}\nThanks for playing.\n\n"
end

__END__