├── .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
")
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 |
--------------------------------------------------------------------------------