├── .yardopts ├── .rspec ├── .rspec-opal ├── Gemfile ├── spec ├── opal_helper.rb ├── gamefic │ ├── command_spec.rb │ ├── query │ │ ├── myself_spec.rb │ │ ├── siblings_spec.rb │ │ ├── children_spec.rb │ │ ├── extended_spec.rb │ │ ├── parent_spec.rb │ │ ├── base_spec.rb │ │ ├── family_spec.rb │ │ ├── descendants_spec.rb │ │ ├── integer_spec.rb │ │ └── text_spec.rb │ ├── scriptable │ │ ├── hooks_spec.rb │ │ ├── responses_spec.rb │ │ ├── seeds_spec.rb │ │ ├── scenes_spec.rb │ │ └── queries_spec.rb │ ├── active │ │ ├── messaging_spec.rb │ │ ├── narratives_spec.rb │ │ └── cue_spec.rb │ ├── proxy │ │ ├── config_spec.rb │ │ └── attr_spec.rb │ ├── scanner │ │ ├── fuzzy_spec.rb │ │ ├── nesting_spec.rb │ │ └── fuzzy_nesting_spec.rb │ ├── scene │ │ ├── base_spec.rb │ │ ├── pause_spec.rb │ │ ├── activity_spec.rb │ │ ├── yes_or_no_spec.rb │ │ ├── active_choice_spec.rb │ │ └── multiple_choice_spec.rb │ ├── core_ext │ │ ├── string_spec.rb │ │ └── array_spec.rb │ ├── scene_spec.rb │ ├── props │ │ └── output_spec.rb │ ├── binding_spec.rb │ ├── scripting │ │ └── entities_spec.rb │ ├── messenger_spec.rb │ ├── dispatcher_spec.rb │ ├── request_spec.rb │ ├── action_spec.rb │ ├── entity_spec.rb │ ├── response_spec.rb │ ├── order_spec.rb │ ├── scanner_spec.rb │ ├── chapter_spec.rb │ ├── narrator_spec.rb │ ├── node_spec.rb │ ├── narrative_spec.rb │ ├── snapshots_spec.rb │ ├── describable_spec.rb │ ├── scriptable_spec.rb │ ├── active_spec.rb │ ├── subplot_spec.rb │ └── plot_spec.rb ├── gamefic_spec.rb ├── spec_helper.rb ├── fixtures │ ├── narrative_with_features.rb │ └── modular │ │ ├── modular_test_plot.rb │ │ └── modular_test_script.rb └── shared_helper.rb ├── lib ├── gamefic │ ├── version.rb │ ├── proxy │ │ ├── pick.rb │ │ ├── pick_ex.rb │ │ ├── attr.rb │ │ ├── base.rb │ │ └── config.rb │ ├── actor.rb │ ├── query │ │ ├── myself.rb │ │ ├── parent.rb │ │ ├── children.rb │ │ ├── siblings.rb │ │ ├── descendants.rb │ │ ├── subqueries.rb │ │ ├── ascendants.rb │ │ ├── global.rb │ │ ├── result.rb │ │ ├── extended.rb │ │ ├── family.rb │ │ ├── integer.rb │ │ ├── text.rb │ │ └── base.rb │ ├── props │ │ ├── pause.rb │ │ ├── yes_or_no.rb │ │ ├── multiple_partial.rb │ │ ├── default.rb │ │ ├── multiple_choice.rb │ │ └── output.rb │ ├── proxy.rb │ ├── scripting │ │ ├── seeds.rb │ │ ├── syntaxes.rb │ │ ├── responses.rb │ │ ├── proxies.rb │ │ ├── hooks.rb │ │ ├── scenes.rb │ │ └── entities.rb │ ├── scene │ │ ├── conclusion.rb │ │ ├── activity.rb │ │ ├── pause.rb │ │ ├── yes_or_no.rb │ │ ├── multiple_choice.rb │ │ ├── active_choice.rb │ │ └── base.rb │ ├── props.rb │ ├── scanner │ │ ├── fuzzy_nesting.rb │ │ ├── fuzzy.rb │ │ ├── base.rb │ │ ├── nesting.rb │ │ ├── strict.rb │ │ └── result.rb │ ├── logging.rb │ ├── query.rb │ ├── match.rb │ ├── scene.rb │ ├── expression.rb │ ├── core_ext │ │ ├── string.rb │ │ └── array.rb │ ├── binding.rb │ ├── scripting.rb │ ├── command.rb │ ├── scriptable.rb │ ├── chapter.rb │ ├── scriptable │ │ ├── syntaxes.rb │ │ ├── seeds.rb │ │ ├── responses.rb │ │ └── queries.rb │ ├── order.rb │ ├── active │ │ ├── messaging.rb │ │ ├── narratives.rb │ │ └── cue.rb │ ├── dispatcher.rb │ ├── narrator.rb │ ├── request.rb │ ├── messenger.rb │ ├── scanner.rb │ ├── subplot.rb │ ├── narrative.rb │ ├── plot.rb │ ├── action.rb │ ├── entity.rb │ ├── response.rb │ └── node.rb └── gamefic.rb ├── bin ├── setup └── console ├── .gitignore ├── .solargraph.yml ├── .rubocop.yml ├── Rakefile ├── LICENSE ├── .github └── workflows │ └── rspec.yml ├── gamefic.gemspec ├── README.md └── CHANGELOG.md /.yardopts: -------------------------------------------------------------------------------- 1 | --plugin yard-solargraph 2 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --color 2 | --require spec_helper 3 | -------------------------------------------------------------------------------- /.rspec-opal: -------------------------------------------------------------------------------- 1 | --color 2 | --require spec_helper 3 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gemspec 4 | -------------------------------------------------------------------------------- /spec/opal_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative 'shared_helper' 4 | -------------------------------------------------------------------------------- /spec/gamefic/command_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | describe Gamefic::Command do 4 | end 5 | -------------------------------------------------------------------------------- /lib/gamefic/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Gamefic 4 | VERSION = '4.2.0' 5 | end 6 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /spec/gamefic_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Gamefic do 4 | it "has a version number" do 5 | expect(Gamefic::VERSION).not_to be nil 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | Gemfile.lock 2 | .buildpath 3 | .project 4 | .settings 5 | .DS_Store 6 | coverage 7 | examples/*/build 8 | examples/*/release 9 | .yardoc 10 | doc 11 | .vscode 12 | /tmp/ 13 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'rubygems' 4 | require 'bundler/setup' 5 | require 'simplecov' 6 | SimpleCov.start 7 | require_relative 'shared_helper' 8 | -------------------------------------------------------------------------------- /spec/fixtures/narrative_with_features.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class NarrativeWithFeatures < Gamefic::Narrative 4 | construct :thing, Gamefic::Entity, name: 'thing' 5 | respond(:command, thing) { nil } 6 | end 7 | -------------------------------------------------------------------------------- /spec/fixtures/modular/modular_test_plot.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class ModularTestPlot < Gamefic::Plot 4 | include ModularTestScript 5 | 6 | introduction do |actor| 7 | actor.parent = place 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /lib/gamefic/proxy/pick.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Gamefic 4 | module Proxy 5 | class Pick < Base 6 | def fetch(narrative) 7 | narrative.pick(*args) 8 | end 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /lib/gamefic/proxy/pick_ex.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Gamefic 4 | module Proxy 5 | class PickEx < Base 6 | def fetch(narrative) 7 | narrative.pick!(*args) 8 | end 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /lib/gamefic/actor.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Gamefic 4 | # An entity that is capable of performing actions and participating in 5 | # scenes. 6 | # 7 | class Actor < Gamefic::Entity 8 | include Gamefic::Active 9 | end 10 | end 11 | -------------------------------------------------------------------------------- /lib/gamefic/proxy/attr.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Gamefic 4 | module Proxy 5 | class Attr < Base 6 | def fetch(narrative) 7 | args.inject(narrative) { |object, key| object.send(key) } 8 | end 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /lib/gamefic/query/myself.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Gamefic 4 | module Query 5 | # Query the subject itself. 6 | # 7 | class Myself < Base 8 | def span(subject) 9 | [subject] 10 | end 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/gamefic/query/parent.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Gamefic 4 | module Query 5 | # Query the subject's parent. 6 | # 7 | class Parent < Base 8 | def span(subject) 9 | [subject.parent].compact 10 | end 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /spec/gamefic/query/myself_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | describe Gamefic::Query::Myself do 4 | it 'finds itself' do 5 | context = Gamefic::Entity.new 6 | myself = Gamefic::Query::Myself.new.span(context) 7 | expect(myself).to eq([context]) 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /spec/gamefic/scriptable/hooks_spec.rb: -------------------------------------------------------------------------------- 1 | describe Gamefic::Scriptable::Hooks do 2 | let(:object) { Object.new.extend(Gamefic::Scriptable::Hooks) } 3 | 4 | it 'adds player output blocks' do 5 | object.on_player_output {} 6 | expect(object.player_output_blocks).to be_one 7 | end 8 | end 9 | -------------------------------------------------------------------------------- /lib/gamefic/props/pause.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Gamefic 4 | module Props 5 | # Props for Pause scenes. 6 | # 7 | class Pause < Default 8 | def prompt 9 | @prompt ||= 'Press enter to continue...' 10 | end 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/gamefic/proxy/base.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Gamefic 4 | module Proxy 5 | class Base 6 | attr_reader :args 7 | 8 | def initialize *args 9 | @args = args 10 | end 11 | 12 | def fetch(narrative); end 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/gamefic/proxy.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Gamefic 4 | module Proxy 5 | require 'gamefic/proxy/base' 6 | require 'gamefic/proxy/attr' 7 | require 'gamefic/proxy/config' 8 | require 'gamefic/proxy/pick' 9 | require 'gamefic/proxy/pick_ex' 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /lib/gamefic/scripting/seeds.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Gamefic 4 | module Scripting 5 | module Seeds 6 | # @return [Array] 7 | def seeds 8 | (included_scripts.flat_map(&:seeds) + self.class.seeds).uniq 9 | end 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /lib/gamefic/scene/conclusion.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Gamefic 4 | module Scene 5 | # A scene that ends an actor's participation in a narrative. 6 | # 7 | class Conclusion < Base 8 | def self.type 9 | 'Conclusion' 10 | end 11 | end 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /lib/gamefic/query/children.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Gamefic 4 | module Query 5 | # Query the subject's children. 6 | # 7 | class Children < Base 8 | include Subqueries 9 | 10 | def span(subject) 11 | subject.children 12 | end 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/gamefic/query/siblings.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Gamefic 4 | module Query 5 | # Query the subject's siblings (i.e., entities with the same parent). 6 | class Siblings < Base 7 | def span(subject) 8 | (subject.parent&.children || []) - [subject] 9 | end 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /lib/gamefic/scripting/syntaxes.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Gamefic 4 | module Scripting 5 | module Syntaxes 6 | def syntaxes 7 | included_scripts.flat_map(&:syntaxes) 8 | end 9 | 10 | def synonyms 11 | syntaxes.map(&:synonym).uniq 12 | end 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/gamefic/proxy/config.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Gamefic 4 | module Proxy 5 | class Config < Base 6 | def fetch(narrative) 7 | args.inject(narrative.config) { |hash, key| hash[key] } 8 | end 9 | 10 | def [](key) 11 | args.push key 12 | self 13 | end 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/gamefic/props.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Gamefic 4 | module Props 5 | require 'gamefic/props/default' 6 | require 'gamefic/props/multiple_choice' 7 | require 'gamefic/props/multiple_partial' 8 | require 'gamefic/props/pause' 9 | require 'gamefic/props/yes_or_no' 10 | require 'gamefic/props/output' 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /lib/gamefic/scanner/fuzzy_nesting.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Gamefic 4 | module Scanner 5 | # Fuzzy scanning for entities inside of other entities, e.g., `soc in draw` 6 | # would match `sock in drawer`. 7 | # 8 | class FuzzyNesting < Nesting 9 | def subprocessor 10 | Fuzzy 11 | end 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /spec/gamefic/active/messaging_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | describe Gamefic::Active::Messaging do 4 | let(:object) { Object.new.extend Gamefic::Active::Messaging } 5 | 6 | it 'buffers messages' do 7 | buffered = object.buffer { object.stream 'hello' } 8 | expect(buffered).to eq('hello') 9 | expect(object.messages).to be_empty 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /.solargraph.yml: -------------------------------------------------------------------------------- 1 | --- 2 | include: 3 | - "**/*.rb" 4 | exclude: 5 | - spec/**/* 6 | - test/**/* 7 | - vendor/**/* 8 | - ".bundle/**/*" 9 | require: [] 10 | domains: [] 11 | reporters: 12 | - rubocop 13 | - require_not_found 14 | formatter: 15 | rubocop: 16 | cops: safe 17 | except: [] 18 | only: [] 19 | extra_args: [] 20 | require_paths: [] 21 | plugins: [] 22 | max_files: 5000 23 | -------------------------------------------------------------------------------- /spec/gamefic/query/siblings_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | describe Gamefic::Query::Siblings do 4 | it 'finds siblings' do 5 | parent = Gamefic::Entity.new 6 | context = Gamefic::Entity.new parent: parent 7 | sibling = Gamefic::Entity.new parent: parent 8 | result = Gamefic::Query::Siblings.new.span(context) 9 | expect(result).to eq([sibling]) 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /lib/gamefic/scene/activity.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Gamefic 4 | module Scene 5 | # A scene that accepts player commands for actors to perform. 6 | # 7 | class Activity < Base 8 | def finish 9 | actor.perform props.input 10 | super 11 | end 12 | 13 | def self.type 14 | 'Activity' 15 | end 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require "bundler/setup" 4 | require "gamefic-sdk" 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 | -------------------------------------------------------------------------------- /spec/gamefic/query/children_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | describe Gamefic::Query::Children do 4 | it 'finds direct children' do 5 | context = Gamefic::Entity.new 6 | child = Gamefic::Entity.new parent: context 7 | _grandchild = Gamefic::Entity.new parent: child 8 | matches = Gamefic::Query::Children.new.span(context) 9 | expect(matches).to eq([child]) 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /spec/gamefic/proxy/config_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Gamefic::Proxy::Config do 4 | it 'fetches an attribute' do 5 | hash = { 6 | thing: { 7 | name: 'thing name' 8 | } 9 | } 10 | object = OpenStruct.new(config: hash) 11 | proxy = Gamefic::Proxy::Config.new[:thing][:name] 12 | expect(proxy.fetch(object)).to eq(hash[:thing][:name]) 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /lib/gamefic/query/descendants.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Gamefic 4 | module Query 5 | # Query the subject's children and accessible grandchildren. 6 | # 7 | class Descendants < Base 8 | include Subqueries 9 | 10 | def span(subject) 11 | subject.children.flat_map do |child| 12 | [child] + subquery_accessible(child) 13 | end 14 | end 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/gamefic/scene/pause.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Gamefic 4 | module Scene 5 | # Pause a scene. This rig simply runs on_start and waits for user input 6 | # before proceeding to on_finish. The user input itself is ignored by 7 | # default. 8 | # 9 | class Pause < Base 10 | use_props_class Props::Pause 11 | 12 | def self.type 13 | 'Pause' 14 | end 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /spec/fixtures/modular/modular_test_script.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ModularTestScript 4 | extend Gamefic::Scriptable 5 | 6 | construct :place, Gamefic::Entity, name: 'place' 7 | 8 | construct :thing, Gamefic::Entity, name: 'thing', parent: place 9 | 10 | construct :unreferenced, Gamefic::Entity, name: 'unreferenced', parent: place 11 | 12 | respond :use, thing do |actor| 13 | actor[:used] = thing 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /spec/gamefic/scanner/fuzzy_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | describe Gamefic::Scanner::Fuzzy do 4 | it 'handles unicode characters' do 5 | one = Gamefic::Entity.new name: 'ぇワ' 6 | two = Gamefic::Entity.new name: 'two' 7 | objects = [one, two] 8 | token = 'ぇ' 9 | result = Gamefic::Scanner::Fuzzy.scan(objects, token) 10 | expect(result.matched).to eq([one]) 11 | expect(result.remainder).to eq('') 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /spec/gamefic/scene/base_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | describe Gamefic::Scene::Base do 4 | let(:actor) { Gamefic::Actor.new } 5 | 6 | let(:base) { Gamefic::Scene::Base.new(actor) } 7 | 8 | it 'initializes Base props' do 9 | expect(base.props).to be_a(Gamefic::Props::Default) 10 | end 11 | 12 | describe '#start' do 13 | it 'returns props' do 14 | expect(base.start).to be(base.props) 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/gamefic/props/yes_or_no.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Gamefic 4 | module Props 5 | # A MultipleChoice variant that only allows Yes or No. 6 | # 7 | class YesOrNo < MultiplePartial 8 | def yes? 9 | selection == 'Yes' 10 | end 11 | 12 | def no? 13 | selection == 'No' 14 | end 15 | 16 | def options 17 | @options ||= %w[Yes No] 18 | end 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/gamefic/scene/yes_or_no.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Gamefic 4 | module Scene 5 | # A specialized MultipleChoice scene that only accepts Yes or No. 6 | # 7 | class YesOrNo < MultipleChoice 8 | use_props_class Props::YesOrNo 9 | 10 | def initialize(...) 11 | super 12 | props.options 13 | end 14 | 15 | def self.type 16 | 'YesOrNo' 17 | end 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /lib/gamefic/query/subqueries.rb: -------------------------------------------------------------------------------- 1 | module Gamefic 2 | module Query 3 | module Subqueries 4 | module_function 5 | 6 | # Return an array of the entity's accessible descendants. 7 | # 8 | # @param entity [Entity] 9 | # @return [Array] 10 | def subquery_accessible(entity) 11 | entity.accessible.flat_map do |child| 12 | [child] + subquery_accessible(child) 13 | end 14 | end 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /spec/gamefic/query/extended_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | describe Gamefic::Query::Extended do 4 | it 'finds siblings and their descendants' do 5 | parent = Gamefic::Entity.new 6 | context = Gamefic::Entity.new parent: parent 7 | sibling = Gamefic::Entity.new parent: parent 8 | nephew = Gamefic::Entity.new parent: sibling 9 | result = Gamefic::Query::Extended.new.span(context) 10 | expect(result).to eq([sibling, nephew]) 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /lib/gamefic/query/ascendants.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Gamefic 4 | module Query 5 | # Query the subject's parent and accessible grandparents. 6 | # 7 | class Ascendants < Base 8 | include Subqueries 9 | 10 | def span(subject) 11 | [subject.parent].tap { |result| result.push result.last.parent while result.last&.parent&.accessible&.include?(result.last) } 12 | .compact 13 | end 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/gamefic/props/multiple_partial.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Gamefic 4 | module Props 5 | # A subclass of MultipleChoice props that matches partial input. 6 | # 7 | class MultiplePartial < MultipleChoice 8 | private 9 | 10 | def index_by_text(input) 11 | matches = options.map.with_index { |text, idx| next idx if text.downcase.start_with?(input.downcase) }.compact 12 | matches.first if matches.one? 13 | end 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | Layout/LineLength: 2 | Description: 'Limit lines to 80 characters.' 3 | StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#80-character-limits' 4 | Enabled: false 5 | 6 | Style/StringLiterals: 7 | Description: 'Checks if uses of quotes match the configured preference.' 8 | StyleGuide: 'https://github.com/bbatsov/ruby-style-guide#consistent-string-literals' 9 | Enabled: false 10 | 11 | Style/Documentation: 12 | Description: 'Document classes and non-namespace modules.' 13 | Enabled: false 14 | -------------------------------------------------------------------------------- /spec/gamefic/query/parent_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | describe Gamefic::Query::Parent do 4 | it 'finds a parent' do 5 | parent = Gamefic::Entity.new 6 | context = Gamefic::Entity.new parent: parent 7 | result = Gamefic::Query::Parent.new.span(context) 8 | expect(result).to eq([parent]) 9 | end 10 | 11 | it 'returns an empty array without a parent' do 12 | context = Gamefic::Entity.new 13 | result = Gamefic::Query::Parent.new.span(context) 14 | expect(result).to eq([]) 15 | end 16 | end -------------------------------------------------------------------------------- /spec/gamefic/scene/pause_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | describe Gamefic::Scene::Pause do 4 | let(:actor) { Gamefic::Actor.new } 5 | 6 | let(:base) { Gamefic::Scene::Pause.new(actor) } 7 | 8 | it 'sets a default prompt' do 9 | base.start 10 | expect(base.props.prompt).to eq('Press enter to continue...') 11 | end 12 | 13 | it 'retains existing prompts' do 14 | base.props.prompt = 'My custom prompt' 15 | base.start 16 | expect(base.props.prompt).to eq('My custom prompt') 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /lib/gamefic/logging.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'logger' 4 | 5 | module Gamefic 6 | # A simple logger. 7 | # 8 | module Logging 9 | module_function 10 | 11 | # @return [Logger] 12 | def logger 13 | Gamefic.logger 14 | end 15 | end 16 | 17 | class << self 18 | def logger 19 | @logger ||= Logger.new($stderr).tap do |lggr| 20 | lggr.level = Logger::WARN 21 | lggr.formatter = proc { |sev, _dt, _prog, msg| "[#{sev}] #{msg}\n" } 22 | end 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/gamefic/scanner/fuzzy.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Gamefic 4 | module Scanner 5 | # Fuzzy token matching. 6 | # 7 | # An entity will match a word in a fuzzy scan if it matches the beginning 8 | # of one of the entity's keywords, e.g., `pen` is a fuzzy token match for 9 | # the keyword `pencil`. 10 | # 11 | class Fuzzy < Strict 12 | def match_word available, word 13 | available.select { |obj| obj.keywords.any? { |wrd| wrd.start_with?(word) } } 14 | end 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/gamefic/query.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'gamefic/query/base' 4 | require 'gamefic/query/text' 5 | require 'gamefic/query/integer' 6 | require 'gamefic/query/result' 7 | require 'gamefic/query/subqueries' 8 | require 'gamefic/query/global' 9 | require 'gamefic/query/children' 10 | require 'gamefic/query/myself' 11 | require 'gamefic/query/descendants' 12 | require 'gamefic/query/ascendants' 13 | require 'gamefic/query/parent' 14 | require 'gamefic/query/siblings' 15 | require 'gamefic/query/extended' 16 | require 'gamefic/query/family' 17 | -------------------------------------------------------------------------------- /lib/gamefic/query/global.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Gamefic 4 | module Query 5 | # Query all the entities in the subject's epic. 6 | # 7 | # If the subject is not an actor, this query will always return an empty 8 | # result. 9 | # 10 | class Global < Base 11 | def span(subject) 12 | return [] unless subject.is_a?(Active) 13 | 14 | subject.narratives.entities 15 | end 16 | 17 | def precision 18 | @precision ||= super - 2000 19 | end 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /lib/gamefic/match.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Gamefic 4 | class Match 5 | # @return [Object] 6 | attr_reader :argument 7 | 8 | # @return [Object] 9 | attr_reader :token 10 | 11 | # @return [Integer] 12 | attr_reader :strictness 13 | 14 | # @param argument [Object] 15 | # @param token [Object] 16 | # @param strictness [Integer] 17 | def initialize(argument, token, strictness) 18 | @argument = argument 19 | @token = token 20 | @strictness = strictness 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/gamefic/query/result.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Gamefic 4 | module Query 5 | # The result of a query. 6 | # 7 | class Result 8 | # @return [Entity, Array, String, nil] 9 | attr_reader :match 10 | 11 | # @return [String] 12 | attr_reader :remainder 13 | 14 | attr_reader :strictness 15 | 16 | def initialize(match, remainder, strictness = 0) 17 | @match = match 18 | @remainder = remainder 19 | @strictness = strictness 20 | end 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/gamefic/scene.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Gamefic 4 | # Narratives use scenes to process game turns. The start of a scene defines 5 | # the output to be sent to the player. The finish processes player input. 6 | # 7 | module Scene 8 | require 'gamefic/scene/base' 9 | require 'gamefic/scene/activity' 10 | require 'gamefic/scene/multiple_choice' 11 | require 'gamefic/scene/active_choice' 12 | require 'gamefic/scene/pause' 13 | require 'gamefic/scene/yes_or_no' 14 | require 'gamefic/scene/conclusion' 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/gamefic/query/extended.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Gamefic 4 | module Query 5 | # Query the subject's siblings and their descendants. Unlike `Family`, the 6 | # subject's descendants are excluded from results. 7 | # 8 | # Descendants need to be `accessible` to be included in the query. 9 | # 10 | class Extended < Base 11 | include Subqueries 12 | 13 | def span(subject) 14 | Siblings.span(subject).flat_map do |child| 15 | [child] + subquery_accessible(child) 16 | end 17 | end 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /lib/gamefic/expression.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Gamefic 4 | # A tokenization of an input from available syntaxes. 5 | # 6 | class Expression 7 | # @return [Symbol] 8 | attr_reader :verb 9 | 10 | # @return [Array] 11 | attr_reader :tokens 12 | 13 | # @param verb [Symbol, nil] 14 | # @param tokens [Array] 15 | def initialize(verb, tokens) 16 | @verb = verb 17 | @tokens = tokens 18 | end 19 | 20 | def inspect 21 | "#<#{self.class} #{([verb] + tokens).map(&:inspect).join(', ')}>" 22 | end 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /spec/gamefic/core_ext/string_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | describe String do 4 | it "capitalizes the first letter without changing the rest" do 5 | expect("name".cap_first).to eq("Name") 6 | expect("beginning of sentence".cap_first).to eq("Beginning of sentence") 7 | expect("ALL CAPS".cap_first).to eq("ALL CAPS") 8 | end 9 | 10 | it "splits words on any whitespace" do 11 | expect("one two".keywords).to eq(["one", "two"]) 12 | expect(" one two ".keywords).to eq(["one", "two"]) 13 | expect("one, two\nthree".keywords).to eq(["one,", "two", "three"]) 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /spec/gamefic/proxy/attr_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Gamefic::Proxy::Attr do 4 | it 'fetches an attribute' do 5 | struct = OpenStruct.new(thing: Gamefic::Entity.new) 6 | proxy = Gamefic::Proxy::Attr.new(:thing) 7 | expect(proxy.fetch(struct)).to be(struct.thing) 8 | end 9 | 10 | it 'chains attributes' do 11 | entity = Gamefic::Entity.new(name: 'thing name') 12 | object = Object.new 13 | object.define_singleton_method(:thing) { entity } 14 | proxy = Gamefic::Proxy::Attr.new(:thing, :name) 15 | expect(proxy.fetch(object)).to eq(object.thing.name) 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /spec/gamefic/scene/activity_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | describe Gamefic::Scene::Activity do 4 | let(:klass) do 5 | Class.new(Gamefic::Narrative) do 6 | respond(:command) { |actor| actor[:executed] = true} 7 | end 8 | end 9 | 10 | let(:plot) { klass.new } 11 | 12 | it 'performs a command' do 13 | actor = plot.introduce 14 | activity = Gamefic::Scene::Activity.new(actor) 15 | activity.start.enter 'command' 16 | activity.finish 17 | expect(actor.queue).to be_empty 18 | expect(activity.props.input).to eq('command') 19 | expect(actor[:executed]).to be(true) 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/gamefic/core_ext/string.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class String 4 | # Capitalize the first letter without changing the rest of the string. 5 | # (String#capitalize makes the rest of the string lower-case.) 6 | # 7 | # @return [String] The capitalized text 8 | def capitalize_first 9 | "#{self[0, 1].upcase}#{self[1, length]}" 10 | end 11 | alias cap_first capitalize_first 12 | 13 | # Get an array of words split by any whitespace. 14 | # 15 | # @return [Array] 16 | def keywords 17 | gsub(/[\s-]+/, ' ').strip.downcase.split 18 | end 19 | 20 | # @return [String] 21 | def normalize 22 | keywords.join(' ') 23 | end 24 | end 25 | -------------------------------------------------------------------------------- /spec/gamefic/scriptable/responses_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | describe Gamefic::Scriptable::Responses do 4 | let(:object) { Object.new.extend(Gamefic::Scriptable) } 5 | 6 | it 'saves a response' do 7 | object.respond :verb 8 | expect(object.responses).to be_one 9 | expect(object.responses.first.verb).to eq(:verb) 10 | end 11 | 12 | it 'handles unicode verbs' do 13 | response = object.respond(:'ꩺ') 14 | available = object.responses_for(:'ꩺ') 15 | expect(available).to eq([response]) 16 | end 17 | 18 | it 'converts strings to symbols' do 19 | response = object.respond('ꩺ') 20 | available = object.responses_for('ꩺ') 21 | expect(available).to eq([response]) 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'bundler/setup' 4 | require 'bundler/gem_tasks' 5 | require 'rspec/core/rake_task' 6 | require 'opal/rspec/rake_task' 7 | 8 | RSpec::Core::RakeTask.new(:spec) do |t| 9 | t.pattern = Dir.glob('spec/**/*_spec.rb') 10 | t.rspec_opts = '--format documentation' 11 | # t.rspec_opts << ' more options' 12 | #t.rcov = true 13 | end 14 | task :default => :spec 15 | 16 | Opal::RSpec::RakeTask.new(:opal) do |_, config| 17 | Opal.append_path File.join(__dir__, 'lib') 18 | Opal.append_path File.join(__dir__, 'spec', 'fixtures', 'modular') 19 | config.default_path = 'spec' 20 | config.pattern = 'spec/**/*_spec.rb' 21 | config.requires = ['opal_helper', 'modular_test_script', 'modular_test_plot'] 22 | end 23 | -------------------------------------------------------------------------------- /spec/gamefic/scriptable/seeds_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | describe Gamefic::Scriptable::Seeds do 4 | it 'seeds a block' do 5 | klass = Class.new do 6 | extend Gamefic::Scriptable::Seeds 7 | seed {} 8 | end 9 | 10 | expect(klass.seeds).to be_one 11 | end 12 | 13 | it 'seeds methods by name' do 14 | klass = Class.new do 15 | extend Gamefic::Scriptable::Seeds 16 | def meth1; end 17 | def meth2; end 18 | seed :meth1, :meth2 19 | end 20 | 21 | expect(klass.seeds).to be_one 22 | end 23 | 24 | it 'seeds methods by def' do 25 | klass = Class.new do 26 | extend Gamefic::Scriptable::Seeds 27 | seed def meth1; end 28 | end 29 | 30 | expect(klass.seeds).to be_one 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /lib/gamefic/scripting/responses.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Gamefic 4 | module Scripting 5 | module Responses 6 | # @return [Array] 7 | def responses 8 | included_scripts.flat_map(&:responses) 9 | .map { |response| response.bind(self) } 10 | end 11 | 12 | # @return [Array] 13 | def responses_for *verbs 14 | # @todo This double reversal is odd, but Gamefic::Standard fails in 15 | # Opal without it. 16 | included_scripts.reverse 17 | .flat_map { |script| script.responses_for(*verbs) } 18 | .reverse 19 | .map { |response| response.bind(self) } 20 | end 21 | end 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /spec/gamefic/scene_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | describe Gamefic::Scene do 4 | let(:stage_func) { Gamefic::Narrative.new } 5 | 6 | it 'executes start blocks from blocks' do 7 | executed = false 8 | klass = Class.new(Gamefic::Scene::Base) do 9 | on_start { executed = true } 10 | end 11 | actor = Gamefic::Actor.new 12 | scene = klass.new(actor) 13 | scene.start 14 | expect(executed).to be(true) 15 | end 16 | 17 | it 'executes finish blocks from blocks' do 18 | executed = false 19 | klass = Class.new(Gamefic::Scene::Base) do 20 | on_finish { executed = true } 21 | end 22 | actor = Gamefic::Actor.new 23 | scene = klass.new(actor) 24 | scene.finish 25 | expect(executed).to be(true) 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /spec/gamefic/props/output_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | describe Gamefic::Props::Output do 4 | it 'delegates readable methods' do 5 | output = Gamefic::Props::Output.new 6 | Gamefic::Props::Output::READER_METHODS.each do |mthd| 7 | expect { output.send(mthd) }.not_to raise_error 8 | end 9 | end 10 | 11 | it 'delegates writable methods' do 12 | output = Gamefic::Props::Output.new 13 | Gamefic::Props::Output::WRITER_METHODS.each do |mthd| 14 | output.send(mthd, 'test value') 15 | expect(output.send(mthd.to_s[0..-2])).to eq('test value') 16 | end 17 | end 18 | 19 | it 'raises NoMethodError' do 20 | output = Gamefic::Props::Output.new 21 | expect { output.queue = 'my message' }.to raise_error(NoMethodError) 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/gamefic/query/family.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Gamefic 4 | module Query 5 | # Query the subject's ascendants, descendants, siblings, and siblings' 6 | # descendants. 7 | # 8 | # Entities other than the subject's parent and immediate children need to 9 | # be `accessible` to be included in the query. 10 | # 11 | class Family < Base 12 | include Subqueries 13 | 14 | def span(subject) 15 | Ascendants.span(subject) + Descendants.span(subject) + match_sibling_branches(subject) 16 | end 17 | 18 | private 19 | 20 | def match_sibling_branches(subject) 21 | Siblings.span(subject).flat_map do |child| 22 | [child] + subquery_accessible(child) 23 | end 24 | end 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /spec/gamefic/binding_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | describe Gamefic::Binding do 4 | describe 'registry' do 5 | it 'pushes an object binding' do 6 | obj = Object.new 7 | bin = Object.new 8 | Gamefic::Binding.push obj, bin 9 | expect(Gamefic::Binding.for(obj)).to be(bin) 10 | end 11 | 12 | it 'pops an object binding' do 13 | obj = Object.new 14 | bin = Object.new 15 | Gamefic::Binding.push obj, bin 16 | Gamefic::Binding.pop obj 17 | expect(Gamefic::Binding.for(obj)).to be_nil 18 | end 19 | 20 | it 'deletes empty keys' do 21 | obj = Object.new 22 | bin = Object.new 23 | Gamefic::Binding.push obj, bin 24 | Gamefic::Binding.pop obj 25 | expect(Gamefic::Binding.registry[obj]).to be_nil 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /spec/gamefic/scanner/nesting_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | describe Gamefic::Scanner::Nesting do 4 | it 'finds children' do 5 | drawer = Gamefic::Entity.new(name: 'drawer') 6 | sock = Gamefic::Entity.new(name: 'sock', parent: drawer) 7 | Gamefic::Entity.new(name: 'thing', parent: drawer) 8 | 9 | result = Gamefic::Scanner::Nesting.scan([drawer, sock], 'sock in drawer') 10 | expect(result.matched).to eq([sock]) 11 | end 12 | 13 | it 'finds grandchildren' do 14 | drawer = Gamefic::Entity.new(name: 'drawer') 15 | sock = Gamefic::Entity.new(name: 'sock', parent: drawer) 16 | coin = Gamefic::Entity.new(name: 'coin', parent: sock) 17 | 18 | result = Gamefic::Scanner::Nesting.scan([drawer, sock, coin], 'coin from sock in drawer') 19 | expect(result.matched).to eq([coin]) 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/gamefic/query/integer.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Gamefic 4 | module Query 5 | # A special query that handles integers instead of entities. 6 | # 7 | class Integer < Base 8 | # @param name [String, nil] 9 | def initialize(name: self.class.name) 10 | super(name: name) 11 | end 12 | 13 | def filter(_subject, token) 14 | return Result.new(token, '') if token.is_a?(::Integer) 15 | 16 | words = token.keywords 17 | number = words.shift 18 | return Result.new(nil, token) unless number =~ /\d+/ 19 | 20 | Result.new(number.to_i, words.join(' ')) 21 | end 22 | 23 | def precision 24 | -10_000 25 | end 26 | 27 | def accept?(_subject, token) 28 | token.is_a?(::Integer) 29 | end 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /spec/gamefic/scanner/fuzzy_nesting_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | describe Gamefic::Scanner::FuzzyNesting do 4 | it 'finds children' do 5 | drawer = Gamefic::Entity.new(name: 'drawer') 6 | sock = Gamefic::Entity.new(name: 'sock', parent: drawer) 7 | Gamefic::Entity.new(name: 'thing', parent: drawer) 8 | 9 | result = Gamefic::Scanner::FuzzyNesting.scan([drawer, sock], 'soc in dra') 10 | expect(result.matched).to eq([sock]) 11 | end 12 | 13 | it 'finds grandchildren' do 14 | drawer = Gamefic::Entity.new(name: 'drawer') 15 | sock = Gamefic::Entity.new(name: 'sock', parent: drawer) 16 | coin = Gamefic::Entity.new(name: 'coin', parent: sock) 17 | 18 | result = Gamefic::Scanner::FuzzyNesting.scan([drawer, sock, coin], 'coi from soc in dra') 19 | expect(result.matched).to eq([coin]) 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/gamefic/scene/multiple_choice.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Gamefic 4 | module Scene 5 | # A scene that presents a list of choices. If the input does not match any 6 | # of the choices, the scene gets recued. 7 | # 8 | class MultipleChoice < Base 9 | use_props_class Props::MultipleChoice 10 | 11 | def initialize(...) 12 | super 13 | props.options.concat(context[:options] || []) 14 | end 15 | 16 | def start 17 | super 18 | props.output[:options] = props.options 19 | props 20 | end 21 | 22 | def finish 23 | return super if props.selected? 24 | 25 | actor.tell format(props.invalid_message, input: props.input) 26 | actor.recue 27 | end 28 | 29 | def self.type 30 | 'MultipleChoice' 31 | end 32 | end 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /spec/gamefic/scripting/entities_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | describe Gamefic::Scripting::Entities do 4 | let(:object) { Object.new.extend(Gamefic::Scripting::Entities) } 5 | 6 | describe '#pick!' do 7 | it 'finds a match' do 8 | exist = object.make Gamefic::Entity, name: 'red dog' 9 | match = object.pick! 'red' 10 | expect(exist).to be(match) 11 | end 12 | 13 | it 'raises when there is no match' do 14 | expect { 15 | object.pick! 'something that does not exist' 16 | }.to raise_error(RuntimeError) 17 | end 18 | 19 | it 'raises when there is are multiple matches' do 20 | object.make Gamefic::Entity, name: 'red dog' 21 | object.make Gamefic::Entity, name: 'red house' 22 | expect { 23 | object.pick! 'red' 24 | }.to raise_error(RuntimeError) 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /spec/gamefic/query/base_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | describe Gamefic::Query::Base do 4 | it 'sets default precision to zero' do 5 | base = Gamefic::Query::Base.new 6 | expect(base.precision).to eq(0) 7 | end 8 | 9 | it 'calculates precision with classes' do 10 | base = Gamefic::Query::Base.new(Gamefic::Entity) 11 | expect(base.precision).to eq(300) 12 | end 13 | 14 | it 'calculates precision with modules' do 15 | base = Gamefic::Query::Base.new(Gamefic::Active) 16 | expect(base.precision).to eq(100) 17 | end 18 | 19 | it 'calculates precision with symbols' do 20 | base = Gamefic::Query::Base.new(:valid?) 21 | expect(base.precision).to eq(1) 22 | end 23 | 24 | it 'calculates precision with multiple arguments' do 25 | base = Gamefic::Query::Base.new(:one?, :two?) 26 | expect(base.precision).to eq(2) 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /spec/gamefic/active/narratives_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | describe Gamefic::Active::Narratives do 4 | describe '#understand?' do 5 | let(:klass) do 6 | Class.new(Gamefic::Plot) do 7 | respond(:foo) {} 8 | 9 | respond(nil, plaintext) {} 10 | 11 | interpret 'bar', 'foo' 12 | end 13 | end 14 | 15 | let(:narratives) { Gamefic::Active::Narratives.new.add(klass.new) } 16 | 17 | it 'returns true for known verbs' do 18 | expect(narratives.understand?('foo')).to be(true) 19 | end 20 | 21 | it 'returns true for known syntax synonyms' do 22 | expect(narratives.understand?('bar')).to be(true) 23 | end 24 | 25 | it 'returns false for unknown verbs' do 26 | expect(narratives.understand?('baz')).to be(false) 27 | end 28 | 29 | it 'returns false for nil' do 30 | expect(narratives.understand?(nil)).to be(false) 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /spec/shared_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'ostruct' 4 | require 'gamefic' 5 | 6 | require_relative 'fixtures/narrative_with_features' 7 | 8 | RSpec.configure do |config| 9 | # Run specs in random order to surface order dependencies. If you find an 10 | # order dependency and want to debug it, you can fix the order by providing 11 | # the seed, which is printed after each run. 12 | # --seed 1234 13 | # config.order = :random 14 | 15 | # Seed global randomization in this process using the `--seed` CLI option. 16 | # Setting this allows you to use `--seed` to deterministically reproduce 17 | # test failures related to randomization by passing the same `--seed` value 18 | # as the one that triggered the failure. 19 | # Kernel.srand config.seed 20 | 21 | config.after :each do 22 | Gamefic::Narrative.responses.clear 23 | Gamefic::Plot.responses.clear 24 | Gamefic::Subplot.responses.clear 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /lib/gamefic/props/default.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Gamefic 4 | module Props 5 | # A collection of data related to a scene. Scenes define which Props class 6 | # they use. Props can be accessed in a scene's on_start and on_finish 7 | # callbacks. 8 | # 9 | # Props::Default includes the most common attributes that a scene requires. 10 | # Scenes are able but not required to subclass it. Some scenes, like 11 | # MultipleChoice, use specialized Props subclasses, but in many cases, 12 | # Props::Default is sufficient. 13 | # 14 | class Default 15 | # @return [String] 16 | attr_writer :prompt 17 | 18 | # @return [String] 19 | attr_accessor :input 20 | 21 | def prompt 22 | @prompt ||= '>' 23 | end 24 | 25 | def output 26 | @output ||= Props::Output.new 27 | end 28 | 29 | # @param text [String] 30 | def enter(text) 31 | @input = text 32 | end 33 | end 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /spec/gamefic/query/family_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | describe Gamefic::Query::Family do 4 | it 'finds extended family' do 5 | parent = Gamefic::Entity.new name: 'parent' 6 | context = Gamefic::Entity.new parent: parent, name: 'context' 7 | sibling = Gamefic::Entity.new parent: parent, name: 'sibling' 8 | nephew = Gamefic::Entity.new parent: sibling, name: 'nephew' 9 | child = Gamefic::Entity.new parent: context, name: 'child' 10 | family = Gamefic::Query::Family.new.span(context) 11 | expect(family).to eq([parent, child, sibling, nephew]) 12 | end 13 | 14 | it 'rejects inaccessible entities' do 15 | parent = Gamefic::Entity.new 16 | context = Gamefic::Entity.new parent: parent 17 | sibling = Gamefic::Entity.new parent: parent 18 | sibling.instance_eval { define_singleton_method(:accessible) { [] } } 19 | _nephew = Gamefic::Entity.new parent: sibling 20 | family = Gamefic::Query::Family.new.span(context) 21 | expect(family).to eq([parent, sibling]) 22 | end 23 | end 24 | -------------------------------------------------------------------------------- /lib/gamefic/scripting/proxies.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Gamefic 4 | module Scripting 5 | # Methods for referencing entities from proxies. 6 | # 7 | module Proxies 8 | # Convert a proxy into its referenced entity. 9 | # 10 | # This method can receive any kind of object. If it's a proxy, its entity 11 | # will be returned. If it's an array, each of its elements will be 12 | # unproxied. If it's a hash, each of its values will be unproxied. Any 13 | # other object will be returned unchanged. 14 | # 15 | # @param object [Object] 16 | # @return [Object] 17 | def unproxy(object) 18 | case object 19 | when Proxy::Base 20 | object.fetch self 21 | when Array 22 | object.map { |obj| unproxy obj } 23 | when Hash 24 | object.transform_values { |val| unproxy val } 25 | when Response, Query::Base 26 | object.bind(self) 27 | else 28 | object 29 | end 30 | end 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /lib/gamefic.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'json' 4 | require 'gamefic/version' 5 | require 'gamefic/logging' 6 | require 'gamefic/core_ext/array' 7 | require 'gamefic/core_ext/string' 8 | require 'gamefic/syntax' 9 | require 'gamefic/response' 10 | require 'gamefic/match' 11 | require 'gamefic/request' 12 | require 'gamefic/order' 13 | require 'gamefic/query' 14 | require 'gamefic/scanner' 15 | require 'gamefic/expression' 16 | require 'gamefic/command' 17 | require 'gamefic/action' 18 | require 'gamefic/props' 19 | require 'gamefic/scene' 20 | require 'gamefic/proxy' 21 | require 'gamefic/scriptable' 22 | require 'gamefic/scripting' 23 | require 'gamefic/binding' 24 | require 'gamefic/narrative' 25 | require 'gamefic/plot' 26 | require 'gamefic/chapter' 27 | require 'gamefic/subplot' 28 | require 'gamefic/node' 29 | require 'gamefic/describable' 30 | require 'gamefic/messenger' 31 | require 'gamefic/entity' 32 | require 'gamefic/dispatcher' 33 | require 'gamefic/active' 34 | require 'gamefic/active/cue' 35 | require 'gamefic/actor' 36 | require 'gamefic/narrator' 37 | -------------------------------------------------------------------------------- /lib/gamefic/binding.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Gamefic 4 | class Binding 5 | class << self 6 | def registry 7 | @registry ||= {} 8 | end 9 | 10 | def push(object, narrative) 11 | registry[object] ||= [] 12 | registry[object].push narrative 13 | end 14 | 15 | def pop(object) 16 | registry[object].pop 17 | registry.delete(object) if registry[object].empty? 18 | end 19 | 20 | def for(object) 21 | registry.fetch(object, []).last 22 | end 23 | end 24 | 25 | # @return [Narrative] 26 | attr_reader :narrative 27 | 28 | # @return [Proc] 29 | attr_reader :code 30 | 31 | # @param narrative [Narrative] 32 | # @param code [Proc] 33 | def initialize(narrative, code) 34 | @narrative = narrative 35 | @code = code 36 | end 37 | 38 | def call(*args) 39 | args.each { |arg| Binding.push arg, @narrative } 40 | @narrative.instance_exec(*args, &@code) 41 | ensure 42 | args.each { |arg| Binding.pop arg } 43 | end 44 | alias [] call 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /lib/gamefic/scanner/base.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Gamefic 4 | module Scanner 5 | # A base class for scanners that match tokens to entities. 6 | # 7 | class Base 8 | # @return [Array] 9 | attr_reader :selection 10 | 11 | # @return [String] 12 | attr_reader :token 13 | 14 | # @param selection [Array] 15 | # @param token [String] 16 | def initialize selection, token 17 | @selection = selection 18 | @token = token 19 | end 20 | 21 | # @return [Result] 22 | def scan 23 | unmatched_result 24 | end 25 | 26 | # @param selection [Array] 27 | # @param token [String] 28 | # @return [Result] 29 | def self.scan selection, token 30 | new(selection, token).scan 31 | end 32 | 33 | private 34 | 35 | def unmatched_result 36 | Result.unmatched(selection, token, self.class) 37 | end 38 | 39 | def matched_result matched, remainder 40 | Result.new(selection, token, matched, remainder, self.class) 41 | end 42 | end 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /spec/gamefic/messenger_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | describe Gamefic::Messenger do 4 | let(:messenger) { Gamefic::Messenger.new } 5 | 6 | it 'streams messages' do 7 | messenger.stream 'test' 8 | expect(messenger.messages).to eq('test') 9 | end 10 | 11 | it 'formats tells into paragraphs' do 12 | messenger.tell 'test' 13 | expect(messenger.messages).to eq('

