demiurge 0.2.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +9 -0
- data/.travis.yml +5 -0
- data/.yardopts +5 -0
- data/AUTHORS.txt +6 -0
- data/CODE_OF_CONDUCT.md +74 -0
- data/CONCEPTS.md +271 -0
- data/Gemfile +4 -0
- data/HACKING.md +34 -0
- data/LICENSE.txt +21 -0
- data/README.md +181 -0
- data/RELOADING.md +94 -0
- data/Rakefile +10 -0
- data/SECURITY.md +103 -0
- data/WORLD_FILES.md +134 -0
- data/bin/console +14 -0
- data/bin/setup +8 -0
- data/demiurge.gemspec +31 -0
- data/exe/demirun +16 -0
- data/lib/demiurge/action_item.rb +643 -0
- data/lib/demiurge/agent.rb +338 -0
- data/lib/demiurge/container.rb +194 -0
- data/lib/demiurge/dsl.rb +583 -0
- data/lib/demiurge/exception.rb +170 -0
- data/lib/demiurge/inert_state_item.rb +21 -0
- data/lib/demiurge/intention.rb +164 -0
- data/lib/demiurge/location.rb +85 -0
- data/lib/demiurge/notification_names.rb +93 -0
- data/lib/demiurge/tmx.rb +439 -0
- data/lib/demiurge/util.rb +67 -0
- data/lib/demiurge/version.rb +4 -0
- data/lib/demiurge/zone.rb +108 -0
- data/lib/demiurge.rb +812 -0
- metadata +165 -0
data/lib/demiurge/dsl.rb
ADDED
@@ -0,0 +1,583 @@
|
|
1
|
+
require_relative "../demiurge"
|
2
|
+
|
3
|
+
class Demiurge::Engine
|
4
|
+
# This method loads new World File code into an existing engine. It
|
5
|
+
# should be passed a list of filenames, normally roughly the same
|
6
|
+
# list that was passed to Demiurge::DSL.engine_from_dsl_files to
|
7
|
+
# create the engine initially.
|
8
|
+
#
|
9
|
+
# See {#reload_from_dsl_text} for more details and allowed
|
10
|
+
# options. It is identical in operation except for taking code as
|
11
|
+
# parameters instead of filenames.
|
12
|
+
#
|
13
|
+
# @see file:RELOADING.md
|
14
|
+
# @see Demiurge::DSL.engine_from_dsl_files
|
15
|
+
# @see #reload_from_dsl_text
|
16
|
+
# @see #load_state_from_dump
|
17
|
+
# @param filenames [Array<String>] An array of filenames, suitable for calling File.read on
|
18
|
+
# @param options [Hash] An optional hash of options for modifying the behavior of the reload
|
19
|
+
# @option options [Boolean] verify_only Only see if the new engine code would load, don't replace any code.
|
20
|
+
# @option options [Boolean] guessing_okay Attempt to guess about renamed objects
|
21
|
+
# @option options [Boolean] no_addition Don't allow adding new StateItems, raise an error instead
|
22
|
+
# @option options [Boolean] no_addition Don't allow removing StateItems, raise an error instead
|
23
|
+
# @return [void] A configured Engine
|
24
|
+
# @since 0.0.1
|
25
|
+
def reload_from_dsl_files(*filenames, options: {})
|
26
|
+
filename_string_pairs = filenames.map { |fn| [fn, File.read(fn)] }
|
27
|
+
engine_from_dsl_text(*filename_string_pairs)
|
28
|
+
end
|
29
|
+
|
30
|
+
# This method loads new World File code into an existing engine. It
|
31
|
+
# should be passed a list of "specs", normally roughly the same list
|
32
|
+
# that was passed to Demiurge::DSL.engine_from_dsl_files to create
|
33
|
+
# the engine initially. A "spec" is either a single string of World
|
34
|
+
# File DSL code, or a two-element array of the form: ["label",
|
35
|
+
# "code"]. Each is a string. "Code" is World File DSL Ruby code,
|
36
|
+
# while "label" is the name that will be used in stack traces.
|
37
|
+
#
|
38
|
+
# Options:
|
39
|
+
#
|
40
|
+
# * verify_only - only check for errors, don't load the new code into the engine, aka "dry run mode"
|
41
|
+
# * guessing_okay - attempt to notice renames and modify old state to new state accordingly
|
42
|
+
# * no_addition - if any new StateItem would be created, raise a NonMatchingStateError
|
43
|
+
# * no_removal - if any StateItem would be removed, raise a NonMatchingStateError
|
44
|
+
#
|
45
|
+
# Note that if "guessing_okay" is turned on, certain cases where
|
46
|
+
# same-type or (in the future) similar-type items are added and
|
47
|
+
# removed may be "guessed" as renames rather than treated as
|
48
|
+
# addition or removal.
|
49
|
+
#
|
50
|
+
# Where the files and world objects are
|
51
|
+
# essentially the same, this should reload any code changes into the
|
52
|
+
# engine. Where they are different, the method will try to determine
|
53
|
+
# the best match between the new and old state, but may fail at
|
54
|
+
# doing so.
|
55
|
+
#
|
56
|
+
# Ordinarily, this method should be called on a fully-configured,
|
57
|
+
# fully-initialized engine with its full state loaded, in between
|
58
|
+
# ticks.
|
59
|
+
#
|
60
|
+
# When in doubt, it's better to save state and reload the engine
|
61
|
+
# from nothing. This gives far better opportunities for a human to
|
62
|
+
# determine what changes have occurred and manually fix any errors.
|
63
|
+
# A game's current state is a complex thing and a computer simply
|
64
|
+
# cannot correctly determine all possible changes to it in a useful
|
65
|
+
# way.
|
66
|
+
#
|
67
|
+
# @see file:RELOADING.md
|
68
|
+
# @see Demiurge::DSL.engine_from_dsl_files
|
69
|
+
# @see Demiurge::DSL.engine_from_dsl_text
|
70
|
+
# @see #load_state_from_dump
|
71
|
+
# @param specs [Array<String>,Array<Array<String>>] An array of specs, see above
|
72
|
+
# @param options [Hash] An optional hash of options for modifying the behavior of the reload
|
73
|
+
# @option options [Boolean] verify_only Only see if the new engine code would load, don't replace any code.
|
74
|
+
# @option options [Boolean] guessing_okay Attempt to guess about renamed objects
|
75
|
+
# @option options [Boolean] no_addition Don't allow adding new StateItems, raise an error instead
|
76
|
+
# @option options [Boolean] no_addition Don't allow removing StateItems, raise an error instead
|
77
|
+
# @return [void] A configured Engine
|
78
|
+
# @since 0.0.1
|
79
|
+
def reload_from_dsl_text(*specs, options: { "verify_only" => false, "guessing_okay" => false, "no_addition" => false, "no_removal" => false })
|
80
|
+
old_engine = self
|
81
|
+
new_engine = nil
|
82
|
+
|
83
|
+
send_notification type: Demiurge::Notifications::LoadWorldVerify, zone: "admin", location: nil, actor: nil, include_context: true
|
84
|
+
|
85
|
+
begin
|
86
|
+
new_engine = Demiurge::DSL.engine_from_dsl_text(*specs)
|
87
|
+
rescue
|
88
|
+
# Didn't work? Leave the existing engine intact, but raise.
|
89
|
+
raise Demiurge::Errors::CannotLoadWorldFiles.new("Error reloading World File text")
|
90
|
+
end
|
91
|
+
|
92
|
+
# Match up old and new state items, but the "admin" InertStateItem doesn't get messed with
|
93
|
+
old_item_names = old_engine.all_item_names - ["admin"]
|
94
|
+
new_item_names = new_engine.all_item_names - ["admin"]
|
95
|
+
|
96
|
+
shared_item_names = old_item_names & new_item_names
|
97
|
+
added_item_names = new_item_names - shared_item_names
|
98
|
+
removed_item_names = old_item_names - shared_item_names
|
99
|
+
renamed_pairs = {}
|
100
|
+
|
101
|
+
if options["guessing_okay"]
|
102
|
+
# For right this second, don't guess. When guessing happens,
|
103
|
+
# this will populate the renamed_pairs hash.
|
104
|
+
end
|
105
|
+
|
106
|
+
if options["no_addition"] && !added_item_names.empty?
|
107
|
+
raise NonMatchingStateError.new "StateItems added when they weren't allowed: #{added_item_names.inspect}!"
|
108
|
+
end
|
109
|
+
|
110
|
+
if options["no_removal"] && !removed_item_names.empty?
|
111
|
+
raise NonMatchingStateError.new "StateItems removed when they weren't allowed: #{removed_item_names.inspect}!"
|
112
|
+
end
|
113
|
+
|
114
|
+
# Okay, finished with error checking - the dry-run is over
|
115
|
+
return if options["verify_only"]
|
116
|
+
|
117
|
+
# Now, replace the engine code
|
118
|
+
|
119
|
+
send_notification type: Demiurge::Notifications::LoadWorldStart, zone: "admin", location: nil, actor: nil, include_context: true
|
120
|
+
|
121
|
+
# Replace all actions performed by ActionItems
|
122
|
+
old_engine.replace_all_actions_for_all_items(new_engine.all_actions_for_all_items)
|
123
|
+
|
124
|
+
# For removed items, delete the StateItem from the old engine
|
125
|
+
removed_item_names.each do |removed_item_name|
|
126
|
+
old_engine.unregister_state_item(old_engine.item_by_name removed_item_name)
|
127
|
+
end
|
128
|
+
|
129
|
+
# For added items, use the state data from the new engine and add the item to the old engine
|
130
|
+
added_item_names.each do |added_item_name|
|
131
|
+
new_item = new_engine.item_by_name(added_item_name)
|
132
|
+
ss = new_item.structured_state
|
133
|
+
old_engine.register_state_item(StateItem.from_name_type(old_engine, *ss))
|
134
|
+
end
|
135
|
+
|
136
|
+
# A rename is basically an add and a remove... But not necessarily
|
137
|
+
# with the same name. And the newly-created item uses the state
|
138
|
+
# from the older item. A rename is also permitted to choose a new
|
139
|
+
# type, so create the new item in the old engine with the new name
|
140
|
+
# and type, but the old state.
|
141
|
+
renamed_pairs.each do |old_name, new_name|
|
142
|
+
old_item = old_engine.item_by_name(old_name)
|
143
|
+
new_item = new_engine.item_by_name(new_name)
|
144
|
+
|
145
|
+
old_type, _, old_state = *old_item.structured_state
|
146
|
+
new_type, _, new_state = *new_item.structured_state
|
147
|
+
|
148
|
+
old_engine.unregister_state_item(old_item)
|
149
|
+
old_engine.register_state_item(StateItem.from_name_type(old_engine, new_type, new_name, old_state))
|
150
|
+
end
|
151
|
+
|
152
|
+
send_notification type: Demiurge::Notifications::LoadWorldEnd, zone: "admin", location: nil, actor: nil, include_context: true
|
153
|
+
nil
|
154
|
+
end
|
155
|
+
end
|
156
|
+
|
157
|
+
# This module contains the Builder classes that parse the World File DSL.
|
158
|
+
#
|
159
|
+
# @since 0.0.1
|
160
|
+
module Demiurge::DSL
|
161
|
+
# This is a primary method for creating a new Demiurge Engine. It
|
162
|
+
# should be passed a list of filenames to load World File DSL
|
163
|
+
# from. It will return a fully-configured Engine which has called
|
164
|
+
# finished_init. If the Engine should load from an existing
|
165
|
+
# state-dump, that can be accomplished via load_state_from_dump.
|
166
|
+
#
|
167
|
+
# @see file:RELOADING.md
|
168
|
+
# @see Demiurge::Engine#load_state_from_dump
|
169
|
+
# @see Demiurge::Engine#reload_from_dsl_files
|
170
|
+
# @param filenames [Array<String>] An array of filenames, suitable for calling File.read on
|
171
|
+
# @return [Demiurge::Engine] A configured Engine
|
172
|
+
# @since 0.0.1
|
173
|
+
def self.engine_from_dsl_files(*filenames)
|
174
|
+
filename_string_pairs = filenames.map { |fn| [fn, File.read(fn)] }
|
175
|
+
engine_from_dsl_text(*filename_string_pairs)
|
176
|
+
end
|
177
|
+
|
178
|
+
# This method takes either strings containing World File DSL text,
|
179
|
+
# or name/string pairs. If a pair is supplied, the name gives the
|
180
|
+
# origin of the text for error messages.
|
181
|
+
#
|
182
|
+
# @see file:RELOADING.md
|
183
|
+
# @see Demiurge::Engine#load_state_from_dump
|
184
|
+
# @see Demiurge::Engine#reload_from_dsl_text
|
185
|
+
# @param specs [Array<String>, Array<Array<String>>] Either an array of chunks of DSL text, or an Array of two-element Arrays. Each two-element Array is a String name followed by a String of DSL text
|
186
|
+
# @return [Demiurge::Engine] A configured Engine
|
187
|
+
# @since 0.0.1
|
188
|
+
def self.engine_from_dsl_text(*specs)
|
189
|
+
builder = Demiurge::DSL::TopLevelBuilder.new
|
190
|
+
|
191
|
+
specs.each do |spec|
|
192
|
+
if spec.is_a?(String)
|
193
|
+
builder.instance_eval spec
|
194
|
+
elsif spec.is_a?(Array)
|
195
|
+
if spec.size != 2
|
196
|
+
raise "Not sure what to do with a #{spec.size}-elt array, normally this is a filename/string pair!"
|
197
|
+
end
|
198
|
+
builder.instance_eval spec[1], spec[0]
|
199
|
+
else
|
200
|
+
raise "Not sure what to do in engine_from_dsl_text with a #{spec.class}!"
|
201
|
+
end
|
202
|
+
end
|
203
|
+
|
204
|
+
builder.built_engine
|
205
|
+
end
|
206
|
+
|
207
|
+
# ActionItemBuilder is the parent class of all Builder classes
|
208
|
+
# except the {Demiurge::DSL::TopLevelBuilder}. It's used for a block
|
209
|
+
# of the World File DSL.
|
210
|
+
#
|
211
|
+
# @since 0.0.1
|
212
|
+
class ActionItemBuilder
|
213
|
+
# @return [StateItem] The item built by this builder
|
214
|
+
attr_reader :built_item
|
215
|
+
|
216
|
+
private
|
217
|
+
def check_options(hash, legal_options)
|
218
|
+
illegal_options = hash.keys - legal_options
|
219
|
+
raise "Illegal options #{illegal_options.inspect} passed to #{caller(1, 3).inspect}!" unless illegal_options.empty?
|
220
|
+
end
|
221
|
+
public
|
222
|
+
|
223
|
+
# @return [Array<String>] Legal options to pass to {ActionItemBuilder#initialize}
|
224
|
+
LEGAL_OPTIONS = [ "state", "type", "no_build" ]
|
225
|
+
|
226
|
+
# Initialize a DSL Builder block to configure some sort of ActionItem.
|
227
|
+
#
|
228
|
+
# @param name [String] The name to be registered with the Engine
|
229
|
+
# @param engine [Demiurge::Engine] The engine that will include this item
|
230
|
+
# @param options [Hash] Options for how the DSL block acts
|
231
|
+
# @option options [Hash] state The initial state Hash to create the item with
|
232
|
+
# @option options [String] type The item type to create
|
233
|
+
# @option options [Boolean] no_build If true, don't create and register a new StateItem with the Engine
|
234
|
+
# @return [void]
|
235
|
+
# @since 0.0.1
|
236
|
+
def initialize(name, engine, options = {})
|
237
|
+
check_options(options, LEGAL_OPTIONS)
|
238
|
+
@name = name
|
239
|
+
@engine = engine
|
240
|
+
@state = options["state"] || {}
|
241
|
+
@position = nil
|
242
|
+
@display = nil # This is display-specific information that gets passed to the display library
|
243
|
+
|
244
|
+
unless options["type"]
|
245
|
+
raise "You must pass a type when initializing a builder!"
|
246
|
+
end
|
247
|
+
unless options["no_build"]
|
248
|
+
@built_item = ::Demiurge::StateItem.from_name_type(@engine, options["type"], @name, @state)
|
249
|
+
@engine.register_state_item(@built_item)
|
250
|
+
end
|
251
|
+
nil
|
252
|
+
end
|
253
|
+
|
254
|
+
# If the DSL block sets state on the Builder object, this allows
|
255
|
+
# it to get to the internal Hash object rather than a
|
256
|
+
# wrapper. This should only be used internally to the DSL, not by
|
257
|
+
# others.
|
258
|
+
#
|
259
|
+
# @see #state
|
260
|
+
# @return [Hash] The state Hash
|
261
|
+
# @api private
|
262
|
+
# @since 0.0.1
|
263
|
+
def __state_internal
|
264
|
+
@built_item.state
|
265
|
+
end
|
266
|
+
|
267
|
+
# Get the state, or at least a wrapper object to it, for DSL usage.
|
268
|
+
#
|
269
|
+
# @return [Demiurge::ActionItemInternal::ActionItemStateWrapper] The state Hash wrapper
|
270
|
+
# @since 0.0.1
|
271
|
+
def state
|
272
|
+
@wrapper ||= ::Demiurge::ActionItemInternal::ActionItemStateWrapper.new(self)
|
273
|
+
end
|
274
|
+
|
275
|
+
# Register an action with the Engine. Since StateItems are
|
276
|
+
# effectively disposable, we need somewhere outside of the
|
277
|
+
# StateItem itself to store its actions as Ruby code. The Engine
|
278
|
+
# is the place they are stored.
|
279
|
+
#
|
280
|
+
# @param action [Hash] The action hash to use as the internal action structure
|
281
|
+
# @api private
|
282
|
+
# @since 0.0.1
|
283
|
+
private
|
284
|
+
def register_built_action(action)
|
285
|
+
raise("Must specify a string 'name' to register_build_action! Only gave #{action.inspect}!") unless action["name"]
|
286
|
+
check_options(action, ::Demiurge::ActionItem::ACTION_LEGAL_KEYS)
|
287
|
+
@built_item.register_actions(action["name"] => action)
|
288
|
+
end
|
289
|
+
public
|
290
|
+
|
291
|
+
# Perform the given action every so many ticks. This will set up
|
292
|
+
# the necessary state entries to cause the action to occur each
|
293
|
+
# time that many ticks pass. The given action name is attached to
|
294
|
+
# the given block (if any.) The named action can be modified using
|
295
|
+
# define_action if you want to set extra settings like engine_code
|
296
|
+
# or busy for the action in question. If no block is given, you
|
297
|
+
# should use define_action to create the action in question, or it
|
298
|
+
# will have no definition and cause errors.
|
299
|
+
#
|
300
|
+
# @param action_name [String] The action name for this item to use repeatedly
|
301
|
+
# @param t [Integer] The number of ticks that pass between actions
|
302
|
+
# @yield [...] Called when the action is performed with any arguments supplied by the caller
|
303
|
+
# @yieldreturn [void]
|
304
|
+
# @return [void]
|
305
|
+
# @since 0.0.1
|
306
|
+
def every_X_ticks(action_name, t, &block)
|
307
|
+
raise("Must provide a positive number for how many ticks, not #{t.inspect}!") unless t.is_a?(Numeric) && t >= 0.0
|
308
|
+
@built_item.state["everies"] ||= []
|
309
|
+
@built_item.state["everies"] << { "action" => action_name, "every" => t, "counter" => 0 }
|
310
|
+
@built_item.register_actions(action_name => { "name" => action_name, "block" => block })
|
311
|
+
nil
|
312
|
+
end
|
313
|
+
|
314
|
+
# Set the position of the built object.
|
315
|
+
#
|
316
|
+
# @param pos [String] The new position string for this built object.
|
317
|
+
# @return [void]
|
318
|
+
# @since 0.0.1
|
319
|
+
def position(pos)
|
320
|
+
@built_item.state["position"] = pos
|
321
|
+
nil
|
322
|
+
end
|
323
|
+
|
324
|
+
# Pass a block through that is intended for the display library to
|
325
|
+
# use later. If no display library is used, this is a no-op.
|
326
|
+
#
|
327
|
+
# @yield [] The block will be called by the display library, in a display-library-specific context, or not at all
|
328
|
+
# @return [void]
|
329
|
+
# @since 0.0.1
|
330
|
+
def display(&block)
|
331
|
+
# Need to figure out how to pass this through to the Display
|
332
|
+
# library. By design, the simulation/state part of Demiurge
|
333
|
+
# ignores this completely.
|
334
|
+
@built_item.register_actions("$display" => { "name" => "$display", "block" => block })
|
335
|
+
nil
|
336
|
+
end
|
337
|
+
|
338
|
+
# The specified action will be called for notifications of the
|
339
|
+
# appropriate type.
|
340
|
+
# @todo Figure out timing of the subscription - right now it will use the item's location midway through parsing the DSL!
|
341
|
+
#
|
342
|
+
# @param event [String] The name of the notification to subscribe to
|
343
|
+
# @param action_name [String] The action name of the new action
|
344
|
+
# @param options [Hash] Additional specifications about what/how to subscribe
|
345
|
+
# @option options [String,:all] location The location name to subscribe for - defaults to this item's location
|
346
|
+
# @option options [String,:all] zone The zone name to subscribe for - defaults to this item's zone
|
347
|
+
# @option options [String,:all] actor The acting item name to subscribe for - defaults to any item
|
348
|
+
# @yield [Hash] Receives notification hashes when these notifications occur
|
349
|
+
# @return [void]
|
350
|
+
# @since 0.0.1
|
351
|
+
def on_notification(event, action_name, options = {}, &block)
|
352
|
+
@built_item.state["on_handlers"] ||= {}
|
353
|
+
@built_item.state["on_handlers"][event] = action_name
|
354
|
+
register_built_action("name" => action_name, "block" => block)
|
355
|
+
|
356
|
+
location = options[:location] || options["location"] || @built_item.location
|
357
|
+
zone = options[:zone] || options["zone"] || location.zone_name || @built_item.zone_name
|
358
|
+
item = options[:actor] || options["actor"] || :all
|
359
|
+
|
360
|
+
@engine.subscribe_to_notifications type: event, zone: zone, location: location, actor: item do |notification|
|
361
|
+
# To keep this statedump-safe, need to look up the item again
|
362
|
+
# every time. @built_item isn't guaranteed to last.
|
363
|
+
@engine.item_by_name(@name).run_action(action_name, notification)
|
364
|
+
end
|
365
|
+
nil
|
366
|
+
end
|
367
|
+
|
368
|
+
# "on" is an older name for "on_notification" and is deprecated.
|
369
|
+
# @deprecated
|
370
|
+
alias_method :on, :on_notification
|
371
|
+
|
372
|
+
# The specified action will be called for Intentions using the
|
373
|
+
# appropriate action. This is used to modify or cancel an
|
374
|
+
# Intention before it runs.
|
375
|
+
#
|
376
|
+
# @param caught_action [String] The action type of the Intention being caught, or "all" for all intentions
|
377
|
+
# @param action_to_run [String] The action name of the new intercepting action
|
378
|
+
# @yield [Intention] Receives the Intention when these Intentions occur
|
379
|
+
# @return [void]
|
380
|
+
# @since 0.0.1
|
381
|
+
def on_intention(caught_action, action_to_run, &block)
|
382
|
+
@built_item.state["on_action_handlers"] ||= {}
|
383
|
+
raise "Already have an on_action (offer) handler for action #{caught_action}! Failing!" if @built_item.state["on_action_handlers"][caught_action]
|
384
|
+
@built_item.state["on_action_handlers"][caught_action] = action_to_run
|
385
|
+
register_built_action("name" => action_to_run, "block" => block)
|
386
|
+
nil
|
387
|
+
end
|
388
|
+
|
389
|
+
# "on_action" is an older name for "on_intention" and is deprecated.
|
390
|
+
# @deprecated
|
391
|
+
alias_method :on_action, :on_intention
|
392
|
+
|
393
|
+
# If you want to define an action for later calling, or to set
|
394
|
+
# options on an action that was defined as part of another
|
395
|
+
# handler, you can call define_action to make that happen.
|
396
|
+
#
|
397
|
+
# @example Make an every_X_ticks action also keep the agent busy and run as Engine code
|
398
|
+
# ```
|
399
|
+
# every_X_ticks("burp", 15) { engine.item_by_name("sooper sekrit").ruby_only_burp_action }
|
400
|
+
# define_action("burp", "engine_code" => true, "busy" => 7)
|
401
|
+
# ```
|
402
|
+
#
|
403
|
+
# @param action_name [String] The action name to declare or modify
|
404
|
+
# @param options [Hash] Options for this action
|
405
|
+
# @option options [Integer] busy How many ticks an agent should remain busy for after taking this action
|
406
|
+
# @option options [Boolean] engine_code If true, use the EngineBlockRunner instead of a normal runner for this code; usually a bad idea
|
407
|
+
# @option options [Array<String>] tags Tags that the action can be queried by later - useful for tagging player or agent actions, or admin-only actions
|
408
|
+
# @yield [...] Actions receive whatever arguments their later caller supplies
|
409
|
+
# @yieldreturn [void]
|
410
|
+
# @return [void]
|
411
|
+
# @since 0.0.1
|
412
|
+
def define_action(action_name, options = {}, &block)
|
413
|
+
legal_options = [ "busy", "engine_code", "tags" ]
|
414
|
+
illegal_keys = options.keys - legal_options
|
415
|
+
raise("Illegal keys #{illegal_keys.inspect} passed to define_action of #{action_name.inspect}!") unless illegal_keys.empty?;
|
416
|
+
register_built_action({ "name" => action_name, "block" => block }.merge(options))
|
417
|
+
nil
|
418
|
+
end
|
419
|
+
end
|
420
|
+
|
421
|
+
# This is the top-level DSL Builder class, for parsing the top syntactic level of the World Files.
|
422
|
+
#
|
423
|
+
# @since 0.0.1
|
424
|
+
class TopLevelBuilder
|
425
|
+
# This is the private structure of type names that are registered with the Demiurge World File DSL
|
426
|
+
@@types = {}
|
427
|
+
|
428
|
+
# Constructor for a new set of World Files and their top-level state.
|
429
|
+
def initialize(options = {})
|
430
|
+
@zones = []
|
431
|
+
@engine = options["engine"] || ::Demiurge::Engine.new(types: @@types, state: [])
|
432
|
+
end
|
433
|
+
|
434
|
+
# For now, this just declares an InertStateItem for a given name.
|
435
|
+
# It doesn't change the behavior at all. It just keeps that item
|
436
|
+
# name from being "orphaned" state that doesn't correspond to any
|
437
|
+
# state item.
|
438
|
+
#
|
439
|
+
# Later, this may be a way to describe how important or transitory
|
440
|
+
# state is - is it reset like a zone? Completely transient?
|
441
|
+
# Cleared per reboot?
|
442
|
+
#
|
443
|
+
# @param item_name [String] The item name for scoping the state in the Engine
|
444
|
+
# @param options [Hash] Options about the InertStateItem
|
445
|
+
# @option options [String] zone The zone this InertStateItem considers itself to be in, defaults to "admin"
|
446
|
+
# @option options [Hash] state The initial state Hash
|
447
|
+
# @option options [String] type The object type to instantiate, if not InertStateItem
|
448
|
+
# @return [void]
|
449
|
+
# @since 0.0.1
|
450
|
+
def inert(item_name, options = {})
|
451
|
+
zone_name = options["zone"] || "admin"
|
452
|
+
state = options["state"] || {}
|
453
|
+
state.merge! "zone" => zone_name, "home_zone" => zone_name
|
454
|
+
inert_item = ::Demiurge::StateItem.from_name_type(@engine, options["type"] || "InertStateItem", item_name, state)
|
455
|
+
@engine.register_state_item(inert_item)
|
456
|
+
nil
|
457
|
+
end
|
458
|
+
|
459
|
+
# Start a new Zone block, using a ZoneBuilder.
|
460
|
+
def zone(name, options = {}, &block)
|
461
|
+
if @zones.any? { |z| z.name == name }
|
462
|
+
# Reopening an existing zone
|
463
|
+
builder = ZoneBuilder.new(name, @engine, options.merge("existing" => @zones.detect { |z| z.name == name }))
|
464
|
+
else
|
465
|
+
builder = ZoneBuilder.new(name, @engine, options)
|
466
|
+
end
|
467
|
+
|
468
|
+
builder.instance_eval(&block)
|
469
|
+
new_zone = builder.built_item
|
470
|
+
|
471
|
+
@zones |= [ new_zone ] if new_zone # Add if not already present
|
472
|
+
nil
|
473
|
+
end
|
474
|
+
|
475
|
+
# It's hard to figure out where and how to register types and
|
476
|
+
# plugins for the World File format. By their nature, they need to
|
477
|
+
# be in place before an Engine exists, so that's not the right
|
478
|
+
# place. If they didn't exist before engines, we'd somehow need to
|
479
|
+
# register them with each engine as it was created. Since Engines
|
480
|
+
# keep track of that, that's exactly the same problem we're trying
|
481
|
+
# to solve, just for the Engine builder. And it seems like
|
482
|
+
# "register this plugin with Demiurge World Files" is more of a
|
483
|
+
# process-global operation than a per-Engine operation. So these
|
484
|
+
# wind up in awkward spots.
|
485
|
+
def self.register_type(name, klass)
|
486
|
+
if @@types[name.to_s]
|
487
|
+
raise("Attempting to re-register type #{name.inspect} with a different class!") unless @@types[name.to_s] == klass
|
488
|
+
else
|
489
|
+
@@types[name.to_s] = klass
|
490
|
+
end
|
491
|
+
end
|
492
|
+
|
493
|
+
# Return the built Engine, but first call the .finished_init
|
494
|
+
# callback. This will make sure that cached and duplicated data
|
495
|
+
# structures are properly filled in.
|
496
|
+
def built_engine
|
497
|
+
@engine.finished_init
|
498
|
+
@engine
|
499
|
+
end
|
500
|
+
end
|
501
|
+
|
502
|
+
# Declare an "agent" block in the World File DSL.
|
503
|
+
class AgentBuilder < ActionItemBuilder
|
504
|
+
def initialize(name, engine, options = {})
|
505
|
+
options = { "type" => "Agent" }.merge(options)
|
506
|
+
super(name, engine, options)
|
507
|
+
end
|
508
|
+
end
|
509
|
+
|
510
|
+
# Declare a "zone" block in the World File DSL.
|
511
|
+
class ZoneBuilder < ActionItemBuilder
|
512
|
+
# Constructor. See if this zone name already exists, and either
|
513
|
+
# create a new zone or append to the old one.
|
514
|
+
def initialize(name, engine, options = {})
|
515
|
+
@existing = options.delete("existing")
|
516
|
+
if @existing
|
517
|
+
old_type = @existing.state_type
|
518
|
+
new_type = options["type"]
|
519
|
+
if new_type && old_type != new_type
|
520
|
+
raise("Can't reopen zone with type #{(options["type"] || "Unspecified").inspect} after creating with type #{old_type.inspect}!")
|
521
|
+
end
|
522
|
+
options["no_build"] = true
|
523
|
+
@built_item = @existing
|
524
|
+
end
|
525
|
+
super(name, engine, options.merge("type" => options["type"] || "Zone"))
|
526
|
+
@locations = []
|
527
|
+
@agents = []
|
528
|
+
end
|
529
|
+
|
530
|
+
# Declare a location in this zone.
|
531
|
+
def location(name, options = {}, &block)
|
532
|
+
state = { "zone" => @name, "home_zone" => @name }.merge(options)
|
533
|
+
builder = LocationBuilder.new(name, @engine, "type" => options["type"] || "Location", "state" => state)
|
534
|
+
builder.instance_eval(&block)
|
535
|
+
@built_item.state["contents"] << name
|
536
|
+
nil
|
537
|
+
end
|
538
|
+
|
539
|
+
# Declare an agent in this zone. If the agent doesn't get a
|
540
|
+
# location declaration, by default the agent will usually be
|
541
|
+
# invisible (not an interactable location) but will be
|
542
|
+
# instantiable as a parent.
|
543
|
+
def agent(name, options = {}, &block)
|
544
|
+
state = { "zone" => @name, "home_zone" => @name }.merge(options)
|
545
|
+
builder = AgentBuilder.new(name, @engine, "type" => options["type"] || "Agent", "state" => state)
|
546
|
+
builder.instance_eval(&block)
|
547
|
+
@built_item.state["contents"] << name
|
548
|
+
nil
|
549
|
+
end
|
550
|
+
end
|
551
|
+
|
552
|
+
# Declare a "location" block in a World File.
|
553
|
+
class LocationBuilder < ActionItemBuilder
|
554
|
+
# Constructor for a "location" DSL block
|
555
|
+
def initialize(name, engine, options = {})
|
556
|
+
options["type"] ||= "Location"
|
557
|
+
super
|
558
|
+
@agents = []
|
559
|
+
end
|
560
|
+
|
561
|
+
# Declare a description for this location.
|
562
|
+
def description(d)
|
563
|
+
@state["description"] = d
|
564
|
+
end
|
565
|
+
|
566
|
+
# Declare an agent in this location.
|
567
|
+
def agent(name, options = {}, &block)
|
568
|
+
state = { "position" => @name, "zone" => @state["zone"], "home_zone" => @state["zone"] }
|
569
|
+
builder = AgentBuilder.new(name, @engine, options.merge("state" => state) )
|
570
|
+
builder.instance_eval(&block)
|
571
|
+
@built_item.state["contents"] << name
|
572
|
+
nil
|
573
|
+
end
|
574
|
+
end
|
575
|
+
|
576
|
+
end
|
577
|
+
|
578
|
+
Demiurge::DSL::TopLevelBuilder.register_type "ActionItem", Demiurge::ActionItem
|
579
|
+
Demiurge::DSL::TopLevelBuilder.register_type "InertStateItem", Demiurge::InertStateItem
|
580
|
+
Demiurge::DSL::TopLevelBuilder.register_type "Zone", Demiurge::Zone
|
581
|
+
Demiurge::DSL::TopLevelBuilder.register_type "Location", Demiurge::Location
|
582
|
+
Demiurge::DSL::TopLevelBuilder.register_type "Agent", Demiurge::Agent
|
583
|
+
Demiurge::DSL::TopLevelBuilder.register_type "WanderingAgent", Demiurge::WanderingAgent
|