3
\$\begingroup\$

In the game of Narcotic Solitaire, played with a standard 52-card deck, the player deals a row of four cards onto the table, from left to right. If all four cards are of the same value, they are removed from the game. If two or three cards are of the same value, they are moved on top of the leftmost card of that value. Once the whole deck has been dealt, the piles are gathered from right to left, and the game continues until all the cards have been removed.

As there are no elements of choice in this game, I wrote a Ruby program to simulate many games and determine how long it will take to complete. The game cannot always be won: sometimes it will enter an infinite loop, a fact I discovered only through writing the program!

SECONDS_IN_MINUTE = 60
MINUTES_IN_HOUR = 60
PERCENT = 100
CARDS_PER_DEAL = 4
CARDS_IN_DECK = 52
NUM_SUITS = 4

# Given an array of four numbers, returns an array of arrays with all identical
# numbers shifted to the leftmost matching number.
# e.g. [4, 4, 3, 1] returns [[4, 4], [], [3], [1]], and
# [1, 4, 4, 4] returns [[1], [4, 4, 4], [], []].
# If all the values are the same, returns an array of four empty arrays,
# equivalent to removing the cards from the game.
def proper_piles(cards)
    piles = Array.new(CARDS_PER_DEAL){[]}
    # Return an array of four empty arrays if the cards all match.
    return piles if cards.all?{|card| cards[0] == card}
    # Enclose each card in the array in its own array
    piles = cards.map{|card| [card]}
    # Check every card for matches with subsequent cards, and move it in the
    # event of a match
    piles.each_index do |i|
        (i..piles.size - 1).each do |j|
            if piles[i][0] == piles[j][0]
                moved_card = piles[j].delete_at(0)
                # If we compare two empty arrays, moved_card will be nil,
                # which is why we have the if clause below.
                piles[i] << moved_card if !moved_card.nil?
            end
        end
    end
    piles
end


def narcotic_iterations(deck)
    seconds_to_place_card = 1
    seconds_to_gather_deck = 5
    time = 0
    did_loop = false
    past_decks = []
    # Outer loop executes until all the cards have been taken away
    until deck.empty?
        piles = Array.new(CARDS_PER_DEAL){Array.new}
        # Inner loop goes through one deck
        until deck.empty?
            # Get the proper arrangement of the cards dealt from the deck
            deal = proper_piles(Array.new(CARDS_PER_DEAL){deck.pop})
            deal.each_index do |i|
                # += is used rather than indexing and << because array + []
                # has no effect, exactly what we want.
                piles[i] += deal[i]
            end
            time += CARDS_PER_DEAL * seconds_to_place_card
        end 

        # Finally, merge all the piles into a single deck.
        # The deck is not reversed because it is face up through
        # the whole game, and piles are gathered from right to left.
        deck = piles.flatten
        # Detect an infinite loop
        if past_decks.include?(deck)
            did_loop = true
            break
        end
        past_decks << deck.clone
        time += seconds_to_gather_deck
    end
    return time, did_loop
end

def narcotic_benchmark(num_trials)
    # Suits are not necessary for the game, only card values
    deck = Array.new(CARDS_IN_DECK){|i| i%(CARDS_IN_DECK / NUM_SUITS) + 1}
    total_time = 0
    num_loops = 0
    num_trials.times do |i|
        iteration_time, did_loop = narcotic_iterations(deck.shuffle)
        if did_loop
            num_loops += 1
        else
            total_time += iteration_time
        end
    end
    average_time = (total_time.to_f / num_trials)
    loop_percent = (num_loops.to_f / num_trials) * PERCENT
    completion_time_in_hours =
            (average_time / (SECONDS_IN_MINUTE * MINUTES_IN_HOUR)).round
    completion_time_in_minutes =
            (average_time / MINUTES_IN_HOUR).round % MINUTES_IN_HOUR
    puts "Average estimated completion time is %d hours %d minutes" % [
            completion_time_in_hours, completion_time_in_minutes]
    puts "%.2f percent of decks can be completed" % [PERCENT - loop_percent]
end

if ARGV[0].nil?
    puts "Please provide the number of times to run the simulation."
else
    narcotic_benchmark(ARGV[0].to_i)
end

Sample output:

$ ruby narcotic.rb 50
Average estimated completion time is 2 hours 14 minutes
34.00 percent of decks can be completed
$ ruby narcotic.rb 500
Average estimated completion time is 3 hours 37 minutes
48.60 percent of decks can be completed
\$\endgroup\$
  • \$\begingroup\$ Are you asking about fixing said infinite loop? \$\endgroup\$ – Jamal Jan 16 '15 at 3:03
  • \$\begingroup\$ @Jamal No, not at all. I have that handled in the program, as can be seen. \$\endgroup\$ – EMBLEM Jan 16 '15 at 3:08
1
\$\begingroup\$

Looks really good! I would change this:

    if did_loop
        num_loops += 1
    else
        total_time += iteration_time
    end

to this:

    did_loop ? num_loops += 1 : total_time += iteration_time

You very rarely see these "short"

if..
  one line..
else
  one line..
end 

constructions in ruby.

piles[i] << moved_card if !moved_card.nil?

Don't use if !(condition) for simple conditions, use unless. But in this particular case you are just checking against nil, which is already 'falsey'. Just do this:

piles[i] << moved_card if moved_card

or if you prefer this syntax,

moved_card && piles[i] << moved_card

(but the first is more readable)

\$\endgroup\$
  • \$\begingroup\$ I made the second fix, but for the first: if I don't include a space before the question mark, I get syntax error, unexpected ':', expecting end-of-input, and if I do, I also get syntax error, unexpected tSTRING_BEG, expecting keyword_do or '{' or '(' \$\endgroup\$ – EMBLEM Jan 21 '15 at 4:49

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.