test

') 14 | end 15 | 16 | it 'formats multiple paragraphs' do 17 | messenger.tell "paragraph 1\n\nparagraph 2" 18 | expect(messenger.messages).to eq('

paragraph 1

paragraph 2

') 19 | end 20 | 21 | it 'avoids redundant paragraphs' do 22 | messenger.tell '

paragraph

' 23 | expect(messenger.messages).to eq('

paragraph

') 24 | end 25 | 26 | it 'buffers messages' do 27 | buffered = messenger.buffer do 28 | messenger.stream 'buffered' 29 | end 30 | expect(buffered).to eq('buffered') 31 | expect(messenger.messages).to be_empty 32 | end 33 | 34 | it 'flushes messages' do 35 | messenger.stream 'text' 36 | flushed = messenger.flush 37 | expect(flushed).to eq('text') 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /spec/gamefic/active/cue_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Gamefic::Active::Cue do 4 | it 'cues a named scene' do 5 | klass = Class.new(Gamefic::Narrative) do 6 | pause(:pause_scene) {} 7 | end 8 | plot = klass.new 9 | player = plot.introduce 10 | cue = Gamefic::Active::Cue.new(player, :pause_scene, plot) 11 | cue.start 12 | expect(cue.scene.name).to eq('pause_scene') 13 | end 14 | 15 | it 'cues a scene class' do 16 | scene_class = Class.new(Gamefic::Scene::Base) do 17 | rename 'my_scene' 18 | end 19 | player = Gamefic::Actor.new 20 | cue = Gamefic::Active::Cue.new(player, scene_class, nil) 21 | expect(cue.scene.name).to eq('my_scene') 22 | end 23 | 24 | describe '#prepare' do 25 | it 'clears active messages' do 26 | plot = Gamefic::Plot.new 27 | player = plot.introduce 28 | player.tell 'message' 29 | cue = Gamefic::Active::Cue.new(player, plot.default_scene, plot) 30 | cue.start 31 | cue.prepare 32 | expect(player.messages).to be_empty 33 | expect(cue.props.output.messages).to include('message') 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /spec/gamefic/query/descendants_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | describe Gamefic::Query::Descendants do 4 | it 'finds extended family' do 5 | parent = Gamefic::Entity.new name: 'parent' 6 | context = Gamefic::Entity.new parent: parent, name: 'context' 7 | sibling = Gamefic::Entity.new parent: parent, name: 'sibling' 8 | _nephew = Gamefic::Entity.new parent: sibling, name: 'nephew' 9 | child = Gamefic::Entity.new parent: context, name: 'child' 10 | grandchild = Gamefic::Entity.new parent: child, name: 'grandchild' 11 | descendants = Gamefic::Query::Descendants.new.span(context) 12 | expect(descendants).to eq([child, grandchild]) 13 | end 14 | 15 | it 'rejects inaccessible entities' do 16 | parent = Gamefic::Entity.new 17 | context = Gamefic::Entity.new parent: parent, name: 'context' 18 | child = Gamefic::Entity.new parent: context, name: 'child' 19 | child.instance_eval { define_singleton_method(:accessible) { [] } } 20 | _grandchild = Gamefic::Entity.new parent: child, name: 'grandchild' 21 | descendants = Gamefic::Query::Descendants.new.span(context) 22 | expect(descendants).to eq([child]) 23 | end 24 | end -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Gamefic 2 | Copyright (c) 2013 by Fred Snyder for Castwide Technologies 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy 5 | of this software and associated documentation files (the "Software"), to deal 6 | in the Software without restriction, including without limitation the rights 7 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | copies of the Software, and to permit persons to whom the Software is 9 | furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in 12 | all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 20 | THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /lib/gamefic/scanner/nesting.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Gamefic 4 | module Scanner 5 | # Strict scanning for entities inside of other entities, e.g., `sock inside drawer`. 6 | # 7 | class Nesting < Base 8 | NEST_REGEXP = / in | on | of | from | inside | inside of | from inside | off | out | out of /.freeze 9 | 10 | def subprocessor 11 | Strict 12 | end 13 | 14 | def scan 15 | return unmatched_result unless token =~ NEST_REGEXP 16 | 17 | denest 18 | end 19 | 20 | private 21 | 22 | def denest 23 | near = selection 24 | far = selection 25 | parts = token.split(NEST_REGEXP) 26 | until parts.empty? 27 | current = parts.pop 28 | last_result = subprocessor.scan(near, current) 29 | last_result = subprocessor.scan(far, current) if last_result.matched.empty? && near != far 30 | return unmatched_result if last_result.matched.empty? || last_result.matched.length > 1 31 | 32 | near = last_result.matched.first.children & selection 33 | far = last_result.matched.first.flatten & selection 34 | end 35 | last_result 36 | end 37 | end 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /.github/workflows/rspec.yml: -------------------------------------------------------------------------------- 1 | # This workflow uses actions that are not certified by GitHub. 2 | # They are provided by a third-party and are governed by 3 | # separate terms of service, privacy policy, and support 4 | # documentation. 5 | # This workflow will download a prebuilt Ruby version, install dependencies and run tests with rspec. 6 | # For more information see: https://github.com/marketplace/actions/setup-ruby-jruby-and-truffleruby 7 | 8 | name: RSpec 9 | 10 | on: 11 | push: 12 | branches: [ master ] 13 | pull_request: 14 | branches: [ master ] 15 | 16 | permissions: 17 | contents: read 18 | 19 | jobs: 20 | test: 21 | 22 | runs-on: ubuntu-latest 23 | strategy: 24 | matrix: 25 | ruby-version: ['2.7', '3.0', '3.1', '3.3', '3.4'] 26 | 27 | steps: 28 | - uses: actions/checkout@v3 29 | - name: Set up Ruby 30 | uses: ruby/setup-ruby@v1 31 | with: 32 | ruby-version: ${{ matrix.ruby-version }} 33 | bundler-cache: false 34 | - name: Set up Node 35 | uses: actions/setup-node@v3 36 | - name: Install dependencies 37 | run: bundle install 38 | - name: Run Ruby tests 39 | run: bundle exec rspec 40 | - name: Run Opal tests 41 | run: bundle exec rake opal 42 | -------------------------------------------------------------------------------- /spec/gamefic/query/integer_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | describe Gamefic::Query::Integer do 4 | describe '#query' do 5 | it 'matches numeric strings' do 6 | querydef = Gamefic::Query::Integer.new 7 | result = querydef.filter(nil, '100') 8 | expect(result.match).to eq(100) 9 | end 10 | 11 | it 'matches integers' do 12 | querydef = Gamefic::Query::Integer.new 13 | result = querydef.filter(nil, 100) 14 | expect(result.match).to eq(100) 15 | end 16 | 17 | it 'rejects unmatched strings' do 18 | querydef = Gamefic::Query::Integer.new 19 | result = querydef.filter(nil, 'some') 20 | expect(result.match).to be_nil 21 | end 22 | end 23 | 24 | describe '#accept?' do 25 | it 'accepts integers' do 26 | querydef = Gamefic::Query::Integer.new 27 | expect(querydef.accept?(nil, 100)).to be(true) 28 | end 29 | end 30 | 31 | it 'passes integers to responses' do 32 | klass = Class.new(Gamefic::Plot) do 33 | respond(:set, integer) { |actor, number| actor[:hit_points] = number } 34 | end 35 | plot = klass.new 36 | player = plot.introduce 37 | player.perform 'set 100' 38 | expect(player[:hit_points]).to eq(100) 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /lib/gamefic/scripting.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Gamefic 4 | # An instance module that enables scripting. 5 | # 6 | # Including `Gamefic::Scripting` also extends `Gamefic::Scriptable`. 7 | # 8 | module Scripting 9 | require 'gamefic/scripting/proxies' 10 | require 'gamefic/scripting/entities' 11 | require 'gamefic/scripting/hooks' 12 | require 'gamefic/scripting/responses' 13 | require 'gamefic/scripting/syntaxes' 14 | require 'gamefic/scripting/seeds' 15 | require 'gamefic/scripting/scenes' 16 | 17 | extend Scriptable 18 | include Scriptable::Queries 19 | include Entities 20 | include Hooks 21 | include Responses 22 | include Seeds 23 | include Scenes 24 | include Syntaxes 25 | 26 | # @return [Array>] 27 | def included_scripts 28 | self.class.included_scripts 29 | end 30 | 31 | # @param symbol [Symbol] 32 | # @return [Array] 33 | def find_and_bind(symbol) 34 | included_scripts.flat_map { |script| script.send(symbol) } 35 | .map { |blk| Binding.new(self, blk) } 36 | end 37 | 38 | def self.included(other) 39 | super 40 | other.extend Scriptable 41 | end 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /lib/gamefic/command.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Gamefic 4 | # A concrete representation of an input as a verb and an array of arguments. 5 | # 6 | class Command 7 | # @return [Symbol] 8 | attr_reader :verb 9 | 10 | # @return [Array, Entity, String>] 11 | attr_reader :arguments 12 | 13 | # @return [String, nil] 14 | attr_reader :input 15 | 16 | # @param verb [Symbol] 17 | # @param arguments [Array, Entity, String>] 18 | # @param meta [Boolean] 19 | # @param input [String, nil] 20 | def initialize(verb, arguments, meta = false, input = nil) 21 | @verb = verb 22 | @arguments = arguments 23 | @meta = meta 24 | @input = input 25 | @cancelled = false 26 | end 27 | 28 | def cancel 29 | @cancelled = true 30 | end 31 | alias stop cancel 32 | 33 | def cancelled? 34 | @cancelled 35 | end 36 | alias stopped? cancelled? 37 | 38 | def meta? 39 | @meta 40 | end 41 | 42 | def active? 43 | !meta? 44 | end 45 | 46 | def inspect 47 | "#<#{self.class} #{([verb] + arguments).map(&:inspect).join(', ')}>" 48 | end 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /lib/gamefic/scriptable.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'set' 4 | 5 | module Gamefic 6 | # A class module that enables scripting. 7 | # 8 | # Narratives extend Scriptable to enable definition of scripts and seeds. 9 | # Modules can also be extended with Scriptable to make them includable to 10 | # other Scriptables. 11 | # 12 | # @example Include a scriptable module in a plot 13 | # module MyScript 14 | # extend Gamefic::Scriptable 15 | # 16 | # respond :myscript do |actor| 17 | # actor.tell "This command was added by MyScript" 18 | # end 19 | # end 20 | # 21 | # class MyPlot < Gamefic::Plot 22 | # include MyScript 23 | # end 24 | # 25 | module Scriptable 26 | require 'gamefic/scriptable/hooks' 27 | require 'gamefic/scriptable/queries' 28 | require 'gamefic/scriptable/syntaxes' 29 | require 'gamefic/scriptable/responses' 30 | require 'gamefic/scriptable/scenes' 31 | require 'gamefic/scriptable/seeds' 32 | 33 | include Hooks 34 | include Queries 35 | include Responses 36 | include Scenes 37 | include Seeds 38 | include Syntaxes 39 | 40 | def included_scripts 41 | ancestors.that_are(Scriptable).uniq 42 | end 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /lib/gamefic/chapter.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Gamefic 4 | class Chapter < Narrative 5 | # @return [Plot] 6 | attr_reader :plot 7 | 8 | # @return [Hash] 9 | attr_reader :config 10 | 11 | # @param plot [Plot] 12 | def initialize(plot, **config) 13 | @plot = plot 14 | @concluding = false 15 | @config = config 16 | configure 17 | @config.freeze 18 | super() 19 | end 20 | 21 | def players 22 | plot.players 23 | end 24 | 25 | def conclude 26 | # @todo Void entities? 27 | @concluding = true 28 | end 29 | 30 | def concluding? 31 | @concluding 32 | end 33 | 34 | def self.bind_from_plot *methods 35 | methods.flatten.each do |method| 36 | define_method(method) { plot.send(method) } 37 | define_singleton_method(method) { Proxy::Attr.new(method) } 38 | end 39 | end 40 | 41 | def included_scripts 42 | super - plot.included_scripts 43 | end 44 | 45 | # Subclasses can override this method to handle additional configuration. 46 | # 47 | # @return [void] 48 | def configure; end 49 | 50 | class << self 51 | def config 52 | Proxy::Config.new 53 | end 54 | end 55 | end 56 | end 57 | -------------------------------------------------------------------------------- /spec/gamefic/scene/yes_or_no_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | describe Gamefic::Scene::YesOrNo do 4 | let(:actor) { Gamefic::Actor.new } 5 | 6 | let(:yes_or_no) { Gamefic::Scene::YesOrNo.new(actor) } 7 | 8 | it 'initializes YesOrNo props' do 9 | expect(yes_or_no.props).to be_a(Gamefic::Props::YesOrNo) 10 | end 11 | 12 | it 'flags yes?' do 13 | yes_or_no.props.enter 'yes' 14 | yes_or_no.finish 15 | expect(actor.queue).to be_empty 16 | expect(yes_or_no.props.input).to eq('yes') 17 | expect(yes_or_no.props.selection).to eq('Yes') 18 | expect(yes_or_no.props.index).to eq(0) 19 | expect(yes_or_no.props.number).to eq(1) 20 | expect(yes_or_no.props).to be_yes 21 | end 22 | 23 | it 'flags no?' do 24 | yes_or_no.props.enter 'no' 25 | yes_or_no.finish 26 | expect(actor.queue).to be_empty 27 | expect(yes_or_no.props.input).to eq('no') 28 | expect(yes_or_no.props.selection).to eq('No') 29 | expect(yes_or_no.props.index).to eq(1) 30 | expect(yes_or_no.props.number).to eq(2) 31 | expect(yes_or_no.props).to be_no 32 | end 33 | 34 | it 'cancels on invalid input' do 35 | yes_or_no.props.enter 'maybe' 36 | yes_or_no.finish 37 | expect(actor.queue).to be_empty 38 | expect(actor.messages).to include('not a valid choice') 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /gamefic.gemspec: -------------------------------------------------------------------------------- 1 | lib = File.expand_path('../lib', __FILE__) 2 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 3 | require 'gamefic/version' 4 | require 'date' 5 | 6 | Gem::Specification.new do |s| 7 | s.name = 'gamefic' 8 | s.version = Gamefic::VERSION 9 | s.date = Date.today.strftime("%Y-%m-%d") 10 | s.summary = "Gamefic" 11 | s.description = "An adventure game and interactive fiction framework" 12 | s.authors = ["Fred Snyder"] 13 | s.email = 'fsnyder@gamefic.com' 14 | s.homepage = 'https://gamefic.com' 15 | s.license = 'MIT' 16 | 17 | s.files = Dir.chdir(File.expand_path('..', __FILE__)) do 18 | `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) } 19 | end 20 | s.require_paths = ['lib'] 21 | 22 | s.required_ruby_version = '>= 2.7.0' 23 | 24 | s.add_runtime_dependency 'base64', '~> 0.1' 25 | s.add_runtime_dependency 'yard-solargraph', '~> 0.1' 26 | 27 | s.add_development_dependency 'opal', '~> 1.7' 28 | s.add_development_dependency 'opal-rspec', '~> 1.0' 29 | s.add_development_dependency 'opal-sprockets', '~> 1.0' 30 | s.add_development_dependency 'rake', '~> 13.2' 31 | s.add_development_dependency 'rspec', '~> 3.5', '>= 3.5.0' 32 | s.add_development_dependency 'simplecov', '~> 0.14' 33 | end 34 | -------------------------------------------------------------------------------- /lib/gamefic/scripting/hooks.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Gamefic 4 | module Scripting 5 | # Scripting hook methods are instance methods that return callbacks for 6 | # execution in the context of a scriptable module or narrative. They 7 | # collect the procs defined in Scriptable hook methods and bind them to the 8 | # instance for execution. 9 | # 10 | module Hooks 11 | # @return [Array] 12 | def before_commands 13 | find_and_bind(:before_commands) 14 | end 15 | 16 | # @return [Array] 17 | def after_commands 18 | find_and_bind(:after_commands) 19 | end 20 | 21 | # @return [Array] 22 | def ready_blocks 23 | find_and_bind(:ready_blocks) 24 | end 25 | 26 | # @return [Array] 27 | def update_blocks 28 | find_and_bind(:update_blocks) 29 | end 30 | 31 | # @return [Array] 32 | def player_output_blocks 33 | find_and_bind(:player_output_blocks) 34 | end 35 | 36 | # @return [Array] 37 | def conclude_blocks 38 | find_and_bind(:conclude_blocks) 39 | end 40 | 41 | # @return [Array] 42 | def player_conclude_blocks 43 | find_and_bind(:player_conclude_blocks) 44 | end 45 | end 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /spec/gamefic/dispatcher_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | describe Gamefic::Dispatcher do 4 | it 'selects strict over fuzzy matches' do 5 | # @type klass [Class] 6 | klass = Class.new(Gamefic::Plot) do 7 | construct :room, Gamefic::Entity, name: 'room' 8 | construct :bookshelf, Gamefic::Entity, name: 'bookshelf', parent: room 9 | construct :books, Gamefic::Entity, name: 'books', parent: room 10 | 11 | respond(:look, books) { |_, _| } 12 | respond(:look, bookshelf) { |_, _| } 13 | end 14 | 15 | plot = klass.new 16 | player = plot.introduce 17 | player.parent = plot.room 18 | 19 | request = Gamefic::Request.new(player, 'look books') 20 | dispatcher = Gamefic::Dispatcher.new(request) 21 | command = dispatcher.execute 22 | # Dispatcher should find an exact match for the @books response, even 23 | # though @bookshelf gets tested first 24 | expect(command.arguments.first.name).to eq('books') 25 | end 26 | 27 | it 'cancels commands' do 28 | executed = false 29 | klass = Class.new(Gamefic::Narrative) do 30 | respond(:foo) { executed = true } 31 | before_command { |_, command| command.stop } 32 | end 33 | 34 | plot = klass.new 35 | player = plot.introduce 36 | player.perform('foo') 37 | expect(executed).to be(false) 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Gamefic 2 | 3 | **A Ruby Interactive Fiction Framework** 4 | 5 | Gamefic is a system for developing and playing adventure games and interactive 6 | fiction. This gem provides the core library for running game narratives. 7 | 8 | Developers should refer to the [Gamefic SDK](https://github.com/castwide/gamefic-sdk) 9 | for information about writing, building, and distributing Gamefic apps. 10 | 11 | ## Development 12 | 13 | After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment. 14 | 15 | To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org). 16 | 17 | ## Contributing 18 | 19 | Bug reports and pull requests are welcome on GitHub at https://github.com/castwide/gamefic. 20 | 21 | ## License 22 | 23 | The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). 24 | 25 | ## More Information 26 | 27 | Go to [the official Gamefic website](http://gamefic.com) for games, news, and 28 | more documentation. 29 | -------------------------------------------------------------------------------- /lib/gamefic/scanner/strict.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Gamefic 4 | module Scanner 5 | # Strict token matching. 6 | # 7 | # An entity will only match a word in a strict scan if the entire word 8 | # matches one of the entity's keywords. 9 | # 10 | class Strict < Base 11 | NOISE = %w[ 12 | a an the of some 13 | ].freeze 14 | 15 | # @return [Result] 16 | def scan 17 | words = token.keywords 18 | available = selection.clone 19 | filtered = [] 20 | words.each_with_index do |word, idx| 21 | # @todo This might not be the best way to filter articles, but it works for now 22 | tested = %w[a an the].include?(word) ? available : match_word(available, word) 23 | return matched_result(reduce_noise(filtered, words[0, idx]), words[idx..].join(' ')) if tested.empty? 24 | 25 | filtered = tested 26 | available = filtered 27 | end 28 | matched_result(reduce_noise(filtered, words), '') 29 | end 30 | 31 | def match_word available, word 32 | available.select { |obj| (obj.keywords + obj.nuance.keywords).include?(word) } 33 | end 34 | 35 | def reduce_noise entities, keywords 36 | noiseless = keywords - NOISE 37 | entities.reject { |entity| (noiseless - entity.nuance.keywords).empty? } 38 | end 39 | end 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /lib/gamefic/scriptable/syntaxes.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Gamefic 4 | module Scriptable 5 | module Syntaxes 6 | # Create an alternate Syntax for a response. 7 | # The command and its translation can be parameterized. 8 | # 9 | # @example Create a synonym for an `inventory` response. 10 | # interpret "catalogue", "inventory" 11 | # # The command "catalogue" will be translated to "inventory" 12 | # 13 | # @example Create a parameterized synonym for a `look` response. 14 | # interpret "scrutinize :entity", "look :entity" 15 | # # The command "scrutinize chair" will be translated to "look chair" 16 | # 17 | # @param command [String] The format of the original command 18 | # @param translation [String] The format of the translated command 19 | # @return [Syntax] the Syntax object 20 | def interpret(command, translation) 21 | parts = Syntax.split(command) 22 | additions = if parts.first.include?('|') 23 | parts.first.split('|').map { |verb| Syntax.new("#{verb} #{verb[1..].join(' ')}", translation) } 24 | else 25 | [Syntax.new(command, translation)] 26 | end 27 | syntaxes.concat additions 28 | additions 29 | end 30 | 31 | def syntaxes 32 | @syntaxes ||= [] 33 | end 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /lib/gamefic/query/text.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Gamefic 4 | module Query 5 | # A special query that handles text instead of entities. 6 | # 7 | class Text < Base 8 | # @param argument [String, Regexp] 9 | # @param name [String, nil] 10 | def initialize(argument = /.*/, name: self.class.name) 11 | super(argument, name: name) 12 | validate_argument 13 | end 14 | 15 | def argument 16 | arguments.first 17 | end 18 | 19 | # @return [String, Regexp] 20 | def select(_subject) 21 | argument 22 | end 23 | 24 | def filter(_subject, token) 25 | if match? token 26 | Result.new(token, '') 27 | else 28 | Result.new(nil, token) 29 | end 30 | end 31 | 32 | def precision 33 | -10_000 34 | end 35 | 36 | def accept?(_subject, token) 37 | match?(token) 38 | end 39 | 40 | private 41 | 42 | def match?(token) 43 | return false unless token.is_a?(String) && !token.empty? 44 | 45 | case argument 46 | when Regexp 47 | token.match?(argument) 48 | else 49 | argument == token 50 | end 51 | end 52 | 53 | def validate_argument 54 | return if argument.is_a?(String) || argument.is_a?(Regexp) 55 | 56 | raise ArgumentError, 'Invalid text query argument' 57 | end 58 | end 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /lib/gamefic/order.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Gamefic 4 | # Build actions from explicit verbs and arguments. 5 | # 6 | # The Active#execute method uses Order to bypass the parser while 7 | # generating actions to be executed in the Dispatcher. 8 | # 9 | class Order 10 | # @param actor [Actor] 11 | # @param verb [Symbol] 12 | # @param arguments [Array] 13 | def initialize(actor, verb, arguments) 14 | @actor = actor 15 | @verb = verb 16 | @arguments = arguments 17 | end 18 | 19 | # @return [Array] 20 | def to_actions 21 | Action.sort( 22 | actor.narratives 23 | .responses_for(verb) 24 | .map { |response| match_arguments(response) } 25 | .compact 26 | ) 27 | end 28 | 29 | private 30 | 31 | # @return [Actor] 32 | attr_reader :actor 33 | 34 | # @return [Symbol] 35 | attr_reader :verb 36 | 37 | # @return [Array] 38 | attr_reader :arguments 39 | 40 | def match_arguments(response) 41 | return nil if response.queries.length != arguments.length 42 | 43 | matches = response.queries.zip(arguments).each_with_object([]) do |zipped, result| 44 | query, param = zipped 45 | return nil unless query.accept?(actor, param) 46 | 47 | result.push Match.new(param, param, 1000) 48 | end 49 | 50 | Action.new(actor, response, matches, nil) 51 | end 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /lib/gamefic/active/messaging.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Gamefic 4 | module Active 5 | # A module for active entities that provides a default Messenger with 6 | # a few shortcuts. 7 | # 8 | module Messaging 9 | # @return [Messenger] 10 | def messenger 11 | @messenger ||= Messenger.new 12 | end 13 | 14 | # Send a message to the entity. 15 | # 16 | # This method will automatically wrap the message in HTML paragraphs. 17 | # To send a message without paragraph formatting, use #stream instead. 18 | # 19 | # @param message [String] 20 | def tell(message) 21 | messenger.tell message 22 | end 23 | 24 | # Send a message to the entity as raw text. 25 | # 26 | # Unlike #tell, this method will not wrap the message in HTML paragraphs. 27 | # 28 | # @param message [String] 29 | def stream(message) 30 | messenger.stream message 31 | end 32 | 33 | # @return [String] 34 | def messages 35 | messenger.messages 36 | end 37 | 38 | # Create a temporary buffer while yielding the given block and return the 39 | # buffered text. 40 | # 41 | # @return [String] 42 | def buffer &block 43 | messenger.buffer(&block) 44 | end 45 | 46 | # Clear the current buffer. 47 | # 48 | # @return [String] The buffer's messages 49 | def flush 50 | messenger.flush 51 | end 52 | end 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /spec/gamefic/request_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Gamefic::Request do 4 | describe '#to_actions' do 5 | it 'returns matching actions' do 6 | klass = Class.new(Gamefic::Plot) do 7 | respond(:verb1) {} 8 | respond(:verb2) {} 9 | end 10 | plot = klass.new 11 | player = plot.introduce 12 | request = Gamefic::Request.new(player, 'verb1') 13 | actions = request.to_actions 14 | expect(actions).to be_one 15 | expect(actions.first.verb).to be(:verb1) 16 | end 17 | 18 | it 'returns matching actions with arguments' do 19 | klass = Class.new(Gamefic::Plot) do 20 | respond(:verb, anywhere(Gamefic::Entity)) {} 21 | end 22 | plot = klass.new 23 | player = plot.introduce 24 | thing = plot.make(Gamefic::Entity, name: 'thing') 25 | request = Gamefic::Request.new(player, 'verb thing') 26 | actions = request.to_actions 27 | expect(actions).to be_one 28 | expect(actions.first.verb).to be(:verb) 29 | expect(actions.first.arguments).to eq([thing]) 30 | end 31 | 32 | it 'matches unicode characters' do 33 | klass = Class.new(Gamefic::Plot) do 34 | construct :thing, Gamefic::Entity, name: 'ꩺ' 35 | respond('ぇワ', anywhere) {} 36 | end 37 | plot = klass.new 38 | player = plot.introduce 39 | request = Gamefic::Request.new(player, 'ぇワ ꩺ') 40 | actions = request.to_actions 41 | expect(actions).to be_one 42 | expect(actions.first.verb).to be('ぇワ'.to_sym) 43 | end 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /spec/gamefic/action_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | describe Gamefic::Action do 4 | let(:actor) { Gamefic::Actor.new } 5 | 6 | describe '#valid?' do 7 | it 'returns true for valid argument lengths' do 8 | response = Gamefic::Response.new(:verb) {} 9 | action = Gamefic::Action.new(actor, response, []) 10 | expect(action).to be_valid 11 | expect(action).not_to be_invalid 12 | end 13 | 14 | it 'returns false for invalid argument lengths' do 15 | response = Gamefic::Response.new(:verb, 'text') {} 16 | action = Gamefic::Action.new(actor, response, []) 17 | expect(action).to be_invalid 18 | expect(action).not_to be_valid 19 | end 20 | 21 | it 'returns true for valid argument queries' do 22 | thing = Gamefic::Entity.new name: 'thing', parent: actor 23 | response = Gamefic::Response.new(:verb, thing) {} 24 | match = Gamefic::Match.new(thing, thing, 1000) 25 | action = Gamefic::Action.new(actor, response, [match]) 26 | expect(action).to be_valid 27 | expect(action).not_to be_invalid 28 | end 29 | end 30 | 31 | describe '#execute' do 32 | it 'runs a valid action' do 33 | thing = Gamefic::Entity.new parent: actor 34 | response = Gamefic::Response.new :verb, thing do |actor| 35 | actor[:executed] = true 36 | end 37 | match = Gamefic::Match.new(thing, thing, 1000) 38 | action = Gamefic::Action.new(actor, response, [match]) 39 | expect(action.execute).to be(action) 40 | expect(actor[:executed]).to be(true) 41 | end 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /lib/gamefic/dispatcher.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Gamefic 4 | # The action executor for character commands. 5 | # 6 | class Dispatcher 7 | # @param actionable [#to_actions] 8 | def initialize(actionable) 9 | @actions = actionable.to_actions 10 | end 11 | 12 | # Start executing actions in the dispatcher. 13 | # 14 | # @return [Command, nil] 15 | def execute 16 | return if action || actions.empty? 17 | 18 | @action = actions.shift 19 | Gamefic.logger.info "Dispatching #{actor.inspect} #{command.inspect}" 20 | run_hooks_and_response 21 | command 22 | end 23 | 24 | # Execute the next available action. 25 | # 26 | # Actors should run #execute first. 27 | # 28 | # @return [Action, nil] 29 | def proceed 30 | return if !action || command.cancelled? 31 | 32 | actions.shift&.execute 33 | end 34 | 35 | private 36 | 37 | # @return [Array] 38 | attr_reader :actions 39 | 40 | # @return [Action, nil] 41 | attr_reader :action 42 | 43 | # @return [Actor, nil] 44 | def actor 45 | action.actor 46 | end 47 | 48 | # @return [Command] 49 | def command 50 | action.command 51 | end 52 | 53 | def run_hooks(list) 54 | list.each do |blk| 55 | blk[actor, command] 56 | break if command.cancelled? 57 | end 58 | end 59 | 60 | def run_hooks_and_response 61 | run_hooks actor.narratives.before_commands 62 | command.freeze 63 | return if command.cancelled? 64 | 65 | action.execute 66 | run_hooks actor.narratives.after_commands 67 | end 68 | end 69 | end 70 | -------------------------------------------------------------------------------- /lib/gamefic/narrator.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Gamefic 4 | # A narrative controller. 5 | # 6 | class Narrator 7 | # @return [Plot] 8 | attr_reader :plot 9 | 10 | def initialize(plot) 11 | @plot = plot 12 | last_cues 13 | end 14 | 15 | # Cast a player character in the plot. 16 | # 17 | # @param character [Actor, Active] 18 | # @return [Actor, Active] 19 | def cast(character = plot.introduce) 20 | plot.cast character 21 | end 22 | 23 | # Uncast a player character from the plot. 24 | # 25 | # @param character [Actor, Active] 26 | # @return [Actor, Active] 27 | def uncast(character) 28 | plot.uncast character 29 | end 30 | 31 | def players 32 | plot.players 33 | end 34 | 35 | # Start a turn. 36 | # 37 | # @return [void] 38 | def start 39 | next_cues 40 | plot.ready_blocks.each(&:call) 41 | plot.turn 42 | cues.each(&:prepare) 43 | end 44 | 45 | # Finish a turn. 46 | # 47 | # @return [void] 48 | def finish 49 | cues.each(&:finish) 50 | cues.clear 51 | plot.update_blocks.each(&:call) 52 | end 53 | 54 | def concluding? 55 | plot.concluding? 56 | end 57 | 58 | private 59 | 60 | # @return [Array] 61 | def cues 62 | @cues ||= [] 63 | end 64 | 65 | # @return [void] 66 | def last_cues 67 | cues.replace(plot.players.map(&:last_cue)) 68 | .compact 69 | end 70 | 71 | # @return [void] 72 | def next_cues 73 | cues.replace(plot.players.map { |player| player.next_cue || player.cue(plot.default_scene) }) 74 | .each(&:start) 75 | end 76 | end 77 | end 78 | -------------------------------------------------------------------------------- /lib/gamefic/scanner/result.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Gamefic 4 | module Scanner 5 | # The result of an attempt to scan objects against a token in a Scanner. It 6 | # provides an array of matching objects, the text that matched them, and the 7 | # text that remains unmatched. 8 | # 9 | class Result 10 | # The scanned objects 11 | # 12 | # @return [Array, String, Regexp] 13 | attr_reader :scanned 14 | 15 | # The scanned token 16 | # 17 | # @return [String] 18 | attr_reader :token 19 | 20 | # The matched objects 21 | # 22 | # @return [Array, String] 23 | attr_reader :matched 24 | alias match matched 25 | 26 | # The remaining (unmatched) portion of the token 27 | # 28 | # @return [String] 29 | attr_reader :remainder 30 | 31 | attr_reader :processor 32 | 33 | def initialize scanned, token, matched, remainder, processor 34 | @scanned = scanned 35 | @token = token 36 | @matched = matched 37 | @remainder = remainder 38 | @processor = processor 39 | end 40 | 41 | # The strictness of the scanner that produced this result. 42 | # 43 | # @return [Integer] 44 | def strictness 45 | @strictness ||= Scanner.strictness(processor) 46 | end 47 | 48 | def filter *args 49 | Scanner::Result.new( 50 | scanned, 51 | token, 52 | match.that_are(*args), 53 | remainder, 54 | processor 55 | ) 56 | end 57 | 58 | def self.unmatched scanned, token, processor 59 | new(scanned, token, [], token, processor) 60 | end 61 | end 62 | end 63 | end 64 | -------------------------------------------------------------------------------- /spec/gamefic/query/text_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | describe Gamefic::Query::Text do 4 | it 'matches without arguments' do 5 | querydef = Gamefic::Query::Text.new 6 | result = querydef.filter(nil, 'anything') 7 | expect(result.match).to eq('anything') 8 | end 9 | 10 | it 'returns matched expressions' do 11 | querydef = Gamefic::Query::Text.new(/result/) 12 | result = querydef.filter(nil, 'result') 13 | expect(result.match).to eq('result') 14 | end 15 | 16 | it 'returns matched strings' do 17 | querydef = Gamefic::Query::Text.new('result') 18 | result = querydef.filter(nil, 'result') 19 | expect(result.match).to eq('result') 20 | end 21 | 22 | it 'rejects unmatched strings' do 23 | querydef = Gamefic::Query::Text.new('right') 24 | result = querydef.filter(nil, 'wrong') 25 | expect(result.match).to be_nil 26 | end 27 | 28 | it 'rejects unmatched partial strings' do 29 | querydef = Gamefic::Query::Text.new('right') 30 | result = querydef.filter(nil, 'rig') 31 | expect(result.match).to be_nil 32 | end 33 | 34 | it 'rejects unmatched expressions' do 35 | querydef = Gamefic::Query::Text.new(/right/) 36 | result = querydef.filter(nil, 'wrong') 37 | expect(result.match).to be_nil 38 | end 39 | 40 | it 'accepts matching tokens' do 41 | querydef = Gamefic::Query::Text.new 42 | expect(querydef.accept?(nil, 'something')).to be(true) 43 | end 44 | 45 | it 'rejects non-string tokens' do 46 | querydef = Gamefic::Query::Text.new 47 | entity = Gamefic::Entity.new 48 | expect(querydef.accept?(nil, entity)).to be(false) 49 | end 50 | 51 | it 'raises errors for invalid arguments' do 52 | expect { Gamefic::Query::Text.new(Object.new) }.to raise_error(ArgumentError) 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /spec/gamefic/scene/active_choice_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | describe Gamefic::Scene::ActiveChoice do 4 | let(:klass) do 5 | Class.new(Gamefic::Plot) do 6 | active_choice :active_choice do |scene| 7 | scene.on_start do |_actor, props| 8 | props.options.push 'one', 'two' 9 | end 10 | scene.on_finish do |actor, props| 11 | actor[:executed] ||= "selection #{props.selection}" 12 | end 13 | end 14 | respond(:command) { |actor| actor[:executed] = 'command' } 15 | end 16 | end 17 | 18 | let(:plot) { klass.new } 19 | 20 | let(:actor) { plot.introduce } 21 | 22 | let(:narrator) { Gamefic::Narrator.new(plot) } 23 | 24 | it 'selects valid input' do 25 | actor.cue :active_choice 26 | narrator.start 27 | actor.queue.push 'one' 28 | narrator.finish 29 | expect(actor[:executed]).to eq('selection one') 30 | end 31 | 32 | it 'performs on invalid input' do 33 | actor.cue :active_choice 34 | narrator.start 35 | actor.queue.push 'command' 36 | narrator.finish 37 | expect(actor[:executed]).to eq('command') 38 | end 39 | 40 | it 'recues on invalid input' do 41 | scene = plot.named_scenes[:active_choice] 42 | scene.without_selection :recue 43 | actor.cue :active_choice 44 | narrator.start 45 | actor.queue.push 'command' 46 | narrator.finish 47 | narrator.start 48 | expect(actor.last_cue.key).to be(:active_choice) 49 | end 50 | 51 | it 'continues on invalid input' do 52 | scene = plot.named_scenes[:active_choice] 53 | scene.without_selection :continue 54 | actor.cue :active_choice 55 | narrator.start 56 | actor.queue.push 'command' 57 | narrator.finish 58 | narrator.start 59 | expect(actor.last_cue.key).to be(plot.default_scene) 60 | end 61 | end 62 | -------------------------------------------------------------------------------- /lib/gamefic/scripting/scenes.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Gamefic 4 | module Scripting 5 | module Scenes 6 | # @return [Scene::Base] 7 | def default_scene 8 | self.class.default_scene 9 | end 10 | 11 | # @return [Scene::Conclusion] 12 | def default_conclusion 13 | self.class.default_conclusion 14 | end 15 | 16 | # @return [Array] 17 | def introductions 18 | included_scripts.reverse 19 | .flat_map(&:introductions) 20 | .map { |blk| Binding.new(self, blk) } 21 | end 22 | 23 | # @return [Hash] 24 | def named_scenes 25 | {}.merge(*included_scripts.flat_map(&:named_scenes)) 26 | end 27 | 28 | # Prepare a scene to be executed. Scenes can be accessed by their class 29 | # or by a symbolic name if one has been defined in this narrative. 30 | # 31 | # @param name_or_class [Symbol, Class] 32 | # @param actor [Actor] 33 | # @param props [Props::Default] 34 | # @return [Scene::Base] 35 | def prepare name_or_class, actor, props, **context 36 | scene_classes_map[name_or_class]&.new(actor, self, props, **context).tap do |scene| 37 | scene&.rename(name_or_class.to_s) if name_or_class.is_a?(Symbol) 38 | end 39 | end 40 | 41 | # @return [Array] 42 | def scenes 43 | self.class.scenes 44 | end 45 | 46 | # @param name_or_class [Symbol, Class] 47 | # @return [Scene::Base] 48 | def scene_class(name_or_class) 49 | scene_classes_map[name_or_class] 50 | end 51 | 52 | private 53 | 54 | def scene_classes_map 55 | {}.merge(*included_scripts.flat_map(&:scene_classes_map)) 56 | end 57 | end 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /lib/gamefic/request.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Gamefic 4 | # Build actions from text. 5 | # 6 | # Active#perform uses Request to parse user input into actions for execution 7 | # by the Dispatcher. 8 | # 9 | class Request 10 | # @param actor [Actor] 11 | # @param input [String] 12 | def initialize(actor, input) 13 | @actor = actor 14 | @input = input 15 | end 16 | 17 | # @return [Array] 18 | def to_actions 19 | Action.sort( 20 | Syntax.tokenize(input, actor.narratives.syntaxes) 21 | .flat_map { |expression| expression_to_actions(expression) } 22 | ) 23 | end 24 | 25 | private 26 | 27 | # @return [Actor] 28 | attr_reader :actor 29 | 30 | # @return [String] 31 | attr_reader :input 32 | 33 | def expression_to_actions(expression) 34 | Gamefic.logger.info "Evaluating #{expression.inspect}" 35 | actor.narratives 36 | .responses_for(expression.verb) 37 | .map { |response| match_expression response, expression } 38 | .compact 39 | end 40 | 41 | def match_expression(response, expression) 42 | return nil if expression.tokens.length > response.queries.length 43 | 44 | remainder = '' 45 | matches = response.queries 46 | .zip(expression.tokens) 47 | .each_with_object([]) do |zipped, results| 48 | query, token = zipped 49 | result = query.filter(actor, "#{remainder} #{token}".strip) 50 | return nil unless result.match 51 | 52 | results.push Match.new(result.match, token.to_s[0..-result.remainder.length - 1], result.strictness) 53 | remainder = result.remainder 54 | end 55 | return nil unless remainder.empty? 56 | 57 | Action.new(actor, response, matches, input) 58 | end 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /lib/gamefic/messenger.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Gamefic 4 | # Message formatting and buffering. 5 | # 6 | class Messenger 7 | def initialize 8 | @buffers = [''] 9 | end 10 | 11 | # Create a temporary buffer while yielding the given block and return the 12 | # buffered text. 13 | # 14 | # @return [String] 15 | def buffer 16 | @buffers.push('') 17 | yield if block_given? 18 | @buffers.pop 19 | end 20 | 21 | # Add a formatted message to the current buffer. 22 | # 23 | # This method will automatically wrap the message in HTML paragraphs. 24 | # To send a message without formatting, use #stream instead. 25 | # 26 | # @param message [String, #to_s] 27 | # @return [String] The messages in the current buffer 28 | def tell(message) 29 | @buffers.push(@buffers.pop + format(message.to_s)) 30 | .last 31 | end 32 | 33 | # Add a raw text message to the current buffer. 34 | # 35 | # Unlike #tell, this method will not wrap the message in HTML paragraphs. 36 | # 37 | # @param message [String, #to_s] 38 | # @return [String] The messages in the current buffer 39 | def stream(message) 40 | @buffers.push(@buffers.pop + message.to_s) 41 | .last 42 | end 43 | 44 | # Get the currently buffered messages. 45 | # 46 | # @return [String] 47 | def messages 48 | @buffers.last 49 | end 50 | 51 | # Clear the current buffer. 52 | # 53 | # @return [String] The flushed message 54 | def flush 55 | @buffers.pop.tap { @buffers.push '' } 56 | end 57 | 58 | def format(message) 59 | "

