Ruby Quiz 1

Here is my solution to Ruby Quiz 1: The Solitaire Cypher. Jump down to the solution or the unit test.

Back to my Ruby Quiz solutions page.

Solution

#! /usr/bin/env ruby

RANKS = %w(A 2 3 4 5 6 7 8 9 10 J Q K)
SUITS = %w(C D H S)
JOKER_RANK = 'joker'
JOKER_VALUE = -1

class Card
    def Card.value_to_chr(value)
	i = value
	i -= 26 while i > 26
	(i + ?A - 1).chr
    end
    def Card.chr_to_value(chr)
	i = chr[0] - ?A + 1
	i += 26 while i < 0
	i
    end

    def initialize(rank, suit)
	@rank = rank
	@suit = suit
	if rank == JOKER_RANK
	    @value = JOKER_VALUE
	else
	    @value = (SUITS.index(suit) * 13) + RANKS.index(rank) + 1
	end
    end

    def to_s
# 	return @value.to_s if @value != JOKER_VALUE
# 	return @suit.to_s
 	"#{@rank}#{@suit} #{@value.to_s}"
    end

    def to_i
	@value
    end

    def chr
	Card.value_to_chr(@value)
    end
end

class Deck

    def initialize
	@cards = []
	SUITS.each { | suit |
	    RANKS.each { | rank | @cards << Card.new(rank, suit) }
	}
	@joker_a = Card.new(JOKER_RANK, 'A')
	@cards << @joker_a
	@joker_b = Card.new(JOKER_RANK, 'B')
	@cards << @joker_b
    end

    # Keys the deck and returns itself.
    def key
	# do nothing; keyed when initialized
	self
    end

    # Return the next keystream value as a number (not a string).
    # Keep going until we have a non-joker value.
    def next_keystream
	val = JOKER_VALUE
	until val != JOKER_VALUE
	    val = generate_next_keystream_value
	end
	val
    end

    # Return the next keystream value as a number 1-26 (not a string).
    def generate_next_keystream_value
	move(@joker_a, 1)
	move(@joker_b, 2)
	triple_cut()
	count_cut()
	return output_number()
    end

    # Move a card a certain distance. Wrap around the end of the deck.
    def move(card, distance)
	old_pos = @cards.index(card)
	new_pos = old_pos + distance
	new_pos -= (@cards.length-1) if new_pos >= @cards.length
	@cards[old_pos,1] = []
	@cards[new_pos,0] = [card]
    end

    # Perform a triple cut around the two jokers. All cards above the top
    # joker move to below the bottom joker and vice versa. The jokers and the
    # cards between them do not move.
    def triple_cut
	i = @cards.index(@joker_a)
	j = @cards.index(@joker_b)
	j, i = i, j if j < i	# make sure i < j
	@cards = slice(j+1, -1) + slice(i, j) + slice(0, i-1)
    end

    # Perform a count cut using the value of the bottom card. Cut the bottom
    # card's value in cards off the top of the deck and reinsert them just
    # above the bottom card.
    def count_cut
	i = @cards[@cards.length - 1].to_i
	@cards = slice(i, -2) + slice(0, i-1) + [@cards[@cards.length-1]]
    end

    # Returns a non-nil cut of cards from the deck.
    def slice(from, to)
	slice = @cards[from..to]
	return slice || []
    end

    # Return the output number (not letter). Convert the top card to its
    # value and count down that many cards from the top of the deck, with the
    # top card itself being card number one. Look at the card immediately
    # after your count and convert it to a letter. This is the next letter in
    # the keystream. If the output card is a joker, no letter is generated
    # this sequence. This step does not alter the deck.
    def output_number
	i = @cards[0].to_i
	i -= @cards.length if i >= @cards.length
	num = @cards[i].to_i
	num -= 26 if num > 26
	num
    end

    def to_s
	@cards.join(' ')
    end
end

class CryptKeeper

    def initialize(deck)
	@keyed_deck = deck
    end

    def decrypt(str)
	build_crypto(str) { | key, msg_num |
	    diff = msg_num - key
	    diff += 26 if diff < 1
	    diff
	}
    end

    def encrypt(str)
	build_crypto(str) { | key, msg_num |
	    sum = msg_num + key
	    sum -= 26 if sum > 26
	    sum
	}
    end

    # Returns a string after yielding key/msg_num pairs and collecting
    # the results.
    def build_crypto(str)
	deck = @keyed_deck.dup
	answer = ''
	str.split(//).each { | c |
	    if c == ' '
		answer << ' '
		next
	    end

	    msg_num = Card.chr_to_value(c)
	    key = deck.next_keystream
	    answer << Card.value_to_chr(yield key, msg_num)
	}
	answer
    end

end

# Prepare input argument, translating it from an arbitrary string into
# blocks of five characters, all uppercase.
def prep_arg(str)
    str = str.upcase.gsub(/[^A-Z]/, '')
    words = []
    while str.length > 0
	words << str[0...5]
	str[0...5] = ''
    end

    last_len = words[words.length-1].length
    words[words.length-1] += ('X' * (5 - last_len)) if last_len < 5
    words.join(' ')
end

if __FILE__ == $0
    if ARGV[0]
	puts CryptKeeper.new(Deck.new.key).decrypt(prep_arg(ARGV[0]))
    else
	puts CryptKeeper.new(Deck.new.key).decrypt('CLEPK HHNIY CFPWH FDFEH')
	puts CryptKeeper.new(Deck.new.key).decrypt('ABVAW LWZSY OORYK DUPVH')
    end
end

Unit Test

#! /usr/bin/env ruby

require 'test/unit.rb'
require 'test/unit/ui/console/testrunner'
require 'solitaire_cypher.rb'

class SolitaireCypherTest < Test::Unit::TestCase

    KNOWN_PLAINTEXT = 'CODEI NRUBY LIVEL ONGER'
    KNOWN_CYPHER = 'GLNCQ MJAFF FVOMB JIYCB'

    def setup
	@deck = Deck.new.key
	@crypt_keeper = CryptKeeper.new(@deck)
    end

    def test_value_to_chr
	assert_equal('A', Card.value_to_chr(1))
	assert_equal('Z', Card.value_to_chr(26))
    end

    def test_chr_to_value
	assert_equal(1, Card.chr_to_value("A"))
	assert_equal(26, Card.chr_to_value("Z"))
    end

    def test_keystream
	expected = %w(D W J X H Y R F D G)
	deck = Deck.new.key
	expected.each { | exp |
	    key = deck.next_keystream
	    assert_equal(exp, Card.value_to_chr(key))
	}
    end

    def test_decrypt_known_cypher
	assert_equal(KNOWN_PLAINTEXT, @crypt_keeper.decrypt(KNOWN_CYPHER))
    end

    def test_encrypt_known_message
	assert_equal(KNOWN_CYPHER, @crypt_keeper.encrypt(KNOWN_PLAINTEXT))
    end
end

Test::Unit::UI::Console::TestRunner.run(SolitaireCypherTest)