├── .rspec ├── spec ├── goby │ ├── bach.mid │ ├── driver_spec.rb │ ├── music_spec.rb │ ├── battle │ │ ├── escape_spec.rb │ │ ├── battle_command_spec.rb │ │ ├── use_spec.rb │ │ ├── attack_spec.rb │ │ └── battle_spec.rb │ ├── scaffold_spec.rb │ ├── event │ │ ├── event_spec.rb │ │ ├── npc_spec.rb │ │ ├── chest_spec.rb │ │ └── shop_spec.rb │ ├── item │ │ ├── legs_spec.rb │ │ ├── torso_spec.rb │ │ ├── helmet_spec.rb │ │ ├── shield_spec.rb │ │ ├── food_spec.rb │ │ ├── equippable_spec.rb │ │ ├── item_spec.rb │ │ └── weapon_spec.rb │ ├── map │ │ ├── map_spec.rb │ │ └── tile_spec.rb │ ├── extension_spec.rb │ ├── util_spec.rb │ ├── entity │ │ ├── monster_spec.rb │ │ ├── fighter_spec.rb │ │ └── player_spec.rb │ └── world_command_spec.rb └── spec_helper.rb ├── exe └── goby ├── bin ├── setup ├── console ├── bundler ├── goby ├── rake ├── thor ├── ldiff ├── rspec ├── htmldiff ├── coveralls ├── term_cdiff ├── term_mandel ├── term_colortab ├── term_decolor ├── term_display └── bundle ├── script └── reinstall.sh ├── .travis.yml ├── Gemfile ├── res └── scaffold │ ├── simple │ ├── farm.rb │ └── main.rb │ └── gitignore ├── lib ├── goby │ ├── item │ │ ├── legs.rb │ │ ├── helmet.rb │ │ ├── shield.rb │ │ ├── torso.rb │ │ ├── item.rb │ │ ├── food.rb │ │ ├── weapon.rb │ │ └── equippable.rb │ ├── battle │ │ ├── escape.rb │ │ ├── use.rb │ │ ├── battle_command.rb │ │ ├── battle.rb │ │ └── attack.rb │ ├── event │ │ ├── chest.rb │ │ ├── npc.rb │ │ ├── event.rb │ │ └── shop.rb │ ├── scaffold.rb │ ├── map │ │ ├── map.rb │ │ └── tile.rb │ ├── driver.rb │ ├── extension.rb │ ├── music.rb │ ├── entity │ │ ├── monster.rb │ │ ├── fighter.rb │ │ ├── entity.rb │ │ └── player.rb │ ├── util.rb │ └── world_command.rb └── goby.rb ├── goby.gemspec ├── LICENSE ├── .gitignore ├── README.md └── CONTRIBUTING.md /.rspec: -------------------------------------------------------------------------------- 1 | --color 2 | --require spec_helper 3 | -------------------------------------------------------------------------------- /spec/goby/bach.mid: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nskins/goby/HEAD/spec/goby/bach.mid -------------------------------------------------------------------------------- /exe/goby: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require 'goby' 4 | Goby::Scaffold::simple "goby-project" -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | set -vx 5 | 6 | bundle install 7 | 8 | # Do any other automated setup that you need to do here 9 | -------------------------------------------------------------------------------- /script/reinstall.sh: -------------------------------------------------------------------------------- 1 | # This series of commands is called 2 | # often during development in order 3 | # to install the (local) source code. 4 | gem uninstall goby 5 | gem build goby 6 | gem install goby -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | language: ruby 3 | rvm: 4 | - 2.5.0 5 | before_install: gem install bundler -v 1.16.1 6 | 7 | notifications: 8 | email: false 9 | 10 | script: "bundle exec rspec" -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | gemspec 3 | 4 | # Test coverage. 5 | gem 'coveralls', require: false 6 | 7 | # Specify your gem's dependencies in goby.gemspec 8 | gem 'rspec-mocks' 9 | 10 | # Required for Travis CI. 11 | group :test do 12 | gem 'rake' 13 | end 14 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require "bundler/setup" 4 | require "goby" 5 | 6 | # You can add fixtures and/or initialization code here to make experimenting 7 | # with your gem easier. You can also use a different console, if you like. 8 | 9 | # (If you use this, don't forget to add pry to your Gemfile!) 10 | # require "pry" 11 | # Pry.start 12 | 13 | require "irb" 14 | IRB.start(__FILE__) 15 | -------------------------------------------------------------------------------- /bin/bundler: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | # 4 | # This file was generated by Bundler. 5 | # 6 | # The application 'bundler' is installed as part of a gem, and 7 | # this file is here to facilitate running it. 8 | # 9 | 10 | require "pathname" 11 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../Gemfile", 12 | Pathname.new(__FILE__).realpath) 13 | 14 | require "rubygems" 15 | require "bundler/setup" 16 | 17 | load Gem.bin_path("bundler", "bundler") 18 | -------------------------------------------------------------------------------- /spec/goby/driver_spec.rb: -------------------------------------------------------------------------------- 1 | require 'goby' 2 | 3 | RSpec.describe do 4 | 5 | let(:player) { Player.new } 6 | 7 | # Success for each of these tests mean 8 | # that run_driver exits without error. 9 | context "run driver" do 10 | it "should exit on 'quit'" do 11 | __stdin("quit\n") { run_driver(player) } 12 | end 13 | 14 | it "should accept various commands in a looping fashion" do 15 | __stdin("w\ne\ns\nn\nquit\n") { run_driver(player) } 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /res/scaffold/simple/farm.rb: -------------------------------------------------------------------------------- 1 | # This is an example of how to create a Map. You can 2 | # define the name, where to respawn, and the 2D display of 3 | # the Map - each point is referred to as a Tile. 4 | class Farm < Map 5 | def initialize 6 | super(name: "Farm") 7 | 8 | # Define the main tiles on this map. 9 | grass = Tile.new(description: "You are standing on some grass.") 10 | 11 | # Fill the map with "grass." 12 | @tiles = Array.new(9) { Array.new(5) { grass.clone } } 13 | 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /spec/goby/music_spec.rb: -------------------------------------------------------------------------------- 1 | require 'goby' 2 | 3 | RSpec.describe Music do 4 | 5 | include Music 6 | 7 | before(:all) do 8 | _relativePATH = File.expand_path File.dirname(__FILE__) 9 | @music = _relativePATH + "/bach.mid" 10 | end 11 | 12 | # If you do not have BGM support and would like to run the 13 | # test suite locally, replace 'it' with 'xit'. 14 | it "should play and stop the music" do 15 | set_playback(true) 16 | set_program("timidity") 17 | play_music(@music) 18 | sleep(0.01) 19 | stop_music 20 | end 21 | 22 | end -------------------------------------------------------------------------------- /spec/goby/battle/escape_spec.rb: -------------------------------------------------------------------------------- 1 | require 'goby' 2 | 3 | RSpec.describe Goby::Escape do 4 | 5 | let(:player) { Player.new } 6 | let(:monster) { Monster.new } 7 | let(:escape) { Escape.new } 8 | 9 | context "constructor" do 10 | it "has an appropriate default name" do 11 | expect(escape.name).to eq "Escape" 12 | end 13 | end 14 | 15 | context "run" do 16 | # The purpose of this test is to run the code without error. 17 | it "should return a usable result" do 18 | # Exercise both branches of this function w/ high probability. 19 | 20.times do 20 | escape.run(player, monster) 21 | expect(player.escaped).to_not be nil 22 | end 23 | end 24 | end 25 | 26 | end 27 | -------------------------------------------------------------------------------- /lib/goby/item/legs.rb: -------------------------------------------------------------------------------- 1 | require 'goby' 2 | 3 | module Goby 4 | 5 | # Can be worn in the Player's outfit. 6 | class Legs < Item 7 | include Equippable 8 | 9 | # @param [String] name the name. 10 | # @param [Integer] price the cost in a shop. 11 | # @param [Boolean] consumable upon use, the item is lost when true. 12 | # @param [Boolean] disposable allowed to sell or drop item when true. 13 | # @param [Hash] stat_change the change in stats for when the item is equipped. 14 | def initialize(name: "Legs", price: 0, consumable: false, disposable: true, stat_change: {}) 15 | super(name: name, price: price, consumable: consumable, disposable: disposable) 16 | @type = :legs 17 | @stat_change = stat_change 18 | end 19 | 20 | attr_reader :type, :stat_change 21 | end 22 | 23 | end -------------------------------------------------------------------------------- /lib/goby/item/helmet.rb: -------------------------------------------------------------------------------- 1 | require 'goby' 2 | 3 | module Goby 4 | 5 | # Can be worn in the Player's outfit. 6 | class Helmet < Item 7 | include Equippable 8 | 9 | # @param [String] name the name. 10 | # @param [Integer] price the cost in a shop. 11 | # @param [Boolean] consumable upon use, the item is lost when true. 12 | # @param [Boolean] disposable allowed to sell or drop item when true. 13 | # @param [Hash] stat_change the change in stats for when the item is equipped. 14 | def initialize(name: "Helmet", price: 0, consumable: false, disposable: true, stat_change: {}) 15 | super(name: name, price: price, consumable: consumable, disposable: disposable) 16 | @type = :helmet 17 | @stat_change = stat_change 18 | end 19 | 20 | attr_reader :type, :stat_change 21 | end 22 | 23 | end -------------------------------------------------------------------------------- /lib/goby/item/shield.rb: -------------------------------------------------------------------------------- 1 | require 'goby' 2 | 3 | module Goby 4 | 5 | # Can be worn in the Player's outfit. 6 | class Shield < Item 7 | include Equippable 8 | 9 | # @param [String] name the name. 10 | # @param [Integer] price the cost in a shop. 11 | # @param [Boolean] consumable upon use, the item is lost when true. 12 | # @param [Boolean] disposable allowed to sell or drop item when true. 13 | # @param [Hash] stat_change the change in stats for when the item is equipped. 14 | def initialize(name: "Shield", price: 0, consumable: false, disposable: true, stat_change: {}) 15 | super(name: name, price: price, consumable: consumable, disposable: disposable) 16 | @type = :shield 17 | @stat_change = stat_change 18 | end 19 | 20 | attr_reader :type, :stat_change 21 | end 22 | 23 | end -------------------------------------------------------------------------------- /lib/goby/item/torso.rb: -------------------------------------------------------------------------------- 1 | require 'goby' 2 | 3 | module Goby 4 | 5 | # Can be worn in the Player's outfit. 6 | class Torso < Item 7 | include Equippable 8 | 9 | # @param [String] name the name. 10 | # @param [Integer] price the cost in a shop. 11 | # @param [Boolean] consumable determines whether the item is lost when used. 12 | # @param [Boolean] disposable allowed to sell or drop item when true. 13 | # @param [Hash] stat_change the change in stats for when the item is equipped. 14 | def initialize(name: "Torso", price: 0, consumable: false, disposable: true, stat_change: {}) 15 | super(name: name, price: price, consumable: consumable, disposable: disposable) 16 | @type = :torso 17 | @stat_change = stat_change 18 | end 19 | 20 | attr_reader :type, :stat_change 21 | end 22 | 23 | end -------------------------------------------------------------------------------- /spec/goby/scaffold_spec.rb: -------------------------------------------------------------------------------- 1 | require 'goby' 2 | require 'fileutils' 3 | 4 | RSpec.describe Scaffold do 5 | 6 | context "simple" do 7 | it "should create the appropriate directories & files" do 8 | 9 | project = "goby-project" 10 | Scaffold::simple project 11 | 12 | # Ensure all of the directories exist. 13 | expect(Dir.exists? "#{project}").to be true 14 | [ '', 'battle', 'entity', 15 | 'event', 'item', 'map' ].each do |dir| 16 | expect(Dir.exist? "#{project}/src/#{dir}").to be true 17 | end 18 | 19 | # Ensure all of the files exist. 20 | [ '.gitignore', 'src/main.rb', 'src/map/farm.rb' ].each do |file| 21 | expect(File.exist? "#{project}/#{file}").to be true 22 | end 23 | 24 | # Clean up the scaffolding. 25 | FileUtils.remove_dir project 26 | 27 | end 28 | end 29 | 30 | end -------------------------------------------------------------------------------- /bin/goby: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # 5 | # This file was generated by Bundler. 6 | # 7 | # The application 'goby' is installed as part of a gem, and 8 | # this file is here to facilitate running it. 9 | # 10 | 11 | require "pathname" 12 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../Gemfile", 13 | Pathname.new(__FILE__).realpath) 14 | 15 | bundle_binstub = File.expand_path("../bundle", __FILE__) 16 | 17 | if File.file?(bundle_binstub) 18 | if File.read(bundle_binstub, 150) =~ /This file was generated by Bundler/ 19 | load(bundle_binstub) 20 | else 21 | abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run. 22 | Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.") 23 | end 24 | end 25 | 26 | require "rubygems" 27 | require "bundler/setup" 28 | 29 | load Gem.bin_path("goby", "goby") 30 | -------------------------------------------------------------------------------- /bin/rake: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # 5 | # This file was generated by Bundler. 6 | # 7 | # The application 'rake' is installed as part of a gem, and 8 | # this file is here to facilitate running it. 9 | # 10 | 11 | require "pathname" 12 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../Gemfile", 13 | Pathname.new(__FILE__).realpath) 14 | 15 | bundle_binstub = File.expand_path("../bundle", __FILE__) 16 | 17 | if File.file?(bundle_binstub) 18 | if File.read(bundle_binstub, 150) =~ /This file was generated by Bundler/ 19 | load(bundle_binstub) 20 | else 21 | abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run. 22 | Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.") 23 | end 24 | end 25 | 26 | require "rubygems" 27 | require "bundler/setup" 28 | 29 | load Gem.bin_path("rake", "rake") 30 | -------------------------------------------------------------------------------- /bin/thor: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # 5 | # This file was generated by Bundler. 6 | # 7 | # The application 'thor' is installed as part of a gem, and 8 | # this file is here to facilitate running it. 9 | # 10 | 11 | require "pathname" 12 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../Gemfile", 13 | Pathname.new(__FILE__).realpath) 14 | 15 | bundle_binstub = File.expand_path("../bundle", __FILE__) 16 | 17 | if File.file?(bundle_binstub) 18 | if File.read(bundle_binstub, 150) =~ /This file was generated by Bundler/ 19 | load(bundle_binstub) 20 | else 21 | abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run. 22 | Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.") 23 | end 24 | end 25 | 26 | require "rubygems" 27 | require "bundler/setup" 28 | 29 | load Gem.bin_path("thor", "thor") 30 | -------------------------------------------------------------------------------- /bin/ldiff: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # 5 | # This file was generated by Bundler. 6 | # 7 | # The application 'ldiff' is installed as part of a gem, and 8 | # this file is here to facilitate running it. 9 | # 10 | 11 | require "pathname" 12 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../Gemfile", 13 | Pathname.new(__FILE__).realpath) 14 | 15 | bundle_binstub = File.expand_path("../bundle", __FILE__) 16 | 17 | if File.file?(bundle_binstub) 18 | if File.read(bundle_binstub, 150) =~ /This file was generated by Bundler/ 19 | load(bundle_binstub) 20 | else 21 | abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run. 22 | Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.") 23 | end 24 | end 25 | 26 | require "rubygems" 27 | require "bundler/setup" 28 | 29 | load Gem.bin_path("diff-lcs", "ldiff") 30 | -------------------------------------------------------------------------------- /bin/rspec: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # 5 | # This file was generated by Bundler. 6 | # 7 | # The application 'rspec' is installed as part of a gem, and 8 | # this file is here to facilitate running it. 9 | # 10 | 11 | require "pathname" 12 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../Gemfile", 13 | Pathname.new(__FILE__).realpath) 14 | 15 | bundle_binstub = File.expand_path("../bundle", __FILE__) 16 | 17 | if File.file?(bundle_binstub) 18 | if File.read(bundle_binstub, 150) =~ /This file was generated by Bundler/ 19 | load(bundle_binstub) 20 | else 21 | abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run. 22 | Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.") 23 | end 24 | end 25 | 26 | require "rubygems" 27 | require "bundler/setup" 28 | 29 | load Gem.bin_path("rspec-core", "rspec") 30 | -------------------------------------------------------------------------------- /bin/htmldiff: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # 5 | # This file was generated by Bundler. 6 | # 7 | # The application 'htmldiff' is installed as part of a gem, and 8 | # this file is here to facilitate running it. 9 | # 10 | 11 | require "pathname" 12 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../Gemfile", 13 | Pathname.new(__FILE__).realpath) 14 | 15 | bundle_binstub = File.expand_path("../bundle", __FILE__) 16 | 17 | if File.file?(bundle_binstub) 18 | if File.read(bundle_binstub, 150) =~ /This file was generated by Bundler/ 19 | load(bundle_binstub) 20 | else 21 | abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run. 22 | Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.") 23 | end 24 | end 25 | 26 | require "rubygems" 27 | require "bundler/setup" 28 | 29 | load Gem.bin_path("diff-lcs", "htmldiff") 30 | -------------------------------------------------------------------------------- /bin/coveralls: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # 5 | # This file was generated by Bundler. 6 | # 7 | # The application 'coveralls' is installed as part of a gem, and 8 | # this file is here to facilitate running it. 9 | # 10 | 11 | require "pathname" 12 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../Gemfile", 13 | Pathname.new(__FILE__).realpath) 14 | 15 | bundle_binstub = File.expand_path("../bundle", __FILE__) 16 | 17 | if File.file?(bundle_binstub) 18 | if File.read(bundle_binstub, 150) =~ /This file was generated by Bundler/ 19 | load(bundle_binstub) 20 | else 21 | abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run. 22 | Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.") 23 | end 24 | end 25 | 26 | require "rubygems" 27 | require "bundler/setup" 28 | 29 | load Gem.bin_path("coveralls", "coveralls") 30 | -------------------------------------------------------------------------------- /goby.gemspec: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | lib = File.expand_path('../lib', __FILE__) 3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 4 | 5 | Gem::Specification.new do |spec| 6 | spec.name = "goby" 7 | spec.version = "0.2.0" 8 | spec.authors = ["Nicholas Skinsacos"] 9 | spec.email = ["nskins@umich.edu"] 10 | 11 | spec.summary = %q{Framework for creating text RPGs.} 12 | spec.homepage = "https://github.com/nskins/goby" 13 | spec.license = "MIT" 14 | 15 | spec.files = Dir["{exe}/**/*", "{lib}/**/*", "{res}/**/*", "LICENSE", "README.md" ] 16 | spec.bindir = "exe" 17 | spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } 18 | spec.require_paths = ["lib"] 19 | 20 | spec.add_development_dependency "bundler", "~> 1.16" 21 | spec.add_development_dependency "rake", "~> 10.0" 22 | spec.add_development_dependency "rspec", "~> 3.5" 23 | end 24 | -------------------------------------------------------------------------------- /spec/goby/event/event_spec.rb: -------------------------------------------------------------------------------- 1 | require 'goby' 2 | 3 | RSpec.describe Event do 4 | let(:event) { Event.new } 5 | let(:entity) { Entity.new } 6 | 7 | context "constructor" do 8 | it "has the correct default parameters" do 9 | expect(event.command).to eq "event" 10 | expect(event.mode).to eq 0 11 | expect(event.visible).to eq true 12 | end 13 | 14 | it "correctly assigns custom parameters" do 15 | box = Event.new(command: "open", 16 | mode: 1, 17 | visible: false) 18 | expect(box.command).to eq "open" 19 | expect(box.mode).to eq 1 20 | expect(box.visible).to eq false 21 | end 22 | end 23 | 24 | context "run" do 25 | it "prints the default run text for a default event" do 26 | entity = Entity.new 27 | expect { event.run(entity) }.to output(Event::DEFAULT_RUN_TEXT).to_stdout 28 | end 29 | end 30 | 31 | end 32 | -------------------------------------------------------------------------------- /bin/term_cdiff: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # 5 | # This file was generated by Bundler. 6 | # 7 | # The application 'term_cdiff' is installed as part of a gem, and 8 | # this file is here to facilitate running it. 9 | # 10 | 11 | require "pathname" 12 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../Gemfile", 13 | Pathname.new(__FILE__).realpath) 14 | 15 | bundle_binstub = File.expand_path("../bundle", __FILE__) 16 | 17 | if File.file?(bundle_binstub) 18 | if File.read(bundle_binstub, 150) =~ /This file was generated by Bundler/ 19 | load(bundle_binstub) 20 | else 21 | abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run. 22 | Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.") 23 | end 24 | end 25 | 26 | require "rubygems" 27 | require "bundler/setup" 28 | 29 | load Gem.bin_path("term-ansicolor", "term_cdiff") 30 | -------------------------------------------------------------------------------- /bin/term_mandel: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # 5 | # This file was generated by Bundler. 6 | # 7 | # The application 'term_mandel' is installed as part of a gem, and 8 | # this file is here to facilitate running it. 9 | # 10 | 11 | require "pathname" 12 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../Gemfile", 13 | Pathname.new(__FILE__).realpath) 14 | 15 | bundle_binstub = File.expand_path("../bundle", __FILE__) 16 | 17 | if File.file?(bundle_binstub) 18 | if File.read(bundle_binstub, 150) =~ /This file was generated by Bundler/ 19 | load(bundle_binstub) 20 | else 21 | abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run. 22 | Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.") 23 | end 24 | end 25 | 26 | require "rubygems" 27 | require "bundler/setup" 28 | 29 | load Gem.bin_path("term-ansicolor", "term_mandel") 30 | -------------------------------------------------------------------------------- /bin/term_colortab: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # 5 | # This file was generated by Bundler. 6 | # 7 | # The application 'term_colortab' is installed as part of a gem, and 8 | # this file is here to facilitate running it. 9 | # 10 | 11 | require "pathname" 12 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../Gemfile", 13 | Pathname.new(__FILE__).realpath) 14 | 15 | bundle_binstub = File.expand_path("../bundle", __FILE__) 16 | 17 | if File.file?(bundle_binstub) 18 | if File.read(bundle_binstub, 150) =~ /This file was generated by Bundler/ 19 | load(bundle_binstub) 20 | else 21 | abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run. 22 | Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.") 23 | end 24 | end 25 | 26 | require "rubygems" 27 | require "bundler/setup" 28 | 29 | load Gem.bin_path("term-ansicolor", "term_colortab") 30 | -------------------------------------------------------------------------------- /bin/term_decolor: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # 5 | # This file was generated by Bundler. 6 | # 7 | # The application 'term_decolor' is installed as part of a gem, and 8 | # this file is here to facilitate running it. 9 | # 10 | 11 | require "pathname" 12 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../Gemfile", 13 | Pathname.new(__FILE__).realpath) 14 | 15 | bundle_binstub = File.expand_path("../bundle", __FILE__) 16 | 17 | if File.file?(bundle_binstub) 18 | if File.read(bundle_binstub, 150) =~ /This file was generated by Bundler/ 19 | load(bundle_binstub) 20 | else 21 | abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run. 22 | Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.") 23 | end 24 | end 25 | 26 | require "rubygems" 27 | require "bundler/setup" 28 | 29 | load Gem.bin_path("term-ansicolor", "term_decolor") 30 | -------------------------------------------------------------------------------- /bin/term_display: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # 5 | # This file was generated by Bundler. 6 | # 7 | # The application 'term_display' is installed as part of a gem, and 8 | # this file is here to facilitate running it. 9 | # 10 | 11 | require "pathname" 12 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../Gemfile", 13 | Pathname.new(__FILE__).realpath) 14 | 15 | bundle_binstub = File.expand_path("../bundle", __FILE__) 16 | 17 | if File.file?(bundle_binstub) 18 | if File.read(bundle_binstub, 150) =~ /This file was generated by Bundler/ 19 | load(bundle_binstub) 20 | else 21 | abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run. 22 | Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.") 23 | end 24 | end 25 | 26 | require "rubygems" 27 | require "bundler/setup" 28 | 29 | load Gem.bin_path("term-ansicolor", "term_display") 30 | -------------------------------------------------------------------------------- /lib/goby.rb: -------------------------------------------------------------------------------- 1 | # Import order matters. 2 | 3 | require 'goby/scaffold' 4 | require 'goby/extension' 5 | require 'goby/util' 6 | require 'goby/world_command' 7 | require 'goby/music' 8 | require 'goby/driver' 9 | 10 | require 'goby/battle/battle' 11 | require 'goby/battle/battle_command' 12 | require 'goby/battle/attack' 13 | require 'goby/battle/escape' 14 | require 'goby/battle/use' 15 | 16 | require 'goby/map/map' 17 | require 'goby/map/tile' 18 | 19 | require 'goby/entity/entity' 20 | require 'goby/entity/fighter' 21 | require 'goby/entity/monster' 22 | require 'goby/entity/player' 23 | 24 | require 'goby/event/event' 25 | require 'goby/event/chest' 26 | require 'goby/event/npc' 27 | require 'goby/event/shop' 28 | 29 | require 'goby/item/item' 30 | require 'goby/item/food' 31 | 32 | require 'goby/item/equippable' 33 | require 'goby/item/helmet' 34 | require 'goby/item/legs' 35 | require 'goby/item/shield' 36 | require 'goby/item/torso' 37 | require 'goby/item/weapon' 38 | -------------------------------------------------------------------------------- /res/scaffold/simple/main.rb: -------------------------------------------------------------------------------- 1 | require 'goby' 2 | 3 | include Goby 4 | 5 | require_relative 'map/farm.rb' 6 | 7 | # Set this to true in order to use BGM. 8 | Music::set_playback(false) 9 | 10 | # By default, we've included no music files. 11 | # The Music module also includes a function 12 | # to change the music-playing program. 13 | 14 | # Clear the terminal. 15 | system("clear") 16 | 17 | # Allow the player to load an existing game. 18 | if File.exists?("player.yaml") 19 | print "Load the saved file?: " 20 | input = player_input 21 | if input.is_positive? 22 | player = load_game("player.yaml") 23 | end 24 | end 25 | 26 | # No load? Create a new player. 27 | if player.nil? 28 | # A Location specifies the Map and (y,x) coordinates of a Player. 29 | home = Location.new(Farm.new, C[1, 1]) 30 | 31 | # Use the Player constructor to set the 32 | # location, stats, gold, inventory, and more. 33 | player = Player.new(location: home) 34 | 35 | end 36 | 37 | run_driver(player) 38 | -------------------------------------------------------------------------------- /lib/goby/battle/escape.rb: -------------------------------------------------------------------------------- 1 | require 'goby' 2 | 3 | module Goby 4 | 5 | # Allows an Entity to try to escape from the opponent. 6 | class Escape < BattleCommand 7 | 8 | # Text for successful escape. 9 | SUCCESS = "Successful escape!\n\n" 10 | # Text for failed escape. 11 | FAILURE = "Unable to escape!\n\n" 12 | 13 | # Initializes the Escape command. 14 | def initialize 15 | super(name: "Escape") 16 | end 17 | 18 | # Samples a probability to determine if the user will escape from battle. 19 | # 20 | # @param [Entity] user the one who is trying to escape. 21 | # @param [Entity] enemy the one from whom the user wants to escape. 22 | def run(user, enemy) 23 | 24 | # Higher probability of escape when the enemy has low agility. 25 | if (user.sample_agilities(enemy)) 26 | user.escaped = true 27 | type(SUCCESS) 28 | return 29 | end 30 | 31 | # Should already be false. 32 | user.escaped = false 33 | type(FAILURE) 34 | end 35 | 36 | end 37 | 38 | end -------------------------------------------------------------------------------- /lib/goby/event/chest.rb: -------------------------------------------------------------------------------- 1 | require 'goby' 2 | 3 | module Goby 4 | 5 | # A chest containing gold and/or items. 6 | class Chest < Event 7 | 8 | # @param [Integer] mode convenient way for a chest to have multiple actions. 9 | # @param [Boolean] visible whether the chest can be seen/activated. 10 | # @param [Integer] gold the amount of gold in this chest. 11 | # @param [[Item]] treasures the items found in this chest. 12 | def initialize(mode: 0, visible: true, gold: 0, treasures: []) 13 | super(mode: mode, visible: visible) 14 | @command = "open" 15 | @gold = gold 16 | @treasures = treasures 17 | end 18 | 19 | # The function that runs when the player opens the chest. 20 | # 21 | # @param [Player] player the one opening the chest. 22 | def run(player) 23 | type("You open the treasure chest...\n\n") 24 | sleep(1) unless ENV['TEST'] 25 | player.add_loot(@gold, @treasures) 26 | @visible = false 27 | end 28 | 29 | attr_reader :gold, :treasures 30 | 31 | end 32 | 33 | end -------------------------------------------------------------------------------- /lib/goby/event/npc.rb: -------------------------------------------------------------------------------- 1 | require 'goby' 2 | 3 | module Goby 4 | 5 | # A non-player character with whom the player can interact. 6 | # Always activated with the 'talk' command. 7 | class NPC < Event 8 | 9 | # @param [String] name the name. 10 | # @param [Integer] mode convenient way for a NPC to have multiple actions. 11 | # @param [Boolean] visible whether the NPC can be seen/activated. 12 | def initialize(name: "NPC", mode: 0, visible: true) 13 | super(mode: mode, visible: visible) 14 | @name = name 15 | @command = "talk" 16 | end 17 | 18 | # The function that runs when the player speaks to the NPC. 19 | # 20 | # @param [Player] player the one speaking to the NPC. 21 | def run(player) 22 | say "Hello!\n\n" 23 | end 24 | 25 | # Function that allows NPCs to output a string of words. 26 | # 27 | # @param [String] words string of words for the NPC to speak. 28 | def say(words) 29 | type "#{name}: #{words}" 30 | end 31 | 32 | attr_accessor :name 33 | end 34 | 35 | end 36 | -------------------------------------------------------------------------------- /lib/goby/scaffold.rb: -------------------------------------------------------------------------------- 1 | module Goby 2 | 3 | # Functions for scaffolding starter projects. 4 | module Scaffold 5 | 6 | # Simple starter project w/o testing. 7 | # 8 | # @param [String] project the project name. 9 | def self.simple(project) 10 | 11 | # TODO: detect existence of project folder. 12 | 13 | # Make the directory structure. 14 | Dir.mkdir project 15 | dirs = [ '', 'battle', 'entity', 16 | 'event', 'item', 'map' ] 17 | dirs.each do |dir| 18 | Dir.mkdir "#{project}/src/#{dir}" 19 | end 20 | 21 | # Create the source files. 22 | gem_location = %x[gem which goby].chomp "/lib/goby.rb\n" 23 | files = { '.gitignore': '../gitignore', 24 | 'src/main.rb': 'main.rb', 25 | 'src/map/farm.rb': 'farm.rb' } 26 | files.each do |dest, source| 27 | File.open("#{project}/#{dest.to_s}", 'w') do |w| 28 | w.write(File.read "#{gem_location}/res/scaffold/simple/#{source}") 29 | end 30 | end 31 | 32 | end 33 | 34 | end 35 | 36 | end -------------------------------------------------------------------------------- /spec/goby/item/legs_spec.rb: -------------------------------------------------------------------------------- 1 | require 'goby' 2 | 3 | RSpec.describe Legs do 4 | 5 | context "constructor" do 6 | it "has the correct default parameters" do 7 | legs = Legs.new 8 | expect(legs.name).to eq "Legs" 9 | expect(legs.price).to eq 0 10 | expect(legs.consumable).to eq false 11 | expect(legs.disposable).to eq true 12 | expect(legs.type).to eq :legs 13 | end 14 | 15 | it "correctly assigns custom parameters" do 16 | pants = Legs.new(name: "Pants", 17 | price: 20, 18 | consumable: true, 19 | disposable: false, 20 | stat_change: {attack: 2, defense: 2}) 21 | expect(pants.name).to eq "Pants" 22 | expect(pants.price).to eq 20 23 | expect(pants.consumable).to eq true 24 | expect(pants.disposable).to eq false 25 | expect(pants.stat_change[:attack]).to eq 2 26 | expect(pants.stat_change[:defense]).to eq 2 27 | # Cannot be overwritten. 28 | expect(pants.type).to eq :legs 29 | end 30 | end 31 | 32 | end 33 | -------------------------------------------------------------------------------- /spec/goby/item/torso_spec.rb: -------------------------------------------------------------------------------- 1 | require 'goby' 2 | 3 | RSpec.describe Torso do 4 | 5 | context "constructor" do 6 | it "has the correct default parameters" do 7 | torso = Torso.new 8 | expect(torso.name).to eq "Torso" 9 | expect(torso.price).to eq 0 10 | expect(torso.consumable).to eq false 11 | expect(torso.disposable).to eq true 12 | expect(torso.type).to eq :torso 13 | end 14 | 15 | it "correctly assigns custom parameters" do 16 | shirt = Torso.new(name: "Shirt", 17 | price: 20, 18 | consumable: true, 19 | disposable: false, 20 | stat_change: {attack: 2, defense: 2}) 21 | expect(shirt.name).to eq "Shirt" 22 | expect(shirt.price).to eq 20 23 | expect(shirt.consumable).to eq true 24 | expect(shirt.disposable).to eq false 25 | expect(shirt.stat_change[:attack]).to eq 2 26 | expect(shirt.stat_change[:defense]).to eq 2 27 | # Cannot be overwritten. 28 | expect(shirt.type).to eq :torso 29 | end 30 | end 31 | 32 | end 33 | -------------------------------------------------------------------------------- /lib/goby/event/event.rb: -------------------------------------------------------------------------------- 1 | module Goby 2 | 3 | # A Player can interact with these on the Map. 4 | class Event 5 | 6 | # The default text for when the event doesn't do anything. 7 | DEFAULT_RUN_TEXT = "Nothing happens.\n\n" 8 | 9 | # @param [String] command the command to activate the event. 10 | # @param [Integer] mode convenient way for an event to have multiple actions. 11 | # @param [Boolean] visible true when the event can be seen/activated. 12 | def initialize(command: "event", mode: 0, visible: true) 13 | @command = command 14 | @mode = mode 15 | @visible = visible 16 | end 17 | 18 | # The function that runs when the player activates the event. 19 | # Override this function for subclasses. 20 | # 21 | # @param [Player] player the one activating the event. 22 | def run(player) 23 | print DEFAULT_RUN_TEXT 24 | end 25 | 26 | # @param [Event] rhs the event on the right. 27 | def ==(rhs) 28 | @command == rhs.command 29 | end 30 | 31 | # Specify the command in the subclass. 32 | attr_accessor :command, :mode, :visible 33 | 34 | end 35 | 36 | end 37 | -------------------------------------------------------------------------------- /spec/goby/item/helmet_spec.rb: -------------------------------------------------------------------------------- 1 | require 'goby' 2 | 3 | RSpec.describe Helmet do 4 | 5 | context "constructor" do 6 | it "has the correct default parameters" do 7 | helmet = Helmet.new 8 | expect(helmet.name).to eq "Helmet" 9 | expect(helmet.price).to eq 0 10 | expect(helmet.consumable).to eq false 11 | expect(helmet.disposable).to eq true 12 | expect(helmet.type).to eq :helmet 13 | end 14 | 15 | it "correctly assigns custom parameters" do 16 | big_hat = Helmet.new(name: "Big Hat", 17 | price: 20, 18 | consumable: true, 19 | disposable: false, 20 | stat_change: {attack: 2, defense: 2}) 21 | expect(big_hat.name).to eq "Big Hat" 22 | expect(big_hat.price).to eq 20 23 | expect(big_hat.consumable).to eq true 24 | expect(big_hat.disposable).to eq false 25 | expect(big_hat.stat_change[:attack]).to eq 2 26 | expect(big_hat.stat_change[:defense]).to eq 2 27 | # Cannot be overwritten. 28 | expect(big_hat.type).to eq :helmet 29 | end 30 | end 31 | 32 | end 33 | -------------------------------------------------------------------------------- /spec/goby/item/shield_spec.rb: -------------------------------------------------------------------------------- 1 | require 'goby' 2 | 3 | RSpec.describe Shield do 4 | 5 | context "constructor" do 6 | it "has the correct default parameters" do 7 | shield = Shield.new 8 | expect(shield.name).to eq "Shield" 9 | expect(shield.price).to eq 0 10 | expect(shield.consumable).to eq false 11 | expect(shield.disposable).to eq true 12 | expect(shield.type).to eq :shield 13 | end 14 | 15 | it "correctly assigns custom parameters" do 16 | buckler = Shield.new(name: "Buckler", 17 | price: 20, 18 | consumable: true, 19 | disposable: false, 20 | stat_change: {attack: 2, defense: 2}) 21 | expect(buckler.name).to eq "Buckler" 22 | expect(buckler.price).to eq 20 23 | expect(buckler.consumable).to eq true 24 | expect(buckler.disposable).to eq false 25 | expect(buckler.stat_change[:attack]).to eq 2 26 | expect(buckler.stat_change[:defense]).to eq 2 27 | # Cannot be overwritten. 28 | expect(buckler.type).to eq :shield 29 | end 30 | end 31 | 32 | end 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016-2018 Benjamin Foster & Nicholas Skinsacos 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /lib/goby/battle/use.rb: -------------------------------------------------------------------------------- 1 | require 'goby' 2 | 3 | module Goby 4 | 5 | # Allows an Entity to use an Item in battle. 6 | class Use < BattleCommand 7 | 8 | # Initializes the Use command. 9 | def initialize 10 | super(name: "Use") 11 | end 12 | 13 | # Returns true iff the user's inventory is empty. 14 | # 15 | # @param [Entity] user the one who is using the command. 16 | # @return [Boolean] status of the user's inventory. 17 | def fails?(user) 18 | empty = user.inventory.empty? 19 | if empty 20 | print "#{user.name}'s inventory is empty!\n\n" 21 | end 22 | return empty 23 | end 24 | 25 | # Uses the specified Item on the specified Entity. 26 | # Note that enemy is not necessarily on whom the Item is used. 27 | # 28 | # @param [Entity] user the one who is using the command. 29 | # @param [Entity] enemy the one on whom the command is used. 30 | def run(user, enemy) 31 | # Determine the item and on whom to use the item. 32 | pair = user.choose_item_and_on_whom(enemy) 33 | return if (!pair) 34 | user.use_item(pair.first, pair.second) 35 | end 36 | 37 | end 38 | 39 | end -------------------------------------------------------------------------------- /spec/goby/event/npc_spec.rb: -------------------------------------------------------------------------------- 1 | require 'goby' 2 | 3 | RSpec.describe NPC do 4 | let(:npc) { NPC.new } 5 | let(:entity) { Entity.new } 6 | 7 | context "constructor" do 8 | it "has the correct default parameters" do 9 | expect(npc.name).to eq "NPC" 10 | expect(npc.command).to eq "talk" 11 | expect(npc.mode).to eq 0 12 | expect(npc.visible).to eq true 13 | end 14 | 15 | it "correctly assigns custom parameters" do 16 | john = NPC.new(name: "John", 17 | mode: 1, 18 | visible: false) 19 | expect(john.name).to eq "John" 20 | expect(john.command).to eq "talk" # Always talk command. 21 | expect(john.mode).to eq 1 22 | expect(john.visible).to eq false 23 | end 24 | end 25 | 26 | context "use" do 27 | it "prints the default text for the default NPC" do 28 | expect { npc.run(entity) }.to output("NPC: Hello!\n\n").to_stdout 29 | end 30 | end 31 | 32 | context "say" do 33 | subject(:npc) { NPC.new } 34 | 35 | it "outputs the NPC's name and words argument" do 36 | expect { npc.say("Welcome to Goby") }.to output("NPC: Welcome to Goby").to_stdout 37 | end 38 | end 39 | 40 | end 41 | -------------------------------------------------------------------------------- /lib/goby/map/map.rb: -------------------------------------------------------------------------------- 1 | module Goby 2 | 3 | # A 2D arrangement of Tiles. The Player can move around on it. 4 | class Map 5 | 6 | # @param [String] name the name. 7 | # @param [[Tile]] tiles the content of the map. 8 | def initialize(name: "Map", tiles: [[Tile.new]], music: nil) 9 | @name = name 10 | @tiles = tiles 11 | @music = music 12 | end 13 | 14 | # Returns true when @tiles[y][x] is an existing index of @tiles. 15 | # Otherwise, returns false. 16 | # 17 | # @param [Integer] y the y-coordinate. 18 | # @param [Integer] x the x-coordinate. 19 | # @return [Boolean] the existence of the tile. 20 | def in_bounds(y, x) 21 | return (y.nonnegative? && y < @tiles.length && x.nonnegative? && x < @tiles[y].length) 22 | end 23 | 24 | # Prints the map in a nice format. 25 | def to_s 26 | output = "" 27 | @tiles.each do |row| 28 | row.each do |tile| 29 | output += (tile.graphic + " ") 30 | end 31 | output += "\n" 32 | end 33 | return output 34 | end 35 | 36 | # @param [Map] rhs the Map on the right. 37 | def ==(rhs) 38 | return @name == rhs.name 39 | end 40 | 41 | attr_accessor :name, :tiles, :music 42 | 43 | end 44 | 45 | end 46 | -------------------------------------------------------------------------------- /lib/goby/driver.rb: -------------------------------------------------------------------------------- 1 | require 'goby' 2 | 3 | module Goby 4 | 5 | include Music 6 | include WorldCommand 7 | 8 | # Runs the main game loop. 9 | # 10 | # @param [Player] player the player of the game. 11 | def run_driver(player) 12 | while (run_turn(player)); end 13 | stop_music 14 | end 15 | 16 | private 17 | 18 | # Clear the terminal and display the minimap. 19 | # 20 | # @param [Player] player the player of the game. 21 | def clear_and_minimap(player) 22 | system("clear") unless ENV["TEST"] 23 | describe_tile(player) 24 | end 25 | 26 | # Runs a single command from the player on the world map. 27 | # 28 | # @param [Player] player the player of the game. 29 | # @return [Bool] true iff the player does not want to quit. 30 | def run_turn(player) 31 | 32 | # Play music and re-display the minimap (when appropriate). 33 | music = player.location.map.music 34 | play_music(music) if music 35 | if player.moved 36 | clear_and_minimap(player) 37 | player.moved = false 38 | end 39 | 40 | # Receive input and run the command. 41 | input = player_input prompt: '> ' 42 | interpret_command(input, player) 43 | 44 | return !input.eql?("quit") 45 | end 46 | 47 | end 48 | -------------------------------------------------------------------------------- /res/scaffold/gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | *.rbc 3 | /.config 4 | /coverage/ 5 | /InstalledFiles 6 | /pkg/ 7 | /spec/reports/ 8 | /spec/examples.txt 9 | /test/tmp/ 10 | /test/version_tmp/ 11 | /tmp/ 12 | 13 | # Used by dotenv library to load environment variables. 14 | # .env 15 | 16 | ## Specific to RubyMotion: 17 | .dat* 18 | .repl_history 19 | build/ 20 | *.bridgesupport 21 | build-iPhoneOS/ 22 | build-iPhoneSimulator/ 23 | 24 | ## Specific to RubyMotion (use of CocoaPods): 25 | # 26 | # We recommend against adding the Pods directory to your .gitignore. However 27 | # you should judge for yourself, the pros and cons are mentioned at: 28 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 29 | # 30 | # vendor/Pods/ 31 | 32 | ## Documentation cache and generated files: 33 | /.yardoc/ 34 | /_yardoc/ 35 | /doc/ 36 | /rdoc/ 37 | 38 | ## Environment normalization: 39 | /.bundle/ 40 | /vendor/bundle 41 | /lib/bundler/man/ 42 | 43 | # for a library or gem, you might want to ignore these files since the code is 44 | # intended to run in multiple environments; otherwise, check them in: 45 | # Gemfile.lock 46 | # .ruby-version 47 | # .ruby-gemset 48 | 49 | # unless supporting rvm < 1.11.0 or doing something fancy, ignore this: 50 | .rvmrc 51 | 52 | # this is the save file 53 | player.yaml -------------------------------------------------------------------------------- /lib/goby/item/item.rb: -------------------------------------------------------------------------------- 1 | module Goby 2 | 3 | # Can be used by an Entity in order to trigger anything specified. 4 | # Placed into the Entity's inventory. 5 | class Item 6 | 7 | # Default text when the Item doesn't do anything. 8 | DEFAULT_USE_TEXT = "Nothing happens.\n\n" 9 | 10 | # @param [String] name the name. 11 | # @param [Integer] price the cost in a shop. 12 | # @param [Boolean] consumable upon use, the item is lost when true. 13 | # @param [Boolean] disposable allowed to sell or drop item when true. 14 | def initialize(name: "Item", price: 0, consumable: true, disposable: true) 15 | @name = name 16 | @price = price 17 | @consumable = consumable 18 | @disposable = disposable 19 | end 20 | 21 | # The function that executes when one uses the item. 22 | # 23 | # @param [Entity] user the one using the item. 24 | # @param [Entity] entity the one on whom the item is used. 25 | def use(user, entity) 26 | print DEFAULT_USE_TEXT 27 | end 28 | 29 | # @param [Item] rhs the item on the right. 30 | def ==(rhs) 31 | return @name.casecmp(rhs.name).zero? 32 | end 33 | 34 | # @return [String] the name of the Item. 35 | def to_s 36 | @name 37 | end 38 | 39 | attr_accessor :name, :price, :consumable, :disposable 40 | 41 | end 42 | 43 | end -------------------------------------------------------------------------------- /lib/goby/extension.rb: -------------------------------------------------------------------------------- 1 | require 'set' 2 | 3 | # Provides additional methods for Array. 4 | class Array 5 | # Returns true iff the array is not empty. 6 | def nonempty? 7 | return !empty? 8 | end 9 | end 10 | 11 | # Provides additional methods for Integer. 12 | class Integer 13 | 14 | # Returns true if the integer is a positive number. 15 | def positive? 16 | return self > 0 17 | end 18 | 19 | # Returns true if the integer is not a positive number. 20 | def nonpositive? 21 | return self <= 0 22 | end 23 | 24 | # Returns true if the integer is a negative number. 25 | def negative? 26 | return self < 0 27 | end 28 | 29 | # Returns true if the integer is not a negative number. 30 | def nonnegative? 31 | return self >= 0 32 | end 33 | 34 | end 35 | 36 | # Provides additional methods for String. 37 | class String 38 | 39 | # Set of all known positive responses. 40 | POSITIVE_RESPONSES = Set.new [ "ok", "okay", "sure", "y", "ye", "yeah", "yes" ] 41 | # Set of all known negative responses. 42 | NEGATIVE_RESPONSES = Set.new [ "n", "nah", "no", "nope" ] 43 | 44 | # Returns true iff the string is affirmative/positive. 45 | def is_positive? 46 | return POSITIVE_RESPONSES.include?(self) 47 | end 48 | 49 | # Returns true iff the string is negatory/negative. 50 | def is_negative? 51 | return NEGATIVE_RESPONSES.include?(self) 52 | end 53 | 54 | end 55 | -------------------------------------------------------------------------------- /spec/goby/map/map_spec.rb: -------------------------------------------------------------------------------- 1 | require 'goby' 2 | 3 | RSpec.describe Map do 4 | 5 | let(:lake) { Map.new(name: "Lake", 6 | tiles: [ [ Tile.new, Tile.new(passable: false) ] ] ) } 7 | 8 | context "constructor" do 9 | it "has the correct default parameters" do 10 | map = Map.new 11 | expect(map.name).to eq "Map" 12 | expect(map.tiles[0][0].passable).to be true 13 | end 14 | 15 | it "correctly assigns custom parameters" do 16 | expect(lake.name).to eq "Lake" 17 | expect(lake.tiles[0][0].passable).to be true 18 | expect(lake.tiles[0][1].passable).to be false 19 | end 20 | end 21 | 22 | context "to_s" do 23 | it "should display a simple map" do 24 | expect(lake.to_s).to eq("· ■ \n") 25 | end 26 | end 27 | 28 | context "in bounds" do 29 | it "returns true when the coordinates are within the map bounds" do 30 | expect(lake.in_bounds(0,0)).to eq true 31 | expect(lake.in_bounds(0,1)).to eq true 32 | end 33 | 34 | it "returns false when the coordinates are outside the map bounds" do 35 | expect(lake.in_bounds(-1,0)).to eq false 36 | expect(lake.in_bounds(0,-1)).to eq false 37 | expect(lake.in_bounds(-1,-1)).to eq false 38 | expect(lake.in_bounds(1,0)).to eq false 39 | expect(lake.in_bounds(0,2)).to eq false 40 | expect(lake.in_bounds(1,1)).to eq false 41 | end 42 | end 43 | 44 | end 45 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Created by https://www.gitignore.io/api/ruby 3 | 4 | ### Ruby ### 5 | *.gem 6 | *.rbc 7 | /.config 8 | /coverage/ 9 | /InstalledFiles 10 | /pkg/ 11 | /spec/reports/ 12 | /spec/examples.txt 13 | /test/tmp/ 14 | /test/version_tmp/ 15 | /tmp/ 16 | 17 | # Used by dotenv library to load environment variables. 18 | # .env 19 | 20 | ## Specific to RubyMotion: 21 | .dat* 22 | .repl_history 23 | build/ 24 | *.bridgesupport 25 | build-iPhoneOS/ 26 | build-iPhoneSimulator/ 27 | 28 | ## Specific to RubyMotion (use of CocoaPods): 29 | # 30 | # We recommend against adding the Pods directory to your .gitignore. However 31 | # you should judge for yourself, the pros and cons are mentioned at: 32 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 33 | # 34 | # vendor/Pods/ 35 | 36 | ## Documentation cache and generated files: 37 | /.yardoc/ 38 | /_yardoc/ 39 | /doc/ 40 | /rdoc/ 41 | 42 | ## Environment normalization: 43 | /.bundle/ 44 | /vendor/bundle 45 | /lib/bundler/man/ 46 | 47 | # for a library or gem, you might want to ignore these files since the code is 48 | # intended to run in multiple environments; otherwise, check them in: 49 | Gemfile.lock 50 | .ruby-version 51 | .ruby-gemset 52 | 53 | # unless supporting rvm < 1.11.0 or doing something fancy, ignore this: 54 | .rvmrc 55 | 56 | todo.txt 57 | player.yaml 58 | 59 | #Ignore RubyMine/IntelliJ project files 60 | /.idea/ -------------------------------------------------------------------------------- /spec/goby/battle/battle_command_spec.rb: -------------------------------------------------------------------------------- 1 | require 'goby' 2 | 3 | RSpec.describe Goby::BattleCommand do 4 | 5 | let(:cmd) { BattleCommand.new } 6 | 7 | context "constructor" do 8 | it "has the correct default parameters" do 9 | expect(cmd.name).to eq "BattleCommand" 10 | end 11 | 12 | it "correctly assigns custom parameters" do 13 | dance = BattleCommand.new(name: "Dance") 14 | expect(dance.name).to eq "Dance" 15 | end 16 | end 17 | 18 | context "run" do 19 | it "prints the default message" do 20 | user = Entity.new 21 | enemy = Entity.new 22 | # Rspec: expect output. 23 | expect { cmd.run(user, enemy) }.to output(Goby::BattleCommand::NO_ACTION).to_stdout 24 | end 25 | end 26 | 27 | context "fails?" do 28 | it "returns false for the trivial case" do 29 | entity = Entity.new 30 | expect(cmd.fails?(entity)).to be false 31 | end 32 | end 33 | 34 | context "equality operator" do 35 | it "returns true for the seemingly trivial case" do 36 | expect(cmd).to eq cmd 37 | end 38 | 39 | it "returns false for commands with different names" do 40 | dance = BattleCommand.new(name: "Dance") 41 | kick = BattleCommand.new(name: "Kick") 42 | expect(dance).not_to eq kick 43 | end 44 | end 45 | 46 | context "to_s" do 47 | it "returns the name of the BattleCommand" do 48 | expect(cmd.to_s).to eq cmd.name 49 | end 50 | end 51 | 52 | end 53 | -------------------------------------------------------------------------------- /lib/goby/battle/battle_command.rb: -------------------------------------------------------------------------------- 1 | module Goby 2 | 3 | # Commands that are used in the battle system. At each turn, 4 | # an Entity specifies which BattleCommand to use. 5 | class BattleCommand 6 | 7 | # Text for when the battle command does nothing. 8 | NO_ACTION = "Nothing happens.\n\n" 9 | 10 | # @param [String] name the name. 11 | def initialize(name: "BattleCommand") 12 | @name = name 13 | end 14 | 15 | # This method can prevent the user from using this command 16 | # based on a defined condition. Override for subclasses. 17 | # 18 | # @param [Entity] user the one who is using the command. 19 | # @return [Boolean] true iff the command cannot be used. 20 | def fails?(user) 21 | false 22 | end 23 | 24 | # The process that runs when this command is used in battle. 25 | # Override this function for subclasses. 26 | # 27 | # @param [Entity] user the one who is using the command. 28 | # @param [Entity] entity the one on whom the command is used. 29 | def run(user, entity) 30 | print NO_ACTION 31 | end 32 | 33 | # @return [String] the name of the BattleCommand. 34 | def to_s 35 | @name 36 | end 37 | 38 | # @param [BattleCommand] rhs the command on the right. 39 | # @return [Boolean] true iff the commands are considered equal. 40 | def ==(rhs) 41 | @name.casecmp(rhs.name).zero? 42 | end 43 | 44 | attr_accessor :name 45 | 46 | end 47 | 48 | end -------------------------------------------------------------------------------- /spec/goby/battle/use_spec.rb: -------------------------------------------------------------------------------- 1 | require 'goby' 2 | 3 | RSpec.describe Use do 4 | 5 | let(:use) { Use.new } 6 | 7 | context "constructor" do 8 | it "has an appropriate default name" do 9 | expect(use.name).to eq "Use" 10 | end 11 | end 12 | 13 | context "run" do 14 | let(:player) { Player.new(stats: { max_hp: 10, hp: 3 }, 15 | battle_commands: [@use], 16 | inventory: [C[Food.new(recovers: 5), 1]]) } 17 | let(:monster) { Monster.new } 18 | 19 | it "uses the specified item and remove it from the entity's inventory" do 20 | # RSpec input example. Also see spec_helper.rb for __stdin method. 21 | __stdin("food\n", "player\n") do 22 | use.run(player, monster) 23 | expect(player.stats[:hp]).to eq 8 24 | expect(player.inventory.empty?).to be true 25 | end 26 | end 27 | 28 | it "has no effect when the user chooses to pass" do 29 | __stdin("pass\n") do 30 | use.run(player, monster) 31 | expect(player.inventory.empty?).to be false 32 | end 33 | end 34 | end 35 | 36 | context "fails?" do 37 | 38 | let(:entity) { Entity.new } 39 | 40 | it "returns true when the user's inventory is empty" do 41 | expect(use.fails?(entity)).to be true 42 | end 43 | 44 | it "returns false when the user has at least one item" do 45 | entity.add_item(Item.new) 46 | expect(use.fails?(entity)).to be false 47 | end 48 | end 49 | 50 | end 51 | -------------------------------------------------------------------------------- /spec/goby/item/food_spec.rb: -------------------------------------------------------------------------------- 1 | require 'goby' 2 | 3 | RSpec.describe Food do 4 | 5 | let(:food) { Food.new } 6 | let(:magic_banana) { Food.new(name: "Magic Banana", price: 5, 7 | consumable: false, disposable: false, 8 | recovers: 10) } 9 | 10 | context "constructor" do 11 | it "has the correct default parameters" do 12 | expect(food.name).to eq "Food" 13 | expect(food.price).to eq 0 14 | expect(food.consumable).to eq true 15 | expect(food.disposable).to eq true 16 | expect(food.recovers).to eq 0 17 | end 18 | 19 | it "correctly assigns custom parameters" do 20 | expect(magic_banana.name).to eq "Magic Banana" 21 | expect(magic_banana.price).to eq 5 22 | expect(magic_banana.consumable).to eq false 23 | expect(magic_banana.disposable).to eq false 24 | expect(magic_banana.recovers).to eq 10 25 | end 26 | end 27 | 28 | context "use" do 29 | it "heals the entity's HP in a trivial case" do 30 | entity = Entity.new(stats: { hp: 5, max_hp: 20 }) 31 | magic_banana.use(entity, entity) 32 | expect(entity.stats[:hp]).to eq 15 33 | end 34 | 35 | it "does not heal over the entity's max HP" do 36 | entity = Entity.new(stats: { hp: 15, max_hp: 20 }) 37 | magic_banana.use(entity, entity) 38 | expect(entity.stats[:hp]).to eq 20 39 | end 40 | end 41 | 42 | it "heals another entity's HP as appropriate" do 43 | bob = Entity.new(name: "Bob") 44 | marge = Entity.new(name: "Marge", 45 | stats: { hp: 5, max_hp: 20 }) 46 | magic_banana.use(bob, marge) 47 | expect(marge.stats[:hp]).to eq 15 48 | end 49 | 50 | end 51 | -------------------------------------------------------------------------------- /lib/goby/music.rb: -------------------------------------------------------------------------------- 1 | module Goby 2 | 3 | # Methods for playing/stopping background music (BGM). 4 | module Music 5 | 6 | # Specify the program that should play the music. 7 | # Without overwriting, it is set to a default (see @@program). 8 | # 9 | # @param [String] name the name of the music-playing program. 10 | def set_program(name) 11 | @@program = name 12 | end 13 | 14 | # Specify if music should play or not. 15 | # May be useful to stop/start music for dramatic effect. 16 | # 17 | # @param [Boolean] flag true iff music should play. 18 | def set_playback(flag) 19 | @@playback = flag 20 | end 21 | 22 | # Starts playing the music from the specified file. 23 | # This has only been tested on Ubuntu w/ .mid files. 24 | # 25 | # @param [String] filename the file containing the music. 26 | def play_music(filename) 27 | return unless @@playback 28 | 29 | if (filename != @@file) 30 | stop_music 31 | @@file = filename 32 | 33 | # This thread loops the music until one calls #stop_music. 34 | @@thread = Thread.new { 35 | while (true) 36 | Process.wait(@@pid) if @@pid 37 | @@pid = Process.spawn("#{@@program} #{filename}", :out=>"/dev/null") 38 | end 39 | } 40 | end 41 | end 42 | 43 | # Kills the music process and the looping thread. 44 | def stop_music 45 | return unless @@playback 46 | 47 | Process.kill("SIGKILL", @@pid) if @@pid 48 | @@pid = nil 49 | 50 | @@thread.kill if @@thread 51 | @@thread = nil 52 | 53 | @@file = nil 54 | end 55 | 56 | @@file = nil 57 | @@pid = nil 58 | @@playback = false 59 | @@program = "timidity" 60 | @@thread = nil 61 | 62 | end 63 | 64 | end -------------------------------------------------------------------------------- /lib/goby/item/food.rb: -------------------------------------------------------------------------------- 1 | require 'goby' 2 | 3 | module Goby 4 | 5 | # Recovers HP when used. 6 | class Food < Item 7 | 8 | # @param [String] name the name. 9 | # @param [Integer] price the cost in a shop. 10 | # @param [Boolean] consumable upon use, the item is lost when true. 11 | # @param [Boolean] disposable allowed to sell or drop item when true. 12 | # @param [Integer] recovers the amount of HP recovered when used. 13 | def initialize(name: "Food", price: 0, consumable: true, disposable: true, recovers: 0) 14 | super(name: name, price: price, consumable: consumable, disposable: disposable) 15 | @recovers = recovers 16 | end 17 | 18 | # Heals the entity. 19 | # 20 | # @param [Entity] user the one using the food. 21 | # @param [Entity] entity the one on whom the food is used. 22 | def use(user, entity) 23 | if entity.stats[:hp] + recovers > entity.stats[:max_hp] 24 | this_recover = entity.stats[:max_hp] - entity.stats[:hp] 25 | heal_entity(entity, entity.stats[:max_hp]) 26 | else 27 | current_hp = entity.stats[:hp] 28 | this_recover = @recovers 29 | heal_entity(entity, current_hp + this_recover) 30 | end 31 | 32 | # Helpful output. 33 | print "#{user.name} uses #{name}" 34 | if (user == entity) 35 | print " and " 36 | else 37 | print " on #{entity.name}!\n#{entity.name} " 38 | end 39 | print "recovers #{this_recover} HP!\n\n" 40 | print "#{entity.name}'s HP: #{entity.stats[:hp]}/#{entity.stats[:max_hp]}\n\n" 41 | 42 | end 43 | 44 | # The amount of HP that the food recovers. 45 | attr_reader :recovers 46 | 47 | private 48 | 49 | #sets the hp of entity to new_hp 50 | def heal_entity(entity, new_hp) 51 | entity.set_stats(hp: new_hp) 52 | end 53 | 54 | end 55 | 56 | end -------------------------------------------------------------------------------- /lib/goby/item/weapon.rb: -------------------------------------------------------------------------------- 1 | require 'goby' 2 | 3 | module Goby 4 | 5 | # Can be worn in the Player's outfit. 6 | class Weapon < Item 7 | include Equippable 8 | 9 | # @param [String] name the name. 10 | # @param [Integer] price the cost in a shop. 11 | # @param [Boolean] consumable determines whether the item is lost when used. 12 | # @param [Boolean] disposable allowed to sell or drop item when true. 13 | # @param [Hash] stat_change the change in stats for when the item is equipped. 14 | # @param [Attack] attack the attack which is added to the entity's battle commands. 15 | def initialize(name: "Weapon", price: 0, consumable: false, disposable: true, stat_change: {}, attack: nil) 16 | super(name: name, price: price, consumable: consumable, disposable: disposable) 17 | @attack = attack 18 | @type = :weapon 19 | @stat_change = stat_change 20 | end 21 | 22 | # Equips onto the entity and changes the entity's attributes accordingly. 23 | # 24 | # @param [Entity] entity the entity who is equipping the equippable. 25 | def equip(entity) 26 | prev_weapon = nil 27 | if entity.outfit[@type] 28 | prev_weapon = entity.outfit[@type] 29 | end 30 | 31 | super(entity) 32 | 33 | if (prev_weapon && prev_weapon.attack) 34 | entity.remove_battle_command(prev_weapon.attack) 35 | end 36 | 37 | if @attack 38 | entity.add_battle_command(@attack) 39 | end 40 | 41 | end 42 | 43 | # Unequips from the entity and changes the entity's attributes accordingly. 44 | # 45 | # @param [Entity] entity the entity who is unequipping the equippable. 46 | def unequip(entity) 47 | super(entity) 48 | 49 | if @attack 50 | entity.remove_battle_command(@attack) 51 | end 52 | 53 | end 54 | 55 | attr_reader :type, :stat_change 56 | # An instance of Attack. 57 | attr_accessor :attack 58 | end 59 | 60 | end -------------------------------------------------------------------------------- /lib/goby/map/tile.rb: -------------------------------------------------------------------------------- 1 | module Goby 2 | 3 | # Describes a single location on a Map. Can have Events and Monsters. 4 | # Provides variables that control its graphical representation on the Map. 5 | class Tile 6 | 7 | # Default graphic for passable tiles. 8 | DEFAULT_PASSABLE = "·" 9 | # Default graphic for impassable tiles. 10 | DEFAULT_IMPASSABLE = "■" 11 | 12 | # @param [Boolean] passable if true, the player can move here. 13 | # @param [Boolean] seen if true, it will be printed on the map. 14 | # @param [String] description a summary/message of the contents. 15 | # @param [[Event]] events the events found on this tile. 16 | # @param [[Monster]] monsters the monsters found on this tile. 17 | # @param [String] graphic the respresentation of this tile graphically. 18 | def initialize(passable: true, seen: false, description: "", events: [], monsters: [], graphic: nil) 19 | @passable = passable 20 | @seen = seen 21 | @description = description 22 | @events = events 23 | @monsters = monsters 24 | @graphic = graphic.nil? ? default_graphic : graphic 25 | end 26 | 27 | # Create deep copy of Tile. 28 | # 29 | # @return Tile a new Tile object 30 | def clone 31 | # First serialize the object, and then deserialize that into a new ruby object 32 | serialized_tile = Marshal.dump(self) 33 | new_tile = Marshal.load(serialized_tile) 34 | return new_tile 35 | end 36 | 37 | # Convenient conversion to String. 38 | # 39 | # @return [String] the string representation. 40 | def to_s 41 | return @seen ? @graphic + " " : " " 42 | end 43 | 44 | attr_accessor :passable, :seen, :description, :events, :monsters, :graphic 45 | 46 | private 47 | 48 | # Returns the default graphic by considering passable. 49 | def default_graphic 50 | return @passable ? DEFAULT_PASSABLE : DEFAULT_IMPASSABLE 51 | end 52 | 53 | end 54 | 55 | end -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Goby [![Build Status](https://travis-ci.org/nskins/goby.png)](https://travis-ci.org/nskins/goby) [![Coverage Status](https://coveralls.io/repos/github/nskins/goby/badge.svg?branch=master)](https://coveralls.io/github/nskins/goby?branch=master) 2 | 3 | Goby is a Ruby framework for creating [CLI-based](https://en.wikipedia.org/wiki/Command-line_interface) [role-playing games](https://en.wikipedia.org/wiki/Role-playing_video_game). Goby comes with out-of-the-box support for 2D map development, background music, monster battles, customizable items & map events, stats, equipment, and so much more. With thorough testing and documentation, it's even easy to expand upon the framework for special, unique features. If you are looking to create the next classic command-line RPG, then look no further! 4 | 5 | Goby will always be free and open source software. If you have any questions, please contact nskins@umich.edu. 6 | 7 | ## Example Games 8 | 9 | Interested to see what you can do with Goby? Look no further! 10 | 11 | - [Ayara](https://github.com/nskins/ayara): an exploration-based RPG that takes place in a city. 12 | - [Ostrichland](https://github.com/gintavang/Ostrichland): the precursor to the Goby framework! 13 | 14 | ## Getting Started 15 | 16 | In order to start using Goby in your application, follow these instructions: 17 | 18 | Add this line to your application's Gemfile: 19 | 20 | ```ruby 21 | gem 'goby' 22 | ``` 23 | 24 | And then execute: 25 | 26 | $ bundle install 27 | 28 | Or install it yourself as: 29 | 30 | $ gem install goby 31 | 32 | ## Contributing 33 | 34 | Thank you for your interest in contributing! Please read our [guidelines](https://github.com/nskins/goby/blob/master/CONTRIBUTING.md) before sending a pull request. 35 | 36 | ## Documentation 37 | 38 | We use [YARD](https://github.com/lsegal/yard) for documentation. In order to generate the documentation (which will be stored in the doc/ directory), run the following command in the project's root directory: 39 | 40 | $ yardoc 41 | -------------------------------------------------------------------------------- /spec/goby/item/equippable_spec.rb: -------------------------------------------------------------------------------- 1 | require 'goby' 2 | 3 | RSpec.describe Equippable do 4 | let(:equippable) { Class.new { extend Equippable } } 5 | let(:entity) { Entity.new } 6 | 7 | context "placeholder methods" do 8 | 9 | it "forces :stat_change to be implemented" do 10 | expect { equippable.stat_change }.to raise_error(NotImplementedError, 'An Equippable Item must implement a stat_change Hash') 11 | end 12 | 13 | it "forces :type to be implemented" do 14 | expect { equippable.type }.to raise_error(NotImplementedError, 'An Equippable Item must have a type') 15 | end 16 | end 17 | 18 | context "alter stats" do 19 | 20 | before (:each) do 21 | allow(equippable).to receive(:stat_change) { { attack: 2, defense: 3, agility: 4, max_hp: 2 } } 22 | end 23 | 24 | it "changes the entity's stats in the trivial case" do 25 | equippable.alter_stats(entity, true) 26 | expect(entity.stats[:attack]).to eq 3 27 | expect(entity.stats[:defense]).to eq 4 28 | expect(entity.stats[:agility]).to eq 5 29 | expect(entity.stats[:max_hp]).to eq 3 30 | expect(entity.stats[:hp]).to eq 1 31 | 32 | equippable.alter_stats(entity, false) 33 | expect(entity.stats[:attack]).to eq 1 34 | expect(entity.stats[:defense]).to eq 1 35 | expect(entity.stats[:agility]).to eq 1 36 | expect(entity.stats[:max_hp]).to eq 1 37 | expect(entity.stats[:hp]).to eq 1 38 | end 39 | 40 | it "does not lower stats below 1" do 41 | equippable.alter_stats(entity, false) 42 | expect(entity.stats[:attack]).to eq 1 43 | expect(entity.stats[:defense]).to eq 1 44 | expect(entity.stats[:agility]).to eq 1 45 | expect(entity.stats[:max_hp]).to eq 1 46 | expect(entity.stats[:hp]).to eq 1 47 | end 48 | 49 | it "automatically decreases hp to match max_hp" do 50 | entity.set_stats({ max_hp: 3, hp: 2 }) 51 | equippable.alter_stats(entity, false) 52 | expect(entity.stats[:max_hp]).to eq 1 53 | expect(entity.stats[:hp]).to eq 1 54 | end 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /lib/goby/battle/battle.rb: -------------------------------------------------------------------------------- 1 | require 'goby' 2 | 3 | module Goby 4 | 5 | # Representation of a fight between two Fighters. 6 | class Battle 7 | 8 | # @param [Entity] entity_a the first entity in the battle 9 | # @param [Entity] entity_b the second entity in the battle 10 | def initialize(entity_a, entity_b) 11 | @entity_a = entity_a 12 | @entity_b = entity_b 13 | end 14 | 15 | # Determine the winner of the battle 16 | # 17 | # @return [Entity] the winner of the battle 18 | def determine_winner 19 | type("#{entity_a.name} enters a battle with #{entity_b.name}!\n\n") 20 | until someone_dead? 21 | #Determine order of attacks 22 | attackers = determine_order 23 | 24 | # Both choose an attack. 25 | attacks = attackers.map { |attacker| attacker.choose_attack } 26 | 27 | 2.times do |i| 28 | # The attacker runs its attack on the other attacker. 29 | attacks[i].run(attackers[i], attackers[(i + 1) % 2]) 30 | 31 | if (attackers[i].escaped) 32 | attackers[i].escaped = false 33 | return 34 | end 35 | 36 | break if someone_dead? 37 | end 38 | end 39 | 40 | #If @entity_a is dead return @entity_b, otherwise return @entity_a 41 | entity_a.stats[:hp] <=0 ? entity_b : entity_a 42 | end 43 | 44 | private 45 | 46 | # Determine the order of attack based on the entitys' agilities 47 | # 48 | # @return [Array] the entities in the order of attack 49 | def determine_order 50 | sum = entity_a.stats[:agility] + entity_b.stats[:agility] 51 | random_number = Random.rand(0..sum - 1) 52 | 53 | if random_number < entity_a.stats[:agility] 54 | [entity_a, entity_b] 55 | else 56 | [entity_b, entity_a] 57 | end 58 | end 59 | 60 | # Check if either entity is is dead 61 | # 62 | # @return [Boolean] whether an entity is dead or not 63 | def someone_dead? 64 | entity_a.stats[:hp] <= 0 || entity_b.stats[:hp] <= 0 65 | end 66 | 67 | attr_reader :entity_a, :entity_b 68 | end 69 | 70 | end -------------------------------------------------------------------------------- /spec/goby/item/item_spec.rb: -------------------------------------------------------------------------------- 1 | require 'goby' 2 | 3 | RSpec.describe Item do 4 | let(:item) { Item.new } 5 | 6 | context "constructor" do 7 | it "has the correct default parameters" do 8 | expect(item.name).to eq "Item" 9 | expect(item.price).to eq 0 10 | expect(item.consumable).to eq true 11 | expect(item.disposable).to eq true 12 | end 13 | 14 | it "correctly assigns some custom parameters" do 15 | book = Item.new(name: "Book", disposable: false) 16 | expect(book.name).to eq "Book" 17 | expect(book.price).to eq 0 18 | expect(book.consumable).to eq true 19 | expect(book.disposable).to eq false 20 | end 21 | 22 | it "correctly assigns all custom parameters" do 23 | hammer = Item.new(name: "Hammer", 24 | price: 40, 25 | consumable: false, 26 | disposable: false) 27 | expect(hammer.name).to eq "Hammer" 28 | expect(hammer.price).to eq 40 29 | expect(hammer.consumable).to eq false 30 | expect(hammer.disposable).to eq false 31 | end 32 | end 33 | 34 | context "use" do 35 | it "prints the default string for the base item" do 36 | user = Entity.new(name: "User") # who uses the item. 37 | whom = Entity.new(name: "Whom") # on whom the item is used. 38 | expect { item.use(user, whom) }.to output(Item::DEFAULT_USE_TEXT).to_stdout 39 | end 40 | end 41 | 42 | context "equality operator" do 43 | it "returns true for the seemingly trivial case" do 44 | expect(item).to eq Item.new 45 | end 46 | 47 | it "returns true when the names are the only same parameter" do 48 | item1 = Item.new(price: 30, consumable: true) 49 | item2 = Item.new(price: 40, consumable: false) 50 | expect(item1).to eq item2 51 | end 52 | 53 | it "returns false for items with different names" do 54 | hammer = Item.new(name: "Hammer") 55 | banana = Item.new(name: "Banana") 56 | expect(hammer).not_to eq banana 57 | end 58 | end 59 | 60 | context "to_s" do 61 | it "returns the name of the Item" do 62 | expect(item.to_s).to eq item.name 63 | end 64 | end 65 | 66 | end 67 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Thank you for your interest in contributing! Contributors of all skill levels are welcome. We do our best to identify issues that are suitable for open-source newcomers and veterans alike. Please see the [Issues](https://github.com/nskins/goby/issues) tab or tackle a completely unknown problem or feature! Also, please feel free to reach out to the owner ([nskins@umich.edu](mailto:nskins@umich.edu)) for any questions, comments, etc. 4 | 5 | Please follow these guidelines to ease the process of merging your contribution. 6 | 7 | ## Code Style 8 | 9 | Blank lines should contain no spaces. Use standard functions in Ruby to the best of your knowledge (don't reinvent the wheel). Write [DRY](https://en.wikipedia.org/wiki/Don%27t_repeat_yourself) code. Indeed, make your implementation both readable and succinct. 10 | 11 | ## Testing 12 | 13 | Writing test cases is very important and should be done first. All features/bugs must include test(s) verifying their correctness. Aim to cover as many lines of your implementation as possible. There are examples showing how to [provide input](https://github.com/nskins/goby/blob/master/spec/goby/util_spec.rb) and [expect output](https://github.com/nskins/goby/blob/master/spec/goby/event/event_spec.rb). Organize the tests in the same way as the many existing tests: 14 | 15 | ```ruby 16 | Rspec.describe Class do 17 | 18 | context "function" do 19 | it "should do this" do 20 | ... 21 | end 22 | 23 | ... 24 | 25 | it "should do that" do 26 | ... 27 | end 28 | end 29 | 30 | end 31 | ``` 32 | 33 | ## Documentation 34 | 35 | Keep the documentation up-to-date. Use the style present throughout the codebase. For functions, write a description, parameters (if any), and the return value (if non-void). Use your best judgment when writing comments. 36 | 37 | ## Pull Requests 38 | 39 | Ensure that all of your code includes the commits from the `master` branch. Run the `rspec` command in the top-level directory to verify that all tests are passing. You will make your pull request to the branch specified in the issue tracker. If no branch has been mentioned, please write a comment on the appropriate issue, and we will follow up shortly. 40 | -------------------------------------------------------------------------------- /lib/goby/battle/attack.rb: -------------------------------------------------------------------------------- 1 | require 'goby' 2 | 3 | module Goby 4 | 5 | # Simple physical attack. 6 | class Attack < BattleCommand 7 | 8 | # @param [String] name the name. 9 | # @param [Integer] strength the strength. 10 | # @param [Integer] success_rate the chance of success. 11 | def initialize(name: "Attack", strength: 1, success_rate: 100) 12 | super(name: name) 13 | @strength = strength 14 | @success_rate = success_rate 15 | end 16 | 17 | # Determine how much damage this attack will do on the enemy. 18 | # 19 | # @param [Entity] user the one using the attack. 20 | # @param [Entity] enemy the one on whom the attack is used. 21 | # @return [Integer] the amount of damage to inflict on the enemy. 22 | def calculate_damage(user, enemy) 23 | 24 | # RANDOMIZE ATTACK 25 | inflict = Random.rand(0.05..0.15).round(2) 26 | multiplier = 1 27 | 28 | if enemy.stats[:defense] > user.stats[:attack] 29 | multiplier = 1 - ((enemy.stats[:defense] * 0.1) - (user.stats[:attack] * inflict)) 30 | 31 | # Prevent a negative multiplier. 32 | multiplier = 0 if multiplier.negative? 33 | 34 | else 35 | multiplier = 1 + ((user.stats[:attack] * inflict) - (enemy.stats[:defense] * 0.1)) 36 | end 37 | 38 | return (@strength * multiplier).round(0) 39 | end 40 | 41 | # Inflicts damage on the enemy and prints output. 42 | # 43 | # @param [Entity] user the one who is using the attack. 44 | # @param [Entity] enemy the one on whom the attack is used. 45 | def run(user, enemy) 46 | if (Random.rand(100) < @success_rate) 47 | 48 | # Damage the enemy. 49 | original_enemy_hp = enemy.stats[:hp] 50 | damage = calculate_damage(user, enemy) 51 | old_hp = enemy.stats[:hp] 52 | enemy.set_stats(hp: old_hp - damage) 53 | 54 | type("#{user.name} uses #{@name}!\n\n") 55 | type("#{enemy.name} takes #{original_enemy_hp - enemy.stats[:hp]} damage!\n") 56 | type("#{enemy.name}'s HP: #{original_enemy_hp} -> #{enemy.stats[:hp]}\n\n") 57 | 58 | else 59 | type("#{user.name} tries to use #{@name}, but it fails.\n\n") 60 | end 61 | 62 | end 63 | 64 | attr_accessor :strength, :success_rate 65 | 66 | end 67 | 68 | end -------------------------------------------------------------------------------- /spec/goby/map/tile_spec.rb: -------------------------------------------------------------------------------- 1 | require 'goby' 2 | 3 | RSpec.describe Tile do 4 | 5 | context "constructor" do 6 | it "has the correct default parameters" do 7 | tile = Tile.new 8 | expect(tile.passable).to eq true 9 | expect(tile.seen).to eq false 10 | expect(tile.description).to eq "" 11 | expect(tile.events).to eq [] 12 | expect(tile.monsters).to eq [] 13 | expect(tile.graphic).to eq Tile::DEFAULT_PASSABLE 14 | end 15 | 16 | it "correctly assigns default graphic for non-passable tiles" do 17 | tile = Tile.new(passable:false) 18 | expect(tile.graphic).to eq Tile::DEFAULT_IMPASSABLE 19 | end 20 | 21 | it "correctly overrides the default passable graphic" do 22 | tile = Tile.new(graphic: '$') 23 | expect(tile.graphic).to eq '$' 24 | end 25 | 26 | it "correctly assigns custom parameters" do 27 | pond = Tile.new(passable: false, 28 | seen: true, 29 | description: "Wet", 30 | events: [Event.new], 31 | monsters: [Monster.new], 32 | graphic: '#') 33 | expect(pond.passable).to eq false 34 | expect(pond.seen).to eq true 35 | expect(pond.description).to eq "Wet" 36 | expect(pond.events).to eq [Event.new] 37 | expect(pond.monsters).to eq [Monster.new] 38 | expect(pond.graphic).to eq '#' 39 | end 40 | end 41 | 42 | context "to s" do 43 | it "should return two spaces when unseen" do 44 | tile = Tile.new(seen: false, graphic: '$') 45 | expect(tile.to_s).to eq " " 46 | end 47 | 48 | it "should return the graphic & one space when seen" do 49 | tile = Tile.new(seen: true, graphic: '$') 50 | expect(tile.to_s).to eq "$ " 51 | end 52 | end 53 | 54 | context "clone" do 55 | it "should make a deep copy of the tile" do 56 | tile = Tile.new(description: "This is a test tile", events: [Event.new], monsters: [Monster.new]) 57 | new_tile = tile.clone 58 | 59 | new_tile.events.pop 60 | new_tile.monsters.pop 61 | new_tile.description = "This is the deep-copied clone tile" 62 | 63 | expect(tile.events.length).not_to eq(0) 64 | expect(tile.monsters.length).not_to eq(0) 65 | expect(tile.description).not_to eq(new_tile.description) 66 | end 67 | end 68 | end 69 | -------------------------------------------------------------------------------- /spec/goby/item/weapon_spec.rb: -------------------------------------------------------------------------------- 1 | require 'goby' 2 | 3 | RSpec.describe Weapon do 4 | 5 | let(:weapon) { Weapon.new } 6 | let(:entity) { Entity.new.extend(Fighter) } 7 | let(:dummy_fighter_class) { Class.new(Entity) {include Fighter} } 8 | 9 | context "constructor" do 10 | it "has the correct default parameters" do 11 | expect(weapon.name).to eq "Weapon" 12 | expect(weapon.price).to eq 0 13 | expect(weapon.consumable).to eq false 14 | expect(weapon.disposable).to eq true 15 | expect(weapon.type).to eq :weapon 16 | end 17 | 18 | it "correctly assigns custom parameters" do 19 | pencil = Weapon.new(name: "Pencil", 20 | price: 20, 21 | consumable: true, 22 | disposable: false, 23 | stat_change: {attack: 2, defense: 2}) 24 | expect(pencil.name).to eq "Pencil" 25 | expect(pencil.price).to eq 20 26 | expect(pencil.consumable).to eq true 27 | expect(pencil.disposable).to eq false 28 | expect(pencil.stat_change[:attack]).to eq 2 29 | expect(pencil.stat_change[:defense]).to eq 2 30 | # Cannot be overwritten. 31 | expect(pencil.type).to eq :weapon 32 | end 33 | end 34 | 35 | context "equip" do 36 | it "correctly equips the weapon and alters the stats of a Fighter Entity" do 37 | weapon = Weapon.new(stat_change: {attack: 3}, 38 | attack: Attack.new) 39 | weapon.equip(entity) 40 | expect(entity.outfit[:weapon]).to eq Weapon.new 41 | expect(entity.stats[:attack]).to eq 4 42 | expect(entity.battle_commands).to eq [Attack.new] 43 | end 44 | end 45 | 46 | context "use" do 47 | it "should print an appropriate message for how to equip" do 48 | expect { weapon.use(entity, entity) }.to output( 49 | "Type 'equip Weapon' to equip this item.\n\n" 50 | ).to_stdout 51 | end 52 | end 53 | 54 | context "unequip" do 55 | it "correctly unequips an equipped item from a Fighter Entity" do 56 | weapon = Weapon.new(stat_change: {agility: 4}, 57 | attack: Attack.new) 58 | entity = dummy_fighter_class.new(outfit: {weapon: weapon}) 59 | 60 | weapon.unequip(entity) 61 | expect(entity.outfit).to be_empty 62 | expect(entity.battle_commands).to be_empty 63 | expect(entity.stats[:agility]).to eq 1 64 | end 65 | end 66 | 67 | end 68 | -------------------------------------------------------------------------------- /lib/goby/item/equippable.rb: -------------------------------------------------------------------------------- 1 | module Goby 2 | 3 | # Provides methods for equipping & unequipping an Item. 4 | module Equippable 5 | 6 | # The function that returns the type of the item. 7 | # Subclasses must override this function. 8 | # 9 | def stat_change 10 | raise(NotImplementedError, 'An Equippable Item must implement a stat_change Hash') 11 | end 12 | 13 | # The function that returns the change in stats for when the item is equipped. 14 | # Subclasses must override this function. 15 | # 16 | def type 17 | raise(NotImplementedError, 'An Equippable Item must have a type') 18 | end 19 | 20 | # Alters the stats of the entity 21 | # 22 | # @param [Entity] entity the entity equipping/unequipping the item. 23 | # @param [Boolean] equipping flag for when the item is being equipped or unequipped. 24 | # @todo ensure stats cannot go below zero (but does it matter..?). 25 | def alter_stats(entity, equipping) 26 | stats_to_change = entity.stats.dup 27 | affected_stats = [:attack, :defense, :agility, :max_hp] 28 | 29 | operator = equipping ? :+ : :- 30 | affected_stats.each do |stat| 31 | stats_to_change[stat]= stats_to_change[stat].send(operator, stat_change[stat]) if stat_change[stat] 32 | end 33 | 34 | entity.set_stats(stats_to_change) 35 | 36 | #do not kill entity by unequipping 37 | if entity.stats[:hp] < 1 38 | entity.set_stats(hp: 1) 39 | end 40 | end 41 | 42 | # Equips onto the entity and changes the entity's attributes accordingly. 43 | # 44 | # @param [Entity] entity the entity who is equipping the equippable. 45 | def equip(entity) 46 | prev_item = entity.outfit[type] 47 | 48 | entity.outfit[type] = self 49 | alter_stats(entity, true) 50 | 51 | if prev_item 52 | prev_item.alter_stats(entity, false) 53 | entity.add_item(prev_item) 54 | end 55 | 56 | print "#{entity.name} equips #{@name}!\n\n" 57 | end 58 | 59 | # Unequips from the entity and changes the entity's attributes accordingly. 60 | # 61 | # @param [Entity] entity the entity who is unequipping the equippable. 62 | def unequip(entity) 63 | entity.outfit.delete(type) 64 | alter_stats(entity, false) 65 | 66 | print "#{entity.name} unequips #{@name}!\n\n" 67 | end 68 | 69 | # The function that executes when one uses the equippable. 70 | # 71 | # @param [Entity] user the one using the item. 72 | # @param [Entity] entity the one on whom the item is used. 73 | def use(user, entity) 74 | print "Type 'equip #{@name}' to equip this item.\n\n" 75 | end 76 | 77 | end 78 | 79 | end -------------------------------------------------------------------------------- /spec/goby/extension_spec.rb: -------------------------------------------------------------------------------- 1 | require 'goby' 2 | 3 | RSpec.describe Array do 4 | context "nonempty?" do 5 | it "returns true when the array contains elements" do 6 | expect([1].nonempty?).to be true 7 | expect(["Hello"].nonempty?).to be true 8 | expect([false, false].nonempty?).to be true 9 | end 10 | 11 | it "returns false when the array contains no elements" do 12 | expect([].nonempty?).to be false 13 | expect(Array.new.nonempty?).to be false 14 | end 15 | end 16 | end 17 | 18 | RSpec.describe Integer do 19 | 20 | context "is positive?" do 21 | it "returns true for an integer value that is greater than zero" do 22 | expect(0.positive?).to be false 23 | expect(1.positive?).to be true 24 | expect(-1.positive?).to be false 25 | end 26 | end 27 | 28 | context "is nonpositive?" do 29 | it "returns true for an integer value less than or equal to zero" do 30 | expect(0.nonpositive?).to be true 31 | expect(1.nonpositive?).to be false 32 | expect(-1.nonpositive?).to be true 33 | end 34 | end 35 | 36 | context "is negative?" do 37 | it "returns true for an integer value less than zero" do 38 | expect(0.negative?).to be false 39 | expect(1.negative?).to be false 40 | expect(-1.negative?).to be true 41 | end 42 | end 43 | 44 | context "is nonnegative?" do 45 | it "returns true for an integer value greater than or equal to zero" do 46 | expect(0.nonnegative?).to be true 47 | expect(1.nonnegative?).to be true 48 | expect(-1.nonnegative?).to be false 49 | end 50 | end 51 | 52 | end 53 | 54 | RSpec.describe String do 55 | 56 | context "is positive?" do 57 | it "returns true for a positive string" do 58 | expect("y".is_positive?).to be true 59 | expect("yes".is_positive?).to be true 60 | expect("yeah".is_positive?).to be true 61 | expect("ok".is_positive?).to be true 62 | end 63 | 64 | it "returns false for a non-positive string" do 65 | expect("maybe".is_positive?).to be false 66 | expect("whatever".is_positive?).to be false 67 | expect("no".is_positive?).to be false 68 | expect("nah".is_positive?).to be false 69 | end 70 | end 71 | 72 | context "is negative?" do 73 | it "returns true for a negative string" do 74 | expect("n".is_negative?).to be true 75 | expect("no".is_negative?).to be true 76 | expect("nah".is_negative?).to be true 77 | expect("nope".is_negative?).to be true 78 | end 79 | 80 | it "returns false for a non-negative string" do 81 | expect("maybe".is_negative?).to be false 82 | expect("whatever".is_negative?).to be false 83 | expect("sure".is_negative?).to be false 84 | expect("okey dokey".is_negative?).to be false 85 | end 86 | end 87 | 88 | end 89 | -------------------------------------------------------------------------------- /spec/goby/event/chest_spec.rb: -------------------------------------------------------------------------------- 1 | require 'goby' 2 | 3 | RSpec.describe Chest do 4 | 5 | let!(:player) { Player.new } 6 | let!(:chest) { Chest.new } 7 | let!(:gold_chest) { Chest.new(gold: 100) } 8 | let!(:treasure_chest) { Chest.new(treasures: [Item.new, Food.new, Helmet.new]) } 9 | let!(:giant_chest) { Chest.new(gold: 100, treasures: [Item.new, Food.new, Helmet.new]) } 10 | 11 | context "constructor" do 12 | it "has the correct default parameters" do 13 | expect(chest.mode).to be_zero 14 | expect(chest.visible).to be true 15 | expect(chest.gold).to be_zero 16 | expect(chest.treasures).to be_empty 17 | end 18 | 19 | it "correctly assigns custom parameters" do 20 | chest = Chest.new(mode: 5, visible: false, gold: 3, treasures: [Item.new]) 21 | expect(chest.mode).to eq 5 22 | expect(chest.visible).to be false 23 | expect(chest.gold).to be 3 24 | expect(chest.treasures[0]).to eq Item.new 25 | end 26 | end 27 | 28 | context "run" do 29 | it "should no longer be visible after the first open" do 30 | chest.run(player) 31 | expect(chest.visible).to be false 32 | end 33 | 34 | it "correctly gives the player nothing for no gold, no treasure" do 35 | chest.run(player) 36 | expect(player.gold).to be_zero 37 | expect(player.inventory).to be_empty 38 | end 39 | 40 | it "correctly gives the player only gold when no treasure" do 41 | gold_chest.run(player) 42 | expect(player.gold).to be 100 43 | expect(player.inventory).to be_empty 44 | end 45 | 46 | it "correctly gives the player only treasure when no gold" do 47 | treasure_chest.run(player) 48 | expect(player.gold).to be_zero 49 | expect(player.inventory.size).to be 3 50 | end 51 | 52 | it "correctly gives the player both gold and treasure when both" do 53 | giant_chest.run(player) 54 | expect(player.gold).to be 100 55 | expect(player.inventory.size).to be 3 56 | end 57 | 58 | it "outputs that there is no loot for no gold, no treasure" do 59 | expect { chest.run(player) }.to output( 60 | "You open the treasure chest...\n\nLoot: nothing!\n\n" 61 | ).to_stdout 62 | end 63 | 64 | it "outputs that there is only gold when no treasure" do 65 | expect { gold_chest.run(player) }.to output( 66 | "You open the treasure chest...\n\nLoot: \n* 100 gold\n\n" 67 | ).to_stdout 68 | end 69 | 70 | it "outputs that there is only treasure when no gold" do 71 | expect { treasure_chest.run(player) }.to output( 72 | "You open the treasure chest...\n\n"\ 73 | "Loot: \n* Item\n* Food\n* Helmet\n\n" 74 | ).to_stdout 75 | end 76 | 77 | it "outputs that there is both treasure and gold when both" do 78 | expect { giant_chest.run(player) }.to output( 79 | "You open the treasure chest...\n\n"\ 80 | "Loot: \n* 100 gold\n* Item\n* Food\n* Helmet\n\n" 81 | ).to_stdout 82 | end 83 | end 84 | 85 | end 86 | -------------------------------------------------------------------------------- /spec/goby/battle/attack_spec.rb: -------------------------------------------------------------------------------- 1 | require 'goby' 2 | 3 | RSpec.describe Goby::Attack do 4 | 5 | let!(:user) { Player.new(stats: { max_hp: 50, attack: 6, defense: 4 }) } 6 | let!(:enemy) { Monster.new(stats: { max_hp: 30, attack: 3, defense: 2 }) } 7 | let(:attack) { Attack.new(strength: 5) } 8 | let(:cry) { Attack.new(name: "Cry", success_rate: 0) } 9 | 10 | context "constructor" do 11 | it "has the correct default parameters" do 12 | attack = Attack.new 13 | expect(attack.name).to eq "Attack" 14 | expect(attack.strength).to eq 1 15 | expect(attack.success_rate).to eq 100 16 | end 17 | 18 | it "correctly assigns all custom parameters" do 19 | poke = Attack.new(name: "Poke", 20 | strength: 12, 21 | success_rate: 95) 22 | expect(poke.name).to eq "Poke" 23 | expect(poke.strength).to eq 12 24 | expect(poke.success_rate).to eq 95 25 | end 26 | 27 | it "correctly assigns only one custom parameter" do 28 | attack = Attack.new(success_rate: 77) 29 | expect(attack.name).to eq "Attack" 30 | expect(attack.strength).to eq 1 31 | expect(attack.success_rate).to eq 77 32 | end 33 | end 34 | 35 | context "equality operator" do 36 | it "returns true for the seemingly trivial case" do 37 | expect(Attack.new).to eq Attack.new 38 | end 39 | 40 | it "returns false for commands with different names" do 41 | poke = Attack.new(name: "Poke") 42 | kick = Attack.new(name: "Kick") 43 | expect(poke).not_to eq kick 44 | end 45 | end 46 | 47 | context "run" do 48 | it "does the appropriate amount of damage for attack > defense" do 49 | attack.run(user, enemy) 50 | expect(enemy.stats[:hp]).to be_between(21, 24) 51 | end 52 | 53 | it "prevents the enemy's HP from falling below 0" do 54 | user.set_stats(attack: 200) 55 | attack.run(user, enemy) 56 | expect(enemy.stats[:hp]).to be_zero 57 | end 58 | 59 | it "does the appropriate amount of damage for defense > attack" do 60 | attack.run(enemy, user) 61 | expect(user.stats[:hp]).to be_between(45, 46) 62 | end 63 | 64 | it "prints an appropriate message for a failed attack" do 65 | expect { cry.run(user, enemy) }.to output( 66 | "Player tries to use Cry, but it fails.\n\n" 67 | ).to_stdout 68 | end 69 | end 70 | 71 | context "calculate damage" do 72 | it "returns within the appropriate range for attack > defense" do 73 | damage = attack.calculate_damage(user, enemy) 74 | expect(damage).to be_between(6, 9) 75 | end 76 | 77 | it "returns within the appropriate range for defense > attack" do 78 | damage = attack.calculate_damage(enemy, user) 79 | expect(damage).to be_between(4, 5) 80 | end 81 | 82 | it "defaults to 0 when the defense is very high" do 83 | enemy.set_stats(defense: 100) 84 | damage = attack.calculate_damage(user, enemy) 85 | expect(damage).to be_zero 86 | end 87 | end 88 | 89 | end 90 | -------------------------------------------------------------------------------- /lib/goby/entity/monster.rb: -------------------------------------------------------------------------------- 1 | require 'goby' 2 | 3 | module Goby 4 | 5 | # An Entity controlled by the CPU. Used for battle against Players. 6 | class Monster < Entity 7 | 8 | include Fighter 9 | 10 | # @param [String] name the name. 11 | # @param [Hash] stats hash of stats 12 | # @param [[C(Item, Integer)]] inventory an array of pairs of items and their respective amounts. 13 | # @param [Integer] gold the max amount of gold that can be rewarded to the opponent. 14 | # @param [[BattleCommand]] battle_commands the commands that can be used in battle. 15 | # @param [Hash] outfit the coolection of equippable items currently worn. 16 | # @param [[C(Item, Integer)]] treasures an array of treasures and the likelihood of receiving each. 17 | def initialize(name: "Monster", stats: {}, inventory: [], gold: 0, battle_commands: [], outfit: {}, 18 | treasures: []) 19 | super(name: name, stats: stats, inventory: inventory, gold: gold, outfit: outfit) 20 | @treasures = treasures 21 | 22 | # Find the total number of treasures in the distribution. 23 | @total_treasures = 0 24 | @treasures.each do |pair| 25 | @total_treasures += pair.second 26 | end 27 | 28 | add_battle_commands(battle_commands) 29 | end 30 | 31 | # Provides a deep copy of the monster. This is necessary since 32 | # the monster can use up its items in battle. 33 | # 34 | # @return [Monster] deep copy of the monster. 35 | def clone 36 | # Create a shallow copy for most of the variables. 37 | monster = super 38 | 39 | # Reset the copy's inventory. 40 | monster.inventory = [] 41 | 42 | # Create a deep copy of the inventory. 43 | @inventory.each do |pair| 44 | monster.inventory << C[pair.first.clone, pair.second] 45 | end 46 | 47 | return monster 48 | end 49 | 50 | # What to do if the Monster dies in a Battle. 51 | def die 52 | # Do nothing special. 53 | end 54 | 55 | # What to do if a Monster wins a Battle. 56 | def handle_victory(fighter) 57 | # Take some of the Player's gold. 58 | fighter.sample_gold 59 | end 60 | 61 | # The amount gold given to a victorious Entity after losing a battle 62 | # 63 | # @return[Integer] the amount of gold to award the victorious Entity 64 | def sample_gold 65 | # Sample a random amount of gold. 66 | Random.rand(0..@gold) 67 | end 68 | 69 | # Chooses a treasure based on the sample distribution. 70 | # 71 | # @return [Item] the reward for the victor of the battle (or nil - no treasure). 72 | def sample_treasures 73 | # Return nil for no treasures. 74 | return if total_treasures.zero? 75 | 76 | # Choose uniformly from the total given above. 77 | index = Random.rand(total_treasures) 78 | 79 | # Choose the treasure based on the distribution. 80 | total = 0 81 | treasures.each do |pair| 82 | total += pair.second 83 | return pair.first if index < total 84 | end 85 | end 86 | 87 | attr_reader :treasures, :total_treasures 88 | end 89 | 90 | end 91 | -------------------------------------------------------------------------------- /bin/bundle: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | # 5 | # This file was generated by Bundler. 6 | # 7 | # The application 'bundle' is installed as part of a gem, and 8 | # this file is here to facilitate running it. 9 | # 10 | 11 | require "rubygems" 12 | 13 | m = Module.new do 14 | module_function 15 | 16 | def invoked_as_script? 17 | File.expand_path($0) == File.expand_path(__FILE__) 18 | end 19 | 20 | def env_var_version 21 | ENV["BUNDLER_VERSION"] 22 | end 23 | 24 | def cli_arg_version 25 | return unless invoked_as_script? # don't want to hijack other binstubs 26 | return unless "update".start_with?(ARGV.first || " ") # must be running `bundle update` 27 | bundler_version = nil 28 | update_index = nil 29 | ARGV.each_with_index do |a, i| 30 | if update_index && update_index.succ == i && a =~ Gem::Version::ANCHORED_VERSION_PATTERN 31 | bundler_version = a 32 | end 33 | next unless a =~ /\A--bundler(?:[= ](#{Gem::Version::VERSION_PATTERN}))?\z/ 34 | bundler_version = $1 || ">= 0.a" 35 | update_index = i 36 | end 37 | bundler_version 38 | end 39 | 40 | def gemfile 41 | gemfile = ENV["BUNDLE_GEMFILE"] 42 | return gemfile if gemfile && !gemfile.empty? 43 | 44 | File.expand_path("../../Gemfile", __FILE__) 45 | end 46 | 47 | def lockfile 48 | lockfile = 49 | case File.basename(gemfile) 50 | when "gems.rb" then gemfile.sub(/\.rb$/, gemfile) 51 | else "#{gemfile}.lock" 52 | end 53 | File.expand_path(lockfile) 54 | end 55 | 56 | def lockfile_version 57 | return unless File.file?(lockfile) 58 | lockfile_contents = File.read(lockfile) 59 | return unless lockfile_contents =~ /\n\nBUNDLED WITH\n\s{2,}(#{Gem::Version::VERSION_PATTERN})\n/ 60 | Regexp.last_match(1) 61 | end 62 | 63 | def bundler_version 64 | @bundler_version ||= begin 65 | env_var_version || cli_arg_version || 66 | lockfile_version || "#{Gem::Requirement.default}.a" 67 | end 68 | end 69 | 70 | def load_bundler! 71 | ENV["BUNDLE_GEMFILE"] ||= gemfile 72 | 73 | # must dup string for RG < 1.8 compatibility 74 | activate_bundler(bundler_version.dup) 75 | end 76 | 77 | def activate_bundler(bundler_version) 78 | if Gem::Version.correct?(bundler_version) && Gem::Version.new(bundler_version).release < Gem::Version.new("2.0") 79 | bundler_version = "< 2" 80 | end 81 | gem_error = activation_error_handling do 82 | gem "bundler", bundler_version 83 | end 84 | return if gem_error.nil? 85 | require_error = activation_error_handling do 86 | require "bundler/version" 87 | end 88 | return if require_error.nil? && Gem::Requirement.new(bundler_version).satisfied_by?(Gem::Version.new(Bundler::VERSION)) 89 | warn "Activating bundler (#{bundler_version}) failed:\n#{gem_error.message}\n\nTo install the version of bundler this project requires, run `gem install bundler -v '#{bundler_version}'`" 90 | exit 42 91 | end 92 | 93 | def activation_error_handling 94 | yield 95 | nil 96 | rescue StandardError, LoadError => e 97 | e 98 | end 99 | end 100 | 101 | m.load_bundler! 102 | 103 | if m.invoked_as_script? 104 | load Gem.bin_path("bundler", "bundle") 105 | end 106 | -------------------------------------------------------------------------------- /lib/goby/util.rb: -------------------------------------------------------------------------------- 1 | require 'readline' 2 | require 'yaml' 3 | 4 | # Collection of classes, modules, and functions that make 5 | # up the Goby framework. 6 | module Goby 7 | 8 | # Stores a pair of values as a couple. 9 | class C 10 | 11 | # Syntactic sugar to create a couple using C[a, b] 12 | # 13 | # @param [Object] first the first object in the pair. 14 | # @param [Object] second the second object in the pair. 15 | def self.[](first, second) 16 | C.new(first, second) 17 | end 18 | 19 | # @param [Object] first the first object in the pair. 20 | # @param [Object] second the second object in the pair. 21 | def initialize(first, second) 22 | @first = first 23 | @second = second 24 | end 25 | 26 | # @param [C] rhs the couple on the right. 27 | def ==(rhs) 28 | return ((@first == rhs.first) && (@second == rhs.second)) 29 | end 30 | 31 | attr_accessor :first, :second 32 | end 33 | 34 | # The combination of a map and y-x coordinates, 35 | # which determine a specific position/location on the map. 36 | class Location 37 | 38 | # Location constructor. 39 | # 40 | # @param [Map] map the map component. 41 | # @param [C(Integer, Integer)] coords a pair of y-x coordinates. 42 | def initialize(map, coords) 43 | @map = map 44 | @coords = coords 45 | end 46 | 47 | attr_reader :map, :coords 48 | end 49 | 50 | # Simple player input script. 51 | # 52 | # @param [Boolean] lowercase mark true if response should be returned lowercase. 53 | # @param [String] prompt the prompt for the user to input information. 54 | # @param [Boolean] doublespace mark false if extra space should not be printed after input. 55 | def player_input(lowercase: true, prompt: '', doublespace: true) 56 | 57 | # When using Readline, rspec actually prompts the user for input, freezing the tests. 58 | print prompt 59 | input = (ENV['TEST'] == 'rspec') ? gets.chomp : Readline.readline(" \b", false) 60 | puts "\n" if doublespace 61 | 62 | if ((input.size > 1) and (input != Readline::HISTORY.to_a[-1])) 63 | Readline::HISTORY.push(input) 64 | end 65 | 66 | return lowercase ? input.downcase : input 67 | end 68 | 69 | # Prints text as if it were being typed. 70 | # 71 | # @param [String] message the message to type out. 72 | def type(message) 73 | 74 | # Amount of time to sleep between printing character. 75 | time = ENV['TEST'] ? 0 : 0.015 76 | 77 | # Sleep between printing of each char. 78 | message.split("").each do |i| 79 | sleep(time) if time.nonzero? 80 | print i 81 | end 82 | end 83 | 84 | # Serializes the player object into a YAML file and saves it 85 | # 86 | # @param [Player] player the player object to be saved. 87 | # @param [String] filename the name under which to save the file. 88 | def save_game(player, filename) 89 | 90 | # Set 'moved' to true so we see minimap on game load. 91 | player.moved = true 92 | player_data = YAML::dump(player) 93 | player.moved = false 94 | 95 | File.open(filename, "w") do |file| 96 | file.puts player_data 97 | end 98 | print "Successfully saved the game!\n\n" 99 | return 100 | end 101 | 102 | # Reads and check the save file and parses into the player object 103 | # 104 | # @param [String] filename the file containing the save data. 105 | # @return [Player] the player corresponding to the save data. 106 | def load_game(filename) 107 | begin 108 | player = YAML.load_file(filename) 109 | return player 110 | rescue 111 | return nil 112 | end 113 | end 114 | 115 | end 116 | -------------------------------------------------------------------------------- /spec/goby/battle/battle_spec.rb: -------------------------------------------------------------------------------- 1 | require 'goby' 2 | 3 | RSpec.describe Goby::Battle do 4 | 5 | let(:dummy_fighter_class) { 6 | Class.new(Entity) do 7 | include Fighter 8 | 9 | def initialize(name: "Player", stats: {}, inventory: [], gold: 0, battle_commands: [], outfit: {}) 10 | super(name: name, stats: stats, inventory: inventory, gold: gold, outfit: outfit) 11 | add_battle_commands(battle_commands) 12 | end 13 | end 14 | } 15 | 16 | context "constructor" do 17 | it "takes two arguments" do 18 | expect { Battle.new }.to raise_error(ArgumentError, "wrong number of arguments (given 0, expected 2)") 19 | 20 | entity_1 = entity_2 = double 21 | battle = Battle.new(entity_1, entity_2) 22 | expect(battle).to be_a Battle 23 | end 24 | end 25 | 26 | context "determine_winner" do 27 | it "prompts both entities to choose an attack" do 28 | entity_1 = spy('entity_1', stats: {hp: 1, agility: 1}) 29 | entity_2 = spy('entity_2', stats: {hp: 1, agility: 1}) 30 | Battle.new(entity_1, entity_2).determine_winner 31 | 32 | expect(entity_1).to have_received(:choose_attack) 33 | expect(entity_2).to have_received(:choose_attack) 34 | end 35 | 36 | it "returns the entity with positive hp" do 37 | entity_1 = dummy_fighter_class.new(name: "Player", 38 | stats: {max_hp: 20, 39 | hp: 15, 40 | attack: 2, 41 | defense: 2, 42 | agility: 4}, 43 | outfit: {weapon: Weapon.new( 44 | attack: Attack.new, 45 | stat_change: {attack: 3, defense: 1} 46 | ), 47 | helmet: Helmet.new( 48 | stat_change: {attack: 1, defense: 5} 49 | ) 50 | }, 51 | battle_commands: [ 52 | Attack.new(name: "Scratch"), 53 | Attack.new(name: "Kick") 54 | ]) 55 | 56 | entity_2 = dummy_fighter_class.new(name: "Clown", 57 | stats: {max_hp: 20, 58 | hp: 15, 59 | attack: 2, 60 | defense: 2, 61 | agility: 4}, 62 | outfit: {weapon: Weapon.new( 63 | attack: Attack.new, 64 | stat_change: {attack: 3, defense: 1} 65 | ), 66 | helmet: Helmet.new( 67 | stat_change: {attack: 1, defense: 5} 68 | ) 69 | }, 70 | battle_commands: [ 71 | Attack.new(name: "Scratch"), 72 | Attack.new(name: "Kick") 73 | ]) 74 | 75 | battle = Battle.new(entity_1, entity_2) 76 | winner = battle.determine_winner 77 | expect([entity_1, entity_2].include?(winner)).to be true 78 | 79 | loser = ([entity_1, entity_2] - [winner])[0] 80 | expect(winner.stats[:hp]).to be > 0 81 | expect(loser.stats[:hp]).to eql(0) 82 | end 83 | 84 | end 85 | end -------------------------------------------------------------------------------- /lib/goby/world_command.rb: -------------------------------------------------------------------------------- 1 | module Goby 2 | 3 | # Functions that handle commands on the "world map." 4 | module WorldCommand 5 | 6 | # String literal providing default commands. 7 | DEFAULT_COMMANDS = 8 | %{ Command Purpose 9 | 10 | w (↑) 11 | a (←) s (↓) d (→) Movement 12 | 13 | help Show the help menu 14 | map Print the map 15 | inv Check inventory 16 | status Show player status 17 | use [item] Use the specified item 18 | drop [item] Drop the specified item 19 | equip [item] Equip the specified item 20 | unequip [item] Unequip the specified item 21 | save Save the game 22 | quit Quit the game 23 | 24 | } 25 | 26 | # String literal for the special commands header. 27 | SPECIAL_COMMANDS_HEADER = "* Special commands: " 28 | # Message for when the player tries to drop a non-existent item. 29 | NO_ITEM_DROP_ERROR = "You can't drop what you don't have!\n\n" 30 | 31 | # Prints the commands that are available everywhere. 32 | def display_default_commands 33 | print DEFAULT_COMMANDS 34 | end 35 | 36 | # Prints the commands that are tile-specific. 37 | # 38 | # @param [Player] player the player who wants to see the special commands. 39 | def display_special_commands(player) 40 | events = player.location.map.tiles[player.location.coords.first][player.location.coords.second].events 41 | if events.nonempty? && events.any? { |event| event.visible } 42 | 43 | print SPECIAL_COMMANDS_HEADER + (events.reduce([]) do |commands, event| 44 | commands << event.command if event.visible 45 | commands 46 | end.join(', ')) + "\n\n" 47 | end 48 | end 49 | 50 | # Prints the default and special (tile-specific) commands. 51 | # 52 | # @param [Player] player the player who needs help. 53 | def help(player) 54 | display_default_commands 55 | display_special_commands(player) 56 | end 57 | 58 | # Describes the tile to the player after each move. 59 | # 60 | # @param [Player] player the player who needs the tile description. 61 | def describe_tile(player) 62 | tile = player.location.map.tiles[player.location.coords.first][player.location.coords.second] 63 | events = tile.events 64 | 65 | player.print_minimap 66 | print "#{tile.description}\n\n" 67 | display_special_commands(player) 68 | end 69 | 70 | # Handles the player's input and executes the appropriate action. 71 | # 72 | # @param [String] command the player's entire command input. 73 | # @param [Player] player the player using the command. 74 | def interpret_command(command, player) 75 | return if command.eql?("quit") 76 | 77 | words = command.split() 78 | 79 | # Default commands that take multiple "arguments" (words). 80 | if (words.size > 1) 81 | 82 | # TODO: this chunk could be a private function. 83 | # Determine the name of the second "argument." 84 | name = words[1] 85 | for i in 2..(words.size - 1) do 86 | name << " " << words[i] 87 | end 88 | 89 | # Determine the appropriate command to use. 90 | # TODO: some of those help messages should be string literals. 91 | if words[0].casecmp("drop").zero? 92 | index = player.has_item(name) 93 | if index && !player.inventory[index].first.disposable 94 | print "You cannot drop that item.\n\n" 95 | elsif index 96 | # TODO: Perhaps the player should be allowed to specify 97 | # how many of the Item to drop. 98 | item = player.inventory[index].first 99 | player.remove_item(item, 1) 100 | print "You have dropped #{item}.\n\n" 101 | else 102 | print NO_ITEM_DROP_ERROR 103 | end 104 | return 105 | elsif words[0].casecmp("equip").zero? 106 | player.equip_item(name); return 107 | elsif words[0].casecmp("unequip").zero? 108 | player.unequip_item(name); return 109 | elsif words[0].casecmp("use").zero? 110 | player.use_item(name, player); return 111 | end 112 | end 113 | 114 | # TODO: map command input to functions? Maybe this can 115 | # also be done with the multiple-word commands? 116 | if command.casecmp("w").zero? 117 | player.move_up; return 118 | elsif command.casecmp("a").zero? 119 | player.move_left; return 120 | elsif command.casecmp("s").zero? 121 | player.move_down; return 122 | elsif command.casecmp("d").zero? 123 | player.move_right; return 124 | elsif command.casecmp("help").zero? 125 | help(player); return 126 | elsif command.casecmp("map").zero? 127 | player.print_map; return 128 | elsif command.casecmp("inv").zero? 129 | player.print_inventory; return 130 | elsif command.casecmp("status").zero? 131 | player.print_status; return 132 | elsif command.casecmp("save").zero? 133 | save_game(player, "player.yaml"); return 134 | end 135 | 136 | # Other commands. 137 | events = player.location.map.tiles[player.location.coords.first][player.location.coords.second].events 138 | events.each do |event| 139 | if (event.visible && words[0] && words[0].casecmp(event.command).zero?) 140 | event.run(player) 141 | return 142 | end 143 | end 144 | 145 | # Text for incorrect commands. 146 | # TODO: Add string literal for this. 147 | puts "That isn't an available command at this time." 148 | print "Type 'help' for a list of available commands.\n\n" 149 | 150 | end 151 | end 152 | 153 | end -------------------------------------------------------------------------------- /lib/goby/entity/fighter.rb: -------------------------------------------------------------------------------- 1 | require 'goby' 2 | 3 | module Goby 4 | 5 | # Methods and variables for something that can battle with another Fighter. 6 | module Fighter 7 | 8 | # Exception thrown when a non-Fighter tries to enter battle. 9 | class UnfightableException < Exception 10 | end 11 | 12 | # The function that handles how an Fighter behaves after losing a battle. 13 | # Subclasses must override this function. 14 | def die 15 | raise(NotImplementedError, 'A Fighter must know how to die.') 16 | end 17 | 18 | # Handles how a Fighter behaves after winning a battle. 19 | # Subclasses must override this function. 20 | # 21 | # @param [Fighter] fighter the Fighter who lost the battle. 22 | def handle_victory(fighter) 23 | raise(NotImplementedError, 'A Fighter must know how to handle victory.') 24 | end 25 | 26 | # The function that returns the treasure given by a Fighter after losing a battle. 27 | # 28 | # @return [Item] the reward for the victor of the battle (or nil - no treasure). 29 | def sample_treasures 30 | raise(NotImplementedError, 'A Fighter must know whether it returns treasure or not after losing a battle.') 31 | end 32 | 33 | # The function that returns the gold given by a Fighter after losing a battle. 34 | # 35 | # @return[Integer] the amount of gold to award the victorious Fighter 36 | def sample_gold 37 | raise(NotImplementedError, 'A Fighter must return some gold after losing a battle.') 38 | end 39 | 40 | # Adds the specified battle command to the Fighter's collection. 41 | # 42 | # @param [BattleCommand] command the command being added. 43 | def add_battle_command(command) 44 | battle_commands.push(command) 45 | 46 | # Maintain sorted battle commands. 47 | battle_commands.sort! { |x, y| x.name <=> y.name } 48 | end 49 | 50 | # Adds the specified battle commands to the Fighter's collection. 51 | # 52 | # @param [Array] battle_commands the commands being added. 53 | def add_battle_commands(battle_commands) 54 | battle_commands.each { |command| add_battle_command(command) } 55 | end 56 | 57 | # Engages in battle with the specified Entity. 58 | # 59 | # @param [Entity] entity the opponent of the battle. 60 | def battle(entity) 61 | unless entity.class.included_modules.include?(Fighter) 62 | raise(UnfightableException, "You can't start a battle with an Entity of type #{entity.class} as it doesn't implement the Fighter module") 63 | end 64 | system("clear") unless ENV['TEST'] 65 | 66 | battle = Battle.new(self, entity) 67 | winner = battle.determine_winner 68 | 69 | if winner.equal?(self) 70 | handle_victory(entity) 71 | entity.die 72 | elsif winner.equal?(entity) 73 | entity.handle_victory(self) 74 | die 75 | end 76 | end 77 | 78 | # Returns the Array for BattleCommands available for the Fighter. 79 | # Sets @battle_commands to an empty Array if it's the first time it's called. 80 | # 81 | # @return [Array] array of the available BattleCommands for the Fighter. 82 | def battle_commands 83 | @battle_commands ||= [] 84 | @battle_commands 85 | end 86 | 87 | # Determines how the Fighter should select an attack in battle. 88 | # Override this method for control over this functionality. 89 | # 90 | # @return [BattleCommand] the chosen battle command. 91 | def choose_attack 92 | battle_commands[Random.rand(@battle_commands.length)] 93 | end 94 | 95 | # Determines how the Fighter should select the item and on whom 96 | # during battle (Use command). Return nil on error. 97 | # 98 | # @param [Fighter] enemy the opponent in battle. 99 | # @return [C(Item, Fighter)] the item and on whom it is to be used. 100 | def choose_item_and_on_whom(enemy) 101 | item = @inventory[Random.rand(@inventory.length)].first 102 | whom = [self, enemy].sample 103 | return C[item, whom] 104 | end 105 | 106 | # Returns the index of the specified command, if it exists. 107 | # 108 | # @param [BattleCommand, String] cmd the battle command (or its name). 109 | # @return [Integer] the index of an existing command. Otherwise nil. 110 | def has_battle_command(cmd) 111 | battle_commands.each_with_index do |command, index| 112 | return index if command.name.casecmp(cmd.to_s).zero? 113 | end 114 | return 115 | end 116 | 117 | # Removes the battle command, if it exists, from the Fighter's collection. 118 | # 119 | # @param [BattleCommand, String] command the command being removed. 120 | def remove_battle_command(command) 121 | index = has_battle_command(command) 122 | battle_commands.delete_at(index) if index 123 | end 124 | 125 | # Prints the available battle commands. 126 | # 127 | # @param [String] header the text to output as a heading for the list of battle commands. 128 | def print_battle_commands(header = "Battle Commands:") 129 | puts header 130 | battle_commands.each do |command| 131 | print "❊ #{command.name}\n" 132 | end 133 | print "\n" 134 | end 135 | 136 | # Appends battle commands to the end of the Fighter print_status output. 137 | def print_status 138 | super 139 | print_battle_commands unless battle_commands.empty? 140 | end 141 | 142 | # Uses the agility levels of the two Fighters to determine who should go first. 143 | # 144 | # @param [Fighter] fighter the opponent with whom the calling Fighter is competing. 145 | # @return [Boolean] true when calling Fighter should go first. Otherwise, false. 146 | def sample_agilities(fighter) 147 | sum = fighter.stats[:agility] + stats[:agility] 148 | Random.rand(sum) < stats[:agility] 149 | end 150 | 151 | end 152 | 153 | end -------------------------------------------------------------------------------- /spec/goby/util_spec.rb: -------------------------------------------------------------------------------- 1 | require 'goby' 2 | 3 | RSpec.describe do 4 | context "couple" do 5 | it "should correctly initialize the couple" do 6 | couple = C["Apple", 1] 7 | expect(couple.first).to eq "Apple" 8 | expect(couple.second).to eq 1 9 | end 10 | 11 | it "should equate two objects based on both 'first' and 'second'" do 12 | couple1 = C["Apple", 1] 13 | couple2 = C["Apple", 1] 14 | expect(couple1).to eq couple2 15 | end 16 | 17 | it "should not equate two objects with a different 'first'" do 18 | couple1 = C["Apple", 1] 19 | couple2 = C["Banana", 1] 20 | expect(couple1).to_not eq couple2 21 | end 22 | 23 | it "should not equate two objects with a different 'second'" do 24 | couple1 = C["Apple", 1] 25 | couple2 = C["Apple", 2] 26 | expect(couple1).to_not eq couple2 27 | end 28 | 29 | it "should not equate two objects with both different 'first' and 'second'" do 30 | couple1 = C["Apple", 1] 31 | couple2 = C["Banana", 2] 32 | expect(couple1).to_not eq couple2 33 | end 34 | end 35 | 36 | context "location" do 37 | it "should correctly initialize the location" do 38 | map = Map.new(name: "Test Map") 39 | location = Location.new(map, C[0, 0]) 40 | expect(location.map.name).to eq "Test Map" 41 | expect(location.coords).to eq C[0, 0] 42 | end 43 | end 44 | 45 | context "player input" do 46 | before (:each) { Readline::HISTORY.pop until Readline::HISTORY.size <= 0 } 47 | 48 | it "should return the same string as given (without newline)" do 49 | __stdin("kick\n") do 50 | input = player_input 51 | expect(input).to eq "kick" 52 | end 53 | end 54 | 55 | it "should correctly add distinct commands to the history" do 56 | __stdin("kick\n") { player_input } 57 | __stdin("use\n") { player_input } 58 | __stdin("inv\n") { player_input } 59 | 60 | expect(Readline::HISTORY.size).to eq 3 61 | expect(Readline::HISTORY[-1]).to eq "inv" 62 | end 63 | 64 | it "should not add repeated commands to the history" do 65 | __stdin("kick\n") { player_input } 66 | __stdin("kick\n") { player_input } 67 | __stdin("inv\n") { player_input } 68 | __stdin("kick\n") { player_input } 69 | 70 | expect(Readline::HISTORY.size).to eq 3 71 | expect(Readline::HISTORY[0]).to eq "kick" 72 | expect(Readline::HISTORY[1]).to eq "inv" 73 | end 74 | 75 | it "should not add single-character commands to the history" do 76 | __stdin("w\n") { player_input } 77 | __stdin("a\n") { player_input } 78 | __stdin("s\n") { player_input } 79 | __stdin("d\n") { player_input } 80 | 81 | expect(Readline::HISTORY.size).to eq 0 82 | end 83 | end 84 | 85 | context "type" do 86 | it "should print the given message" do 87 | expect { type("HELLO") }.to output("HELLO").to_stdout 88 | end 89 | end 90 | 91 | context "save game" do 92 | it "should create the appropriate file" do 93 | player = Player.new 94 | save_game(player, "test.yaml") 95 | expect(File.file?("test.yaml")).to eq true 96 | File.delete("test.yaml") 97 | end 98 | end 99 | 100 | context "load game" do 101 | it "should load the player's information" do 102 | player1 = Player.new(name: "Nicholas", 103 | stats: { max_hp: 5, hp: 3 }) 104 | save_game(player1, "test.yaml") 105 | player2 = load_game("test.yaml") 106 | expect(player2.name).to eq "Nicholas" 107 | expect(player2.stats[:max_hp]).to eq 5 108 | expect(player2.stats[:hp]).to eq 3 109 | File.delete("test.yaml") 110 | end 111 | 112 | it "should return nil if no such file exists" do 113 | player = load_game("test.yaml") 114 | expect(player).to be_nil 115 | end 116 | end 117 | 118 | context "increasing player_input versatility" do 119 | context "handling lowercase functionality" do 120 | it "should maintain case of input if lowercase is marked as false" do 121 | inputs = ["KicK\n", "uSe\n", "INV\n"] 122 | Readline::HISTORY.pop until Readline::HISTORY.size <= 0 123 | 124 | inputs.each do |i| 125 | __stdin(i) { player_input lowercase: false } 126 | end 127 | 128 | expect(Readline::HISTORY.size).to eq 3 129 | i = 0 130 | expect(Readline::HISTORY.all? do |input| 131 | input == inputs[i] 132 | i += 1 133 | end).to eq true 134 | end 135 | 136 | it "returns lowercase input as default" do 137 | inputs = ["KicK\n", "uSe\n", "INV\n"] 138 | Readline::HISTORY.pop until Readline::HISTORY.size <= 0 139 | 140 | inputs.each do |i| 141 | __stdin(i) { player_input } 142 | end 143 | 144 | expect(Readline::HISTORY.size).to eq 3 145 | i = 0 146 | expect(Readline::HISTORY.all? do |input| 147 | input == inputs[i].downcase 148 | i += 1 149 | end).to eq true 150 | end 151 | end 152 | 153 | context "output is determined by given params" do 154 | it "prints an empty string and extra space by default" do 155 | expect{ __stdin('test') {player_input} }.to output("\n").to_stdout 156 | end 157 | 158 | it "prints the correct prompt when given an argument" do 159 | expect{ __stdin('test') {player_input prompt: '> '} }.to output("> \n").to_stdout 160 | end 161 | 162 | it "does not print the newline if doublespace is marked as false" do 163 | expect{ __stdin('test') {player_input doublespace: false} }.to output('').to_stdout 164 | end 165 | 166 | it "prints custom prompt and doesn't print the newline if doublespace is marked as false" do 167 | expect{ __stdin('test') {player_input doublespace: false, prompt: 'testing: '} }.to output("testing: ").to_stdout 168 | end 169 | end 170 | end 171 | end 172 | -------------------------------------------------------------------------------- /lib/goby/event/shop.rb: -------------------------------------------------------------------------------- 1 | require 'goby' 2 | 3 | module Goby 4 | 5 | # Allows a player to buy and sell Items. 6 | class Shop < Event 7 | 8 | # Message for when the shop has nothing to sell. 9 | NO_ITEMS_MESSAGE = "Sorry, we're out of stock right now!\n\n" 10 | # Message for when the player has nothing to sell. 11 | NOTHING_TO_SELL = "You have nothing to sell!\n\n" 12 | # Introductory greeting at the shop. 13 | WARES_MESSAGE = "Please take a look at my wares.\n\n" 14 | 15 | # @param [String] name the name. 16 | # @param [Integer] mode convenient way for a shop to have multiple actions. 17 | # @param [Boolean] visible whether the shop can be seen/activated. 18 | # @param [[Item]] items an array of items that the shop sells. 19 | def initialize(name: "Shop", mode: 0, visible: true, items: []) 20 | super(mode: mode, visible: visible) 21 | @name = name 22 | @command = "shop" 23 | @items = items 24 | end 25 | 26 | # The chain of events for buying an item (w/ error checking). 27 | # 28 | # @param [Player] player the player trying to buy an item. 29 | def buy(player) 30 | 31 | print_items 32 | return if @items.empty? 33 | 34 | print "What would you like (or none)?: " 35 | name = player_input 36 | index = has_item(name) 37 | 38 | # The player does not want to buy an item. 39 | return if name.casecmp("none").zero? 40 | 41 | if index.nil? # non-existent item. 42 | print "I don't have #{name}!\n\n" 43 | return 44 | end 45 | 46 | # The specified item exists in the shop's inventory. 47 | item = @items[index] 48 | print "How many do you want?: " 49 | amount_to_buy = player_input 50 | total_cost = amount_to_buy.to_i * item.price 51 | 52 | if total_cost > player.gold # not enough gold. 53 | puts "You don't have enough gold!" 54 | print "You only have #{player.gold}, but you need #{total_cost}!\n\n" 55 | return 56 | elsif amount_to_buy.to_i < 1 # non-positive amount. 57 | puts "Is this some kind of joke?" 58 | print "You need to request a positive amount!\n\n" 59 | return 60 | end 61 | 62 | # The player specifies a positive amount. 63 | player.remove_gold(total_cost) 64 | player.add_item(item, amount_to_buy.to_i) 65 | print "Thank you for your patronage!\n\n" 66 | 67 | end 68 | 69 | # Returns the index of the specified item, if it exists. 70 | # 71 | # @param [String] name the item's name. 72 | # @return [Integer] the index of an existing item. Otherwise nil. 73 | def has_item(name) 74 | @items.each_with_index do |item, index| 75 | return index if item.name.casecmp(name).zero? 76 | end 77 | return 78 | end 79 | 80 | # Displays the player's current amount of gold 81 | # and a greeting. Inquires about next action. 82 | # 83 | # @param [Player] player the player interacting with the shop. 84 | # @return [String] the player's input. 85 | def print_gold_and_greeting(player) 86 | puts "Current gold in your pouch: #{player.gold}." 87 | print "Would you like to buy, sell, or exit?: " 88 | input = player_input doublespace: false 89 | print "\n" 90 | return input 91 | end 92 | 93 | # Displays a formatted list of the Shop's items 94 | # or a message signaling there is nothing to sell. 95 | def print_items 96 | if @items.empty? 97 | print NO_ITEMS_MESSAGE 98 | else 99 | print WARES_MESSAGE 100 | @items.each { |item| puts "#{item.name} (#{item.price} gold)" } 101 | print "\n" 102 | end 103 | end 104 | 105 | # The amount for which the shop will purchase the item. 106 | # 107 | # @param [Item] item the item in question. 108 | # @return [Integer] the amount for which to purchase. 109 | def purchase_price(item) 110 | item.price / 2 111 | end 112 | 113 | # The default shop experience. 114 | # 115 | # @param [Player] player the player interacting with the shop. 116 | def run(player) 117 | 118 | # Initial greeting. 119 | puts "Welcome to #{@name}." 120 | input = print_gold_and_greeting(player) 121 | 122 | while input.casecmp("exit").nonzero? 123 | if input.casecmp("buy").zero? 124 | buy(player) 125 | elsif input.casecmp("sell").zero? 126 | sell(player) 127 | end 128 | input = print_gold_and_greeting(player) 129 | end 130 | 131 | print "#{player.name} has left #{@name}.\n\n" 132 | end 133 | 134 | # The chain of events for selling an item (w/ error checking). 135 | # 136 | # @param [Player] player the player trying to sell an item. 137 | def sell(player) 138 | 139 | # The player has nothing to sell. 140 | if player.inventory.empty? 141 | print NOTHING_TO_SELL 142 | return 143 | end 144 | 145 | player.print_inventory 146 | 147 | print "What would you like to sell? (or none): " 148 | input = player_input 149 | index = player.has_item(input) 150 | 151 | # The player does not want to sell an item. 152 | return if input.casecmp("none").zero? 153 | 154 | if index.nil? # non-existent item. 155 | print "You can't sell what you don't have.\n\n" 156 | return 157 | end 158 | 159 | item = player.inventory[index].first 160 | item_count = player.inventory[index].second 161 | 162 | unless item.disposable # non-disposable item (cannot sell/drop). 163 | print "You cannot sell that item.\n\n" 164 | return 165 | end 166 | 167 | puts "I'll buy that for #{purchase_price(item)} gold." 168 | print "How many do you want to sell?: " 169 | amount_to_sell = player_input.to_i 170 | 171 | if amount_to_sell > item_count # more than in the inventory. 172 | print "You don't have that many to sell!\n\n" 173 | return 174 | elsif amount_to_sell < 1 # non-positive amount specified. 175 | puts "Is this some kind of joke?" 176 | print "You need to sell a positive amount!\n\n" 177 | return 178 | end 179 | 180 | player.add_gold(purchase_price(item) * amount_to_sell) 181 | player.remove_item(item, amount_to_sell) 182 | print "Thank you for your patronage!\n\n" 183 | 184 | end 185 | 186 | attr_accessor :name, :items 187 | 188 | end 189 | 190 | end -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # This file was generated by the `rspec --init` command. Conventionally, all 2 | # specs live under a `spec` directory, which RSpec adds to the `$LOAD_PATH`. 3 | # The generated `.rspec` file contains `--require spec_helper` which will cause 4 | # this file to always be loaded, without a need to explicitly require it in any 5 | # files. 6 | # 7 | # Given that it is always loaded, you are encouraged to keep this file as 8 | # light-weight as possible. Requiring heavyweight dependencies from this file 9 | # will add to the boot time of your test suite on EVERY test run, even for an 10 | # individual file that may not need all of that loaded. Instead, consider making 11 | # a separate helper file that requires the additional dependencies and performs 12 | # the additional setup, and require it from the spec files that actually need 13 | # it. 14 | # 15 | # The `.rspec` file also contains a few flags that are not defaults but that 16 | # users commonly want. 17 | # 18 | # See http://rubydoc.info/gems/rspec-core/RSpec/Core/Configuration 19 | 20 | require 'coveralls' 21 | Coveralls.wear! 22 | 23 | require 'goby' 24 | 25 | # Set variable to know when testing. 26 | # Also has boolean value true. 27 | ENV['TEST'] = 'rspec' 28 | 29 | # Credit for "discovering" (?) RSpec input: 30 | # https://gist.github.com/nu7hatch/631329 31 | module Helpers 32 | # Replace standard input with faked one StringIO. 33 | def __stdin(*args) 34 | begin 35 | $stdin = StringIO.new 36 | $stdin.puts(args.shift) until args.empty? 37 | $stdin.rewind 38 | yield 39 | ensure 40 | $stdin = STDIN 41 | end 42 | end 43 | end 44 | 45 | RSpec.configure do |config| 46 | include Goby 47 | 48 | # rspec-expectations config goes here. You can use an alternate 49 | # assertion/expectation library such as wrong or the stdlib/minitest 50 | # assertions if you prefer. 51 | config.expect_with :rspec do |expectations| 52 | # This option will default to `true` in RSpec 4. It makes the `description` 53 | # and `failure_message` of custom matchers include text for helper methods 54 | # defined using `chain`, e.g.: 55 | # be_bigger_than(2).and_smaller_than(4).description 56 | # # => "be bigger than 2 and smaller than 4" 57 | # ...rather than: 58 | # # => "be bigger than 2" 59 | expectations.include_chain_clauses_in_custom_matcher_descriptions = true 60 | end 61 | 62 | # rspec-mocks config goes here. You can use an alternate test double 63 | # library (such as bogus or mocha) by changing the `mock_with` option here. 64 | config.mock_with :rspec do |mocks| 65 | # Prevents you from mocking or stubbing a method that does not exist on 66 | # a real object. This is generally recommended, and will default to 67 | # `true` in RSpec 4. 68 | mocks.verify_partial_doubles = true 69 | end 70 | 71 | # This option will default to `:apply_to_host_groups` in RSpec 4 (and will 72 | # have no way to turn it off -- the option exists only for backwards 73 | # compatibility in RSpec 3). It causes shared context metadata to be 74 | # inherited by the metadata hash of host groups and examples, rather than 75 | # triggering implicit auto-inclusion in groups with matching metadata. 76 | config.shared_context_metadata_behavior = :apply_to_host_groups 77 | 78 | # Suppress output & error by redirecting to text files. 79 | original_stderr = $stderr 80 | original_stdout = $stdout 81 | 82 | config.before(:all) do 83 | # Redirect stderr and stdout to /dev/null 84 | $stderr = File.open(File::NULL, "w") 85 | $stdout = File.open(File::NULL, "w") 86 | end 87 | 88 | config.after(:all) do 89 | $stderr = original_stderr 90 | $stdout = original_stdout 91 | end 92 | 93 | # Allow RSpec tests to access this module. 94 | config.include(Helpers) 95 | 96 | # The settings below are suggested to provide a good initial experience 97 | # with RSpec, but feel free to customize to your heart's content. 98 | =begin 99 | # This allows you to limit a spec run to individual examples or groups 100 | # you care about by tagging them with `:focus` metadata. When nothing 101 | # is tagged with `:focus`, all examples get run. RSpec also provides 102 | # aliases for `it`, `describe`, and `context` that include `:focus` 103 | # metadata: `fit`, `fdescribe` and `fcontext`, respectively. 104 | config.filter_run_when_matching :focus 105 | 106 | # Allows RSpec to persist some state between runs in order to support 107 | # the `--only-failures` and `--next-failure` CLI options. We recommend 108 | # you configure your source control system to ignore this file. 109 | config.example_status_persistence_file_path = "spec/examples.txt" 110 | 111 | # Limits the available syntax to the non-monkey patched syntax that is 112 | # recommended. For more details, see: 113 | # - http://rspec.info/blog/2012/06/rspecs-new-expectation-syntax/ 114 | # - http://www.teaisaweso.me/blog/2013/05/27/rspecs-new-message-expectation-syntax/ 115 | # - http://rspec.info/blog/2014/05/notable-changes-in-rspec-3/#zero-monkey-patching-mode 116 | config.disable_monkey_patching! 117 | 118 | # This setting enables warnings. It's recommended, but in some cases may 119 | # be too noisy due to issues in dependencies. 120 | config.warnings = true 121 | 122 | # Many RSpec users commonly either run the entire suite or an individual 123 | # file, and it's useful to allow more verbose output when running an 124 | # individual spec file. 125 | if config.files_to_run.one? 126 | # Use the documentation formatter for detailed output, 127 | # unless a formatter has already been configured 128 | # (e.g. via a command-line flag). 129 | config.default_formatter = 'doc' 130 | end 131 | 132 | # Print the 10 slowest examples and example groups at the 133 | # end of the spec run, to help surface which specs are running 134 | # particularly slow. 135 | config.profile_examples = 10 136 | 137 | # Run specs in random order to surface order dependencies. If you find an 138 | # order dependency and want to debug it, you can fix the order by providing 139 | # the seed, which is printed after each run. 140 | # --seed 1234 141 | config.order = :random 142 | 143 | # Seed global randomization in this process using the `--seed` CLI option. 144 | # Setting this allows you to use `--seed` to deterministically reproduce 145 | # test failures related to randomization by passing the same `--seed` value 146 | # as the one that triggered the failure. 147 | Kernel.srand config.seed 148 | =end 149 | end 150 | -------------------------------------------------------------------------------- /spec/goby/entity/monster_spec.rb: -------------------------------------------------------------------------------- 1 | require 'goby' 2 | 3 | RSpec.describe Monster do 4 | 5 | let(:slime_item) { Item.new } 6 | 7 | let(:wolf) { 8 | Monster.new(name: "Wolf", 9 | stats: { max_hp: 20, 10 | hp: 15, 11 | attack: 2, 12 | defense: 2, 13 | agility: 4 }, 14 | inventory: [C[Item.new, 1]], 15 | gold: 10, 16 | outfit: { weapon: Weapon.new( 17 | attack: Attack.new, 18 | stat_change: {attack: 3, defense: 1} 19 | ), 20 | helmet: Helmet.new( 21 | stat_change: {attack: 1, defense: 5} 22 | ) 23 | }, 24 | battle_commands: [ 25 | Attack.new(name: "Scratch"), 26 | Attack.new(name: "Kick") 27 | ]) 28 | } 29 | let!(:dude) { Player.new(stats: { attack: 10, agility: 10000 }, 30 | battle_commands: [Attack.new(strength: 20), Escape.new, Use.new])} 31 | let!(:slime) { Monster.new(battle_commands: [Attack.new(success_rate: 0)], 32 | gold: 5000, treasures: [C[slime_item, 1]]) } 33 | let(:newb) { Player.new(battle_commands: [Attack.new(success_rate: 0)], 34 | gold: 50, respawn_location: Location.new(Map.new, C[0, 0])) } 35 | 36 | context "constructor" do 37 | it "has the correct default parameters" do 38 | monster = Monster.new 39 | expect(monster.name).to eq "Monster" 40 | expect(monster.stats[:max_hp]).to eq 1 41 | expect(monster.stats[:hp]).to eq 1 42 | expect(monster.stats[:attack]). to eq 1 43 | expect(monster.stats[:defense]).to eq 1 44 | expect(monster.stats[:agility]).to eq 1 45 | expect(monster.inventory).to eq Array.new 46 | expect(monster.gold).to eq 0 47 | expect(monster.outfit).to eq Hash.new 48 | expect(monster.battle_commands).to eq Array.new 49 | expect(monster.treasures).to eq Array.new 50 | end 51 | 52 | it "correctly assigns custom parameters" do 53 | clown = Monster.new(name: "Clown", 54 | stats: { max_hp: 20, 55 | hp: 15, 56 | attack: 2, 57 | defense: 2, 58 | agility: 4 }, 59 | inventory: [C[Item.new, 1]], 60 | gold: 10, 61 | outfit: { weapon: Weapon.new( 62 | attack: Attack.new, 63 | stat_change: {attack: 3, defense: 1} 64 | ), 65 | helmet: Helmet.new( 66 | stat_change: {attack: 1, defense: 5} 67 | ) 68 | }, 69 | battle_commands: [ 70 | Attack.new(name: "Scratch"), 71 | Attack.new(name: "Kick") 72 | ], 73 | treasures: [C[Item.new, 1], 74 | C[nil, 3]]) 75 | expect(clown.name).to eq "Clown" 76 | expect(clown.stats[:max_hp]).to eq 20 77 | expect(clown.stats[:hp]).to eq 15 78 | expect(clown.stats[:attack]).to eq 6 79 | expect(clown.stats[:defense]).to eq 8 80 | expect(clown.stats[:agility]).to eq 4 81 | expect(clown.inventory).to eq [C[Item.new, 1]] 82 | expect(clown.gold).to eq 10 83 | expect(clown.outfit[:weapon]).to eq Weapon.new 84 | expect(clown.outfit[:helmet]).to eq Helmet.new 85 | expect(clown.battle_commands).to eq [ 86 | Attack.new, 87 | Attack.new(name: "Kick"), 88 | Attack.new(name: "Scratch") 89 | ] 90 | expect(clown.treasures).to eq [C[Item.new, 1], 91 | C[nil, 3]] 92 | expect(clown.total_treasures).to eq 4 93 | end 94 | end 95 | 96 | context "clone" do 97 | let(:monster) { Monster.new(inventory: [C[Item.new, 1]]) } 98 | let!(:clone) { monster.clone } 99 | 100 | it "should leave the original's inventory the same" do 101 | clone.use_item("Item", clone) 102 | expect(monster.inventory.size).to eq 1 103 | expect(clone.inventory.size).to be_zero 104 | end 105 | 106 | it "should leave the clone's inventory the same" do 107 | monster.use_item("Item", monster) 108 | expect(monster.inventory.size).to be_zero 109 | expect(clone.inventory.size).to eq 1 110 | end 111 | end 112 | 113 | # Fighter specific specs 114 | 115 | context "fighter" do 116 | it "should be a fighter" do 117 | expect(wolf.class.included_modules.include?(Fighter)).to be true 118 | end 119 | end 120 | 121 | context "battle" do 122 | it "should allow the monster to win in this example" do 123 | __stdin("attack\n") do 124 | wolf.battle(newb) 125 | end 126 | # The amount of gold the Monster had + that returned by the Player 127 | expect(newb.gold).to eq 25 128 | end 129 | 130 | 131 | it "should allow the player to win in this example" do 132 | __stdin("attack\n", "\n") do 133 | slime.battle(dude) 134 | end 135 | expect(dude.inventory.size).to eq 1 136 | end 137 | 138 | it "should allow the stronger monster to win as the attacker" do 139 | wolf.battle(slime) 140 | expect(wolf.inventory.size).to eq 1 141 | end 142 | 143 | it "should allow the stronger monster to win as the defender" do 144 | slime.battle(wolf) 145 | expect(wolf.inventory.size).to eq 1 146 | end 147 | end 148 | 149 | context "die" do 150 | it "does nothing" do 151 | expect(wolf.die).to be_nil 152 | end 153 | end 154 | 155 | context "sample gold" do 156 | it "returns a random amount of gold" do 157 | gold_sample = wolf.sample_gold 158 | expect(gold_sample).to be >= 0 159 | expect(gold_sample).to be <= 10 160 | end 161 | end 162 | 163 | context "sample treasures" do 164 | it "returns the treasure when there is only one" do 165 | expect(slime.sample_treasures).to eq slime_item 166 | end 167 | 168 | it "returns nothing if there are no treasures" do 169 | expect(wolf.sample_treasures).to be_nil 170 | end 171 | end 172 | 173 | end 174 | -------------------------------------------------------------------------------- /spec/goby/event/shop_spec.rb: -------------------------------------------------------------------------------- 1 | require 'goby' 2 | 3 | RSpec.describe Shop do 4 | 5 | let!(:shop) { Shop.new } 6 | let!(:tools) { [Item.new(name: "Basket", price: 5), 7 | Item.new(name: "Knife", price: 10), 8 | Item.new(name: "Fork", price: 12), 9 | Item.new(name: "Screwdriver", price: 7)] } 10 | let!(:tool_shop) { Shop.new(name: "Tool Shop", 11 | mode: 1, 12 | visible: false, 13 | items: tools) } 14 | let!(:apple) { Item.new(name: "Apple", price: 2) } 15 | let!(:banana) { Item.new(name: "Banana", disposable: false) } 16 | 17 | # player1 doesn't have any gold. 18 | let!(:player1) { Player.new(inventory: [C[apple, 3], 19 | C[banana, 1]] ) } 20 | # player2 has nothing in the inventory. 21 | let!(:player2) { Player.new(gold: 50) } 22 | 23 | context "constructor" do 24 | it "has the correct default parameters" do 25 | expect(shop.name).to eq "Shop" 26 | expect(shop.command).to eq "shop" 27 | expect(shop.mode).to eq 0 28 | expect(shop.visible).to eq true 29 | expect(shop.items).to eq Array.new 30 | end 31 | 32 | it "correctly assigns custom parameters" do 33 | expect(tool_shop.name).to eq "Tool Shop" 34 | expect(tool_shop.command).to eq "shop" 35 | expect(tool_shop.mode).to eq 1 36 | expect(tool_shop.visible).to eq false 37 | expect(tool_shop.items).to eq tools 38 | end 39 | end 40 | 41 | context "buy" do 42 | it "should print an error message when the shop has nothing to sell" do 43 | expect { shop.buy(player2) }.to output(Shop::NO_ITEMS_MESSAGE).to_stdout 44 | end 45 | 46 | it "should return if the player doesn't want to buy anything" do 47 | __stdin("none\n") do 48 | tool_shop.buy(player2) 49 | expect(player2.inventory.empty?).to be true 50 | end 51 | end 52 | 53 | it "should return if the player specifies a non-existent item" do 54 | __stdin("pencil\n") do 55 | tool_shop.buy(player2) 56 | expect(player2.inventory.empty?).to be true 57 | end 58 | end 59 | 60 | it "should prevent the player from buying more than (s)he has in gold" do 61 | __stdin("fork\n5\n") do 62 | tool_shop.buy(player2) 63 | expect(player2.inventory.empty?).to be true 64 | end 65 | end 66 | 67 | it "should prevent the player from buying a non-positive amount" do 68 | __stdin("fork\n0\n") do 69 | tool_shop.buy(player2) 70 | expect(player2.inventory.empty?).to be true 71 | end 72 | end 73 | 74 | it "should sell the item to the player for a sensible purchase" do 75 | __stdin("fork\n2\n") do 76 | tool_shop.buy(player2) 77 | expect(player2.inventory.empty?).to be false 78 | expect(player2.gold).to be 26 79 | expect(player2.has_item("Fork")).to eq 0 80 | end 81 | end 82 | end 83 | 84 | context "has item" do 85 | it "returns nil when no such item is available" do 86 | expect(shop.has_item("Basket")).to be_nil 87 | end 88 | 89 | it "returns the index of the item when available" do 90 | expect(tool_shop.has_item("Basket")).to be_zero 91 | expect(tool_shop.has_item("Knife")).to eq 1 92 | expect(tool_shop.has_item("Fork")).to eq 2 93 | expect(tool_shop.has_item("Screwdriver")).to eq 3 94 | end 95 | end 96 | 97 | context "print gold and greeting" do 98 | it "prints the appropriate output" do 99 | __stdin("exit\n") do 100 | expect { shop.print_gold_and_greeting(player2) }.to output( 101 | "Current gold in your pouch: 50.\n"\ 102 | "Would you like to buy, sell, or exit?: \n"\ 103 | ).to_stdout 104 | end 105 | end 106 | 107 | it "returns the input (w/o newline)" do 108 | __stdin("buy\n") do 109 | expect(shop.print_gold_and_greeting(player2)).to eq "buy" 110 | end 111 | end 112 | end 113 | 114 | context "print items" do 115 | it "should print a helpful message when there's nothing to sell" do 116 | expect { shop.print_items }.to output(Shop::NO_ITEMS_MESSAGE).to_stdout 117 | end 118 | 119 | it "should print the formatted list when there are items to sell" do 120 | expect { tool_shop.print_items }.to output( 121 | "#{Shop::WARES_MESSAGE}"\ 122 | "Basket (5 gold)\n"\ 123 | "Knife (10 gold)\n"\ 124 | "Fork (12 gold)\n"\ 125 | "Screwdriver (7 gold)\n\n" 126 | ).to_stdout 127 | end 128 | end 129 | 130 | context "run" do 131 | it "should allow the player to buy an item" do 132 | __stdin("buy\nknife\n3\nexit\n") do 133 | tool_shop.run(player2) 134 | expect(player2.gold).to eq 20 135 | expect(player2.inventory.size).to eq 1 136 | end 137 | end 138 | 139 | it "should allow the player to sell an item" do 140 | __stdin("sell\napple\n3\nexit\n") do 141 | tool_shop.run(player1) 142 | expect(player1.gold).to eq 3 143 | expect(player1.inventory.size).to eq 1 144 | end 145 | end 146 | 147 | it "should allow the player to leave immediately" do 148 | __stdin("exit\n") do 149 | tool_shop.run(player1) 150 | expect(player1.gold).to be_zero 151 | expect(player1.inventory.size).to eq 2 152 | end 153 | end 154 | end 155 | 156 | context "sell" do 157 | it "should print an error message when the player has nothing to sell" do 158 | expect { tool_shop.sell(player2) }.to output(Shop::NOTHING_TO_SELL).to_stdout 159 | end 160 | 161 | it "should return if the player doesn't want to sell anything" do 162 | __stdin("none\n") do 163 | tool_shop.sell(player1) 164 | expect(player1.gold).to be_zero 165 | end 166 | end 167 | 168 | it "should return if the player tries to sell a non-existent item" do 169 | __stdin("object\n") do 170 | tool_shop.sell(player1) 171 | expect(player1.gold).to be_zero 172 | end 173 | end 174 | 175 | it "should return if the player tries to sell a non-disposable item" do 176 | __stdin("banana\n") do 177 | tool_shop.sell(player1) 178 | expect(player1.gold).to be_zero 179 | end 180 | end 181 | 182 | it "should prevent the player from selling more than (s)he has" do 183 | __stdin("apple\n4\n") do 184 | tool_shop.sell(player1) 185 | expect(player1.gold).to be_zero 186 | end 187 | end 188 | 189 | it "should prevent the player from selling a non-positive amount" do 190 | __stdin("apple\n0\n") do 191 | tool_shop.sell(player1) 192 | expect(player1.gold).to be_zero 193 | end 194 | end 195 | 196 | it "should purchase the item for a sensible sale" do 197 | __stdin("apple\n3\n") do 198 | tool_shop.sell(player1) 199 | expect(player1.gold).to be 3 200 | expect(player1.inventory.size).to be 1 201 | end 202 | end 203 | end 204 | end 205 | -------------------------------------------------------------------------------- /spec/goby/world_command_spec.rb: -------------------------------------------------------------------------------- 1 | require 'goby' 2 | 3 | RSpec.describe do 4 | 5 | include WorldCommand 6 | 7 | let!(:map) { Map.new(tiles: [ [ Tile.new, 8 | Tile.new(events: [NPC.new]), 9 | Tile.new(events: [Event.new(visible: false)]) ], 10 | [ Tile.new(events: [Shop.new, NPC.new]), 11 | Tile.new(events: [Event.new(visible: false), Shop.new, NPC.new]) ] ] ) } 12 | 13 | let!(:player) { Player.new(stats: { max_hp: 10, hp: 3 }, 14 | inventory: [ C[Food.new(name: "Banana", recovers: 5), 1], 15 | C[Food.new(name: "Onion", disposable: false), 1], 16 | C[Item.new(name: "Big Book of Stuff"), 1], 17 | C[Helmet.new, 1] ], 18 | location: Location.new(map, C[0, 0])) } 19 | 20 | context "display default commands" do 21 | it "should print the default commands" do 22 | expect { display_default_commands }.to output( 23 | WorldCommand::DEFAULT_COMMANDS).to_stdout 24 | end 25 | end 26 | 27 | context "display special commands" do 28 | it "should print nothing when no special commands are available" do 29 | expect { display_special_commands(player) }.to_not output.to_stdout 30 | end 31 | 32 | it "should print nothing when the only event is non-visible" do 33 | player.move_right 34 | player.move_right 35 | expect { display_special_commands(player) }.to_not output.to_stdout 36 | end 37 | 38 | it "should print one commmand for one event" do 39 | player.move_right 40 | expect { display_special_commands(player) }.to output( 41 | WorldCommand::SPECIAL_COMMANDS_HEADER + "talk\n\n").to_stdout 42 | end 43 | 44 | it "should print two 'separated' commands for two events" do 45 | player.move_down 46 | expect { display_special_commands(player) }.to output( 47 | WorldCommand::SPECIAL_COMMANDS_HEADER + "shop, talk\n\n").to_stdout 48 | end 49 | 50 | it "should ignore the non-visible event" do 51 | player.move_down 52 | player.move_right 53 | expect { display_special_commands(player) }.to output( 54 | WorldCommand::SPECIAL_COMMANDS_HEADER + "shop, talk\n\n").to_stdout 55 | end 56 | end 57 | 58 | context "help" do 59 | it "should print only the default commands when no special commands are available" do 60 | expect { help(player) }.to output(WorldCommand::DEFAULT_COMMANDS).to_stdout 61 | end 62 | 63 | it "should print the default commands and the special commands" do 64 | player.move_right 65 | expect { help(player) }.to output( 66 | WorldCommand::DEFAULT_COMMANDS + WorldCommand::SPECIAL_COMMANDS_HEADER + 67 | "talk\n\n").to_stdout 68 | end 69 | end 70 | 71 | # TODO: tests for describe_tile 72 | context "describe tile" do 73 | 74 | end 75 | 76 | # TODO: test the input of all possible commands. 77 | # TODO: test use/drop/equip/unequip multi-word items. 78 | context "interpret command" do 79 | 80 | context "lowercase" do 81 | it "should correctly move the player around" do 82 | interpret_command("s", player) 83 | expect(player.location.coords).to eq C[1, 0] 84 | interpret_command("d", player) 85 | expect(player.location.coords).to eq C[1, 1] 86 | interpret_command("w", player) 87 | expect(player.location.coords).to eq C[0, 1] 88 | interpret_command("a", player) 89 | expect(player.location.coords).to eq C[0, 0] 90 | end 91 | 92 | it "should display the help text" do 93 | expect { interpret_command("help", player) }.to output( 94 | WorldCommand::DEFAULT_COMMANDS).to_stdout 95 | end 96 | 97 | it "should print the map" do 98 | interpret_command("map", player) 99 | # TODO: expect the map output. 100 | end 101 | 102 | it "should print the inventory" do 103 | interpret_command("inv", player) 104 | # TODO: expect the inventory output. 105 | end 106 | 107 | it "should print the status" do 108 | interpret_command("status", player) 109 | # TODO: expect the status output. 110 | end 111 | 112 | it "should save the game" do 113 | # Rename the original file. 114 | random_string = "n483oR38Avdis3" 115 | File.rename("player.yaml", random_string) if File.exists?("player.yaml") 116 | 117 | interpret_command("save", player) 118 | expect(File.exists?("player.yaml")).to be true 119 | File.delete("player.yaml") 120 | 121 | # Return the original data to the file. 122 | File.rename(random_string, "player.yaml") if File.exists?(random_string) 123 | end 124 | 125 | it "should drop a disposable item" do 126 | interpret_command("drop banana", player) 127 | expect(player.has_item("Banana")).to be_nil 128 | end 129 | 130 | it "should drop the item composed of multiple words" do 131 | interpret_command("drop big book of stuff", player) 132 | expect(player.has_item("Big Book of Stuff")).to be_nil 133 | end 134 | 135 | it "should not drop a non-disposable item" do 136 | interpret_command("drop onion", player) 137 | expect(player.has_item("Onion")).to eq 1 138 | end 139 | 140 | it "should print error text for dropping nonexistent item" do 141 | expect { interpret_command("drop orange", player) }.to output( 142 | WorldCommand::NO_ITEM_DROP_ERROR).to_stdout 143 | end 144 | 145 | it "should not output anything on quit" do 146 | expect { interpret_command("quit", @player) }.to_not output.to_stdout 147 | end 148 | 149 | it "should equip and unequip the specified item" do 150 | interpret_command("equip helmet", player) 151 | expect(player.has_item("Helmet")).to be_nil 152 | expect(player.outfit[:helmet]).to eq Helmet.new 153 | interpret_command("unequip helmet", player) 154 | expect(player.has_item("Helmet")).not_to be_nil 155 | expect(player.outfit[:helmet]).to be_nil 156 | end 157 | 158 | it "should use the specified item" do 159 | interpret_command("use banana", player) 160 | expect(player.has_item("Banana")).to be_nil 161 | expect(player.stats[:hp]).to eq 8 162 | end 163 | 164 | it "should print error text for using nonexistent item" do 165 | expect { interpret_command("use apple", player) }.to output( 166 | Entity::NO_SUCH_ITEM_ERROR).to_stdout 167 | end 168 | 169 | it "should run the event on the tile" do 170 | player.move_right 171 | expect { interpret_command("talk\n", player) }.to output( 172 | "NPC: Hello!\n\n").to_stdout 173 | end 174 | 175 | end 176 | 177 | context "case-insensitive" do 178 | it "should correctly move the player around" do 179 | interpret_command("S", player) 180 | expect(player.location.coords).to eq C[1, 0] 181 | interpret_command("D", player) 182 | expect(player.location.coords).to eq C[1, 1] 183 | interpret_command("W", player) 184 | expect(player.location.coords).to eq C[0, 1] 185 | interpret_command("A", player) 186 | expect(player.location.coords).to eq C[0, 0] 187 | end 188 | end 189 | 190 | end 191 | 192 | end 193 | -------------------------------------------------------------------------------- /lib/goby/entity/entity.rb: -------------------------------------------------------------------------------- 1 | require 'goby' 2 | 3 | module Goby 4 | 5 | # Provides the ability to fight, equip/unequip weapons & armor, 6 | # and carry items & gold. 7 | class Entity 8 | 9 | # Error when the entity specifies a non-existent item. 10 | NO_SUCH_ITEM_ERROR = "What?! You don't have THAT!\n\n" 11 | # Error when the entity specifies an item not equipped. 12 | NOT_EQUIPPED_ERROR = "You are not equipping THAT!\n\n" 13 | 14 | # @param [String] name the name. 15 | # @param [Hash] stats hash of stats 16 | # @option stats [Integer] :max_hp maximum health points. Set to be positive. 17 | # @option stats [Integer] :hp current health points. Set to be nonnegative. 18 | # @option stats [Integer] :attack strength in battle. Set to be positive. 19 | # @option stats [Integer] :defense protection from attacks. Set to be positive. 20 | # @option stats [Integer] :agility speed of commands in battle. Set to be positive. 21 | # @param [[C(Item, Integer)]] inventory a list of pairs of items and their respective amounts. 22 | # @param [Integer] gold the currency used for economical transactions. 23 | # @param [Hash] outfit the collection of equippable items currently worn. 24 | def initialize(name: "Entity", stats: {}, inventory: [], gold: 0, outfit: {}) 25 | @name = name 26 | set_stats(stats) 27 | @inventory = inventory 28 | set_gold(gold) 29 | 30 | # See its attr_accessor below. 31 | @outfit = {} 32 | outfit.each do |type,value| 33 | value.equip(self) 34 | end 35 | 36 | # This should only be switched to true during battle. 37 | @escaped = false 38 | end 39 | 40 | # Adds the given amount of gold. 41 | # 42 | # @param [Integer] gold the amount of gold to add. 43 | def add_gold(gold) 44 | @gold += gold 45 | check_and_set_gold 46 | end 47 | 48 | # Adds the item and the given amount to the inventory. 49 | # 50 | # @param [Item] item the item being added. 51 | # @param [Integer] amount the amount of the item to add. 52 | def add_item(item, amount = 1) 53 | 54 | # Increase the amount if the item already exists in the inventory. 55 | @inventory.each do |couple| 56 | if (couple.first == item) 57 | couple.second += amount 58 | return 59 | end 60 | end 61 | 62 | # If not already in the inventory, push a couple. 63 | @inventory.push(C[item, amount]) 64 | end 65 | 66 | # Adds the specified gold and treasures to the inventory. 67 | # 68 | # @param [Integer] gold the amount of gold. 69 | # @param [[Item]] treasures the list of treasures. 70 | def add_loot(gold, treasures) 71 | type("Loot: ") 72 | if ((gold.positive?) || (treasures && treasures.any?)) 73 | print "\n" 74 | if gold.positive? 75 | type("* #{gold} gold\n") 76 | add_gold(gold) 77 | end 78 | if treasures && treasures.any? 79 | treasures.each do |treasure| 80 | unless treasure.nil? 81 | type("* #{treasure.name}\n") 82 | add_item(treasure) 83 | end 84 | end 85 | end 86 | print "\n" 87 | else 88 | type("nothing!\n\n") 89 | end 90 | end 91 | 92 | # Removes all items from the entity's inventory. 93 | def clear_inventory 94 | while @inventory.size.nonzero? 95 | @inventory.pop 96 | end 97 | end 98 | 99 | # Equips the specified item to the entity's outfit. 100 | # 101 | # @param [Item, String] item the item (or its name) to equip. 102 | def equip_item(item) 103 | 104 | index = has_item(item) 105 | if index 106 | actual_item = inventory[index].first 107 | 108 | # Checks for Equippable without importing the file. 109 | if (defined? actual_item.equip) 110 | actual_item.equip(self) 111 | 112 | # Equipping the item will always remove it from the entity's inventory. 113 | remove_item(actual_item) 114 | else 115 | print "#{actual_item.name} cannot be equipped!\n\n" 116 | end 117 | else 118 | print NO_SUCH_ITEM_ERROR 119 | end 120 | end 121 | 122 | # Returns the index of the specified item, if it exists. 123 | # 124 | # @param [Item, String] item the item (or its name). 125 | # @return [Integer] the index of an existing item. Otherwise nil. 126 | def has_item(item) 127 | inventory.each_with_index do |couple, index| 128 | return index if couple.first.name.casecmp(item.to_s).zero? 129 | end 130 | return 131 | end 132 | 133 | # Prints the inventory in a nice format. 134 | def print_inventory 135 | print "Current gold in pouch: #{@gold}.\n\n" 136 | 137 | if @inventory.empty? 138 | print "#{@name}'s inventory is empty!\n\n" 139 | return 140 | end 141 | 142 | puts "#{@name}'s inventory:" 143 | @inventory.each do |couple| 144 | puts "* #{couple.first.name} (#{couple.second})" 145 | end 146 | print "\n" 147 | end 148 | 149 | # Prints the status in a nice format. 150 | # TODO: encapsulate print_stats and print_equipment in own functions. 151 | def print_status 152 | puts "Stats:" 153 | puts "* HP: #{@stats[:hp]}/#{@stats[:max_hp]}" 154 | puts "* Attack: #{@stats[:attack]}" 155 | puts "* Defense: #{@stats[:defense]}" 156 | puts "* Agility: #{@stats[:agility]}" 157 | print "\n" 158 | 159 | puts "Equipment:" 160 | print "* Weapon: " 161 | puts @outfit[:weapon] ? "#{@outfit[:weapon].name}" : "none" 162 | 163 | print "* Shield: " 164 | puts @outfit[:shield] ? "#{@outfit[:shield].name}" : "none" 165 | 166 | print "* Helmet: " 167 | puts @outfit[:helmet] ? "#{@outfit[:helmet].name}" : "none" 168 | 169 | print "* Torso: " 170 | puts @outfit[:torso] ? "#{@outfit[:torso].name}" : "none" 171 | 172 | print "* Legs: " 173 | puts @outfit[:legs] ? "#{@outfit[:legs].name}" : "none" 174 | 175 | print "\n" 176 | end 177 | 178 | # Removes up to the amount of gold given in the argument. 179 | # Entity's gold will not be less than zero. 180 | # 181 | # @param [Integer] gold the amount of gold to remove. 182 | def remove_gold(gold) 183 | @gold -= gold 184 | check_and_set_gold 185 | end 186 | 187 | # Removes the item, if it exists, and, at most, the given amount from the inventory. 188 | # 189 | # @param [Item] item the item being removed. 190 | # @param [Integer] amount the amount of the item to remove. 191 | def remove_item(item, amount = 1) 192 | 193 | # Decrease the amount if the item already exists in the inventory. 194 | @inventory.each_with_index do |couple, index| 195 | if (couple.first == item) 196 | couple.second -= amount 197 | 198 | # Delete the item if the amount becomes non-positive. 199 | @inventory.delete_at(index) if couple.second.nonpositive? 200 | 201 | return 202 | end 203 | end 204 | end 205 | 206 | # Sets the Entity's gold to the number in the argument. 207 | # Only nonnegative numbers are accepted. 208 | # 209 | # @param [Integer] gold the amount of gold to set. 210 | def set_gold(gold) 211 | @gold = gold 212 | check_and_set_gold 213 | end 214 | 215 | # Sets stats 216 | # 217 | # @param [Hash] passed_in_stats value pairs of stats 218 | # @option passed_in_stats [Integer] :max_hp maximum health points. Set to be positive. 219 | # @option passed_in_stats [Integer] :hp current health points. Set to be nonnegative. 220 | # @option passed_in_stats [Integer] :attack strength in battle. Set to be positive. 221 | # @option passed_in_stats [Integer] :defense protection from attacks. Set to be positive. 222 | # @option passed_in_stats [Integer] :agility speed of commands in battle. Set to be positive. 223 | def set_stats(passed_in_stats) 224 | current_stats = @stats || { max_hp: 1, hp: nil, attack: 1, defense: 1, agility: 1 } 225 | constructed_stats = current_stats.merge(passed_in_stats) 226 | 227 | # Set hp to max_hp if hp not specified 228 | constructed_stats[:hp] = constructed_stats[:hp] || constructed_stats[:max_hp] 229 | # hp should not be greater than max_hp 230 | constructed_stats[:hp] = [constructed_stats[:hp], constructed_stats[:max_hp]].min 231 | #ensure hp is at least 0 232 | constructed_stats[:hp] = constructed_stats[:hp] > 0 ? constructed_stats[:hp] : 0 233 | #ensure all other stats > 0 234 | constructed_stats.each do |key,value| 235 | if [:max_hp, :attack, :defense, :agility].include?(key) 236 | constructed_stats[key] = value.nonpositive? ? 1 : value 237 | end 238 | end 239 | 240 | @stats = constructed_stats 241 | end 242 | 243 | # getter for stats 244 | # 245 | # @return [Object] 246 | def stats 247 | # attr_reader makes sure stats cannot be set via stats= 248 | # freeze makes sure that stats []= cannot be used 249 | @stats.freeze 250 | end 251 | 252 | # Unequips the specified item from the entity's outfit. 253 | # 254 | # @param [Item, String] item the item (or its name) to unequip. 255 | def unequip_item(item) 256 | pair = @outfit.detect { |type, value| value.name.casecmp(item.to_s).zero? } 257 | if pair 258 | # On a successful find, the "detect" method always returns 259 | # an array of length 2; thus, the following line should not fail. 260 | item = pair[1] 261 | item.unequip(self) 262 | add_item(item) 263 | else 264 | print NOT_EQUIPPED_ERROR 265 | end 266 | end 267 | 268 | # Uses the item, if it exists, on the specified entity. 269 | # 270 | # @param [Item, String] item the item (or its name) to use. 271 | # @param [Entity] entity the entity on which to use the item. 272 | def use_item(item, entity) 273 | index = has_item(item) 274 | if index 275 | actual_item = inventory[index].first 276 | actual_item.use(self, entity) 277 | remove_item(actual_item) if actual_item.consumable 278 | else 279 | print NO_SUCH_ITEM_ERROR 280 | end 281 | end 282 | 283 | # @param [Entity] rhs the entity on the right. 284 | def ==(rhs) 285 | @name == rhs.name 286 | end 287 | 288 | attr_accessor :escaped, :inventory, :name 289 | attr_reader :gold, :outfit 290 | 291 | private 292 | 293 | # Safety function that prevents gold 294 | # from decreasing below 0. 295 | def check_and_set_gold 296 | @gold = 0 if @gold.negative? 297 | end 298 | 299 | end 300 | 301 | end 302 | -------------------------------------------------------------------------------- /lib/goby/entity/player.rb: -------------------------------------------------------------------------------- 1 | require 'goby' 2 | 3 | module Goby 4 | 5 | # Extends upon Entity by providing a location in the 6 | # form of a Map and a pair of y-x location. Overrides 7 | # some methods to accept input during battle. 8 | class Player < Entity 9 | 10 | include WorldCommand 11 | include Fighter 12 | 13 | # Default map when no "good" map & location specified. 14 | DEFAULT_MAP = Map.new(tiles: [[Tile.new]]) 15 | # Default location when no "good" map & location specified. 16 | DEFAULT_COORDS = C[0, 0] 17 | 18 | # distance in each direction that tiles are acted upon 19 | # used in: update_map, print_minimap 20 | VIEW_DISTANCE = 2 21 | 22 | # @param [String] name the name. 23 | # @param [Hash] stats hash of stats 24 | # @param [[C(Item, Integer)]] inventory a list of pairs of items and their respective amounts. 25 | # @param [Integer] gold the currency used for economical transactions. 26 | # @param [[BattleCommand]] battle_commands the commands that can be used in battle. 27 | # @param [Hash] outfit the collection of equippable items currently worn. 28 | # @param [Location] location the place at which the player should start. 29 | # @param [Location] respawn_location the place at which the player respawns. 30 | def initialize(name: "Player", stats: {}, inventory: [], gold: 0, battle_commands: [], 31 | outfit: {}, location: nil, respawn_location: nil) 32 | super(name: name, stats: stats, inventory: inventory, gold: gold, outfit: outfit) 33 | @saved_maps = Hash.new 34 | 35 | # Ensure that the map and the location are valid. 36 | new_map = DEFAULT_MAP 37 | new_coords = DEFAULT_COORDS 38 | if (location && location.map && location.coords) 39 | y = location.coords.first; x = location.coords.second 40 | if (location.map.in_bounds(y, x) && location.map.tiles[y][x].passable) 41 | new_map = location.map 42 | new_coords = location.coords 43 | end 44 | end 45 | 46 | add_battle_commands(battle_commands) 47 | 48 | move_to(Location.new(new_map, new_coords)) 49 | @respawn_location = respawn_location || @location 50 | @saved_maps = Hash.new 51 | end 52 | 53 | # Uses player input to determine the battle command. 54 | # 55 | # @return [BattleCommand] the chosen battle command. 56 | def choose_attack 57 | print_battle_commands(header = "Choose an attack:") 58 | 59 | input = player_input 60 | index = has_battle_command(input) 61 | 62 | #input error loop 63 | while !index 64 | puts "You don't have '#{input}'" 65 | print_battle_commands(header = "Try one of these:") 66 | 67 | input = player_input 68 | index = has_battle_command(input) 69 | end 70 | 71 | return @battle_commands[index] 72 | end 73 | 74 | # Requires input to select item and on whom to use it 75 | # during battle (Use command). Return nil on error. 76 | # 77 | # @param [Entity] enemy the opponent in battle. 78 | # @return [C(Item, Entity)] the item and on whom it is to be used. 79 | def choose_item_and_on_whom(enemy) 80 | index = nil 81 | item = nil 82 | 83 | # Choose the item to use. 84 | while !index 85 | print_inventory 86 | puts "Which item would you like to use?" 87 | input = player_input prompt: "(or type 'pass' to forfeit the turn): " 88 | 89 | return if (input.casecmp("pass").zero?) 90 | 91 | index = has_item(input) 92 | 93 | if !index 94 | print NO_SUCH_ITEM_ERROR 95 | else 96 | item = @inventory[index].first 97 | end 98 | end 99 | 100 | whom = nil 101 | 102 | # Choose on whom to use the item. 103 | while !whom 104 | puts "On whom will you use the item (#{@name} or #{enemy.name})?" 105 | input = player_input prompt: "(or type 'pass' to forfeit the turn): " 106 | 107 | return if (input.casecmp("pass").zero?) 108 | 109 | if (input.casecmp(@name).zero?) 110 | whom = self 111 | elsif (input.casecmp(enemy.name).zero?) 112 | whom = enemy 113 | else 114 | print "What?! Choose either #{@name} or #{enemy.name}!\n\n" 115 | end 116 | end 117 | 118 | return C[item, whom] 119 | end 120 | 121 | # Sends the player back to a safe location, 122 | # halves its gold, and restores HP. 123 | def die 124 | sleep(2) unless ENV['TEST'] 125 | 126 | move_to(@respawn_location) 127 | type("After being knocked out in battle,\n") 128 | type("you wake up in #{@location.map.name}.\n\n") 129 | 130 | sleep(2) unless ENV['TEST'] 131 | 132 | # Heal the player. 133 | set_stats(hp: @stats[:max_hp]) 134 | end 135 | 136 | # Retrieve loot obtained by defeating the enemy. 137 | # 138 | # @param [Fighter] fighter the Fighter who lost the battle. 139 | def handle_victory(fighter) 140 | type("#{@name} defeated the #{fighter.name}!\n") 141 | gold = fighter.sample_gold 142 | treasure = fighter.sample_treasures 143 | add_loot(gold, [treasure]) unless gold.nil? && treasure.nil? 144 | 145 | type("Press enter to continue...") 146 | player_input 147 | end 148 | 149 | # Moves the player down. Increases 'y' coordinate by 1. 150 | def move_down 151 | down_tile = C[@location.coords.first + 1, @location.coords.second] 152 | move_to(Location.new(@location.map, down_tile)) 153 | end 154 | 155 | # Moves the player left. Decreases 'x' coordinate by 1. 156 | def move_left 157 | left_tile = C[@location.coords.first, @location.coords.second - 1] 158 | move_to(Location.new(@location.map, left_tile)) 159 | end 160 | 161 | # Moves the player right. Increases 'x' coordinate by 1. 162 | def move_right 163 | right_tile = C[@location.coords.first, @location.coords.second + 1] 164 | move_to(Location.new(@location.map, right_tile)) 165 | end 166 | 167 | # Safe setter function for location and map. 168 | # 169 | # @param [Location] location the new location. 170 | def move_to(location) 171 | 172 | map = location.map 173 | y = location.coords.first 174 | x = location.coords.second 175 | 176 | # Prevents operations on nil. 177 | return if map.nil? 178 | 179 | # Save the map. 180 | @saved_maps[@location.map.name] = @location.map if @location 181 | 182 | # Even if the player hasn't moved, we still change to true. 183 | # This is because we want to re-display the minimap anyway. 184 | @moved = true 185 | 186 | # Prevents moving onto nonexistent and impassable tiles. 187 | return if !(map.in_bounds(y, x) && map.tiles[y][x].passable) 188 | 189 | # Update the location and surrounding tiles. 190 | @location = Location.new( 191 | @saved_maps[map.name] ? @saved_maps[map.name] : map, location.coords) 192 | update_map 193 | 194 | tile = @location.map.tiles[y][x] 195 | unless tile.monsters.empty? 196 | # 50% chance to encounter monster (TODO: too high?) 197 | if [true, false].sample 198 | clone = tile.monsters[Random.rand(0..(tile.monsters.size-1))].clone 199 | battle(clone) 200 | end 201 | end 202 | end 203 | 204 | # Moves the player up. Decreases 'y' coordinate by 1. 205 | def move_up 206 | up_tile = C[@location.coords.first - 1, @location.coords.second] 207 | move_to(Location.new(@location.map, up_tile)) 208 | end 209 | 210 | # Prints the map in regards to what the player has seen. 211 | # Additionally, provides current location and the map's name. 212 | def print_map 213 | 214 | # Provide some spacing from the edge of the terminal. 215 | 3.times { print " " }; 216 | 217 | print @location.map.name + "\n\n" 218 | 219 | @location.map.tiles.each_with_index do |row, r| 220 | # Provide spacing for the beginning of each row. 221 | 2.times { print " " } 222 | 223 | row.each_with_index do |tile, t| 224 | print_tile(C[r, t]) 225 | end 226 | print "\n" 227 | end 228 | 229 | print "\n" 230 | 231 | # Provide some spacing to center the legend. 232 | 3.times { print " " } 233 | 234 | # Prints the legend. 235 | print "¶ - #{@name}'s\n location\n\n" 236 | end 237 | 238 | # Prints a minimap of nearby tiles (using VIEW_DISTANCE). 239 | def print_minimap 240 | print "\n" 241 | for y in (@location.coords.first-VIEW_DISTANCE)..(@location.coords.first+VIEW_DISTANCE) 242 | # skip to next line if out of bounds from above map 243 | next if y.negative? 244 | # centers minimap 245 | 10.times { print " " } 246 | for x in (@location.coords.second-VIEW_DISTANCE)..(@location.coords.second+VIEW_DISTANCE) 247 | # Prevents operations on nonexistent tiles. 248 | print_tile(C[y, x]) if (@location.map.in_bounds(y, x)) 249 | end 250 | # new line if this row is not out of bounds 251 | print "\n" if y < @location.map.tiles.size 252 | end 253 | print "\n" 254 | end 255 | 256 | # Prints the tile based on the player's location. 257 | # 258 | # @param [C(Integer, Integer)] coords the y-x location of the tile. 259 | def print_tile(coords) 260 | if ((@location.coords.first == coords.first) && (@location.coords.second == coords.second)) 261 | print "¶ " 262 | else 263 | print @location.map.tiles[coords.first][coords.second].to_s 264 | end 265 | end 266 | 267 | # Updates the 'seen' attributes of the tiles on the player's current map. 268 | # 269 | # @param [Location] location to update seen attribute for tiles on the map. 270 | def update_map(location = @location) 271 | for y in (location.coords.first-VIEW_DISTANCE)..(location.coords.first+VIEW_DISTANCE) 272 | for x in (location.coords.second-VIEW_DISTANCE)..(location.coords.second+VIEW_DISTANCE) 273 | @location.map.tiles[y][x].seen = true if (@location.map.in_bounds(y, x)) 274 | end 275 | end 276 | end 277 | 278 | # The treasure given by a Player after losing a battle. 279 | # 280 | # @return [Item] the reward for the victor of the battle (or nil - no treasure). 281 | def sample_treasures 282 | nil 283 | end 284 | 285 | # Returns the gold given to a victorious Entity after losing a battle 286 | # and deducts the figure from the Player's total as necessary 287 | # 288 | # @return[Integer] the amount of gold to award the victorious Entity 289 | def sample_gold 290 | gold_lost = 0 291 | # Reduce gold if the player has any. 292 | if @gold.positive? 293 | type("Looks like you lost some gold...\n\n") 294 | gold_lost = @gold/2 295 | @gold -= gold_lost 296 | end 297 | gold_lost 298 | end 299 | 300 | attr_reader :location, :saved_maps 301 | attr_accessor :moved, :respawn_location 302 | 303 | end 304 | 305 | end 306 | -------------------------------------------------------------------------------- /spec/goby/entity/fighter_spec.rb: -------------------------------------------------------------------------------- 1 | require 'goby' 2 | 3 | RSpec.describe Fighter do 4 | 5 | let!(:empty_fighter) { Class.new { extend Fighter } } 6 | let(:fighter_class) { 7 | Class.new(Entity) do 8 | include Fighter 9 | def initialize(name: "Fighter", stats: {}, inventory: [], gold: 0, battle_commands: [], outfit: {}) 10 | super(name: name, stats: stats, inventory: inventory, gold: gold, outfit: outfit) 11 | add_battle_commands(battle_commands) 12 | end 13 | end 14 | } 15 | let(:fighter) { fighter_class.new } 16 | 17 | context "fighter" do 18 | it "is a fighter" do 19 | expect(fighter.class.included_modules.include?(Fighter)).to be true 20 | end 21 | end 22 | 23 | context "placeholder methods" do 24 | it "forces :die to be implemented" do 25 | expect { empty_fighter.die }.to raise_error(NotImplementedError, 'A Fighter must know how to die.') 26 | end 27 | 28 | it "forces :handle_victory to be implemented" do 29 | expect { empty_fighter.handle_victory(fighter) }.to raise_error(NotImplementedError, 'A Fighter must know how to handle victory.') 30 | end 31 | 32 | it "forces :sample_treasures to be implemented" do 33 | expect { empty_fighter.sample_treasures }.to raise_error(NotImplementedError, 'A Fighter must know whether it returns treasure or not after losing a battle.') 34 | end 35 | 36 | it "forces :sample_gold to be implemented" do 37 | expect { empty_fighter.sample_gold }.to raise_error(NotImplementedError, 'A Fighter must return some gold after losing a battle.') 38 | end 39 | end 40 | 41 | context "add battle command" do 42 | it "properly adds the command in a trivial case" do 43 | fighter.add_battle_command(BattleCommand.new) 44 | expect(fighter.battle_commands.length).to eq 1 45 | expect(fighter.battle_commands).to eq [BattleCommand.new] 46 | end 47 | 48 | it "maintains the sorted invariant for a more complex case" do 49 | fighter.add_battle_command(BattleCommand.new(name: "Kick")) 50 | fighter.add_battle_command(BattleCommand.new(name: "Chop")) 51 | fighter.add_battle_command(BattleCommand.new(name: "Grab")) 52 | expect(fighter.battle_commands.length).to eq 3 53 | expect(fighter.battle_commands).to eq [ 54 | BattleCommand.new(name: "Chop"), 55 | BattleCommand.new(name: "Grab"), 56 | BattleCommand.new(name: "Kick")] 57 | end 58 | end 59 | 60 | context "battle" do 61 | it "raises an error when starting a battle against a non-Fighter Entity" do 62 | expect {empty_fighter.battle(Class.new)}.to raise_error(Fighter::UnfightableException, 63 | "You can't start a battle with an Entity of type Class as it doesn't implement the Fighter module") 64 | end 65 | end 66 | 67 | context "choose attack" do 68 | it "randomly selects one of the available commands" do 69 | kick = BattleCommand.new(name: "Kick") 70 | zap = BattleCommand.new(name: "Zap") 71 | entity = fighter_class.new(battle_commands: [kick, zap]) 72 | attack = entity.choose_attack 73 | expect(attack.name).to eq("Kick").or(eq("Zap")) 74 | end 75 | end 76 | 77 | context "choose item and on whom" do 78 | it "randomly selects both item and on whom" do 79 | banana = Item.new(name: "Banana") 80 | axe = Item.new(name: "Axe") 81 | 82 | entity = fighter_class.new(inventory: [C[banana, 1], 83 | C[axe, 3]]) 84 | enemy = fighter_class.new(name: "Enemy") 85 | 86 | pair = entity.choose_item_and_on_whom(enemy) 87 | expect(pair.first.name).to eq("Banana").or(eq("Axe")) 88 | expect(pair.second.name).to eq("Fighter").or(eq("Enemy")) 89 | end 90 | end 91 | 92 | context "equip item" do 93 | it "correctly equips the weapon and alters the stats of a Fighter Entity" do 94 | entity = fighter_class.new(inventory: [C[ 95 | Weapon.new(stat_change: {attack: 3}, 96 | attack: Attack.new), 1]]) 97 | entity.equip_item("Weapon") 98 | expect(entity.outfit[:weapon]).to eq Weapon.new 99 | expect(entity.stats[:attack]).to eq 4 100 | expect(entity.battle_commands).to eq [Attack.new] 101 | end 102 | 103 | it "correctly switches the equipped items and alters status of a Fighter Entity as appropriate" do 104 | entity = fighter_class.new(inventory: [C[ 105 | Weapon.new(name: "Hammer", 106 | stat_change: {attack: 3, 107 | defense: 2, 108 | agility: 4}, 109 | attack: Attack.new(name: "Bash")), 1], 110 | C[ 111 | Weapon.new(name: "Knife", 112 | stat_change: {attack: 5, 113 | defense: 3, 114 | agility: 7}, 115 | attack: Attack.new(name: "Stab")), 1]]) 116 | entity.equip_item("Hammer") 117 | stats = entity.stats 118 | expect(stats[:attack]).to eq 4 119 | expect(stats[:defense]).to eq 3 120 | expect(stats[:agility]).to eq 5 121 | expect(entity.outfit[:weapon].name).to eq "Hammer" 122 | expect(entity.battle_commands).to eq [Attack.new(name: "Bash")] 123 | expect(entity.inventory.length).to eq 1 124 | expect(entity.inventory[0].first.name).to eq "Knife" 125 | 126 | entity.equip_item("Knife") 127 | stats = entity.stats 128 | expect(stats[:attack]).to eq 6 129 | expect(stats[:defense]).to eq 4 130 | expect(stats[:agility]).to eq 8 131 | expect(entity.outfit[:weapon].name).to eq "Knife" 132 | expect(entity.battle_commands).to eq [Attack.new(name: "Stab")] 133 | expect(entity.inventory.length).to eq 1 134 | expect(entity.inventory[0].first.name).to eq "Hammer" 135 | end 136 | end 137 | 138 | context "has battle command" do 139 | it "correctly indicates an absent command for an object argument" do 140 | entity = fighter_class.new(battle_commands: [ 141 | BattleCommand.new(name: "Kick"), 142 | BattleCommand.new(name: "Poke")]) 143 | index = entity.has_battle_command(BattleCommand.new(name: "Chop")) 144 | expect(index).to be_nil 145 | end 146 | 147 | it "correctly indicates a present command for an object argument" do 148 | entity = fighter_class.new(battle_commands: [ 149 | BattleCommand.new(name: "Kick"), 150 | BattleCommand.new(name: "Poke")]) 151 | index = entity.has_battle_command(BattleCommand.new(name: "Poke")) 152 | expect(index).to eq 1 153 | end 154 | 155 | it "correctly indicates an absent command for a string argument" do 156 | entity = fighter_class.new(battle_commands: [ 157 | BattleCommand.new(name: "Kick"), 158 | BattleCommand.new(name: "Poke")]) 159 | index = entity.has_battle_command("Chop") 160 | expect(index).to be_nil 161 | end 162 | 163 | it "correctly indicates a present command for a string argument" do 164 | entity = fighter_class.new(battle_commands: [ 165 | BattleCommand.new(name: "Kick"), 166 | BattleCommand.new(name: "Poke")]) 167 | index = entity.has_battle_command("Poke") 168 | expect(index).to eq 1 169 | end 170 | end 171 | 172 | context "print battle commands" do 173 | it "should print only the default header when there are no battle commands" do 174 | expect { fighter.print_battle_commands }.to output("Battle Commands:\n\n").to_stdout 175 | end 176 | 177 | it "should print the custom header when one is passed" do 178 | expect { fighter.print_battle_commands("Choose an attack:") }.to output("Choose an attack:\n\n").to_stdout 179 | end 180 | 181 | it "should print each battle command in a list" do 182 | kick = Attack.new(name: "Kick") 183 | entity = fighter_class.new(battle_commands: [kick, Use.new, Escape.new]) 184 | expect { entity.print_battle_commands }.to output( 185 | "Battle Commands:\n❊ Escape\n❊ Kick\n❊ Use\n\n" 186 | ).to_stdout 187 | end 188 | end 189 | 190 | context "print status" do 191 | it "prints all of the entity's information without battle commands" do 192 | entity = fighter_class.new(stats: {max_hp: 50, 193 | hp: 30, 194 | attack: 5, 195 | defense: 3, 196 | agility: 4}, 197 | outfit: {helmet: Helmet.new, 198 | legs: Legs.new, 199 | shield: Shield.new, 200 | torso: Torso.new, 201 | weapon: Weapon.new}) 202 | expect { entity.print_status }.to output( 203 | "Stats:\n* HP: 30/50\n* Attack: 5\n* Defense: 3\n* Agility: 4\n\n"\ 204 | "Equipment:\n* Weapon: Weapon\n* Shield: Shield\n* Helmet: Helmet\n"\ 205 | "* Torso: Torso\n* Legs: Legs\n\n" 206 | ).to_stdout 207 | end 208 | 209 | it "prints all of the entity's information including battle commands" do 210 | entity = fighter_class.new(stats: {max_hp: 50, 211 | hp: 30, 212 | attack: 5, 213 | defense: 3, 214 | agility: 4}, 215 | outfit: {helmet: Helmet.new, 216 | legs: Legs.new, 217 | shield: Shield.new, 218 | torso: Torso.new, 219 | weapon: Weapon.new}, 220 | battle_commands: [Escape.new]) 221 | expect { entity.print_status }.to output( 222 | "Stats:\n* HP: 30/50\n* Attack: 5\n* Defense: 3\n* Agility: 4\n\n"\ 223 | "Equipment:\n* Weapon: Weapon\n* Shield: Shield\n* Helmet: Helmet\n"\ 224 | "* Torso: Torso\n* Legs: Legs\n\nBattle Commands:\n❊ Escape\n\n" 225 | ).to_stdout 226 | end 227 | end 228 | 229 | context "remove battle command" do 230 | it "has no effect when no such command is present" do 231 | fighter.add_battle_command(Attack.new(name: "Kick")) 232 | fighter.remove_battle_command(BattleCommand.new(name: "Poke")) 233 | expect(fighter.battle_commands.length).to eq 1 234 | end 235 | 236 | it "correctly removes the command in the trivial case" do 237 | fighter.add_battle_command(Attack.new(name: "Kick")) 238 | fighter.remove_battle_command(Attack.new(name: "Kick")) 239 | expect(fighter.battle_commands.length).to eq 0 240 | end 241 | end 242 | 243 | end 244 | -------------------------------------------------------------------------------- /spec/goby/entity/player_spec.rb: -------------------------------------------------------------------------------- 1 | require 'goby' 2 | 3 | RSpec.describe Player do 4 | 5 | # Constructs a map in the shape of a plus sign. 6 | let!(:map) { Map.new(tiles: [[Tile.new(passable: false), Tile.new, Tile.new(passable: false)], 7 | [Tile.new, Tile.new, Tile.new(monsters: [Monster.new(battle_commands: [Attack.new(success_rate: 0)])])], 8 | [Tile.new(passable: false), Tile.new, Tile.new(passable: false)]]) } 9 | let!(:center) { C[1, 1] } 10 | let!(:passable) { Tile::DEFAULT_PASSABLE } 11 | let!(:impassable) { Tile::DEFAULT_IMPASSABLE } 12 | 13 | let!(:dude) { Player.new(stats: {attack: 10, agility: 10000, map_hp: 2000}, gold: 10, 14 | battle_commands: [Attack.new(strength: 20), Escape.new, Use.new], 15 | location: Location.new(map, center)) } 16 | let!(:slime) { Monster.new(battle_commands: [Attack.new(success_rate: 0)], 17 | gold: 5000, treasures: [C[Item.new, 1]]) } 18 | let!(:newb) { Player.new(battle_commands: [Attack.new(success_rate: 0)], 19 | gold: 50, location: Location.new(map, center), 20 | respawn_location: Location.new(map, C[2, 1])) } 21 | let!(:dragon) { Monster.new(stats: {attack: 50, agility: 10000}, 22 | battle_commands: [Attack.new(strength: 50)]) } 23 | let!(:chest_map) { Map.new(name: "Chest Map", 24 | tiles: [[Tile.new(events: [Chest.new(gold: 5)]), Tile.new(events: [Chest.new(gold: 5)])]]) } 25 | 26 | context "constructor" do 27 | it "has the correct default parameters" do 28 | player = Player.new 29 | expect(player.name).to eq "Player" 30 | expect(player.stats[:max_hp]).to eq 1 31 | expect(player.stats[:hp]).to eq 1 32 | expect(player.stats[:attack]).to eq 1 33 | expect(player.stats[:defense]).to eq 1 34 | expect(player.stats[:agility]).to eq 1 35 | expect(player.inventory).to eq Array.new 36 | expect(player.gold).to eq 0 37 | expect(player.outfit).to eq Hash.new 38 | expect(player.battle_commands).to eq Array.new 39 | expect(player.location.map).to eq Player::DEFAULT_MAP 40 | expect(player.location.coords).to eq Player::DEFAULT_COORDS 41 | expect(player.respawn_location.map).to eq Player::DEFAULT_MAP 42 | expect(player.respawn_location.coords).to eq Player::DEFAULT_COORDS 43 | end 44 | 45 | it "correctly assigns custom parameters" do 46 | hero = Player.new(name: "Hero", 47 | stats: {max_hp: 50, 48 | hp: 35, 49 | attack: 12, 50 | defense: 4, 51 | agility: 9}, 52 | inventory: [C[Item.new, 1]], 53 | gold: 10, 54 | outfit: {weapon: Weapon.new( 55 | attack: Attack.new, 56 | stat_change: {attack: 3, defense: 1}), 57 | helmet: Helmet.new( 58 | stat_change: {attack: 1, defense: 5})}, 59 | battle_commands: [ 60 | BattleCommand.new(name: "Yell"), 61 | BattleCommand.new(name: "Run") 62 | ], 63 | location: Location.new(map, C[1, 1]), 64 | respawn_location: Location.new(map, C[1, 2])) 65 | expect(hero.name).to eq "Hero" 66 | expect(hero.stats[:max_hp]).to eq 50 67 | expect(hero.stats[:hp]).to eq 35 68 | expect(hero.stats[:attack]).to eq 16 69 | expect(hero.stats[:defense]).to eq 10 70 | expect(hero.stats[:agility]).to eq 9 71 | expect(hero.inventory).to eq [C[Item.new, 1]] 72 | expect(hero.gold).to eq 10 73 | expect(hero.outfit[:weapon]).to eq Weapon.new 74 | expect(hero.outfit[:helmet]).to eq Helmet.new 75 | expect(hero.battle_commands).to eq [ 76 | Attack.new, 77 | BattleCommand.new(name: "Run"), 78 | BattleCommand.new(name: "Yell") 79 | ] 80 | expect(hero.location.map).to eq map 81 | expect(hero.location.coords).to eq C[1, 1] 82 | expect(hero.respawn_location.map).to eq map 83 | expect(hero.respawn_location.coords).to eq C[1, 2] 84 | end 85 | 86 | it "sets respawn to start location for no respawn_location" do 87 | player = Player.new(location: Location.new(map, C[1, 1])) 88 | expect(player.respawn_location.map).to eq map 89 | expect(player.respawn_location.coords).to eq C[1, 1] 90 | end 91 | 92 | context "places the player in the default map & location" do 93 | it "receives the nil map" do 94 | player = Player.new(location: Location.new(nil, C[2, 4])) 95 | expect(player.location.map).to eq Player::DEFAULT_MAP 96 | expect(player.location.coords).to eq Player::DEFAULT_COORDS 97 | end 98 | 99 | it "receives nil coordinates" do 100 | player = Player.new(location: Location.new(Map.new, nil)) 101 | expect(player.location.map).to eq Player::DEFAULT_MAP 102 | expect(player.location.coords).to eq Player::DEFAULT_COORDS 103 | end 104 | 105 | it "receives an out-of-bounds location" do 106 | player = Player.new(location: Location.new(Map.new, C[0, 1])) 107 | expect(player.location.map).to eq Player::DEFAULT_MAP 108 | expect(player.location.coords).to eq Player::DEFAULT_COORDS 109 | end 110 | 111 | it "receives an impassable location" do 112 | player = Player.new(location: Location.new( 113 | Map.new(tiles: [[Tile.new(passable: false)]]), C[0, 0])) 114 | expect(player.location.map).to eq Player::DEFAULT_MAP 115 | expect(player.location.coords).to eq Player::DEFAULT_COORDS 116 | end 117 | end 118 | 119 | end 120 | 121 | context "choose attack" do 122 | it "should choose the correct attack based on the input" do 123 | charge = BattleCommand.new(name: "Charge") 124 | zap = BattleCommand.new(name: "Zap") 125 | player = Player.new(battle_commands: [charge, zap]) 126 | 127 | # RSpec input example. Also see spec_helper.rb for __stdin method. 128 | __stdin("kick\n", "zap\n") do 129 | expect(player.choose_attack.name).to eq "Zap" 130 | end 131 | end 132 | end 133 | 134 | context "choose item and on whom" do 135 | let!(:banana) { Item.new(name: "Banana") } 136 | let!(:axe) { Item.new(name: "Axe") } 137 | let!(:entity) { Player.new(inventory: [C[banana, 1], 138 | C[axe, 3]]) } 139 | let!(:enemy) { Entity.new(name: "Enemy") } 140 | 141 | it "should return correct values based on the input" do 142 | # RSpec input example. Also see spec_helper.rb for __stdin method. 143 | __stdin("goulash\n", "axe\n", "bill\n", "enemy\n") do 144 | pair = entity.choose_item_and_on_whom(enemy) 145 | expect(pair.first.name).to eq "Axe" 146 | expect(pair.second.name).to eq "Enemy" 147 | end 148 | end 149 | 150 | context "should return nil on appropriate input" do 151 | it "for item" do 152 | # RSpec input example. Also see spec_helper.rb for __stdin method. 153 | __stdin("goulash\n", "pass\n") do 154 | pair = entity.choose_item_and_on_whom(enemy) 155 | expect(pair).to be_nil 156 | end 157 | end 158 | 159 | it "for whom" do 160 | # RSpec input example. Also see spec_helper.rb for __stdin method. 161 | __stdin("banana\n", "bill\n", "pass\n") do 162 | pair = entity.choose_item_and_on_whom(enemy) 163 | expect(pair).to be_nil 164 | end 165 | end 166 | end 167 | end 168 | 169 | context "move to" do 170 | it "correctly moves the player to a passable tile" do 171 | dude.move_to(Location.new(dude.location.map, C[2, 1])) 172 | expect(dude.location.map).to eq map 173 | expect(dude.location.coords).to eq C[2, 1] 174 | end 175 | 176 | it "prevents the player from moving on an impassable tile" do 177 | dude.move_to(Location.new(dude.location.map, C[2, 2])) 178 | expect(dude.location.map).to eq map 179 | expect(dude.location.coords).to eq center 180 | end 181 | 182 | it "prevents the player from moving on a nonexistent tile" do 183 | dude.move_to(Location.new(dude.location.map, C[3, 3])) 184 | expect(dude.location.map).to eq map 185 | expect(dude.location.coords).to eq center 186 | end 187 | 188 | it "saves the information from previous maps" do 189 | dude.move_to(Location.new(chest_map, C[0, 0])) 190 | interpret_command("open", dude) 191 | expect(dude.gold).to eq 15 192 | dude.move_to(Location.new(Map.new, C[0, 0])) 193 | dude.move_to(Location.new(Map.new(name: "Chest Map"), C[0, 0])) 194 | interpret_command("open", dude) 195 | expect(dude.gold).to eq 15 196 | dude.move_right 197 | interpret_command("open", dude) 198 | expect(dude.gold).to eq 20 199 | end 200 | end 201 | 202 | context "move up" do 203 | it "correctly moves the player to a passable tile" do 204 | dude.move_up 205 | expect(dude.location.map).to eq map 206 | expect(dude.location.coords).to eq C[0, 1] 207 | end 208 | 209 | it "prevents the player from moving on a nonexistent tile" do 210 | dude.move_up; dude.move_up 211 | expect(dude.location.map).to eq map 212 | expect(dude.location.coords).to eq C[0, 1] 213 | end 214 | end 215 | 216 | context "move right" do 217 | it "correctly moves the player to a passable tile" do 218 | 20.times do 219 | __stdin("Attack\n", "\n") do 220 | dude.move_right 221 | expect(dude.location.map).to eq map 222 | expect(dude.location.coords).to eq C[1, 2] 223 | dude.move_left 224 | expect(dude.location.map).to eq map 225 | expect(dude.location.coords).to eq C[1, 1] 226 | end 227 | end 228 | end 229 | 230 | it "prevents the player from moving on a nonexistent tile" do 231 | __stdin("Attack\n", "\n") do 232 | dude.move_right; dude.move_right 233 | expect(dude.location.map).to eq map 234 | expect(dude.location.coords).to eq C[1, 2] 235 | end 236 | end 237 | end 238 | 239 | context "move down" do 240 | it "correctly moves the player to a passable tile" do 241 | dude.move_down 242 | expect(dude.location.map).to eq map 243 | expect(dude.location.coords).to eq C[2, 1] 244 | end 245 | 246 | it "prevents the player from moving on a nonexistent tile" do 247 | dude.move_down; dude.move_down 248 | expect(dude.location.map).to eq map 249 | expect(dude.location.coords).to eq C[2, 1] 250 | end 251 | end 252 | 253 | context "move left" do 254 | it "correctly moves the player to a passable tile" do 255 | dude.move_left 256 | expect(dude.location.map).to eq map 257 | expect(dude.location.coords).to eq C[1, 0] 258 | end 259 | 260 | it "prevents the player from moving on a nonexistent tile" do 261 | dude.move_left; dude.move_left 262 | expect(dude.location.map).to eq map 263 | expect(dude.location.coords).to eq C[1, 0] 264 | end 265 | end 266 | 267 | context "update map" do 268 | let!(:line_map) { Map.new(tiles: [[Tile.new, Tile.new, Tile.new, Tile.new]]) } 269 | let!(:player) { Player.new(location: Location.new(line_map, C[0, 0])) } 270 | 271 | it "uses default argument to update tiles" do 272 | player.update_map 273 | expect(line_map.tiles[0][3].seen).to eq false 274 | end 275 | 276 | it "uses given argument to update tiles" do 277 | player.update_map(Location.new(player.location.map, C[0, 2])) 278 | expect(line_map.tiles[0][3].seen).to eq true 279 | end 280 | end 281 | 282 | context "print map" do 283 | it "should display as appropriate" do 284 | edge_row = "#{impassable} #{passable} #{impassable} \n" 285 | middle_row = "#{passable} ¶ #{passable} \n" 286 | 287 | expect { dude.print_map }.to output( 288 | " Map\n\n"\ 289 | " #{edge_row}"\ 290 | " #{middle_row}"\ 291 | " #{edge_row}"\ 292 | "\n"\ 293 | " ¶ - #{dude.name}'s\n"\ 294 | " location\n\n" 295 | ).to_stdout 296 | end 297 | end 298 | 299 | context "print minimap" do 300 | it "should display as appropriate" do 301 | edge_row = "#{impassable} #{passable} #{impassable} \n" 302 | middle_row = "#{passable} ¶ #{passable} \n" 303 | 304 | expect { dude.print_minimap }.to output( 305 | "\n"\ 306 | " #{edge_row}"\ 307 | " #{middle_row}"\ 308 | " #{edge_row}"\ 309 | " \n" 310 | ).to_stdout 311 | end 312 | end 313 | 314 | context "print tile" do 315 | it "should display the marker on the player's location" do 316 | expect { dude.print_tile(dude.location.coords) }.to output("¶ ").to_stdout 317 | end 318 | 319 | it "should display the graphic of the tile elsewhere" do 320 | expect { dude.print_tile(C[0, 0]) }.to output( 321 | "#{impassable} " 322 | ).to_stdout 323 | expect { dude.print_tile(C[0, 1]) }.to output( 324 | "#{passable} " 325 | ).to_stdout 326 | end 327 | end 328 | 329 | # Fighter specific specs 330 | 331 | context "fighter" do 332 | it "should be a fighter" do 333 | expect(dude.class.included_modules.include?(Fighter)).to be true 334 | end 335 | end 336 | 337 | context "battle" do 338 | it "should allow the player to win in this example" do 339 | __stdin("attack\n", "\n") do 340 | dude.battle(slime) 341 | end 342 | expect(dude.inventory.size).to eq 1 343 | end 344 | 345 | it "should allow the player to escape in this example" do 346 | # Could theoretically fail, but with very low probability. 347 | __stdin("escape\nescape\nescape\n") do 348 | dude.battle(slime) 349 | expect(dude.gold).to eq 10 350 | end 351 | end 352 | 353 | it "should allow the monster to win in this example" do 354 | __stdin("attack\n") do 355 | newb.battle(dragon) 356 | end 357 | # Newb should die and go to respawn location. 358 | expect(newb.gold).to eq 25 359 | expect(newb.location.coords).to eq C[2, 1] 360 | end 361 | 362 | it "should allow the stronger player to win as the attacker" do 363 | __stdin("attack\nattack\n", "\n") do 364 | dude.battle(newb) 365 | end 366 | # Weaker Player should die and go to respawn location. 367 | expect(newb.gold).to eq 25 368 | expect(newb.location.coords).to eq C[2, 1] 369 | # Stronger Player should get weaker Players gold 370 | expect(dude.gold).to eq (35) 371 | end 372 | 373 | it "should allow the stronger player to win as the defender" do 374 | __stdin("attack\nattack\n", "\n") do 375 | newb.battle(dude) 376 | end 377 | # Weaker Player should die and go to respawn location. 378 | expect(newb.gold).to eq 25 379 | expect(newb.location.coords).to eq C[2, 1] 380 | # Stronger Player should get weaker Players gold 381 | expect(dude.gold).to eq (35) 382 | end 383 | 384 | end 385 | 386 | context "die" do 387 | it "moves the player back to his/her respawn location" do 388 | newb.move_left 389 | expect(newb.location.coords).to eq C[1, 0] 390 | newb.die 391 | expect(newb.location.map).to eq map 392 | expect(newb.location.coords).to eq C[2, 1] 393 | end 394 | 395 | it "recovers the player's HP to max" do 396 | newb.set_stats(hp: 0) 397 | newb.die 398 | expect(newb.stats[:hp]).to eq newb.stats[:max_hp] 399 | end 400 | end 401 | 402 | context "sample gold" do 403 | it "reduces the player's gold by half" do 404 | dude.set_gold(10) 405 | dude.sample_gold 406 | expect(dude.gold).to eq 5 407 | end 408 | 409 | it "returns the amount of gold the player has lost" do 410 | dude.set_gold(10) 411 | expect(dude.sample_gold).to eq 5 412 | end 413 | end 414 | 415 | context "sample treasures" do 416 | it "returns nil to indicate the player has lost no treasures" do 417 | expect(dude.sample_treasures).to be_nil 418 | end 419 | end 420 | end 421 | --------------------------------------------------------------------------------