#{message.strip}

" 60 | .gsub(/[ \t\r]*\n[ \t\r]*\n[ \t\r]*/, "

") 61 | .gsub(/[ \t]*\n[ \t]*/, ' ') 62 | .gsub(/

\s*

/, '

') 63 | .gsub(%r{

\s*

}, '

') 64 | end 65 | end 66 | end 67 | -------------------------------------------------------------------------------- /spec/gamefic/scene/multiple_choice_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | describe Gamefic::Scene::MultipleChoice do 4 | let(:actor) { Gamefic::Actor.new } 5 | 6 | let(:multiple_choice) do 7 | Class.new(Gamefic::Scene::MultipleChoice) do |scene| 8 | scene.on_finish { |_, props| raise 'should not happen' unless props.index } 9 | end.new(actor) 10 | end 11 | 12 | it 'initializes MultipleChoice props' do 13 | expect(multiple_choice.props).to be_a(Gamefic::Props::MultipleChoice) 14 | end 15 | 16 | it 'outputs options on start' do 17 | multiple_choice.props.options.concat ['one', 'two', 'three'] 18 | multiple_choice.start 19 | expect(multiple_choice.props.output[:options]).to eq(['one', 'two', 'three']) 20 | end 21 | 22 | it 'sets props on valid input' do 23 | multiple_choice.props.options.concat ['one', 'two', 'three'] 24 | multiple_choice.props.enter 'one' 25 | expect { multiple_choice.finish }.not_to raise_error 26 | expect(actor.queue).to be_empty 27 | expect(multiple_choice.props.input).to eq('one') 28 | expect(multiple_choice.props.selection).to eq('one') 29 | expect(multiple_choice.props.index).to eq(0) 30 | expect(multiple_choice.props.number).to eq(1) 31 | end 32 | 33 | it 'cancels on invalid input' do 34 | multiple_choice.props.options.concat ['one', 'two', 'three'] 35 | multiple_choice.props.enter 'four' 36 | expect { multiple_choice.finish }.not_to raise_error 37 | expect(actor.queue).to be_empty 38 | expect(actor.messages).to include('"four" is not a valid choice.') 39 | end 40 | 41 | it 'allows partial matches' do 42 | klass = Class.new(Gamefic::Scene::MultipleChoice) do 43 | use_props_class Gamefic::Props::MultiplePartial 44 | end 45 | scene = klass.new(actor) 46 | scene.props.options.push 'one', 'two' 47 | scene.props.enter 'tw' 48 | expect(scene.props.index).to eq(1) 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /spec/gamefic/entity_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | describe Gamefic::Entity do 4 | it 'inspects the name' do 5 | entity = Gamefic::Entity.new(name: 'thing') 6 | expect(entity.inspect).to eq('#') 7 | end 8 | 9 | it 'inherits default attributes' do 10 | klass1 = Class.new(Gamefic::Entity) do 11 | attr_accessor :attribute 12 | end 13 | klass1.set_default(attribute: 'one') 14 | 15 | klass2 = Class.new(klass1) 16 | klass2.set_default(attribute: 'two') 17 | 18 | klass3 = Class.new(klass1) 19 | 20 | entity1 = klass1.new 21 | expect(entity1.attribute).to eq('one') 22 | 23 | entity2 = klass2.new 24 | expect(entity2.attribute).to eq('two') 25 | 26 | entity3 = klass3.new 27 | expect(entity3.attribute).to eq('one') 28 | end 29 | 30 | it 'leaves parents' do 31 | room = Gamefic::Entity.new(name: 'room') 32 | person = Gamefic::Actor.new(name: 'person', parent: room) 33 | thing = Gamefic::Entity.new(name: 'thing', parent: person) 34 | 35 | expect(thing.parent).to be(person) 36 | thing.leave 37 | expect(thing.parent).to be(room) 38 | end 39 | 40 | it 'broadcasts to participating actors' do 41 | plot = Gamefic::Plot.new 42 | room = plot.make(Gamefic::Entity, name: 'room') 43 | container = plot.make(Gamefic::Entity, name: 'thing', parent: room) 44 | person = plot.introduce 45 | person.parent = container 46 | 47 | room.broadcast 'Hello, world!' 48 | expect(person.messages).to include('Hello, world!') 49 | end 50 | 51 | it 'does not broadcast to non-participants' do 52 | plot = Gamefic::Plot.new 53 | room = plot.make(Gamefic::Entity, name: 'room') 54 | container = plot.make(Gamefic::Entity, name: 'thing', parent: room) 55 | person = plot.make(Gamefic::Actor, name: 'person', parent: container) 56 | 57 | room.broadcast 'Hello, world!' 58 | expect(person.messages).to be_empty 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /lib/gamefic/scanner.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'gamefic/scanner/result' 4 | require 'gamefic/scanner/base' 5 | require 'gamefic/scanner/strict' 6 | require 'gamefic/scanner/fuzzy' 7 | require 'gamefic/scanner/nesting' 8 | require 'gamefic/scanner/fuzzy_nesting' 9 | 10 | module Gamefic 11 | # A module for matching objects to tokens. 12 | # 13 | module Scanner 14 | DEFAULT_PROCESSORS = [Nesting, Strict, FuzzyNesting, Fuzzy].freeze 15 | 16 | # Scan entities against a token. 17 | # 18 | # @param selection [Array] 19 | # @param token [String] 20 | # @param use [Array>] 21 | # @return [Result, nil] 22 | def self.scan(selection, token, use = processors) 23 | result = nil 24 | use.each do |processor| 25 | result = processor.scan(selection, token) 26 | break result unless result.matched.empty? 27 | end 28 | result 29 | end 30 | 31 | # Select the scanner processors to use in entity queries. Each processor 32 | # will be used in order until one of them returns matches. The default 33 | # processor list is `DEFAULT_PROCESSORS`. 34 | # 35 | # Processor classes should be in order from most to least strict rules 36 | # for matching tokens to entities. 37 | # 38 | # @param klasses [Array>] 39 | # @return [Array>] 40 | def self.use *klasses 41 | processors.replace klasses.flatten 42 | end 43 | 44 | # @return [Array>] 45 | def self.processors 46 | @processors ||= [] 47 | end 48 | 49 | # A measure of a scan processor's strictness based on its order of use. 50 | # Higher values indicate higher strictness. 51 | # 52 | # @return [Integer] 53 | def self.strictness(processor) 54 | (processors.length - (processors.find_index(processor) || processors.length)) * 100 55 | end 56 | 57 | use DEFAULT_PROCESSORS 58 | end 59 | end 60 | -------------------------------------------------------------------------------- /spec/gamefic/response_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Gamefic::Response do 4 | describe '#initialize' do 5 | it 'raises errors for invalid queries' do 6 | expect { Gamefic::Response.new(:example, nil) }.to raise_error(ArgumentError) 7 | end 8 | end 9 | 10 | describe '#meta?' do 11 | it 'is false by default' do 12 | response = Gamefic::Response.new(:verb) {} 13 | expect(response.meta?).to be(false) 14 | end 15 | 16 | it 'is true when set' do 17 | response = Gamefic::Response.new(:verb, meta: true) {} 18 | expect(response.meta?).to be(true) 19 | end 20 | end 21 | 22 | describe '#accept?' do 23 | it 'accepts commands with valid arguments' do 24 | player = Gamefic::Actor.new 25 | entity = Gamefic::Entity.new(parent: player) 26 | response = Gamefic::Response.new(:verb, entity) 27 | command = Gamefic::Command.new(:verb, [entity]) 28 | expect(response.accept?(player, command)).to be(true) 29 | end 30 | 31 | it 'rejects commands with different verbs' do 32 | player = Gamefic::Actor.new 33 | entity = Gamefic::Entity.new(parent: player) 34 | response = Gamefic::Response.new(:verb, entity) 35 | command = Gamefic::Command.new(:other, [entity]) 36 | expect(response.accept?(player, command)).to be(false) 37 | end 38 | 39 | it 'rejects commands with invalid arguments' do 40 | player = Gamefic::Actor.new 41 | entity = Gamefic::Entity.new(parent: player) 42 | other = Gamefic::Entity.new 43 | response = Gamefic::Response.new(:verb, entity) 44 | command = Gamefic::Command.new(:verb, [other]) 45 | expect(response.accept?(player, command)).to be(false) 46 | end 47 | end 48 | 49 | describe '#execute' do 50 | it 'runs blocks with arguments' do 51 | player = Gamefic::Actor.new 52 | response = Gamefic::Response.new(:verb) { |actor| actor[:executed] = true } 53 | response.execute(player) 54 | expect(player[:executed]).to be(true) 55 | end 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /spec/gamefic/order_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Gamefic::Order do 4 | describe '#to_actions' do 5 | it 'returns matching actions' do 6 | klass = Class.new(Gamefic::Plot) do 7 | respond(:verb1) {} 8 | respond(:verb2) {} 9 | end 10 | plot = klass.new 11 | player = plot.introduce 12 | request = Gamefic::Order.new(player, :verb1, []) 13 | actions = request.to_actions 14 | expect(actions).to be_one 15 | expect(actions.first.verb).to be(:verb1) 16 | end 17 | 18 | it 'returns matching actions with arguments' do 19 | klass = Class.new(Gamefic::Plot) do 20 | respond(:verb, anywhere(Gamefic::Entity)) {} 21 | end 22 | plot = klass.new 23 | player = plot.introduce 24 | thing = plot.make(Gamefic::Entity, name: 'thing') 25 | request = Gamefic::Order.new(player, :verb, [thing]) 26 | actions = request.to_actions 27 | expect(actions).to be_one 28 | expect(actions.first.verb).to be(:verb) 29 | expect(actions.first.arguments).to eq([thing]) 30 | end 31 | 32 | it 'skips responses with unmatched arguments' do 33 | klass = Class.new(Gamefic::Plot) do 34 | construct :thing1, Gamefic::Entity, name: 'thing1' 35 | construct :thing2, Gamefic::Entity, name: 'thing2' 36 | respond(:verb, anywhere(thing1)) {} 37 | end 38 | plot = klass.new 39 | player = plot.introduce 40 | request = Gamefic::Order.new(player, :verb, [plot.thing2]) 41 | actions = request.to_actions 42 | expect(actions).to be_empty 43 | end 44 | 45 | it 'matches unicode characters' do 46 | klass = Class.new(Gamefic::Plot) do 47 | construct :thing, Gamefic::Entity, name: 'ꩺ' 48 | respond('ぇワ', anywhere) {} 49 | end 50 | plot = klass.new 51 | player = plot.introduce 52 | request = Gamefic::Order.new(player, :'ぇワ', [plot.thing]) 53 | actions = request.to_actions 54 | expect(actions).to be_one 55 | expect(actions.first.verb).to be('ぇワ'.to_sym) 56 | end 57 | end 58 | end 59 | -------------------------------------------------------------------------------- /lib/gamefic/scene/active_choice.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Gamefic 4 | module Scene 5 | # A scene that presents a list of optional choices. The scene can still 6 | # attempt to process input that does not match any of the options. 7 | # 8 | # Authors can use the `without_selection` class method to select one of 9 | # three actions to take when the user does not enter one of the options: 10 | # `:perform`, `:recue`, or `:continue`. 11 | # 12 | class ActiveChoice < MultipleChoice 13 | WITHOUT_SELECTION_ACTIONS = %i[perform recue continue].freeze 14 | 15 | use_props_class Props::MultipleChoice 16 | 17 | def finish 18 | return super if props.selected? 19 | 20 | send(self.class.without_selection_action) 21 | end 22 | 23 | def without_selection_action 24 | self.class.without_selection_action 25 | end 26 | 27 | def self.type 28 | 'ActiveChoice' 29 | end 30 | 31 | # Select the behavior for input that does not match a selectable option. 32 | # The available settings are `:perform`, `:recue`, and `:continue`. 33 | # 34 | # * `:perform` - Skip the `on_finish` blocks and try to perform the input 35 | # as a command. This is the default behavior. 36 | # * `:recue` - Restart the scene until the user makes a valid selection. 37 | # This is the same behavior as a `MultipleChoice` scene. 38 | # * `:continue` - Execute the `on_finish` blocks regardless of whether the 39 | # input matches an option. 40 | # 41 | # @param action [Symbol] 42 | def self.without_selection(action) 43 | WITHOUT_SELECTION_ACTIONS.include?(action) || 44 | raise(ArgumentError, "without_selection_action must be one of #{WITHOUT_SELECTION_ACTIONS.map(&:inspect).join_or}") 45 | 46 | @without_selection_action = action 47 | end 48 | 49 | # @return [Symbol] 50 | def self.without_selection_action 51 | @without_selection_action ||= :perform 52 | end 53 | 54 | def self.inherited(klass) 55 | super 56 | klass.without_selection without_selection_action 57 | end 58 | 59 | private 60 | 61 | def perform 62 | actor.perform props.input 63 | end 64 | 65 | def recue 66 | actor.tell props.invalid_message 67 | actor.recue 68 | end 69 | 70 | def continue 71 | run_finish_blocks 72 | end 73 | end 74 | end 75 | end 76 | -------------------------------------------------------------------------------- /lib/gamefic/scriptable/seeds.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Gamefic 4 | module Scriptable 5 | module Seeds 6 | # @return [Array] 7 | def seeds 8 | @seeds ||= [] 9 | end 10 | 11 | # Set methods and procs that get executed when a narrative gets initialized. 12 | # 13 | # @example 14 | # class Example < Gamefic::Plot 15 | # attr_reader :thing 16 | # 17 | # seed do 18 | # @thing = make Entity, name: 'thing' 19 | # end 20 | # end 21 | # 22 | def seed *methods, &block 23 | seeds.push(proc { methods.flatten.each { |method| send(method) } }) unless methods.empty? 24 | seeds.push block if block 25 | end 26 | 27 | # Construct an entity. 28 | # 29 | # This method adds an instance method for the entity and a class method to 30 | # reference it with a proxy. 31 | # 32 | # @param name [Symbol, String] The method name for the entity 33 | # @param klass [Class] 34 | # @return [void] 35 | def construct name, klass, **opts 36 | ivname = "@#{name}" 37 | define_method(name) do 38 | return instance_variable_get(ivname) if instance_variable_defined?(ivname) 39 | 40 | instance_variable_set(ivname, make(klass, **unproxy(opts))) 41 | end 42 | seed { send(name) } 43 | define_singleton_method(name) { Proxy::Attr.new(name) } 44 | end 45 | alias attr_make construct 46 | alias attr_seed construct 47 | 48 | # Add an entity to be seeded when the narrative gets instantiated. 49 | # 50 | # @param klass [Class] 51 | # @return [void] 52 | def make klass, **opts 53 | seed { make(klass, **unproxy(opts)) } 54 | end 55 | alias make_seed make 56 | alias seed_make make 57 | end 58 | 59 | # Lazy pick an entity. 60 | # 61 | # @example 62 | # pick('the red box') 63 | # 64 | # @param args [Array] 65 | # @return [Proxy::Pick] 66 | def pick *args 67 | Proxy::Pick.new(*args) 68 | end 69 | alias lazy_pick pick 70 | 71 | # Lazy pick an entity or raise an error. 72 | # 73 | # @note The class method version of `pick!` returns a proxy, so the error 74 | # won't get raised until it gets unproxied in an instance. 75 | # 76 | def pick! *args 77 | Proxy::PickEx.new(*args) 78 | end 79 | alias lazy_pick! pick! 80 | end 81 | end 82 | -------------------------------------------------------------------------------- /spec/gamefic/scriptable/scenes_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | describe Gamefic::Scriptable::Scenes do 4 | let(:object) do 5 | Class.new do 6 | extend Gamefic::Scriptable 7 | end 8 | end 9 | 10 | describe '#multiple_choice' do 11 | it 'creates a multiple choice scene' do 12 | object.multiple_choice(:scene) { |scene| scene.on_start { |_, props| props.concat(%w[one two]) } } 13 | scene = object.named_scenes[:scene].new(nil) 14 | expect(scene).to be_a(Gamefic::Scene::MultipleChoice) 15 | end 16 | 17 | it 'sets choices' do 18 | object.multiple_choice(:scene) { |scene| scene.on_start { |_, props| props.options.push('one', 'two') } } 19 | actor = Gamefic::Actor.new 20 | scene = object.named_scenes[:scene].new(actor) 21 | scene.start 22 | expect(scene.props.options).to eq(%w[one two]) 23 | end 24 | end 25 | 26 | describe '#yes_or_no' do 27 | it 'creates a yes-or-no scene' do 28 | object.yes_or_no(:scene) {} 29 | scene = object.named_scenes[:scene].new(nil) 30 | expect(scene).to be_a(Gamefic::Scene::YesOrNo) 31 | end 32 | 33 | it 'sets a prompt' do 34 | object.yes_or_no(:scene) { |scene| scene.on_start { |_, props| props.prompt = 'What?' } } 35 | actor = Gamefic::Actor.new 36 | scene = object.named_scenes[:scene].new(actor) 37 | scene.start 38 | expect(scene.props.prompt).to eq('What?') 39 | end 40 | end 41 | 42 | describe '#pause' do 43 | it 'creates a pause scene' do 44 | object.pause(:scene) { |_actor, _props| nil } 45 | expect(object.named_scenes[:scene].start_blocks).to be_one 46 | scene = object.named_scenes[:scene].new(nil) 47 | expect(scene).to be_a(Gamefic::Scene::Pause) 48 | end 49 | 50 | it 'sets a prompt' do 51 | object.pause(:scene) { |_actor, props| props.prompt = 'Pause!' } 52 | actor = Gamefic::Actor.new 53 | scene = object.named_scenes[:scene].new(actor) 54 | scene.start 55 | expect(scene.props.prompt).to eq('Pause!') 56 | end 57 | end 58 | 59 | describe '#conclusion' do 60 | it 'creates a conclusion' do 61 | object.conclusion(:scene) { |_actor, _props| nil } 62 | scene = object.named_scenes[:scene].new(nil) 63 | expect(scene).to be_a(Gamefic::Scene::Conclusion) 64 | end 65 | end 66 | 67 | describe '#scene' do 68 | it 'accesses scenes' do 69 | object.block(Gamefic::Scene::Base, :scene) 70 | expect(object.named_scenes[:scene] <= Gamefic::Scene::Base).to be 71 | end 72 | end 73 | end 74 | -------------------------------------------------------------------------------- /lib/gamefic/scriptable/responses.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Gamefic 4 | module Scriptable 5 | module Responses 6 | include Queries 7 | include Syntaxes 8 | 9 | # Create a response to a command. 10 | # A Response uses the `verb` argument to identify the imperative verb 11 | # that triggers the action. It can also accept queries to tokenize the 12 | # remainder of the input and filter for particular entities or 13 | # properties. The `block`` argument is the proc to execute when the input 14 | # matches all of the Response's criteria (i.e., verb and queries). 15 | # 16 | # @example A simple Response. 17 | # respond :wave do |actor| 18 | # actor.tell "Hello!" 19 | # end 20 | # # The command "wave" will respond "Hello!" 21 | # 22 | # @example A Response that accepts a Character 23 | # respond :salute, available(Character) do |actor, character| 24 | # actor.tell "#{The character} returns your salute." 25 | # end 26 | # 27 | # @param verb [Symbol, String, nil] An imperative verb for the command 28 | # @param args [Array] Filters for the command's tokens 29 | # @yieldparam [Gamefic::Actor] 30 | # @yieldreceiver [Object] 31 | # @return [Response] 32 | def respond verb, *args, &proc 33 | response = Response.new(verb&.to_sym, *args, &proc) 34 | responses.push response 35 | syntaxes.push response.syntax 36 | response 37 | end 38 | 39 | # Create a meta response to a command. 40 | # 41 | # @param verb [Symbol, String, nil] An imperative verb for the command 42 | # @param args [Array] Filters for the command's tokens 43 | # @yieldparam [Gamefic::Actor] 44 | # @yieldreceiver [Object] 45 | # @return [Response] 46 | def meta verb, *args, &proc 47 | response = Response.new(verb&.to_sym, *args, meta: true, &proc) 48 | responses.push response 49 | syntaxes.push response.syntax 50 | response 51 | end 52 | 53 | # @return [Array] 54 | def responses 55 | @responses ||= [] 56 | end 57 | 58 | # @return [Array] 59 | def responses_for(*verbs) 60 | symbols = verbs.map { |verb| verb&.to_sym } 61 | responses.select { |response| symbols.include? response.verb } 62 | end 63 | 64 | # @return [Array] 65 | def verbs 66 | responses.select(&:verb).uniq(&:verb) 67 | end 68 | end 69 | end 70 | end 71 | -------------------------------------------------------------------------------- /spec/gamefic/core_ext/array_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | describe Array do 4 | describe '#that_are' do 5 | it "filters by class" do 6 | array = [0, 1, "two", 3] 7 | filtered = array.that_are(String) 8 | expect(filtered.length).to eq(1) 9 | expect(filtered[0]).to eq("two") 10 | end 11 | 12 | it "handles multiple true arguments" do 13 | array = [['one'], ['two'], [], 'four'] 14 | expect(array.that_are(Array, ->(a) { a.any? })).to eq([['one'], ['two']]) 15 | end 16 | 17 | it 'keeps equivalent values' do 18 | array = ['one', 'two', 'three'] 19 | expect(array.that_are('one')).to eq(['one']) 20 | end 21 | end 22 | 23 | describe '#that_are_not' do 24 | it "excludes by class" do 25 | array = [0, 1, "two", 3] 26 | filtered = array.that_are_not(String) 27 | expect(filtered.length).to eq(3) 28 | expect(filtered.include?("two")).to eq(false) 29 | end 30 | 31 | it "handles multiple false arguments" do 32 | array = [['one'], ['two'], '', 'four'] 33 | expect(array.that_are_not(Array, ->(e) { e.empty? })).to eq(['four']) 34 | end 35 | 36 | it 'rejects equivalent values' do 37 | array = ['one', 'two', 'three'] 38 | expect(array.that_are_not('one')).to eq(['two', 'three']) 39 | end 40 | end 41 | 42 | describe '#join_and' do 43 | it "joins with a conjunction" do 44 | array = ["one", "two", "three"] 45 | expect(array.join_and).to eq("one, two, and three") 46 | expect(array.join_and(separator: ', ', and_separator: ' or ')).to eq("one, two, or three") 47 | end 48 | 49 | it "joins two elements with the \"and\" separator" do 50 | array = ["one", "two"] 51 | expect(array.join_and).to eq("one and two") 52 | end 53 | 54 | it "joins three elements without a serial comma" do 55 | array = ["one", "two", "three"] 56 | expect(array.join_and(serial: false)).to eq("one, two and three") 57 | end 58 | 59 | it "keeps duplicate elements" do 60 | array = ["one", "one", "three"] 61 | expect(array.join_and).to eq("one, one, and three") 62 | end 63 | end 64 | 65 | describe '#join_or' do 66 | it 'joins with a conjunction' do 67 | array = ['one', 'two', 'three'] 68 | expect(array.join_or).to eq('one, two, or three') 69 | end 70 | end 71 | 72 | describe '#pop_sample' do 73 | it 'pops a sample' do 74 | array = [1, 2, 3] 75 | sample = array.pop_sample 76 | expect([1, 2, 3]).to include(sample) 77 | expect(array.length).to eq(2) 78 | end 79 | end 80 | end 81 | -------------------------------------------------------------------------------- /lib/gamefic/core_ext/array.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Array 4 | # Get a subset of the array that matches the arguments. 5 | # If the argument is a Class or Module, the elements must be of the type. 6 | # If the argument is a Symbol, it should be a method for which the elements must return true. 7 | # If the argument is an Object, the elements must equal the object. 8 | # 9 | # @example 10 | # animals = ['dog', 'cat', nil] 11 | # animals.that_are(String) #=> ['dog', 'cat'] 12 | # animals.that_are('dog') #=> ['dog'] 13 | # animals.that_are(:nil?) #=> [nil] 14 | # 15 | # @return [Array] 16 | def that_are(*cls) 17 | result = dup 18 | cls.each do |c| 19 | _keep result, c, true 20 | end 21 | result 22 | end 23 | 24 | # Get a subset of the array that does not match the arguments. 25 | # See Array#that_are for information about how arguments are evaluated. 26 | # 27 | # @return [Array] 28 | def that_are_not(*cls) 29 | result = dup 30 | cls.each do |c| 31 | _keep result, c, false 32 | end 33 | result 34 | end 35 | 36 | # Pop a random element from the array. 37 | # 38 | def pop_sample 39 | delete_at(rand(length)) 40 | end 41 | 42 | # Get a string representation of the array that separates elements with 43 | # commas and adds a conjunction before the last element. 44 | # 45 | # @example 46 | # animals = ['a dog', 'a cat', 'a mouse'] 47 | # animals.join_and #=> 'a dog, a cat, and a mouse' 48 | # 49 | # @param separator [String] The separator for all but the last element 50 | # @param and_separator [String] The separator for the last element 51 | # @param serial [Boolean] Use serial separators (e.g., serial commas) 52 | # @return [String] 53 | def join_and(separator: ', ', and_separator: ' and ', serial: true) 54 | if length < 3 55 | join(and_separator) 56 | else 57 | start = self[0..-2] 58 | start.join(separator) + "#{serial ? separator.strip : ''}#{and_separator}#{last}" 59 | end 60 | end 61 | 62 | # @see Array#join_and 63 | # 64 | # @return [String] 65 | def join_or(separator: ', ', or_separator: ' or ', serial: true) 66 | join_and(separator: separator, and_separator: or_separator, serial: serial) 67 | end 68 | 69 | private 70 | 71 | def _keep(arr, cls, bool) 72 | case cls 73 | when Class, Module 74 | arr.keep_if { |i| i.is_a?(cls) == bool } 75 | when Proc 76 | arr.keep_if { |i| !!cls.call(i) == bool } 77 | else 78 | arr.keep_if { |i| (i == cls) == bool } 79 | end 80 | end 81 | end 82 | -------------------------------------------------------------------------------- /lib/gamefic/props/multiple_choice.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Gamefic 4 | module Props 5 | # Props for MultipleChoice scenes. 6 | # 7 | class MultipleChoice < Default 8 | # @return [String] 9 | attr_writer :invalid_message 10 | 11 | # The array of available options. 12 | # 13 | # @return [Array] 14 | def options 15 | @options ||= [] 16 | end 17 | 18 | # A message to send the player for an invalid choice. A formatting 19 | # token named `%s` can be used to inject the user input. 20 | # 21 | # @example 22 | # props.invalid_message = '"%s" is not a valid choice.' 23 | # 24 | # @return [String] 25 | def invalid_message 26 | @invalid_message ||= '"%s" is not a valid choice.' 27 | end 28 | 29 | # The zero-based index of the selected option. 30 | # 31 | # @return [Integer, nil] 32 | def index 33 | return nil unless input 34 | 35 | @index ||= index_of(input) 36 | end 37 | 38 | # The one-based index of the selected option. 39 | # 40 | # @return [Integer, nil] 41 | def number 42 | return nil unless index 43 | 44 | index + 1 45 | end 46 | 47 | # The full text of the selected option. 48 | # 49 | # @return [String, nil] 50 | def selection 51 | return nil unless index 52 | 53 | options[index] 54 | end 55 | 56 | def selected? 57 | !!index 58 | end 59 | 60 | # Get the index of an option using input criteria, e.g., a one-based 61 | # number or the text of the option. The return value is the option's 62 | # zero-based index or nil. 63 | # 64 | # @example 65 | # props = Gamefic::Props::MultipleChoice.new 66 | # props.options.push 'First choice', 'Second choice' 67 | # 68 | # props.index_of(1) # => 0 69 | # props.index_of('Second choice') # => 1 70 | # 71 | # @param option [String, Integer] 72 | # @return [Integer, nil] 73 | def index_of(option) 74 | index_by_number(option) || index_by_text(option) 75 | end 76 | 77 | private 78 | 79 | # @param [String, Integer] 80 | # @return [Integer, nil] 81 | def index_by_number(input) 82 | return input.to_i - 1 if input.to_s.match(/^\d+$/) && options[input.to_i - 1] 83 | 84 | nil 85 | end 86 | 87 | # @param [String] 88 | # @return [Integer, nil] 89 | def index_by_text(input) 90 | options.find_index { |opt| opt.casecmp?(input) } 91 | end 92 | end 93 | end 94 | end 95 | -------------------------------------------------------------------------------- /spec/gamefic/scanner_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Gamefic::Scanner do 4 | it 'returns matching objects' do 5 | one = Gamefic::Entity.new name: 'one' 6 | two = Gamefic::Entity.new name: 'two' 7 | objects = [one, two] 8 | token = 'one' 9 | result = Gamefic::Scanner.scan(objects, token) 10 | expect(result.matched).to eq([one]) 11 | expect(result.remainder).to eq('') 12 | end 13 | 14 | it 'returns empty result for unscaned tokens' do 15 | one = Gamefic::Entity.new name: 'one' 16 | two = Gamefic::Entity.new name: 'two' 17 | objects = [one, two] 18 | token = 'three' 19 | result = Gamefic::Scanner.scan(objects, token) 20 | expect(result.matched).to eq([]) 21 | expect(result.remainder).to eq('three') 22 | end 23 | 24 | it 'returns matches with remainders' do 25 | one = Gamefic::Entity.new name: 'one' 26 | two = Gamefic::Entity.new name: 'two' 27 | objects = [one, two] 28 | token = 'one three' 29 | result = Gamefic::Scanner.scan(objects, token) 30 | expect(result.matched).to eq([one]) 31 | expect(result.remainder).to eq('three') 32 | end 33 | 34 | it 'performs fuzzy matches' do 35 | one = Gamefic::Entity.new name: 'one' 36 | two = Gamefic::Entity.new name: 'two' 37 | three = Gamefic::Entity.new name: 'three', parent: two 38 | objects = [one, two, three] 39 | token = 'thre' 40 | result = Gamefic::Scanner.scan(objects, token) 41 | expect(result.matched).to eq([three]) 42 | expect(result.remainder).to eq('') 43 | end 44 | 45 | it 'returns multiple results' do 46 | one = Gamefic::Entity.new name: 'one' 47 | two = Gamefic::Entity.new name: 'two' 48 | three = Gamefic::Entity.new name: 'three' 49 | objects = [one, two, three] 50 | token = 't' 51 | result = Gamefic::Scanner.scan(objects, token) 52 | expect(result.matched).to eq([two, three]) 53 | expect(result.remainder).to eq('') 54 | end 55 | 56 | it 'denests references' do 57 | one = Gamefic::Entity.new name: 'one' 58 | two = Gamefic::Entity.new name: 'two' 59 | three = Gamefic::Entity.new name: 'three', parent: two 60 | objects = [one, two, three] 61 | token = 'three from two' 62 | result = Gamefic::Scanner.scan(objects, token) 63 | expect(result.matched).to eq([three]) 64 | expect(result.remainder).to eq('') 65 | end 66 | 67 | it 'handles unicode characters' do 68 | one = Gamefic::Entity.new name: 'ꩺ' 69 | two = Gamefic::Entity.new name: 'two' 70 | objects = [one, two] 71 | token = 'ꩺ' 72 | result = Gamefic::Scanner.scan(objects, token) 73 | expect(result.matched).to eq([one]) 74 | expect(result.remainder).to eq('') 75 | end 76 | end 77 | -------------------------------------------------------------------------------- /lib/gamefic/scripting/entities.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'set' 4 | 5 | module Gamefic 6 | module Scripting 7 | # Methods related to managing entities. 8 | # 9 | module Entities 10 | # extend Scriptable 11 | include Proxies 12 | 13 | # @return [Array] 14 | def entities 15 | entity_set.to_a 16 | end 17 | 18 | # @return [Array] 19 | def players 20 | player_set.to_a 21 | end 22 | 23 | # Create an entity. 24 | # 25 | # @example 26 | # class MyPlot < Gamefic::Plot 27 | # seed { make Gamefic::Entity, name: 'thing' } 28 | # end 29 | # 30 | # @param klass [Class] 31 | # @return [Gamefic::Entity] 32 | def make klass, **opts 33 | klass.new(**unproxy(opts)).tap { |entity| entity_set.add entity } 34 | end 35 | 36 | def destroy(entity) 37 | entity.children.each { |child| destroy child } 38 | entity.parent = nil 39 | entity_set.delete entity 40 | entity 41 | end 42 | 43 | def find *args 44 | args.inject(entities) do |entities, arg| 45 | case arg 46 | when String 47 | result = Scanner.scan(entities, arg) 48 | result.remainder.empty? ? result.match : [] 49 | else 50 | entities.that_are(arg) 51 | end 52 | end 53 | end 54 | 55 | # Pick a unique entity based on the given arguments. String arguments are 56 | # used to scan the entities for matching names and synonyms. Return nil 57 | # if an entity could not be found or there is more than one possible 58 | # match. 59 | # 60 | # @return [Gamefic::Entity, nil] 61 | def pick *args 62 | matches = find(*args) 63 | return nil unless matches.one? 64 | 65 | matches.first 66 | end 67 | 68 | # Same as #pick, but raise an error if a unique match could not be found. 69 | # 70 | # 71 | # @raise [RuntimeError] if a unique match was not found. 72 | # 73 | # @param args [Array] 74 | # @return [Gamefic::Entity] 75 | def pick! *args 76 | matches = find(*args) 77 | raise "no entity matching '#{args.inspect}'" if matches.empty? 78 | raise "multiple entities matching '#{args.inspect}': #{matches.join_and}" unless matches.one? 79 | 80 | matches.first 81 | end 82 | 83 | private 84 | 85 | def entity_set 86 | @entity_set ||= Set.new 87 | end 88 | 89 | def player_set 90 | @player_set ||= Set.new 91 | end 92 | end 93 | end 94 | end 95 | -------------------------------------------------------------------------------- /lib/gamefic/subplot.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'gamefic/plot' 4 | 5 | module Gamefic 6 | # Subplots are disposable plots that run inside a parent plot. They can be 7 | # started and concluded at any time during the parent plot's runtime. 8 | # 9 | class Subplot < Narrative 10 | # @return [Hash] 11 | attr_reader :config 12 | 13 | # @return [Plot] 14 | attr_reader :plot 15 | 16 | # @param plot [Gamefic::Plot] 17 | # @param introduce [Gamefic::Actor, Array, nil] 18 | # @param config [Hash] 19 | def initialize plot, introduce: [], **config 20 | @plot = plot 21 | @config = config 22 | configure 23 | @config.freeze 24 | super() 25 | @concluded = false 26 | [introduce].flatten.each { |plyr| self.introduce plyr } 27 | end 28 | 29 | def seeds 30 | super - plot.seeds 31 | end 32 | 33 | def self.persist! 34 | @persistent = true 35 | end 36 | 37 | def self.persistent? 38 | @persistent ||= false 39 | end 40 | 41 | def persistent? 42 | self.class.persistent? 43 | end 44 | 45 | def conclude 46 | conclude_blocks.each(&:call) 47 | players.each do |plyr| 48 | player_conclude_blocks.each { |blk| blk[plyr] } 49 | uncast plyr 50 | end 51 | entities.each { |ent| destroy ent } 52 | @concluded = true 53 | end 54 | 55 | # Start a new subplot based on the provided class. 56 | # 57 | # @note A subplot's host is always the base plot, regardless of whether 58 | # it was branched from another subplot. 59 | # 60 | # @param subplot_class [Class] The Subplot class 61 | # @param introduce [Gamefic::Actor, Array, nil] Players to introduce 62 | # @param config [Hash] Subplot configuration 63 | # @return [Gamefic::Subplot] 64 | def branch subplot_class = Gamefic::Subplot, introduce: [], **config 65 | plot.branch subplot_class, introduce: introduce, **config 66 | end 67 | 68 | # Subclasses can override this method to handle additional configuration 69 | # options. 70 | # 71 | def configure; end 72 | 73 | def concluding? 74 | return super unless persistent? 75 | 76 | @concluded 77 | end 78 | 79 | def introduce(player) 80 | @concluded ? player : super 81 | end 82 | 83 | def prepare(...) 84 | super || plot.prepare(...) 85 | end 86 | 87 | def inspect 88 | "#<#{self.class}>" 89 | end 90 | 91 | class << self 92 | def config 93 | Proxy::Config.new 94 | end 95 | end 96 | end 97 | end 98 | -------------------------------------------------------------------------------- /lib/gamefic/narrative.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'corelib/marshal' if RUBY_ENGINE == 'opal' # Required in browser 4 | 5 | module Gamefic 6 | # A base class for building and managing the resources that compose a story. 7 | # The Plot and Subplot classes inherit from Narrative and provide additional 8 | # functionality. 9 | # 10 | class Narrative 11 | include Scripting 12 | # @!parse extend Gamefic::Scriptable 13 | 14 | select_default_scene Scene::Activity 15 | select_default_conclusion Scene::Conclusion 16 | 17 | def initialize 18 | seeds.each { |blk| instance_exec(&blk) } 19 | end 20 | 21 | # Introduce an actor to the story. 22 | # 23 | # @param player [Gamefic::Actor] 24 | # @return [Gamefic::Actor] 25 | def introduce(player = Gamefic::Actor.new) 26 | cast player 27 | introductions.each { |blk| blk[player] } 28 | player 29 | end 30 | 31 | # A narrative is considered to be concluding when all of its players are in 32 | # a conclusion scene. Engines can use this method to determine whether the 33 | # game is ready to end. 34 | # 35 | def concluding? 36 | players.empty? || players.all?(&:concluding?) 37 | end 38 | 39 | # Add an active entity to the narrative. 40 | # 41 | # @param active [Gamefic::Active] 42 | # @return [Gamefic::Active] 43 | def cast(active) 44 | active.narratives.add self 45 | player_set.add active 46 | entity_set.add active 47 | active 48 | end 49 | 50 | # Remove an active entity from the narrative. 51 | # 52 | # @param active [Gamefic::Active] 53 | # @return [Gamefic::Active] 54 | def uncast(active) 55 | active.narratives.delete self 56 | player_set.delete active 57 | entity_set.delete active 58 | active 59 | end 60 | 61 | # Complete a game turn. 62 | # 63 | # In the base Narrative class, this method runs all applicable player 64 | # conclude blocks and the narrative's own conclude blocks. 65 | # 66 | # @return [void] 67 | def turn 68 | players.select(&:concluding?).each { |plyr| player_conclude_blocks.each { |blk| blk[plyr] } } 69 | conclude_blocks.each(&:call) if concluding? 70 | end 71 | 72 | # @return [String] 73 | def save 74 | Marshal.dump(self) 75 | end 76 | 77 | # @param snapshot [String] 78 | # @return [self] 79 | def self.restore(snapshot) 80 | # @sg-ignore 81 | Marshal.load(snapshot) 82 | end 83 | 84 | def self.inherited(klass) 85 | super 86 | klass.seeds.concat seeds 87 | klass.select_default_scene default_scene 88 | klass.select_default_conclusion default_conclusion 89 | end 90 | end 91 | end 92 | -------------------------------------------------------------------------------- /lib/gamefic/plot.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Gamefic 4 | # The plot is the central narrative. It provides a script interface with 5 | # methods for creating entities, actions, scenes, and hooks. 6 | # 7 | class Plot < Narrative 8 | # @return [Array] 9 | attr_reader :chapters 10 | 11 | def initialize 12 | super 13 | @chapters = self.class.appended_chapter_map.map { |chap, config| chap.new(self, **unproxy(config)) } 14 | end 15 | 16 | def uncast(actor) 17 | subplots.each { |sp| sp.uncast actor } 18 | super 19 | end 20 | 21 | # Get an array of all the current subplots. 22 | # 23 | # @return [Array] 24 | def subplots 25 | @subplots ||= [] 26 | end 27 | 28 | # Start a new subplot based on the provided class. 29 | # 30 | # @param subplot_class [Class] The Subplot class 31 | # @param introduce [Gamefic::Actor, Array] Players to introduce 32 | # @param config [Hash] Subplot configuration 33 | # @return [Gamefic::Subplot] 34 | def branch subplot_class = Gamefic::Subplot, introduce: [], **config 35 | subplot_class.new(self, introduce: introduce, **config) 36 | .tap { |sub| subplots.push sub } 37 | end 38 | 39 | def inspect 40 | "#<#{self.class}>" 41 | end 42 | 43 | def self.append(chapter, **config) 44 | Gamefic.logger.warn "Overwriting existing chapter #{chapter}" if appended_chapter_map.key?(chapter) 45 | 46 | appended_chapter_map[chapter] = config 47 | end 48 | 49 | def self.appended_chapter_map 50 | @appended_chapter_map ||= {} 51 | end 52 | 53 | # Complete a game turn. 54 | # 55 | # In addition to running its own applicable conclude blocks, the Plot class 56 | # will also handle conclude blocks for its chapters and subplots. 57 | # 58 | # @return [void] 59 | def turn 60 | super 61 | subplots.each(&:conclude) if concluding? 62 | chapters.delete_if(&:concluding?) 63 | subplots.delete_if(&:concluding?) 64 | end 65 | 66 | def ready_blocks 67 | super + subplots.flat_map(&:ready_blocks) 68 | end 69 | 70 | def update_blocks 71 | super + subplots.flat_map(&:update_blocks) 72 | end 73 | 74 | def player_output_blocks 75 | super + subplots.flat_map(&:player_output_blocks) 76 | end 77 | 78 | def responses 79 | super + chapters.flat_map(&:responses) 80 | end 81 | 82 | def responses_for(*verbs) 83 | super + chapters.flat_map { |chap| chap.responses_for(*verbs) } 84 | end 85 | 86 | def syntaxes 87 | super + chapters.flat_map(&:syntaxes) 88 | end 89 | 90 | def find_and_bind(symbol) 91 | super + chapters.flat_map { |chap| chap.find_and_bind(symbol) } 92 | end 93 | end 94 | end 95 | -------------------------------------------------------------------------------- /spec/gamefic/chapter_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | describe Gamefic::Chapter do 4 | it 'does not duplicate ready blocks' do 5 | scriptable = Module.new do 6 | extend Gamefic::Scriptable 7 | 8 | on_player_ready {} 9 | end 10 | 11 | chapter_klass = Class.new(Gamefic::Chapter) do 12 | include scriptable 13 | end 14 | 15 | plot_klass = Class.new(Gamefic::Plot) do 16 | include scriptable 17 | append chapter_klass 18 | end 19 | 20 | plot = plot_klass.new 21 | expect(plot.ready_blocks).to be_one 22 | end 23 | 24 | it 'does not duplicate introductions' do 25 | scriptable = Module.new do 26 | extend Gamefic::Scriptable 27 | 28 | introduction {} 29 | end 30 | 31 | chapter_klass = Class.new(Gamefic::Chapter) do 32 | include scriptable 33 | end 34 | 35 | plot_klass = Class.new(Gamefic::Plot) do 36 | include scriptable 37 | append chapter_klass 38 | end 39 | 40 | plot = plot_klass.new 41 | expect(plot.introductions).to be_one 42 | end 43 | 44 | it 'does not duplicate seeds' do 45 | scriptable = Module.new do 46 | extend Gamefic::Scriptable 47 | 48 | make Gamefic::Entity, name: 'thing' 49 | end 50 | 51 | chapter_klass = Class.new(Gamefic::Chapter) do 52 | include scriptable 53 | end 54 | 55 | plot_klass = Class.new(Gamefic::Plot) do 56 | include scriptable 57 | append chapter_klass 58 | end 59 | 60 | plot = plot_klass.new 61 | expect(plot.entities).to be_one 62 | expect(plot.chapters.first.entities).to be_empty 63 | end 64 | 65 | it 'binds methods from plots' do 66 | chapter_klass = Class.new(Gamefic::Chapter) do 67 | bind_from_plot :thing 68 | end 69 | 70 | plot_klass = Class.new(Gamefic::Plot) do 71 | append chapter_klass 72 | 73 | construct :thing, Gamefic::Entity, name: 'thing' 74 | end 75 | 76 | plot = plot_klass.new 77 | expect(plot.chapters.first.thing).to be(plot.thing) 78 | end 79 | 80 | it 'executes responses' do 81 | chapter_klass = Class.new(Gamefic::Chapter) do 82 | bind_from_plot :thing 83 | 84 | respond :take, thing do |actor| 85 | thing.parent = actor 86 | end 87 | end 88 | 89 | plot_klass = Class.new(Gamefic::Plot) do 90 | append chapter_klass 91 | 92 | construct :room, Gamefic::Entity, name: 'room' 93 | construct :thing, Gamefic::Entity, name: 'thing', parent: room 94 | 95 | introduction do |actor| 96 | actor.parent = room 97 | end 98 | end 99 | 100 | plot = plot_klass.new 101 | player = plot.introduce 102 | player.perform 'take thing' 103 | expect(plot.thing.parent).to be(player) 104 | end 105 | end 106 | -------------------------------------------------------------------------------- /lib/gamefic/active/narratives.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Gamefic 4 | module Active 5 | # A narrative container for active entities. 6 | # 7 | class Narratives 8 | include Enumerable 9 | 10 | # @param narrative [Narrative] 11 | # @return [self] 12 | def add(narrative) 13 | narrative_set.add(narrative) 14 | self 15 | end 16 | 17 | # @param narrative [Narrative] 18 | # @return [self] 19 | def delete(narrative) 20 | narrative_set.delete(narrative) 21 | self 22 | end 23 | 24 | def empty? 25 | narrative_set.empty? 26 | end 27 | 28 | # @return [Integer] 29 | def length 30 | narrative_set.length 31 | end 32 | 33 | def one? 34 | narrative_set.one? 35 | end 36 | 37 | # @return [Array] 38 | def responses 39 | narrative_set.flat_map(&:responses) 40 | end 41 | 42 | # @return [Array] 43 | def responses_for(*verbs) 44 | narrative_set.flat_map { |narr| narr.responses_for(*verbs) } 45 | end 46 | 47 | # @return [Array] 48 | def syntaxes 49 | narrative_set.flat_map(&:syntaxes) 50 | end 51 | 52 | # True if the specified verb is understood by any of the narratives. 53 | # 54 | # @param verb [String, Symbol] 55 | def understand?(verb) 56 | verb ? narrative_set.flat_map(&:synonyms).include?(verb.to_sym) : false 57 | end 58 | 59 | # @return [Array] 60 | def before_commands 61 | narrative_set.flat_map(&:before_commands) 62 | end 63 | 64 | # @return [Array] 65 | def after_commands 66 | narrative_set.flat_map(&:after_commands) 67 | end 68 | 69 | # @sg-ignore Type checker has trouble reconciling return type of `Set#each` 70 | # with unresolved `generic` of `Enumerable#each` 71 | def each(&block) 72 | narrative_set.each(&block) 73 | end 74 | 75 | # @return [Array] 76 | def that_are(*args) 77 | narrative_set.to_a.that_are(*args) 78 | end 79 | 80 | # @return [Array] 81 | def that_are_not(*args) 82 | narrative_set.to_a.that_are_not(*args) 83 | end 84 | 85 | # @return [Array] 86 | def entities 87 | narrative_set.flat_map(&:entities) 88 | end 89 | 90 | # @return [Array] 91 | def player_output_blocks 92 | narrative_set.flat_map(&:player_output_blocks).uniq(&:code) 93 | end 94 | 95 | private 96 | 97 | # @return [Set] 98 | def narrative_set 99 | @narrative_set ||= Set.new 100 | end 101 | end 102 | end 103 | end 104 | -------------------------------------------------------------------------------- /lib/gamefic/action.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Gamefic 4 | # The handler for executing a command response. 5 | # 6 | class Action 7 | include Scriptable::Queries 8 | 9 | # @return [Actor] 10 | attr_reader :actor 11 | 12 | # @return [Response] 13 | attr_reader :response 14 | 15 | # @return [Array] 16 | attr_reader :matches 17 | 18 | # @return [String, nil] 19 | attr_reader :input 20 | 21 | # @param actor [Actor] 22 | # @param response [Response] 23 | # @param matches [Array] 24 | # @param input [String, nil] 25 | def initialize(actor, response, matches, input = nil) 26 | @actor = actor 27 | @response = response 28 | @matches = matches 29 | @input = input 30 | end 31 | 32 | def verb 33 | response.verb 34 | end 35 | 36 | def command 37 | @command ||= Command.new(response.verb, matches.map(&:argument), response.meta?, input) 38 | end 39 | 40 | def queries 41 | response.queries 42 | end 43 | 44 | def arguments 45 | matches.map(&:argument) 46 | end 47 | 48 | def execute 49 | response.execute(actor, *arguments) 50 | self 51 | end 52 | 53 | # The total substantiality of the action, based on how many of the 54 | # arguments are concrete entities and whether the action has a verb. 55 | # 56 | def substantiality 57 | arguments.that_are(Entity).length + (verb ? 1 : 0) 58 | end 59 | 60 | # The total strictness of all the matches. 61 | # 62 | # The higher the strictness, the more precisely the tokens from the user 63 | # input match the arguments. For example, if the user is interacting with a 64 | # pencil, the command TAKE PENCIL is stricter than TAKE PEN. 65 | # 66 | # @return [Integer] 67 | def strictness 68 | matches.sum(0, &:strictness) 69 | end 70 | 71 | # The precision of the response. 72 | # 73 | # @return [Integer] 74 | def precision 75 | response.precision 76 | end 77 | 78 | def valid? 79 | response.accept?(actor, command) 80 | end 81 | 82 | def invalid? 83 | !valid? 84 | end 85 | 86 | def meta? 87 | response.meta? 88 | end 89 | 90 | # Sort an array of actions in the order in which a Dispatcher should 91 | # attempt to execute them. 92 | # 93 | # Order is determined by the actions' substantiality, strictness, and 94 | # precision. In the event of a tie, the most recently defined action has 95 | # higher priority. 96 | # 97 | # @param actions [Array] 98 | # @return [Array] 99 | def self.sort(actions) 100 | actions.sort_by.with_index do |action, idx| 101 | [-action.substantiality, -action.strictness, -action.precision, idx] 102 | end 103 | end 104 | end 105 | end 106 | -------------------------------------------------------------------------------- /lib/gamefic/props/output.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Gamefic 4 | module Props 5 | # A container for output sent to players with a hash interface for custom 6 | # data. 7 | # 8 | class Output 9 | READER_METHODS = %i[messages options queue scene prompt last_prompt last_input].freeze 10 | WRITER_METHODS = %i[messages= prompt= last_prompt= last_input=].freeze 11 | 12 | attr_reader :raw_data 13 | 14 | def initialize **data 15 | @raw_data = { 16 | messages: '', 17 | options: [], 18 | queue: [], 19 | scene: {}, 20 | prompt: '' 21 | } 22 | merge! data 23 | end 24 | 25 | # @!attribute [rw] messages 26 | # A text message to be displayed at the start of a scene. 27 | # 28 | # @return [String] 29 | 30 | # @!attribute [rw] options 31 | # An array of options to be presented to the player, e.g., in a 32 | # MultipleChoice scene. 33 | # 34 | # @return [Array] 35 | 36 | # @!attribute [rw] queue 37 | # An array of commands waiting to be executed. 38 | # 39 | # @return [Array] 40 | 41 | # @!attribute [rw] scene 42 | # A hash containing the scene's :name and :type. 43 | # 44 | # @return [Hash] 45 | 46 | # @!attribute [rw] [prompt] 47 | # The input prompt to be displayed to the player. 48 | # 49 | # @return [String] 50 | 51 | # @!attribute [rw] last_input 52 | # The input received from the player in the previous scene. 53 | # 54 | # @return [String, nil] 55 | 56 | # @!attribute [rw] last_prompt 57 | # The input prompt from the previous scene. 58 | # 59 | # @return [String, nil] 60 | 61 | # @param key [Symbol] 62 | def [](key) 63 | raw_data[key] 64 | end 65 | 66 | # @param key [Symbol] 67 | # @param value [Object] 68 | def []=(key, value) 69 | raw_data[key] = value 70 | end 71 | 72 | # @return [Hash] 73 | def to_hash 74 | raw_data.dup 75 | end 76 | 77 | def to_json(_ = nil) 78 | raw_data.to_json 79 | end 80 | 81 | def merge!(data) 82 | data.each { |key, val| self[key] = val } 83 | end 84 | 85 | def replace(data) 86 | raw_data.replace data 87 | end 88 | 89 | def freeze 90 | raw_data.freeze 91 | super 92 | end 93 | 94 | def method_missing method, *args 95 | return raw_data[method] if READER_METHODS.include?(method) 96 | 97 | return raw_data[method.to_s[0..-2].to_sym] = args.first if WRITER_METHODS.include?(method) 98 | 99 | super 100 | end 101 | 102 | def respond_to_missing?(method, _with_private = false) 103 | READER_METHODS.include?(method) || WRITER_METHODS.include?(method) 104 | end 105 | 106 | EMPTY = new.freeze 107 | end 108 | end 109 | end 110 | -------------------------------------------------------------------------------- /lib/gamefic/active/cue.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Gamefic 4 | module Active 5 | # The object that actors use to perform a scene. 6 | # 7 | class Cue 8 | # @return [Actor] 9 | attr_reader :actor 10 | 11 | # @return [Class, Symbol] 12 | attr_reader :key 13 | 14 | # @return [Narrative] 15 | attr_reader :narrative 16 | 17 | # @return [Hash] 18 | attr_reader :context 19 | 20 | # @return [Props::Default, nil] 21 | attr_reader :props 22 | 23 | # @param actor [Actor] 24 | # @param key [Class, Symbol] 25 | # @param narrative [Narrative] 26 | def initialize actor, key, narrative, **context 27 | @actor = actor 28 | @key = key 29 | @narrative = narrative 30 | @context = context 31 | end 32 | 33 | # @return [void] 34 | def start 35 | @props = scene.start 36 | prepare_output 37 | actor.rotate_cue 38 | end 39 | 40 | # @return [void] 41 | def finish 42 | props&.enter(actor.queue.shift&.strip) 43 | scene.finish 44 | end 45 | 46 | # @return [Props::Output] 47 | def output 48 | props&.output.clone.freeze || Props::Output::EMPTY 49 | end 50 | 51 | # @return [Cue] 52 | def restart 53 | Cue.new(actor, key, narrative, **context) 54 | end 55 | 56 | def type 57 | scene&.type 58 | end 59 | 60 | def to_s 61 | scene.to_s 62 | end 63 | 64 | # @return [void] 65 | def prepare 66 | props.output.merge!({ 67 | scene: scene.to_hash, 68 | prompt: props.prompt, 69 | messages: actor.flush, 70 | queue: actor.queue 71 | }) 72 | actor.narratives.player_output_blocks.each { |block| block.call actor, props.output } 73 | end 74 | 75 | # @return [Scene::Base] 76 | def scene 77 | # @note This method always returns a new instance. Scenes identified 78 | # by symbolic keys can be instances of anonymous classes that cannot 79 | # be serialized, so memoizing them breaks snapshots. 80 | narrative&.prepare(key, actor, props, **context) || 81 | try_unblocked_class || 82 | raise("Failed to cue #{key.inspect} in #{narrative.inspect}") 83 | end 84 | 85 | private 86 | 87 | # @return [Scene::Base] 88 | def try_unblocked_class 89 | return unless key.is_a?(Class) && key <= Scene::Base 90 | 91 | Gamefic.logger.warn "Cueing scene #{key} without narrative" unless narrative 92 | key.new(actor, narrative, props, **context) 93 | end 94 | 95 | # @return [void] 96 | def prepare_output 97 | props.output.last_input = actor.last_cue&.props&.input 98 | props.output.last_prompt = actor.last_cue&.props&.prompt 99 | end 100 | end 101 | end 102 | end 103 | -------------------------------------------------------------------------------- /spec/gamefic/narrator_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | describe Gamefic::Narrator do 4 | it 'runs on_player_conclude blocks' do 5 | klass = Class.new(Gamefic::Plot) do 6 | on_player_conclude do |player| 7 | player[:concluded] = true 8 | end 9 | end 10 | plot = klass.new 11 | narrator = Gamefic::Narrator.new(plot) 12 | player = narrator.cast 13 | player.cue plot.default_conclusion 14 | narrator.start 15 | expect(player[:concluded]).to be(true) 16 | end 17 | 18 | it 'runs on_ready blocks' do 19 | ran_on_ready = false 20 | klass = Class.new(Gamefic::Plot) do 21 | on_ready do 22 | ran_on_ready = true 23 | end 24 | end 25 | plot = klass.new 26 | narrator = Gamefic::Narrator.new(plot) 27 | narrator.start 28 | expect(ran_on_ready).to be(true) 29 | end 30 | 31 | it 'runs on_player_ready blocks' do 32 | klass = Class.new(Gamefic::Plot) do 33 | on_player_ready do |player| 34 | player[:ran_on_player_ready] = true 35 | end 36 | end 37 | plot = klass.new 38 | narrator = Gamefic::Narrator.new(plot) 39 | player = narrator.cast 40 | narrator.start 41 | expect(player[:ran_on_player_ready]).to be(true) 42 | end 43 | 44 | it 'runs on_update blocks' do 45 | ran_on_update = false 46 | klass = Class.new(Gamefic::Plot) do 47 | on_update do 48 | ran_on_update = true 49 | end 50 | end 51 | plot = klass.new 52 | narrator = Gamefic::Narrator.new(plot) 53 | narrator.cast 54 | narrator.start 55 | narrator.finish 56 | expect(ran_on_update).to be(true) 57 | end 58 | 59 | it 'runs on_player_update blocks' do 60 | klass = Class.new(Gamefic::Plot) do 61 | on_player_update do |player| 62 | player[:ran_on_player_update] = true 63 | end 64 | end 65 | plot = klass.new 66 | narrator = Gamefic::Narrator.new(plot) 67 | player = narrator.cast 68 | narrator.start 69 | narrator.finish 70 | expect(player[:ran_on_player_update]).to be(true) 71 | end 72 | 73 | it 'adds last_prompt and last_input to output' do 74 | klass = Class.new(Gamefic::Plot) 75 | plot = klass.new 76 | narrator = Gamefic::Narrator.new(plot) 77 | player = narrator.cast 78 | narrator.start 79 | player.queue.push 'my input' 80 | narrator.finish 81 | narrator.start 82 | expect(player.output.last_prompt).to eq('>') 83 | expect(player.output.last_input).to eq('my input') 84 | end 85 | 86 | it 'casts players' do 87 | narrator = Gamefic::Narrator.new(Gamefic::Plot.new) 88 | player = narrator.cast 89 | expect(narrator.players).to eq([player]) 90 | end 91 | 92 | it 'uncasts players' do 93 | narrator = Gamefic::Narrator.new(Gamefic::Plot.new) 94 | player = narrator.cast 95 | narrator.uncast player 96 | expect(narrator.players).to be_empty 97 | end 98 | 99 | it 'reports concluding plots' do 100 | narrator = Gamefic::Narrator.new(Gamefic::Plot.new) 101 | expect(narrator.concluding?).to be(true) 102 | end 103 | end 104 | -------------------------------------------------------------------------------- /lib/gamefic/entity.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Gamefic 4 | # Entities are the people, places, and things that exist in a Gamefic 5 | # narrative. Authors are encouraged to define Entity subclasses to create 6 | # entity types that have additional features or need special handling in 7 | # actions. 8 | # 9 | class Entity 10 | include Describable 11 | include Node 12 | 13 | def initialize **args 14 | klass = self.class 15 | defaults = {} 16 | while klass <= Entity 17 | defaults = klass.default_attributes.merge(defaults) 18 | klass = klass.superclass 19 | end 20 | defaults.merge(args).each_pair { |k, v| send "#{k}=", v } 21 | 22 | yield(self) if block_given? 23 | 24 | post_initialize 25 | end 26 | 27 | # This method can be overridden for additional processing after the entity 28 | # has been created. 29 | # 30 | def post_initialize; end 31 | 32 | # A freeform property dictionary. 33 | # Authors can use the session hash to assign custom properties to the 34 | # entity. It can also be referenced directly using [] without the method 35 | # name, e.g., entity.session[:my_value] or entity[:my_value]. 36 | # 37 | # @return [Hash] 38 | def session 39 | @session ||= {} 40 | end 41 | 42 | # @param key [Symbol] The property's name 43 | # @return The value of the property 44 | def [](key) 45 | session[key] 46 | end 47 | 48 | # @param key [Symbol] The property's name 49 | # @param value The value to set 50 | def []=(key, value) 51 | session[key] = value 52 | end 53 | 54 | def inspect 55 | "#<#{self.class} '#{name}'>" 56 | end 57 | 58 | # Move this entity to its parent entity. 59 | # 60 | # @example 61 | # room = Gamefic::Entity.new(name: 'room') 62 | # person = Gamefic::Entity.new(name: 'person', parent: room) 63 | # thing = Gamefic::Entity.new(name: 'thing', parent: person) 64 | # 65 | # thing.parent #=> person 66 | # thing.leave 67 | # thing.parent #=> room 68 | # 69 | # @return [void] 70 | def leave 71 | self.parent = parent&.parent 72 | end 73 | 74 | # Tell a message to all of this entity's accessible descendants. 75 | # 76 | # @param message [String] 77 | # @return [void] 78 | def broadcast(message) 79 | Query::Descendants.new 80 | .select(self) 81 | .that_are(Active, proc(&:participating?)) 82 | .each { |actor| actor.tell message } 83 | end 84 | 85 | class << self 86 | # Set or update the default attributes for new instances. 87 | # 88 | def set_default **attrs 89 | default_attributes.merge! attrs 90 | end 91 | 92 | # A hash of default attributes when creating an instance. 93 | # 94 | # @return [Hash] 95 | def default_attributes 96 | @default_attributes ||= {} 97 | end 98 | end 99 | end 100 | end 101 | -------------------------------------------------------------------------------- /spec/gamefic/node_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | describe Gamefic::Node do 4 | let(:klass) do 5 | Class.new do 6 | include Gamefic::Node 7 | end 8 | end 9 | 10 | it "adds a node to a parent" do 11 | x = Object.new 12 | x.extend Gamefic::Node 13 | y = Object.new 14 | y.extend Gamefic::Node 15 | y.parent = x 16 | expect(y.parent).to eq(x) 17 | expect(x.children.include? y).to eq(true) 18 | end 19 | 20 | it "removes a node from a parent" do 21 | x = Object.new 22 | x.extend Gamefic::Node 23 | y = Object.new 24 | y.extend Gamefic::Node 25 | y.parent = x 26 | y.parent = nil 27 | expect(y.parent).not_to eq(x) 28 | expect(x.children.include?(y)).to eq(false) 29 | end 30 | 31 | it "flattens a tree of children" do 32 | x = Object.new 33 | x.extend Gamefic::Node 34 | y = Object.new 35 | y.extend Gamefic::Node 36 | z = Object.new 37 | z.extend Gamefic::Node 38 | y.parent = x 39 | z.parent = y 40 | flat = x.flatten 41 | expect(flat).to eq([y, z]) 42 | end 43 | 44 | it "does not permit a node to be its own parent" do 45 | x = Object.new 46 | x.extend Gamefic::Node 47 | expect { 48 | x.parent = x 49 | }.to raise_error Gamefic::NodeError 50 | end 51 | 52 | it "does not permit circular references" do 53 | x = Object.new 54 | x.extend Gamefic::Node 55 | y = Object.new 56 | y.extend Gamefic::Node 57 | x.parent = y 58 | expect { 59 | y.parent = x 60 | }.to raise_error(Gamefic::NodeError) 61 | z = Object.new 62 | z.extend Gamefic::Node 63 | x.parent = y 64 | y.parent = z 65 | expect { 66 | z.parent = x 67 | }.to raise_error Gamefic::NodeError 68 | end 69 | 70 | it 'adds children with #take' do 71 | x = klass.new 72 | y = klass.new 73 | z = klass.new 74 | x.take y, z 75 | expect(x.children).to eq([y, z]) 76 | expect(y.parent).to be(x) 77 | expect(z.parent).to be(x) 78 | end 79 | 80 | it 'checks children with #include?' do 81 | x = klass.new 82 | y = klass.new 83 | y.parent = x 84 | expect(x).to include(y) 85 | end 86 | 87 | it 'checks siblings with #adjacent?' do 88 | top = klass.new 89 | x = klass.new 90 | y = klass.new 91 | x.parent = top 92 | y.parent = top 93 | expect(x).to be_adjacent(y) 94 | end 95 | 96 | it 'has a default :in relation' do 97 | x = klass.new 98 | y = klass.new 99 | x.parent = y 100 | expect(x.relation).to eq(:in) 101 | end 102 | 103 | it 'sets a relation' do 104 | x = klass.new 105 | y = klass.new 106 | x.place y, :on 107 | expect(x.relation).to eq(:on) 108 | end 109 | 110 | it 'resets new relations' do 111 | x = klass.new 112 | y = klass.new 113 | z = klass.new 114 | x.place y, :on 115 | x.parent = z 116 | expect(x.relation).to eq(:in) 117 | end 118 | 119 | it 'has nil relation with nil parent' do 120 | x = klass.new 121 | expect(x.relation).to be_nil 122 | end 123 | 124 | it 'raises on relation without parent' do 125 | x = klass.new 126 | expect { x.relation = :in }.to raise_error(Gamefic::NodeError) 127 | end 128 | 129 | it 'allows nil relation without parent' do 130 | x = klass.new 131 | expect { x.relation = nil }.not_to raise_error 132 | end 133 | end 134 | -------------------------------------------------------------------------------- /spec/gamefic/scriptable/queries_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | describe Gamefic::Scriptable::Queries do 4 | let(:object) do 5 | klass = Class.new do 6 | include Gamefic::Scriptable::Queries 7 | 8 | def entities 9 | @entities ||= [] 10 | end 11 | end 12 | 13 | klass.new.tap do |obj| 14 | obj.entities.push Gamefic::Entity.new(name: 'parent entity') 15 | obj.entities.push Gamefic::Entity.new(name: 'entity one', parent: obj.entities.first) 16 | obj.entities.push Gamefic::Entity.new(name: 'entity two', parent: obj.entities.first) 17 | obj.entities.push Gamefic::Entity.new(name: 'grandchild', parent: obj.entities.first.children.first) 18 | end 19 | end 20 | 21 | describe '#anywhere' do 22 | it 'returns a general query' do 23 | query = object.anywhere 24 | expect(query).to be_a(Gamefic::Query::Global) 25 | end 26 | end 27 | 28 | describe '#parent' do 29 | it 'finds a matching parent' do 30 | query = object.parent 31 | one = object.entities[1] 32 | result = query.filter(one, 'parent') 33 | expect(result.match).to be(object.entities.first) 34 | end 35 | 36 | it 'returns nil without a match' do 37 | query = object.parent 38 | one = object.entities[1] 39 | result = query.filter(one, 'wrong') 40 | expect(result.match).to be(nil) 41 | expect(result.remainder).to eq('wrong') 42 | end 43 | end 44 | 45 | describe '#children' do 46 | it 'finds a matching child' do 47 | query = object.children 48 | parent = object.entities.first 49 | result = query.filter(parent, 'one') 50 | expect(result.match).to be(object.entities[1]) 51 | end 52 | end 53 | 54 | describe '#descendants' do 55 | it 'finds matching descendants' do 56 | query = object.descendants 57 | parent = object.entities.first 58 | expect(query.span(parent).sort_by(&:name)).to eq(object.entities[1..].sort_by(&:name)) 59 | end 60 | end 61 | 62 | describe '#siblings' do 63 | it 'finds a matching sibling' do 64 | query = object.siblings 65 | one = object.entities[1] 66 | result = query.filter(one, 'two') 67 | expect(result.match).to be(object.entities[2]) 68 | end 69 | 70 | it 'does not match the subject' do 71 | query = object.siblings 72 | one = object.entities[1] 73 | result = query.filter(one, 'one') 74 | expect(result.match).to be_nil 75 | expect(result.remainder).to eq('one') 76 | end 77 | end 78 | 79 | describe '#extended' do 80 | it "finds matching siblings and siblings' descendants" do 81 | parent = object.entities.first 82 | subject, sibling = parent.children 83 | sibling_child = Gamefic::Entity.new(name: 'sibling child', parent: sibling) 84 | result = object.extended.span(subject) 85 | expect(result).to eq([sibling, sibling_child]) 86 | end 87 | end 88 | 89 | describe '#myself' do 90 | it 'matches itself' do 91 | query = object.myself 92 | one = object.entities[1] 93 | result = query.filter(one, 'one') 94 | expect(result.match).to be(one) 95 | end 96 | end 97 | 98 | describe '#plaintext' do 99 | it 'returns a text query' do 100 | query = object.plaintext 101 | expect(query).to be_a(Gamefic::Query::Text) 102 | end 103 | end 104 | end 105 | -------------------------------------------------------------------------------- /lib/gamefic/scene/base.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Gamefic 4 | module Scene 5 | # The base class for scenes. Authors can instantiate this class directly 6 | # and customize it with on_start and on_finish blocks. 7 | # 8 | class Base 9 | # @todo Code smell 10 | attr_writer :name 11 | 12 | attr_reader :actor, :narrative, :props, :context 13 | 14 | # @param actor [Actor] 15 | # @param narrative [Narrative, nil] 16 | # @param props [Props::Default, nil] 17 | def initialize(actor, narrative = nil, props = nil, **context) 18 | @actor = actor 19 | @narrative = narrative 20 | @props = props || self.class.props_class.new 21 | @context = context 22 | end 23 | 24 | def name 25 | @name ||= self.class.nickname 26 | end 27 | 28 | def rename(name) 29 | @name = name 30 | end 31 | 32 | # @return [String] 33 | def type 34 | self.class.type 35 | end 36 | 37 | # @return [Props::Default] 38 | def start 39 | run_start_blocks 40 | props 41 | end 42 | 43 | # @return [void] 44 | def finish 45 | run_finish_blocks 46 | end 47 | 48 | def to_hash 49 | { name: name, type: type } 50 | end 51 | 52 | def self.inherited(klass) 53 | super 54 | klass.use_props_class props_class 55 | klass.start_blocks.concat start_blocks 56 | klass.finish_blocks.concat finish_blocks 57 | end 58 | 59 | private 60 | 61 | def execute(block) 62 | Binding.new(narrative, block).call(actor, props, context) 63 | end 64 | 65 | def run_start_blocks 66 | self.class.start_blocks.each { |blk| execute(blk) } 67 | end 68 | 69 | def run_finish_blocks 70 | self.class.finish_blocks.each { |blk| execute(blk) } 71 | end 72 | 73 | class << self 74 | attr_reader :context, :nickname 75 | 76 | def type 77 | 'Base' 78 | end 79 | 80 | def props_class 81 | @props_class ||= Props::Default 82 | end 83 | 84 | def rename(nickname) 85 | @nickname = nickname 86 | end 87 | 88 | # @return [Array] 89 | def start_blocks 90 | @start_blocks ||= [] 91 | end 92 | 93 | # @return [Array] 94 | def finish_blocks 95 | @finish_blocks ||= [] 96 | end 97 | 98 | # @yieldparam actor [Actor] The scene's actor 99 | # @yieldparam props [Props::Default] The scene's props 100 | # @yieldparam context [Hash] Additional context 101 | # @yieldreceiver [Narrative] 102 | def on_start(&block) 103 | start_blocks.push block 104 | end 105 | 106 | # @yieldparam actor [Actor] The scene's actor 107 | # @yieldparam props [Props::Default] The scene's props 108 | # @yieldparam context [Hash] Additional context 109 | # @yieldreceiver [Narrative] 110 | def on_finish(&block) 111 | finish_blocks.push block 112 | end 113 | 114 | protected 115 | 116 | attr_writer :context 117 | 118 | # @param klass [Class] 119 | def use_props_class(klass) 120 | @props_class = klass 121 | end 122 | end 123 | end 124 | end 125 | end 126 | -------------------------------------------------------------------------------- /lib/gamefic/response.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'gamefic/scriptable' 4 | 5 | module Gamefic 6 | # A proc to be executed in response to a command that matches its verb and 7 | # queries. 8 | # 9 | class Response 10 | include Scriptable::Queries 11 | 12 | # @return [Symbol] 13 | attr_reader :verb 14 | 15 | # @return [Array] 16 | attr_reader :queries 17 | 18 | # @return [Proc] 19 | attr_reader :block 20 | 21 | # @param verb [Symbol] 22 | # @param queries [Array] 23 | # @param meta [Boolean] 24 | def initialize verb, *queries, meta: false, &block 25 | @verb = verb&.to_sym 26 | @meta = meta 27 | @block = block 28 | @queries = map_queries(queries) 29 | end 30 | 31 | # The `meta?` flag is just a way for authors to identify responses that 32 | # serve a purpose other than performing in-game actions. Out-of-game 33 | # responses can include features like displaying help documentation or 34 | # listing credits. 35 | # 36 | def meta? 37 | @meta 38 | end 39 | 40 | def syntax 41 | @syntax ||= generate_default_syntax 42 | end 43 | 44 | # True if the Response can be executed for the given actor and command. 45 | # 46 | # @param actor [Active] 47 | # @param command [Command] 48 | def accept?(actor, command) 49 | command.verb == verb && 50 | command.arguments.length == queries.length && 51 | queries.zip(command.arguments).all? { |query, argument| query.accept?(actor, argument) } 52 | end 53 | 54 | def execute *args 55 | Gamefic.logger.warn "Executing unbound response #{inspect}" unless bound? 56 | gamefic_binding.call(*args) 57 | end 58 | 59 | # The total precision of all the response's queries. 60 | # 61 | # @note Precision is decreased if the response has a nil verb. 62 | # 63 | # @return [Integer] 64 | def precision 65 | @precision ||= calculate_precision 66 | end 67 | 68 | def inspect 69 | "#<#{self.class} #{([verb] + queries).map(&:inspect).join(', ')}>" 70 | end 71 | 72 | def bound? 73 | !!gamefic_binding.narrative 74 | end 75 | 76 | def bind(narrative) 77 | clone.inject_binding narrative 78 | end 79 | 80 | protected 81 | 82 | def inject_binding(narrative) 83 | @queries = map_queries(narrative.unproxy(@queries)) 84 | @gamefic_binding = Binding.new(narrative, @block) 85 | self 86 | end 87 | 88 | private 89 | 90 | def gamefic_binding 91 | @gamefic_binding ||= Binding.new(nil, @block) 92 | end 93 | 94 | def generate_default_syntax 95 | args = queries.length.times.map { |num| num.zero? ? ':var' : ":var#{num + 1}" } 96 | tmpl = "#{verb} #{args.join(' ')}".strip 97 | Syntax.new(tmpl, tmpl) 98 | end 99 | 100 | # @return [Integer] 101 | def calculate_precision 102 | total = queries.sum(&:precision) 103 | total -= 1000 unless verb 104 | total 105 | end 106 | 107 | def map_queries(args) 108 | args.map { |arg| select_query(arg) } 109 | end 110 | 111 | def select_query(arg) 112 | case arg 113 | when Entity, Class, Module, Proc, Proxy::Base 114 | available(arg) 115 | when String, Regexp 116 | plaintext(arg) 117 | when Query::Base 118 | arg 119 | else 120 | raise ArgumentError, "invalid argument in response: #{arg.inspect}" 121 | end 122 | end 123 | end 124 | end 125 | -------------------------------------------------------------------------------- /spec/gamefic/narrative_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | describe Gamefic::Narrative do 4 | describe 'class' do 5 | it 'adds a seed' do 6 | blk = proc {} 7 | klass = Class.new(Gamefic::Narrative) do 8 | seed &blk 9 | end 10 | expect(klass.seeds).to eq([blk]) 11 | end 12 | 13 | it 'makes an entity' do 14 | klass = Class.new(Gamefic::Narrative) do 15 | make Gamefic::Entity, name: 'thing' 16 | end 17 | expect(klass.seeds).to be_one 18 | plot = klass.new 19 | expect(plot.entities).to be_one 20 | end 21 | 22 | it 'constructs an entity' do 23 | klass = Class.new(Gamefic::Narrative) do 24 | construct :thing, Gamefic::Entity, name: 'thing' 25 | end 26 | plot = klass.new 27 | expect(plot.thing).to be_a(Gamefic::Entity) 28 | end 29 | 30 | it 'picks an entity' do 31 | klass = Class.new(Gamefic::Narrative) do 32 | make Gamefic::Entity, name: 'room' 33 | make Gamefic::Entity, name: 'thing', parent: pick('room') 34 | end 35 | plot = klass.new 36 | thing = plot.pick('thing') 37 | room = plot.pick('room') 38 | expect(thing.parent).to be(room) 39 | end 40 | 41 | it 'raises pick! errors' do 42 | klass = Class.new(Gamefic::Narrative) do 43 | make Gamefic::Entity, name: 'thing', parent: pick!('not_a_thing') 44 | end 45 | expect { klass.new }.to raise_error(RuntimeError) 46 | end 47 | end 48 | 49 | describe 'instance' do 50 | describe '#initialize' do 51 | it 'adds scenes from scripts' do 52 | klass = Class.new(Gamefic::Narrative) do 53 | pause(:scene) {} 54 | end 55 | narr = klass.new 56 | expect(narr.named_scenes.keys).to eq(%i[scene]) 57 | end 58 | 59 | it 'adds actions from scripts' do 60 | klass = Class.new(Gamefic::Narrative) do 61 | respond(:think) { |actor| actor.tell 'You ponder your predicament.' } 62 | end 63 | narr = klass.new 64 | expect(narr.responses).to be_one 65 | end 66 | 67 | it 'adds entities from seeds' do 68 | blk = proc { make Gamefic::Entity, name: 'entity' } 69 | klass = Class.new(Gamefic::Narrative) do 70 | seed &blk 71 | end 72 | narr = klass.new 73 | expect(narr.entities).to be_one 74 | end 75 | 76 | it 'rejects scenes from seeds' do 77 | klass = Class.new(Gamefic::Narrative) do 78 | seed do 79 | pause(:scene) { |actor| actor.tell 'Pause' } 80 | end 81 | end 82 | expect { klass.new }.to raise_error(NoMethodError) 83 | end 84 | end 85 | 86 | describe '#introduce' do 87 | it 'runs introductions in order of inclusion' do 88 | klass = Class.new(Gamefic::Narrative) do 89 | introduction do |actor| 90 | actor.stream 'first...' 91 | end 92 | 93 | introduction do |actor| 94 | actor.stream 'second' 95 | end 96 | end 97 | 98 | plot = klass.new 99 | actor = plot.introduce 100 | expect(actor.messages).to eq('first...second') 101 | end 102 | end 103 | end 104 | 105 | it 'marshals' do 106 | narr = NarrativeWithFeatures.new 107 | 108 | plyr = Gamefic::Actor.new 109 | narr.cast plyr 110 | 111 | dump = Marshal.dump(narr) 112 | rest = Marshal.load(dump) 113 | expect(rest).to be_a(NarrativeWithFeatures) 114 | expect(rest.players.first.narratives.to_a).to eq([rest]) 115 | end 116 | end 117 | -------------------------------------------------------------------------------- /spec/gamefic/snapshots_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'date' 4 | 5 | class SnapshotTestPlot < Gamefic::Plot 6 | construct :room, Gamefic::Entity, 7 | name: 'room' 8 | 9 | construct :thing, Gamefic::Entity, 10 | name: 'thing', 11 | parent: room 12 | 13 | construct :unicode, Gamefic::Entity, 14 | name: 'ぇワ' 15 | 16 | seed do 17 | # Make sure various other objects can get serialized 18 | @object = Object.new 19 | @date_time = DateTime.new 20 | end 21 | 22 | multiple_choice :anon_scene do 23 | on_start do |_actor, props| 24 | props.options.push 'one', 'two' 25 | end 26 | end 27 | 28 | introduction do |actor| 29 | actor.parent = room 30 | branch Gamefic::Subplot, introduce: actor, configured: thing 31 | end 32 | 33 | respond :look, thing do |actor, thing| 34 | actor.tell "You see #{thing}" 35 | end 36 | 37 | respond :take, thing do |actor, thing| 38 | thing.parent = actor 39 | end 40 | end 41 | 42 | describe 'snapshots' do 43 | let(:plot) { SnapshotTestPlot.new } 44 | let(:player) { plot.introduce } 45 | let(:narrator) { Gamefic::Narrator.new(plot) } 46 | 47 | before :each do 48 | narrator.cast player 49 | narrator.start 50 | end 51 | 52 | context 'after the introduction' do 53 | let(:restored) { SnapshotTestPlot.restore plot.save } 54 | 55 | it 'restores players' do 56 | player = restored.players.first 57 | expect(player.narratives.to_set).to eq([restored, restored.subplots.first].to_set) 58 | end 59 | 60 | it 'restores subplots' do 61 | expect(restored.subplots).to be_one 62 | end 63 | 64 | it 'restores stage instance variables' do 65 | thing = restored.instance_variable_get(:@thing) 66 | expect(thing.name).to eq('thing') 67 | picked = restored.pick('thing') 68 | expect(thing).to be(picked) 69 | end 70 | 71 | it 'restores references in actions' do 72 | player = restored.players.first 73 | player.cue restored.default_scene 74 | player.perform 'look thing' 75 | expect(player.messages).to include('thing') 76 | end 77 | 78 | it 'restores subplot config data' do 79 | expect(restored.subplots.first.config[:configured]).to be(restored.instance_exec { @thing }) 80 | end 81 | 82 | it 'retains player configuration after save' do 83 | expect(plot.players).to be_one 84 | expect(plot.players.first.narratives.length).to eq(2) 85 | end 86 | end 87 | 88 | context 'after a game turn' do 89 | it 'restores output' do 90 | player.queue.push 'look thing' 91 | narrator.finish 92 | narrator.start 93 | 94 | snapshot = plot.save 95 | restored_plot = Gamefic::Narrative.restore snapshot 96 | restored_player = restored_plot.players.first 97 | expect(restored_player.output.to_hash).to eq(player.output.to_hash) 98 | expect(restored_player.output.to_hash).to eq(player.output.to_hash) 99 | end 100 | 101 | it 'restores entity changes' do 102 | player.queue.push 'take thing' 103 | narrator.finish 104 | narrator.start 105 | 106 | snapshot = plot.save 107 | restored_plot = Gamefic::Narrative.restore snapshot 108 | restored_player = restored_plot.players.first 109 | expect(restored_plot.pick('thing').parent).to be(restored_player) 110 | end 111 | 112 | it 'restores with anonymous scenes' do 113 | player.cue :anon_scene 114 | narrator.start 115 | 116 | snapshot = plot.save 117 | restored_plot = Gamefic::Narrative.restore(snapshot) 118 | expect(restored_plot.players.first.last_cue.key).to be(:anon_scene) 119 | end 120 | end 121 | end 122 | -------------------------------------------------------------------------------- /lib/gamefic/scriptable/queries.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Gamefic 4 | module Scriptable 5 | # Scriptable methods related to creating action queries. 6 | # 7 | module Queries 8 | # Define a query that searches all entities in the subject's epic. 9 | # 10 | # If the subject is not an actor, the result will always be empty. 11 | # 12 | # @param args [Array] Query arguments 13 | # @return [Query::Global] 14 | def global *args 15 | Query::Global.new(*args, name: 'global') 16 | end 17 | alias anywhere global 18 | 19 | # Define a query that searches an actor's family of entities. The 20 | # results include the parent, siblings, children, and accessible 21 | # descendants of siblings and children. 22 | # 23 | # @param args [Array] Query arguments 24 | # @return [Query::Family] 25 | def available *args 26 | Query::Family.new(*args, name: 'available') 27 | end 28 | alias family available 29 | alias avail available 30 | 31 | # Define a query that returns the actor's parent. 32 | # 33 | # @param args [Array] Query arguments 34 | # @return [Query::Parent] 35 | def parent *args 36 | Query::Parent.new(*args, name: 'parent') 37 | end 38 | 39 | # Define a query that searches an actor's children. 40 | # 41 | # @param args [Array] Query arguments 42 | # @return [Query::Children] 43 | def children *args 44 | Query::Children.new(*args, name: 'children') 45 | end 46 | 47 | # Define a query that searches an actor's descendants. 48 | # 49 | # @param args [Array] Query arguments 50 | # @return [Query::Descendants] 51 | def descendants *args 52 | Query::Descendants.new(*args) 53 | end 54 | 55 | # Define a query that searches an actor's siblings. 56 | # 57 | # @param args [Array] Query arguments 58 | # @return [Query::Siblings] 59 | def siblings *args 60 | Query::Siblings.new(*args, name: 'siblings') 61 | end 62 | 63 | # Define a query that searches an actor's siblings and their descendants. 64 | # 65 | # @param args [Array] Query arguments 66 | # @return [Query::Extended] 67 | def extended *args 68 | Query::Extended.new(*args, name: 'extended') 69 | end 70 | 71 | # Define a query that returns the actor itself. 72 | # 73 | # @param args [Array] Query arguments 74 | # @return [Query::Myself] 75 | def myself *args 76 | Query::Myself.new(*args, name: 'myself') 77 | end 78 | 79 | # Define a query that performs a plaintext search. It can take a String 80 | # or a RegExp as an argument. If no argument is provided, it will match 81 | # any text it finds in the command. A successful query returns the 82 | # corresponding text instead of an entity. 83 | # 84 | # @param arg [String, Regexp] The string or regular expression to match 85 | # @return [Query::Text] 86 | def plaintext(arg = /.*/) 87 | Query::Text.new arg, name: 'plaintext' 88 | end 89 | 90 | # Define a query that matches integers. Unlike other queries, #integer 91 | # does not take arguments. It will match and return an integer if the 92 | # corresponding command token is an integer or the corresponding input is 93 | # a string representation of an integer. A successful query returns the 94 | # integer instead of an entity. 95 | # 96 | # @return [Query::Integer] 97 | def integer 98 | Query::Integer.new name: 'integer' 99 | end 100 | end 101 | end 102 | end 103 | -------------------------------------------------------------------------------- /lib/gamefic/node.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'set' 4 | 5 | module Gamefic 6 | # Exception raised when setting a node's parent to an invalid object. 7 | # 8 | class NodeError < RuntimeError; end 9 | 10 | # Parent/child relationships for objects. 11 | # 12 | module Node 13 | # The object's parent. 14 | # 15 | # @return [Node, nil] 16 | attr_reader :parent 17 | 18 | # An array of the object's children. 19 | # 20 | # @return [Array] 21 | def children 22 | child_set.to_a.freeze 23 | end 24 | 25 | # Get a flat array of all descendants. 26 | # 27 | # @return [Array] 28 | def flatten 29 | children.flat_map { |child| [child] + child.flatten } 30 | end 31 | 32 | # Set the object's parent. 33 | # 34 | # @param node [Node, nil] 35 | def parent=(node) 36 | return if node == parent 37 | 38 | validate_parent node 39 | 40 | parent&.rem_child self 41 | @parent = node 42 | @relation = nil 43 | parent&.add_child self 44 | end 45 | 46 | # The node's relation to its parent. 47 | # 48 | # The inherently supported relations are `:in` and `:on`, but authors are 49 | # free to define their own. 50 | # 51 | # @return [Symbol, nil] 52 | def relation 53 | @relation ||= (parent ? :in : nil) 54 | end 55 | 56 | # @param symbol [Symbol, nil] 57 | def relation=(symbol) 58 | raise NodeError, "Invalid relation #{symbol.inspect} on #{inspect} without parent" unless parent || !symbol 59 | 60 | @relation = symbol 61 | end 62 | 63 | # Add children to the node. Return all the node's children. 64 | # 65 | # @param children [Array>] 66 | # @param relation [Symbol, nil] 67 | # @return [Array] 68 | def take *children, relation: nil 69 | children.flatten.each { |child| child.put self, relation } 70 | children 71 | end 72 | 73 | def put(parent, relation = nil) 74 | self.parent = parent 75 | @relation = relation 76 | end 77 | alias place put 78 | 79 | # Get an array of children that are accessible to external entities. 80 | # 81 | # A child is considered accessible if external entities can interact with 82 | # it. For Example, an author can designate that the contents of a bowl are 83 | # accessible, while the contents of a locked safe are not. All of an 84 | # entity's children are accessible by default. Authors should override this 85 | # method if they need custom behavior. 86 | # 87 | # @return [Array] 88 | def accessible 89 | children 90 | end 91 | 92 | # True if this node is the other's parent. 93 | # 94 | # @param other [Node] 95 | def include?(other) 96 | other.parent == self 97 | end 98 | 99 | # True if this node and the other node have the same parent. 100 | # 101 | # @param other [Node] 102 | def adjacent?(other) 103 | other.parent == parent 104 | end 105 | 106 | protected 107 | 108 | def add_child(node) 109 | child_set.add node 110 | end 111 | 112 | def rem_child(node) 113 | child_set.delete node 114 | end 115 | 116 | private 117 | 118 | def child_set 119 | @child_set ||= Set.new 120 | end 121 | 122 | def validate_parent(node) 123 | raise NodeError, "Parent of #{inspect} must be a Node, received #{node.inspect}" unless node.is_a?(Node) || node.nil? 124 | raise NodeError, "#{inspect} cannot be its own parent" if node == self 125 | raise NodeError, "#{inspect} cannot be a child of descendant #{node.inspect}" if flatten.include?(node) 126 | end 127 | end 128 | end 129 | -------------------------------------------------------------------------------- /spec/gamefic/describable_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | describe Gamefic::Describable do 4 | let(:described) do 5 | Object.new.tap { |obj| obj.extend Gamefic::Describable } 6 | end 7 | 8 | it "determines indefinite article from name" do 9 | described.name = "a thing" 10 | expect(described.name).to eq("thing") 11 | expect(described.indefinite_article).to eq("a") 12 | described.name = "an object" 13 | expect(described.name).to eq("object") 14 | expect(described.indefinite_article).to eq("an") 15 | end 16 | 17 | it "automatically uses \"the\" for definite articles" do 18 | described.name = "a thing" 19 | expect(described.definite_article).to eq("the") 20 | described.name = "an object" 21 | expect(described.definite_article).to eq("the") 22 | end 23 | 24 | it "uses correct definite and indefinite articles" do 25 | described.name = "a thing" 26 | expect(described.definitely).to eq("the thing") 27 | expect(described.indefinitely).to eq("a thing") 28 | end 29 | 30 | it "tries to guess indefinite articles" do 31 | described.name = "thing" 32 | expect(described.indefinite_article).to eq("a") 33 | described.name = "object" 34 | expect(described.indefinite_article).to eq("an") 35 | end 36 | 37 | it "accepts custom articles" do 38 | definite = "the bunch of" 39 | indefinite = "a bunch of" 40 | name = "grapes" 41 | described.name = name 42 | described.definite_article = definite 43 | described.indefinite_article = indefinite 44 | expect(described.definitely).to eq("#{definite} #{name}") 45 | expect(described.indefinitely).to eq("#{indefinite} #{name}") 46 | end 47 | 48 | it "ignores articles for proper names" do 49 | described.name = "John Smith" 50 | described.proper_named = true 51 | expect(described.definitely).to eq("John Smith") 52 | expect(described.indefinitely).to eq("John Smith") 53 | described.name = "John Doe" 54 | expect(described.definitely).to eq("John Doe") 55 | expect(described.indefinitely).to eq("John Doe") 56 | end 57 | 58 | it "recognizes proper names starting with \"The\"" do 59 | described.proper_named = true 60 | described.name = "The Thing" 61 | expect(described.definitely).to eq("The Thing") 62 | expect(described.indefinitely).to eq("The Thing") 63 | described.name = "The Hulk" 64 | expect(described.definitely).to eq("The Hulk") 65 | expect(described.indefinitely).to eq("The Hulk") 66 | end 67 | 68 | it "recognizes proper names starting with \"the\"" do 69 | described.proper_named = true 70 | described.name = "the Thing" 71 | expect(described.definitely).to eq("the Thing") 72 | expect(described.indefinitely).to eq("the Thing") 73 | described.name = "the Hulk" 74 | expect(described.definitely).to eq("the Hulk") 75 | expect(described.indefinitely).to eq("the Hulk") 76 | end 77 | 78 | it "updates names with definite articles after proper naming" do 79 | described.name = "the Thing" 80 | described.proper_named = true 81 | expect(described.definitely).to eq("the Thing") 82 | end 83 | 84 | it "avoids extraneous spaces for blank articles" do 85 | described.name = "thing" 86 | described.definite_article = "" 87 | described.indefinite_article = "" 88 | expect(described.definitely).to eq("thing") 89 | expect(described.indefinitely).to eq("thing") 90 | end 91 | 92 | it 'tracks descriptions' do 93 | described.name = 'thing' 94 | expect(described).not_to be_has_description 95 | text = 'a described thing' 96 | described.description = text 97 | expect(described).to be_has_description 98 | expect(described.description).to eq(text) 99 | end 100 | 101 | it 'has a default description' do 102 | described.name = 'thing' 103 | expect(described.description).to eq(Gamefic::Describable.default_description % {name: 'the thing', Name: 'The thing'}) 104 | end 105 | end 106 | -------------------------------------------------------------------------------- /lib/gamefic/query/base.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Gamefic 4 | module Query 5 | # A base class for entity-based queries that can be applied to responses. 6 | # Each query matches a command token to an object that can be passed into 7 | # a response callback. 8 | # 9 | # Most queries return entities, but there are also queries for plain text 10 | # and integers. 11 | # 12 | class Base 13 | # @return [Array] 14 | attr_reader :arguments 15 | 16 | # @raise [ArgumentError] if any of the arguments are nil 17 | # 18 | # @param arguments [Array] 19 | # @param name [String] 20 | def initialize *arguments, name: self.class.to_s 21 | raise ArgumentError, "nil argument in query" if arguments.any?(&:nil?) 22 | 23 | @arguments = arguments 24 | @name = name 25 | end 26 | 27 | # Get a query result for a given subject and token. 28 | # 29 | # @param subject [Gamefic::Entity] 30 | # @param token [String] 31 | # @return [Result] 32 | def filter(subject, token) 33 | scan = Scanner.scan(select(subject), token) 34 | return Result.new(nil, scan.token) unless scan.matched.one? 35 | 36 | Result.new(scan.matched.first, scan.remainder, scan.strictness) 37 | end 38 | 39 | # Get an array of entities that match the arguments from the context of 40 | # the subject. 41 | # 42 | # @param subject [Entity] 43 | # @return [Array] 44 | def select(subject) 45 | span(subject).that_are(*arguments) 46 | end 47 | 48 | # Get an array of entities that are candidates for selection from the 49 | # context of the subject. These are the entities that #select will 50 | # filter through query's arguments. 51 | # 52 | # Subclasses should override this method. 53 | # 54 | # @param subject [Entity] 55 | # @return [Array] 56 | def span(_subject) 57 | [] 58 | end 59 | 60 | # True if the object is selectable by the subject. 61 | # 62 | # @param subject [Entity] 63 | # @param object [Entity] 64 | # @return [Boolean] 65 | def accept?(subject, object) 66 | select(subject).include?(object) 67 | end 68 | 69 | # The query's precision. The higher the number, the more specific the 70 | # query is. 71 | # 72 | # In general terms, a query's precision is highest if its arguments 73 | # select for a specific instance of an entity instead of a class of 74 | # entity. 75 | # 76 | # When a command gets parsed, the resulting list of available actions 77 | # gets sorted in descending order of their responses' overall precision, 78 | # so the action with the highest precision gets attempted first. 79 | # 80 | # @return [::Integer] 81 | def precision 82 | @precision ||= calculate_precision 83 | end 84 | 85 | def name 86 | @name || self.class.to_s 87 | end 88 | 89 | def inspect 90 | "#{name}(#{arguments.map(&:inspect).join(', ')})" 91 | end 92 | 93 | def bind(narrative) 94 | clone.tap do |query| 95 | query.instance_exec do 96 | @arguments = narrative.unproxy(@arguments) 97 | end 98 | end 99 | end 100 | 101 | def self.plain 102 | @plain ||= new 103 | end 104 | 105 | def self.span(subject) 106 | plain.span(subject) 107 | end 108 | 109 | private 110 | 111 | def calculate_precision 112 | arguments.sum(0) do |arg| 113 | case arg 114 | when Entity, Proxy::Base 115 | 1000 116 | when Class, Module 117 | class_depth(arg) * 100 118 | else 119 | 1 120 | end 121 | end 122 | end 123 | 124 | def class_depth(klass) 125 | return 1 unless klass.is_a?(Class) 126 | 127 | depth = 1 128 | sup = klass 129 | depth += 1 while (sup = sup.superclass) 130 | depth 131 | end 132 | end 133 | end 134 | end 135 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 4.2.0 - November 27, 2025 2 | - Syntaxes support variable (piped) words 3 | - Props::MultipleChoice#index_of 4 | 5 | ## 4.1.2 - May 25, 2025 6 | - Seed unreferenced entities 7 | - Ensure seed uniqueness 8 | - Add base64 dependency 9 | 10 | ## 4.1.1 - March 2, 2025 11 | - Warn for duplicate chapters 12 | - YARD documentation 13 | 14 | ## 4.1.0 - February 1, 2025 15 | - Correct Scenes#pause block 16 | 17 | ## 4.0.1 - January 26, 2025 18 | - Cue#prepare flushes messages 19 | 20 | ## 4.0.0 - January 25, 2025 21 | - Nuanced scans 22 | - Command hooks 23 | - Refactored queries 24 | - Cue scenes by class or name 25 | - Deprecate underscore verbs 26 | - Move rules to Scriptable 27 | - JIT Scriptable binding 28 | - Sunset Rulebook 29 | - Separate Scriptable and Scripting modules 30 | - ActiveChoice scenes 31 | - Narrator class runs narratives 32 | - Use construct for static entities 33 | - Parent relations 34 | - Consolidate Syntax and Template 35 | - Remove Snapshot module 36 | - Track command activity 37 | - MultiplePartial props 38 | 39 | ## 3.6.0 - October 6, 2024 40 | - Normalized arguments accept strings 41 | - Smarter picks and proxies 42 | - Commands prefer strictness over precision 43 | - Queries scan for ambiguity before filtering through arguments 44 | - Abstract queries 45 | - Command logging 46 | 47 | ## 3.5.0 - October 5, 2024 48 | - Configurable scanners 49 | - Refactored scanners and queries 50 | - Allow assignment to nil instance variables in stage 51 | - Lazy proxies 52 | - Remove buggy index proxies 53 | - Chapter inherits Narrative 54 | - Plot attribute proxy for chapters and subplots 55 | - Entity#leave 56 | - Descendants query 57 | - Persistent subplots 58 | - Entity#broadcast 59 | - Ascendants in family query 60 | 61 | ## 3.4.0 - September 10, 2024 62 | - Chapters 63 | - Subplots and chapters do not repeat plot scripts 64 | - Scriptable.no_scripts is deprecated 65 | - Refactoring/removing unused methods 66 | 67 | ## 3.3.0 - September 1, 2024 68 | - Node#take 69 | - Node#include? 70 | - Reject non-string tokens in Text queries 71 | 72 | ## 3.2.2 - July 21, 2024 73 | - Describable#described? 74 | 75 | ## 3.2.1 - July 1, 2024 76 | - MultipleChoice accepts shortened text 77 | - Return Proxy::Agent from attr_seed and make_seed 78 | - MultipleChoice skips finish blocks for invalid input 79 | 80 | ## 3.2.0 - April 9, 2024 81 | - Bug fix for marshal of structs in Opal 82 | - Add last_input and last_prompt at start of take 83 | 84 | ## 3.1.0 - April 8, 2024 85 | - Dispatcher prioritizes strict token matches 86 | - Scanner builds commands 87 | - Tokenize expressions and execute commands 88 | - Delete concluded subplots last in Plot#ready 89 | - Fix plot conclusion check after subplots conclude 90 | - Correct contexts for conclude and output blocks 91 | - Reinstate Active#last_input 92 | 93 | ## 3.0.0 - January 27, 2024 94 | - Instantiate subplots from snapshots 95 | - Split Action into Response and Action 96 | - Logging 97 | - Remove deprecated Active#perform behavior 98 | - Snapshots use single static index 99 | - Hydration improvements 100 | - Snapshot metadata validation 101 | 102 | ## 2.4.0 - February 11, 2023 103 | - Fix arity of delegated methods in scripts 104 | - Action hooks accept verb filters 105 | - Opal exception for delegated methods 106 | - Support separation of kwargs in Ruby >= 2.7 107 | 108 | ## 2.3.0 - January 25, 2023 109 | - Remove unused Active#actions method 110 | - Add before_action and after_action hooks 111 | 112 | ## 2.2.3 - July 9, 2022 113 | - Fix Ruby version incompatibilities 114 | - Fix private attr_accessor call 115 | 116 | ## 2.2.2 - September 6, 2021 117 | - Darkroom indexes non-static elements 118 | 119 | ## 2.2.1 - September 5, 2021 120 | - Retain unknown values in restored snapshots 121 | 122 | ## 2.2.0 - September 4, 2021 123 | - Dynamically inherit default attributes 124 | 125 | ## 2.1.1 - July 23, 2021 126 | - Remove gamefic/scene/custom autoload 127 | 128 | ## 2.1.0 - June 21, 2021 129 | - Remove redundant MultipleChoice prompt 130 | - Deprecate Scene::Custom 131 | 132 | ## 2.0.3 - December 14, 2020 133 | - Remove unused Index class 134 | - Active#conclude accepts data argument 135 | 136 | ## 2.0.2 - April 25, 2020 137 | - Improved snapshot serialization 138 | -------------------------------------------------------------------------------- /spec/gamefic/scriptable_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | describe Gamefic::Scriptable do 4 | let(:scriptable) { Module.new.extend(Gamefic::Scriptable) } 5 | 6 | it 'includes blocks' do 7 | scriptable.instance_exec do 8 | pause :extended_pause 9 | end 10 | klass = Class.new(Gamefic::Narrative) 11 | klass.include scriptable 12 | narr = klass.new 13 | expect(narr.named_scenes).to include(:extended_pause) 14 | end 15 | 16 | it 'includes scriptable modules once' do 17 | # This test is necessary because Opal can duplicate included modules 18 | scriptable.instance_exec { @foo = Object.new } 19 | other = Module.new.extend(Gamefic::Scriptable) 20 | other.include scriptable 21 | klass = Class.new(Gamefic::Narrative) 22 | klass.include scriptable 23 | klass.include other 24 | expect(klass.included_scripts).to eq(klass.included_scripts.uniq) 25 | end 26 | 27 | it 'scripts introductions' do 28 | klass = Class.new(Gamefic::Plot) do 29 | introduction do |actor| 30 | actor[:introduced] = true 31 | end 32 | end 33 | 34 | plot = klass.new 35 | player = plot.introduce 36 | expect(player[:introduced]).to be(true) 37 | end 38 | 39 | it 'scripts responses' do 40 | klass = Class.new(Gamefic::Plot) do 41 | attr_make :room, Gamefic::Entity, 42 | name: 'room' 43 | 44 | attr_make :item, Gamefic::Entity, 45 | name: 'item', 46 | parent: room 47 | 48 | introduction do |actor| 49 | actor.parent = room 50 | end 51 | 52 | respond(:take, Gamefic::Entity) do |actor, entity| 53 | entity.parent = actor 54 | end 55 | end 56 | 57 | plot = klass.new 58 | player = plot.introduce 59 | player.perform 'take item' 60 | expect(player.children).to include(plot.item) 61 | end 62 | 63 | it 'scripts meta responses' do 64 | klass = Class.new(Gamefic::Plot) do 65 | meta(:execute) { |actor| actor[:executed] = true } 66 | end 67 | 68 | plot = klass.new 69 | player = plot.introduce 70 | player.perform 'execute' 71 | expect(player[:executed]).to be(true) 72 | end 73 | 74 | it 'scripts on_ready' do 75 | executed = false 76 | klass = Class.new(Gamefic::Plot) do 77 | on_ready {} 78 | end 79 | 80 | plot = klass.new 81 | expect(plot.ready_blocks).to be_one 82 | end 83 | 84 | it 'scripts on_player_ready' do 85 | klass = Class.new(Gamefic::Plot) do 86 | on_player_ready {} 87 | end 88 | 89 | plot = klass.new 90 | expect(plot.ready_blocks).to be_one 91 | end 92 | 93 | it 'scripts on_update' do 94 | executed = false 95 | klass = Class.new(Gamefic::Plot) do 96 | on_update {} 97 | end 98 | 99 | plot = klass.new 100 | expect(plot.update_blocks).to be_one 101 | end 102 | 103 | it 'scripts on_player_update' do 104 | klass = Class.new(Gamefic::Plot) do 105 | on_player_update {} 106 | end 107 | 108 | plot = klass.new 109 | expect(plot.update_blocks).to be_one 110 | end 111 | 112 | it 'scripts before_command' do 113 | klass = Class.new(Gamefic::Plot) do 114 | respond(:foo) { |_| nil } 115 | before_command { |actor| actor[:executed] = true } 116 | end 117 | 118 | plot = klass.new 119 | player = plot.introduce 120 | player.perform 'foo' 121 | expect(player[:executed]).to be(true) 122 | end 123 | 124 | it 'scripts after_command' do 125 | klass = Class.new(Gamefic::Plot) do 126 | attr_make :foo, Gamefic::Entity, name: 'foo' 127 | 128 | respond(:foo) { |_| nil } 129 | after_command { foo.name = 'bar' } 130 | end 131 | 132 | plot = klass.new 133 | player = plot.introduce 134 | player.perform 'foo' 135 | expect(plot.foo.name).to eq('bar') 136 | end 137 | 138 | it 'scripts conclusion' do 139 | klass = Class.new(Gamefic::Plot) do 140 | conclusion(:ending) {} 141 | end 142 | 143 | plot = klass.new 144 | # Three scenes including default_scene and default_conclusion 145 | expect(plot.scenes.length).to eq(3) 146 | end 147 | 148 | it 'scripts attribute seeds' do 149 | klass = Class.new(Gamefic::Plot) do 150 | construct :foo, Gamefic::Entity, name: 'foo' 151 | end 152 | 153 | plot = klass.new 154 | expect(plot.foo.name).to eq('foo') 155 | end 156 | end 157 | -------------------------------------------------------------------------------- /spec/gamefic/active_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | describe Gamefic::Active do 4 | let(:object) { Gamefic::Entity.new.tap { |obj| obj.extend Gamefic::Active } } 5 | 6 | it 'performs a command' do 7 | klass = Class.new(Gamefic::Narrative) do 8 | respond(:command) { |actor| actor[:executed] = true } 9 | end 10 | narrative = klass.new 11 | narrative.cast object 12 | object.perform 'command' 13 | expect(object[:executed]).to be(true) 14 | end 15 | 16 | it 'executes a command' do 17 | klass = Class.new(Gamefic::Narrative) do 18 | construct :room, Gamefic::Entity, name: 'room' 19 | construct :item, Gamefic::Entity, name: 'item', parent: room 20 | respond(:command, item) { item[:commanded] = true } 21 | end 22 | narrative = klass.new 23 | narrative.cast object 24 | object.parent = narrative.pick('room') 25 | item = narrative.pick('item') 26 | object.execute :command, item 27 | expect(item[:commanded]).to be(true) 28 | end 29 | 30 | it "formats #tell messages into HTML paragraphs" do 31 | object.tell "This is one paragraph." 32 | expect(object.messages).to eq("

