-
-
Save jamesmartin/829a163a5135eec612cb to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/usr/bin/env ruby | |
# Run 'rake spec' for examples. | |
class GameDataParser | |
attr_reader :rooms | |
def initialize | |
@rooms = [] | |
@objects = [] | |
@synonyms = {} | |
end | |
def parse_rooms(data) | |
data.each do |line| | |
if line.match(/^Room\s+(@\w+):$/) | |
room = Hash.new | |
room["name"] = $1 | |
@rooms << room | |
@parse_multi_lines = false | |
end | |
if line.match(/^\s+Title:(.*)$/) | |
last_room = @rooms.pop | |
last_room["title"] = $1.lstrip | |
@rooms << last_room | |
@parse_multi_lines = false | |
end | |
if line.match(/\s+Description:$/) | |
@parse_multi_lines = true | |
@ml_token = "description" | |
next | |
end | |
if line.match(/\s+Exits:$/) | |
@parse_multi_lines = true | |
@ml_token = "exits" | |
next | |
end | |
if line.match(/\s+Objects:$/) | |
@parse_multi_lines = true | |
@ml_token = "objects" | |
end | |
if @parse_multi_lines | |
case @ml_token | |
when "description" | |
last_room = @rooms.pop | |
if last_room[@ml_token] | |
last_room[@ml_token] = last_room[@ml_token] + " " + line.strip | |
else | |
last_room[@ml_token] = line.strip | |
end | |
@rooms << last_room | |
when "exits" | |
if line.match(/\s+(\w+)\s+to\s+(@\w+)$/) | |
last_room = @rooms.pop | |
if last_room[@ml_token] | |
exit_hash = last_room[@ml_token] | |
exit_hash[$1] = $2 | |
else | |
exit_hash = {$1 => $2} | |
last_room[@ml_token] = exit_hash | |
end | |
@rooms << last_room | |
end | |
when "objects" | |
if line.match(/\s+(\$\w+)$/) | |
last_room = @rooms.pop | |
if last_room[@ml_token] | |
last_room[@ml_token] << $1 | |
else | |
objects_in_room = [$1] | |
last_room[@ml_token] = objects_in_room | |
end | |
@rooms << last_room | |
end | |
end | |
end | |
end | |
@rooms | |
end | |
def parse_synonyms(data) | |
@parse_multi_lines = false | |
data.each do |line| | |
if line.match(/^Synonyms:$/) | |
@parse_multi_lines = true | |
end | |
if @parse_multi_lines | |
if line.match(/^\s+(\w+):\s+(\w+)$/) | |
@synonyms[$2] = $1 | |
end | |
if line.match(/^\s+(\w+):\s+(\w+,\s+\w+).*$/) | |
action = $1 | |
synonyms = $2.split ", " | |
synonyms.each do |synonym| | |
synonym.strip! | |
@synonyms[synonym] = action | |
end | |
end | |
end | |
end | |
@synonyms | |
end | |
def parse_objects(data) | |
data.each do |line| | |
if line.match(/^Object\s+(\$\w+):$/) | |
object = Hash.new | |
object["name"] = $1 | |
@objects << object | |
end | |
if line.match(/\s+Terms:\s+(\w+.*)$/) | |
object = @objects.pop | |
terms = $1.split(", ") | |
unless object.has_key?("terms") | |
object["terms"] = terms | |
end | |
@objects << object | |
end | |
if line.match(/\s+Description:\s+(\w+.*)$/) | |
object = @objects.pop | |
object["description"] = $1 | |
@objects << object | |
end | |
end | |
@objects | |
end | |
end | |
class GameWorld | |
def initialize(game_data, options={}) | |
@game_data = game_data | |
@game_data_parser = options.fetch(:data_parser) { GameDataParser.new() } | |
@rooms = @game_data_parser.parse_rooms(@game_data) | |
@synonyms = @game_data_parser.parse_synonyms(@game_data) | |
@objects = @game_data_parser.parse_objects(@game_data) | |
@inventory = [] | |
if @rooms | |
@first_room = @rooms[0] | |
@current_room = @rooms[0] | |
end | |
end | |
def get_current_room_description | |
result = @current_room["description"] | |
if current_room_contains_any_objects? | |
result += get_description_of_objects_in_current_room | |
end | |
return result | |
end | |
def get_description_of_objects_in_current_room | |
result = "" | |
@current_room["objects"].each do |room_object| | |
@objects.each do |object| | |
if room_object == object["name"] | |
result += "\n\n" + object["description"] | |
end | |
end | |
end | |
return result | |
end | |
def get_current_room_title | |
@current_room["title"] | |
end | |
def current_room_contains_any_objects? | |
@current_room.has_key?("objects") | |
end | |
def current_room_has_an_exit_named?(this_exit) | |
@current_room["exits"].has_key?(this_exit) | |
end | |
def current_room_exit_goes_to(exit_name) | |
@current_room["exits"][exit_name] | |
end | |
def move_to_room_named(room_name) | |
@rooms.each do |room| | |
if room["name"] == room_name | |
@current_room = room | |
end | |
end | |
end | |
def currently_at_start? | |
@current_room["name"] == @first_room["name"] | |
end | |
def resolve_synonymous_actions(action) | |
if action.match(/^(\w+)(.*$)/) | |
verb = $1 | |
noun = $2 | |
end | |
if @synonyms.has_key?(verb) | |
return @synonyms[verb] + noun | |
end | |
action | |
end | |
def invalid_action | |
"There is no way to go in that direction" | |
end | |
def inventory_empty_message | |
"You're not carrying anything" | |
end | |
def item_exists_in_current_room?(item) | |
@current_room["objects"].each do |object| | |
return true if object == item | |
end | |
false | |
end | |
def get_object_description_by_name(object_name) | |
@objects.each do |object| | |
if object["name"] == object_name | |
return object["description"] | |
end | |
end | |
end | |
def get_object_terms_by_name(object_name) | |
@objects.each do |object| | |
if object["name"] == object_name | |
return object["terms"][0] | |
end | |
end | |
end | |
def inventory_contents | |
description = "" | |
items = [] | |
@inventory.each do |item| | |
items << get_object_terms_by_name(item) | |
end | |
if items.length > 0 | |
description = items.join(", ") | |
end | |
description | |
end | |
def add_item_to_inventory(item) | |
if item_exists_in_current_room?(item) | |
@inventory << item | |
remove_object_from_current_room(item) | |
end | |
end | |
def add_object_to_current_room(object_name) | |
if @current_room.has_key?("objects") | |
@current_room["objects"] << object_name | |
else | |
objects = [object_name] | |
@current_room["objects"] = objects | |
end | |
end | |
def remove_object_from_current_room(object_name) | |
@current_room["objects"].delete(object_name) | |
end | |
def drop_item_from_inventory(item) | |
@inventory.delete(item) | |
add_object_to_current_room(item) | |
end | |
def resolve_syonymous_objects(item) | |
@objects.each do |object| | |
object["terms"].each do |term| | |
if term =~ /#{item}/i | |
return object["name"] | |
end | |
end | |
end | |
return "$" + item | |
end | |
def process_action(action) | |
action = resolve_synonymous_actions(action) | |
if current_room_has_an_exit_named?(action) | |
destination_room_name = current_room_exit_goes_to(action) | |
move_to_room_named(destination_room_name) | |
if currently_at_start? | |
return "You're " + get_current_room_title + "." | |
end | |
return get_current_room_description | |
else | |
case action | |
when "look" | |
get_current_room_description | |
when "inventory" | |
if @inventory.empty? | |
inventory_empty_message | |
else | |
inventory_contents | |
end | |
when /^take\s+(\w+.*)$/ | |
item_to_get = resolve_syonymous_objects($1) | |
if add_item_to_inventory(item_to_get) | |
"OK" | |
end | |
when /^drop\s+(\w+)$/ | |
item_to_drop = "$" + $1 | |
drop_item_from_inventory(item_to_drop) | |
when "quit" | |
"Thanks for playing!" | |
else | |
invalid_action | |
end | |
end | |
end | |
end | |
class Game | |
PROMPT = "\n" | |
def initialize(story_path, options={}) | |
@input = options.fetch(:input) { $stdin } | |
@output = options.fetch(:output) { $stdout } | |
@data_reader = options.fetch(:data_reader) { nil } | |
game_data = get_game_data(story_path) | |
@world = options.fetch(:game_world) { GameWorld.new(game_data) } | |
end | |
def get_game_data(story_path) | |
if @data_reader != nil | |
@data_reader.get_data(story_path) | |
else | |
data = File.open(story_path) | |
data.readlines | |
end | |
end | |
def play! | |
start! | |
execute_one_command! until ended? | |
end | |
def start! | |
@output << @world.get_current_room_description | |
@output << PROMPT | |
end | |
def execute_one_command! | |
@action = @input.readline.rstrip | |
@output << @world.process_action(@action) | |
@output << PROMPT | |
end | |
def ended? | |
@action == "quit" | |
end | |
end | |
if $PROGRAM_NAME == __FILE__ | |
story_path = ARGV[0] | |
unless story_path | |
warn "Usage: #{$PROGRAM_NAME} STORY_FILE" | |
exit 1 | |
end | |
game = Game.new(story_path) | |
game.play! | |
end |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
require 'spec_helper' | |
describe Game do | |
before(:each) do | |
@story_path = "/irrelevant/path/to/file.if" | |
@input = stub('$stdin') | |
end | |
describe "#initialize" do | |
it "delegates data reading responsibility" do | |
output = stub('$stdout').as_null_object | |
data_reader = stub('data_reader') | |
data_reader.should_receive(:get_data).with(@story_path).and_return( | |
['Irrelevant']) | |
game_world = stub('GameWorld').as_null_object | |
game = Game.new(@story_path, | |
:input => @input, | |
:output => output, | |
:data_reader => data_reader, | |
:game_world => game_world) | |
end | |
it "raises an exception if the data file is unavailable" | |
# Tricky to test this because the supplied Cucumber features require | |
# that a file on disk be opened by the Game object, leaving little room | |
# to inject the dependency. | |
end | |
describe "#start" do | |
it "asks the game world for the current room" do | |
output = stub('$stdout').as_null_object | |
game_world = stub('GameWorld') | |
data_reader = stub('DataReader').as_null_object | |
game = Game.new(@story_path, | |
:input => @input, | |
:output => output, | |
:data_reader => data_reader, | |
:game_world => game_world) | |
game_world.should_receive(:get_current_room_description) | |
game.start! | |
end | |
it "prints out the description of the first room" do | |
output = stub('$stdout') | |
game_world = stub('GameWorld') | |
data_reader = stub('DataReader').as_null_object | |
game = Game.new(@story_path, | |
:input => @input, | |
:output => output, | |
:data_reader => data_reader, | |
:game_world => game_world) | |
game_world.should_receive(:get_current_room_description).and_return("Some description") | |
output.should_receive(:<<).with("Some description") | |
output.should_receive(:<<).with("\n") | |
game.start! | |
end | |
end | |
describe "#execute_one_command!" do | |
it "prompts the user for input" do | |
# Would be nice to have a prompt indicator for the user (e.g. '>') | |
# but this breaks one of the cucumber features | |
output = stub('$stdout') | |
game_world = stub('GameWorld').as_null_object | |
data_reader = stub('DataReader').as_null_object | |
game = Game.new(@story_path, | |
:input => @input, | |
:output => output, | |
:data_reader => data_reader, | |
:game_world => game_world) | |
@input.should_receive(:readline).and_return('irrelevant') | |
game_world.should_receive(:process_action).and_return("Some description") | |
output.should_receive(:<<).with("Some description") | |
output.should_receive(:<<).with("\n") | |
game.execute_one_command! | |
end | |
it "asks the input for the current action" do | |
output = stub('$stdout').as_null_object | |
game_world = stub('GameWorld').as_null_object | |
data_reader = stub('DataReader').as_null_object | |
game = Game.new(@story_path, | |
:input => @input, | |
:output => output, | |
:data_reader => data_reader, | |
:game_world => game_world) | |
@input.should_receive(:readline).and_return('irrelevant') | |
game.execute_one_command! | |
end | |
it "asks the GameWorld to process the current action" do | |
output = stub('$stdout').as_null_object | |
data_reader = stub('DataReader').as_null_object | |
game_world = stub('GameWorld') | |
game = Game.new(@story_path, | |
:input => @input, | |
:output => output, | |
:data_reader => data_reader, | |
:game_world => game_world) | |
@input.should_receive(:readline).and_return('east') | |
game_world.should_receive(:process_action).with('east') | |
game.execute_one_command! | |
end | |
end | |
describe "#ended?" do | |
it "signals the end of the game if the user enters the quit command" do | |
output = stub('$stdout').as_null_object | |
data_reader = stub('DataReader').as_null_object | |
game_world = stub('GameWorld') | |
game = Game.new(@story_path, | |
:input => @input, | |
:output => output, | |
:data_reader => data_reader, | |
:game_world => game_world) | |
@input.should_receive(:readline).and_return('quit') | |
game_world.should_receive(:process_action) | |
game.execute_one_command! | |
game.ended?.should == true | |
end | |
end | |
end | |
describe GameWorld do | |
describe "#initialize" do | |
before(:each) do | |
@game_data = ["Irrelevant\n", "data\n"] | |
end | |
it "delegates data parsing responsibility" do | |
data_parser = stub('GameDataParser') | |
data_parser.should_receive(:parse_rooms).with(@game_data) | |
data_parser.should_receive(:parse_synonyms).with(@game_data) | |
data_parser.should_receive(:parse_objects).with(@game_data) | |
world = GameWorld.new(@game_data, :data_parser => data_parser) | |
end | |
end | |
describe "#get_current_room_description" do | |
it "returns the current room description" do | |
data_parser = stub('GameDataParser') | |
data_parser.should_receive(:parse_synonyms) | |
data_parser.should_receive(:parse_objects) | |
data_parser.should_receive(:parse_rooms).and_return( | |
[{"name" => "@my_name", | |
"description" => "Some description"}]) | |
world = GameWorld.new(@game_data, :data_parser => data_parser) | |
world.get_current_room_description.should == ("Some description") | |
end | |
end | |
describe "#process_action" do | |
before(:each) do | |
@data_parser = stub('GameDataParser') | |
first_room = {"name" => "@first_room", | |
"title" => "at the First room", | |
"description" => "First description", | |
"exits" => {"east" => "@second_room"}, | |
"objects" => ["$lamp"]} | |
second_room = {"name" => "@second_room", | |
"title" => "at the Second room", | |
"description" => "Second description", | |
"exits" => {"west" => "@first_room", "south" => "@third_room"}} | |
third_room = {"name" => "@third_room", | |
"title" => "at the Third room", | |
"description" => "Third description", | |
"exits" => {"west" => "@second_room", "north" => "@second_room"}, | |
"objects" => ["$keys"]} | |
synonyms = {"e" => "east", | |
"w" => "west", | |
"get" => "take", | |
"l" => "look"} | |
keys = {"name" => "$keys", | |
"description" => "There are some keys here", | |
"terms" => ["keys"]} | |
lamp = {"name" => "$lamp", | |
"description" => "There is a brass lamp here", | |
"terms" => ["Brass lantern", "lantern"]} | |
objects = [keys, lamp] | |
rooms = [first_room, second_room, third_room] | |
@data_parser.should_receive(:parse_synonyms).and_return(synonyms) | |
@data_parser.should_receive(:parse_rooms).and_return(rooms) | |
@data_parser.should_receive(:parse_objects).and_return(objects) | |
@world = GameWorld.new(@game_data, :data_parser => @data_parser) | |
end | |
describe "movement actions" do | |
it "returns the description of the location for a valid action" do | |
@world.process_action("east").should == ("Second description") | |
end | |
it "returns a helpful message if a room is revisited" do | |
@world.process_action("east") | |
@world.process_action("west").should == ("You're at the First room.") | |
end | |
it "displays an error message if a given exit does not exist" do | |
invalid_room_message = "There is no way to go in that direction" | |
@world.process_action("invalid").should == (invalid_room_message) | |
end | |
end | |
describe "synonyms for actions" do | |
it "translates a valid synonym into the related action" do | |
@world.process_action("e").should == ("Second description") | |
end | |
end | |
describe "looking around" do | |
it "displays the current room's description" do | |
@world.process_action("east") | |
@world.process_action("look").should == ("Second description") | |
end | |
end | |
describe "describing rooms containing objects" do | |
it "describes a single object inside a room" do | |
@world.process_action("east").should == ("Second description") | |
@world.process_action("south").should == | |
("Third description\n\nThere are some keys here") | |
end | |
end | |
describe "inventory actions" do | |
it "informs the user if nothing is in the inventory" do | |
@world.process_action("inventory").should == | |
("You're not carrying anything") | |
end | |
it "confirms that the player picked up an item that exists in a room" do | |
@world.process_action("east") | |
@world.process_action("south") | |
@world.process_action("take keys").should == ("OK") | |
@world.process_action("inventory").should == ("keys") | |
end | |
it "confirms that a picked up item has been removed from the room" do | |
@world.process_action("east") | |
@world.process_action("south") | |
@world.process_action("take keys") | |
@world.process_action("inventory") | |
@world.process_action("look").should_not =~ /keys/ | |
end | |
it "displays the contents of the inventory" do | |
@world.process_action("east") | |
@world.process_action("south") | |
@world.process_action("take keys") | |
@world.process_action("inventory").should == ("keys") | |
end | |
it "displays multiple items in the inventory on new lines" do | |
@world.process_action("take lantern") | |
@world.process_action("east") | |
@world.process_action("south") | |
@world.process_action("take keys") | |
@world.process_action("inventory").should == ("Brass lantern, keys") | |
end | |
it "allows items to be dropped from the inventory" do | |
@world.process_action("east") | |
@world.process_action("south") | |
@world.process_action("take keys") | |
@world.process_action("drop keys") | |
@world.process_action("inventory").should_not == ("keys") | |
end | |
it "puts dropped items into the current room" do | |
@world.process_action("east") | |
@world.process_action("south") | |
@world.process_action("take keys") | |
@world.process_action("drop keys") | |
@world.process_action("look").should =~ /keys/ | |
end | |
it "puts dropped items into rooms that never contained objects" do | |
@world.process_action("east") | |
@world.process_action("south") | |
@world.process_action("take keys") | |
@world.process_action("north") | |
@world.process_action("drop keys") | |
@world.process_action("look").should =~ /keys/ | |
end | |
it "allows syonymous terms to be used for getting objects" do | |
@world.process_action("get lantern").should == "OK" | |
end | |
it "handles multiple words as synoymous terms for getting objects" do | |
@world.process_action("get brass lantern").should == "OK" | |
end | |
end | |
end | |
end | |
describe GameDataParser do | |
before(:each) do | |
@parser = GameDataParser.new | |
end | |
describe "#parse_rooms" do | |
it "matches a single room with no contents" do | |
data = ["Room @my_name:\n"] | |
@parser.parse_rooms(data).should == ([{"name" => "@my_name"}]) | |
end | |
it "matches a single room with a title" do | |
data = ["Room @my_name:\n", " Title: Some title\n"] | |
@parser.parse_rooms(data).should == ([{"name" => "@my_name", "title" => "Some title"}]) | |
end | |
it "matches a single room with title and description" do | |
data = ["Room @my_name:\n", | |
" Title: Some title\n", | |
" Description:\n"] | |
data << " Simple description\n" | |
data << " With two lines\n" | |
expected_description = "Simple description With two lines" | |
@parser.parse_rooms(data).should == ([{ | |
"name" => "@my_name", | |
"title" => "Some title", | |
"description" => expected_description}]) | |
end | |
it "matches a single room with exits" do | |
data = ["Room @my_name:\n", | |
" Title: Some title\n", | |
" Description:\n", | |
" Some description\n", | |
" Exits:\n", | |
" east to @some_room\n", | |
" west to @another_room\n"] | |
expected = [{"name" => "@my_name", | |
"title" => "Some title", | |
"description" => "Some description", | |
"exits" => {"east" => "@some_room", "west" => "@another_room"}}] | |
@parser.parse_rooms(data).should == expected | |
end | |
it "parses object data from rooms" do | |
data = ["Room @my_name:\n", | |
" Title: Some title\n", | |
" Description:\n", | |
" Some description\n", | |
" Exits:\n", | |
" east to @some_room\n", | |
" west to @another_room\n", | |
" Objects:\n", | |
" $keys\n", | |
" $lamp\n"] | |
expected = [{"name" => "@my_name", | |
"title" => "Some title", | |
"description" => "Some description", | |
"exits" => {"east" => "@some_room", "west" => "@another_room"}, | |
"objects" => ["$keys", "$lamp"]}] | |
@parser.parse_rooms(data).should == expected | |
end | |
end | |
describe "#parse_synonyms" do | |
it "matches a synonym definition section" do | |
data = ["Synonyms:\n", | |
" north: n\n"] | |
expected = {"n" => "north"} | |
@parser.parse_synonyms(data).should == expected | |
end | |
it "matches multiple synoyms for a single action" do | |
data = ["Synonyms:\n", | |
" look: l, examine \n"] | |
expected = {"l" => "look", "examine" => "look"} | |
@parser.parse_synonyms(data).should == expected | |
end | |
end | |
describe "#parse_objects" do | |
it "matches objects" do | |
data = ["Object $some_keys:\n", | |
" Terms: Set of keys, keys\n", | |
" Description: Some description\n"] | |
expected = [{"name" => "$some_keys", | |
"terms" => ["Set of keys", "keys"], | |
"description" => "Some description"}] | |
@parser.parse_objects(data).should == expected | |
end | |
it "handles Description lines that are unrelated" do | |
data = [" Description:\n", | |
"Object $some_keys:\n", | |
" Terms: Set of keys, keys\n", | |
" Description: Some description\n"] | |
expected = [{"name" => "$some_keys", | |
"terms" => ["Set of keys", "keys"], | |
"description" => "Some description"}] | |
@parser.parse_objects(data).should == expected | |
end | |
end | |
end |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
require 'rubygems' | |
require 'cucumber' | |
require 'cucumber/rake/task' | |
require 'spec/rake/spectask' | |
desc "run all specs" | |
Spec::Rake::SpecTask.new('spec') do |t| | |
t.spec_files = FileList['spec/*.rb'] | |
t.spec_opts = ['--format', 'nested', '--color'] | |
end | |
desc "run all specs with RCov" | |
Spec::Rake::SpecTask.new('spec_with_rcov') do |t| | |
t.spec_files = FileList['spec/*.rb'] | |
t.spec_opts = ['--format', 'nested', '--color'] | |
t.rcov = true | |
t.rcov_opts = ['--exclude', 'spec'] | |
end | |
namespace :cucumber do | |
Cucumber::Rake::Task.new(:default) do |t| | |
t.cucumber_opts = "features --format pretty" | |
t.profile = "default" | |
end | |
Cucumber::Rake::Task.new(:extra_credit) do |t| | |
t.cucumber_opts = "features --format pretty" | |
t.profile = "extra_credit" | |
end | |
end | |
desc "Basic challenge acceptance tests" | |
task :default => ['cucumber:default'] | |
desc "Extra credit tests" | |
task :extra_credit => ['cucumber:extra_credit'] | |
# Make cucumber.el happy | |
task :cucumber => ['cucumber:extra_credit'] |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
This submission is passing the basic cucumber features in 'petite_cave.feature', under Ruby version 1.8.7.
Put 'play_spec.rb' in <project_root>/spec/
Use the modified Rakefile to run the RSpec examples with 'rake spec', or 'rake spec_with_rcov'.
This solution was driven by following TDD as closely as possible. Refactoring has been somewhat neglected, however, every line of production code was written in response to a failing test, which means the code coverage should be fairly high.