This is one paragraph.

") 33 | end 34 | 35 | it "splits #tell messages into multiple paragraphs" do 36 | object.tell "This is paragraph 1.\n\nThis is paragraph 2.\r\n\r\nThis is paragraph 3." 37 | expect(object.messages).to eq("

This is paragraph 1.

This is paragraph 2.

This is paragraph 3.

") 38 | end 39 | 40 | it 'performs actions quietly' do 41 | klass = Class.new(Gamefic::Narrative) do 42 | respond(:command) { |actor| actor.tell 'Keep this quiet' } 43 | end 44 | narr = klass.new 45 | narr.cast object 46 | buffer = object.quietly 'command' 47 | expect(buffer).to include('Keep this quiet') 48 | expect(object.messages).to be_empty 49 | end 50 | 51 | it 'streams a message' do 52 | message = '

unprocessed text'.freeze 53 | object.stream message 54 | expect(object.messages).to eq(message) 55 | end 56 | 57 | it 'cues a scene' do 58 | klass = Class.new(Gamefic::Narrative) do 59 | block :scene 60 | end 61 | narr = klass.new 62 | narr.cast object 63 | expect { object.cue :scene }.not_to raise_error 64 | end 65 | 66 | it 'cues a scene by class' do 67 | scene_klass = Class.new(Gamefic::Scene::Base) 68 | plot_klass = Class.new(Gamefic::Plot) 69 | plot_klass.instance_exec { scene scene_klass, :scene } 70 | plot = plot_klass.new 71 | plot.cast object 72 | expect { object.cue scene_klass }.not_to raise_error 73 | end 74 | 75 | it 'is not concluding by default' do 76 | narr = Gamefic::Narrative.new 77 | narr.cast object 78 | expect(object).not_to be_concluding 79 | end 80 | 81 | describe '#proceed' do 82 | it 'performs the next action in the current dispatcher' do 83 | klass = Class.new(Gamefic::Plot) do 84 | respond(:command) { |actor| actor[:command] = 'first' } 85 | respond(:command) { |actor| actor.proceed } 86 | end 87 | plot = klass.new 88 | plot.cast object 89 | object.perform 'command' 90 | expect(object[:command]).to eq('first') 91 | end 92 | 93 | it 'does nothing without an available action in dispatchers' do 94 | expect(object.proceed).to be_nil 95 | end 96 | end 97 | 98 | describe '#output' do 99 | it 'is frozen' do 100 | expect(object.output).to be_frozen 101 | end 102 | end 103 | 104 | describe '#acting?' do 105 | let(:klass) do 106 | Class.new(Gamefic::Narrative) do 107 | respond(:command) { |actor| actor[:executed] = 'command' } 108 | meta(:meta) { |actor| actor[:executed] = 'meta' } 109 | end 110 | end 111 | 112 | let(:narrative) { klass.new } 113 | 114 | before(:each) { narrative.cast object } 115 | 116 | it 'starts false' do 117 | expect(object).not_to be_acting 118 | end 119 | 120 | it 'turns true after performing a command' do 121 | object.perform 'command' 122 | expect(object).to be_acting 123 | end 124 | 125 | it 'stays false after performing a meta command' do 126 | object.perform 'meta' 127 | expect(object).not_to be_acting 128 | end 129 | 130 | it 'turns false after rotating the cue' do 131 | object.perform 'command' 132 | object.rotate_cue 133 | expect(object).not_to be_acting 134 | end 135 | end 136 | end 137 | -------------------------------------------------------------------------------- /spec/gamefic/subplot_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | describe Gamefic::Subplot do 4 | it "destroys its elements upon conclusion" do 5 | plot = Gamefic::Plot.new 6 | subplot = Gamefic::Subplot.new(plot) 7 | entity = subplot.make(Gamefic::Entity, name: 'entity') 8 | expect(subplot.entities.include? entity).to be(true) 9 | subplot.conclude 10 | expect(subplot.entities.include? entity).to be(false) 11 | end 12 | 13 | it "adds its rulebook to casted characters" do 14 | plot = Gamefic::Plot.new 15 | subplot = Gamefic::Subplot.new(plot) 16 | actor = subplot.cast Gamefic::Actor.new 17 | expect(actor.narratives).to include(subplot) 18 | end 19 | 20 | it "adds its rulebook to introduced characters" do 21 | plot = Gamefic::Plot.new 22 | subplot = Gamefic::Subplot.new(plot) 23 | actor = plot.introduce 24 | subplot.introduce actor 25 | expect(actor.narratives.length).to eq(2) 26 | expect(actor.narratives).to include(subplot) 27 | end 28 | 29 | it "removes its rulebook from exited characters" do 30 | plot = Gamefic::Plot.new 31 | subplot = Gamefic::Subplot.new(plot) 32 | actor = plot.cast Gamefic::Actor.new 33 | subplot.introduce actor 34 | subplot.uncast actor 35 | expect(actor.narratives.length).to eq(1) 36 | expect(actor.narratives).not_to include(subplot) 37 | end 38 | 39 | it 'adds entities to the host plot' do 40 | plot = Gamefic::Plot.new 41 | subplot = Gamefic::Subplot.new(plot) 42 | subplot.instance_exec do 43 | plot.make Gamefic::Entity, name: 'thing' 44 | end 45 | expect(subplot.entities).to be_empty 46 | expect(plot.entities).to be_one 47 | end 48 | 49 | it 'branches additional subplots' do 50 | plot = Gamefic::Plot.new 51 | subplot1 = plot.branch(Gamefic::Subplot) 52 | subplot2 = subplot1.branch(Gamefic::Subplot) 53 | expect(plot.subplots).to eq([subplot1, subplot2]) 54 | end 55 | 56 | it 'runs player conclude blocks' do 57 | klass = Class.new(Gamefic::Subplot) do 58 | on_player_conclude do |player| 59 | player[:concluded] = true 60 | end 61 | end 62 | plot = Gamefic::Plot.new 63 | player = plot.introduce 64 | subplot = plot.branch klass, introduce: player 65 | subplot.conclude 66 | expect(player[:concluded]).to be(true) 67 | end 68 | 69 | # @todo This test might not be necessary anymore. Repeating scripts will be 70 | # less of a concern going forward, especially since seeds no longer exist 71 | # in scriptable modules. 72 | it 'does not repeat scripts included in the plot' do 73 | scriptable = Module.new do 74 | extend Gamefic::Scriptable 75 | respond(:foo) {} 76 | end 77 | 78 | plot_klass = Class.new(Gamefic::Plot) do 79 | include scriptable 80 | end 81 | 82 | subplot_klass = Class.new(Gamefic::Subplot) do 83 | include scriptable 84 | end 85 | 86 | plot = plot_klass.new 87 | subplot = plot.branch(subplot_klass) 88 | expect(subplot.class.responses).to be_empty 89 | end 90 | 91 | it 'is not usually persistent' do 92 | plot = Gamefic::Plot.new 93 | subplot = plot.branch Gamefic::Subplot 94 | expect(subplot).not_to be_persistent 95 | expect(subplot).to be_concluding 96 | end 97 | 98 | it 'can be persistent' do 99 | klass = Class.new(Gamefic::Subplot) do 100 | persist! 101 | end 102 | 103 | plot = Gamefic::Plot.new 104 | subplot = plot.branch klass 105 | expect(subplot).to be_persistent 106 | expect(subplot).not_to be_concluding 107 | subplot.conclude 108 | expect(subplot).to be_concluding 109 | end 110 | 111 | it 'proxies config' do 112 | executed = false 113 | 114 | klass = Class.new(Gamefic::Subplot) do 115 | def configure 116 | config[:thing] = make Gamefic::Entity, name: 'thing' 117 | end 118 | 119 | respond(:execute, anywhere(config[:thing])) { executed = true } 120 | end 121 | 122 | plot = Gamefic::Plot.new 123 | player = plot.introduce 124 | plot.branch klass, introduce: player 125 | player.perform 'execute thing' 126 | expect(executed).to be(true) 127 | end 128 | 129 | it 'prepares scenes' do 130 | plot = Gamefic::Plot.new 131 | player = plot.introduce 132 | subplot = plot.branch(Gamefic::Subplot, introduce: player) 133 | scene = subplot.prepare(Gamefic::Scene::Activity, player, Gamefic::Props::Default.new) 134 | expect(scene).to be_a(Gamefic::Scene::Activity) 135 | end 136 | end 137 | -------------------------------------------------------------------------------- /spec/gamefic/plot_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | unless RUBY_ENGINE == 'opal' 4 | require_relative '../fixtures/modular/modular_test_script' 5 | require_relative '../fixtures/modular/modular_test_plot' 6 | end 7 | 8 | RSpec.describe Gamefic::Plot do 9 | it 'creates responses' do 10 | klass = Class.new(Gamefic::Plot) do 11 | respond :command do |_actor| 12 | nil 13 | end 14 | end 15 | plot = klass.new 16 | expect(plot.responses).to be_one 17 | end 18 | 19 | it 'cues the introduction' do 20 | klass = Class.new(Gamefic::Plot) do 21 | introduction do |actor| 22 | actor.tell 'Hello, world!' 23 | end 24 | end 25 | plot = klass.new 26 | player = plot.introduce 27 | expect(player.messages).to include('Hello, world!') 28 | end 29 | 30 | it 'tracks player subplots' do 31 | plot = Gamefic::Plot.new 32 | player = plot.introduce 33 | subplot = plot.branch Gamefic::Subplot, introduce: player 34 | expect(player.narratives.to_a).to eq([plot, subplot]) 35 | end 36 | 37 | it 'deletes concluded subplots on turns' do 38 | plot = Gamefic::Plot.new 39 | subplot = plot.branch Gamefic::Subplot 40 | expect(plot.subplots).to include(subplot) 41 | subplot.conclude 42 | plot.turn 43 | expect(plot.subplots).to be_empty 44 | end 45 | 46 | it 'supports multiple players' do 47 | plot = Gamefic::Plot.new 48 | player1 = plot.introduce 49 | player2 = plot.introduce 50 | expect(plot.players).to eq([player1, player2]) 51 | end 52 | 53 | it 'uncasts players from plot and subplots' do 54 | plot = Gamefic::Plot.new 55 | player = plot.introduce 56 | plot.branch Gamefic::Subplot, introduce: player 57 | plot.uncast player 58 | 59 | expect(plot.players).to be_empty 60 | expect(plot.subplots.first.players).to be_empty 61 | end 62 | 63 | it 'appends responses from chapters' do 64 | chapter_klass = Class.new(Gamefic::Chapter) do 65 | respond(:chapter) {} 66 | end 67 | 68 | plot_klass = Class.new(Gamefic::Plot) do 69 | append chapter_klass 70 | end 71 | 72 | plot = plot_klass.new 73 | expect(plot.responses_for(:chapter)).to be_one 74 | end 75 | 76 | it 'appends default syntaxes from chapters' do 77 | chapter_klass = Class.new(Gamefic::Chapter) do 78 | respond(:chapter) {} 79 | end 80 | 81 | plot_klass = Class.new(Gamefic::Plot) do 82 | append chapter_klass 83 | 84 | respond(:other) {} 85 | end 86 | 87 | plot = plot_klass.new 88 | expect(plot.syntaxes.map(&:synonym)).to include(:chapter) 89 | end 90 | 91 | it 'executes responses from chapters' do 92 | chapter_klass = Class.new(Gamefic::Chapter) do 93 | respond(:chapter) { |actor| actor[:executed] = true } 94 | end 95 | 96 | plot_klass = Class.new(Gamefic::Plot) do 97 | append chapter_klass 98 | end 99 | 100 | plot = plot_klass.new 101 | actor = plot.introduce 102 | actor.perform 'chapter' 103 | expect(actor[:executed]).to be(true) 104 | end 105 | 106 | it 'binds ready blocks from chapters' do 107 | chapter_klass = Class.new(Gamefic::Chapter) do 108 | on_player_ready do |player| 109 | player[:executed] = true 110 | end 111 | end 112 | 113 | plot_klass = Class.new(Gamefic::Plot) do 114 | append chapter_klass 115 | end 116 | 117 | plot = plot_klass.new 118 | player = plot.introduce 119 | ready_blocks = plot.ready_blocks 120 | expect(ready_blocks).to be_one 121 | ready_blocks.each { |blk| blk[player] } 122 | expect(player[:executed]).to be(true) 123 | end 124 | 125 | it 'deletes concluded chapters on turns' do 126 | chapter_klass = Class.new(Gamefic::Chapter) do 127 | respond(:conclude, 'me') { conclude } 128 | end 129 | 130 | plot_klass = Class.new(Gamefic::Plot) do 131 | append chapter_klass 132 | end 133 | 134 | plot = plot_klass.new 135 | player = plot.introduce 136 | player.perform 'conclude me' 137 | plot.turn 138 | expect(plot.chapters).to be_empty 139 | end 140 | 141 | context 'with a scriptable module' do 142 | let(:plot) { ModularTestPlot.new } 143 | let(:player) { plot.introduce } 144 | 145 | it 'creates entities from the module' do 146 | expect(plot.place).to be_a(Gamefic::Entity) 147 | expect(plot.thing).to be_a(Gamefic::Entity) 148 | end 149 | 150 | it 'sets parents of module entities correctly' do 151 | expect(plot.thing.parent).to be(plot.place) 152 | expect(player.parent).to be(plot.place) 153 | end 154 | 155 | it 'responds to commands with module entity arguments' do 156 | player.perform 'use thing' 157 | expect(player[:used]).to be(plot.thing) 158 | end 159 | 160 | it 'seeds unreferenced entities' do 161 | expect(plot.instance_variable_get(:@unreferenced)).to be_a(Gamefic::Entity) 162 | end 163 | end 164 | end 165 | --------------------------------------------------------------------------------