demiurge 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/lib/demiurge.rb ADDED
@@ -0,0 +1,812 @@
1
+ require "demiurge/version"
2
+ require "demiurge/util"
3
+
4
+ # Predeclare classes that other required files will use.
5
+ module Demiurge
6
+ class StateItem; end
7
+ end
8
+
9
+ require "demiurge/intention"
10
+ require "demiurge/notification_names"
11
+ require "demiurge/exception"
12
+ require "demiurge/action_item"
13
+ require "demiurge/inert_state_item"
14
+ require "demiurge/container"
15
+ require "demiurge/zone"
16
+ require "demiurge/location"
17
+ require "demiurge/agent"
18
+ require "demiurge/dsl"
19
+
20
+ require "multi_json"
21
+
22
+ # Demiurge is a state and simulation library which can be used to
23
+ # create games and similar applications. Its focus is on rich, deep
24
+ # simulation with interesting object interactions. To begin using
25
+ # Demiurge, see the {Demiurge::Engine} class and/or the
26
+ # {file:README.md} file. For more detail, see {file:CONCEPTS.md}.
27
+ module Demiurge
28
+
29
+ # The Engine class encapsulates one "world" of simulation. This
30
+ # includes state and code for all items, subscriptions to various
31
+ # events and the ability to reload state or code at a later point.
32
+ # It is entirely possible to have multiple Engine objects containing
33
+ # different objects and subscriptions which are unrelated to each
34
+ # other. If Engines share references in common, that sharing is
35
+ # ordinarily a bug. Currently only registered {Demiurge::DSL} Object
36
+ # Types should be shared.
37
+ #
38
+ # @since 0.0.1
39
+ class Engine
40
+ include ::Demiurge::Util # For copyfreeze and deepcopy
41
+
42
+ # @return [Integer] The number of ticks that have occurred since the beginning of this Engine's history.
43
+ attr_reader :ticks
44
+
45
+ # @return [Hash{String=>String}] The current execution context for notifications and logging.
46
+ attr_reader :execution_context
47
+
48
+ # This is the constructor for a new Engine object. Most frequently
49
+ # this will be called by {Demiurge::DSL} or another external
50
+ # source which will also supply item types and initial state.
51
+ #
52
+ # @param types [Hash] A name/value hash of item types supported by this engine's serialization.
53
+ # @param state [Array] An array of serialized Demiurge items in {Demiurge::StateItem} structured array format.
54
+ # @return [void]
55
+ # @since 0.0.1
56
+ def initialize(types: {}, state: [])
57
+ @klasses = {}
58
+ if types
59
+ types.each do |tname, tval|
60
+ register_type(tname, tval)
61
+ end
62
+ end
63
+
64
+ @finished_init = false
65
+ @state_items = {}
66
+ @zones = []
67
+ state_from_structured_array(state || [])
68
+
69
+ @item_actions = {}
70
+
71
+ @subscriptions_by_tracker = {}
72
+
73
+ @queued_notifications = []
74
+ @queued_intentions = []
75
+
76
+ @execution_context = []
77
+
78
+ nil
79
+ end
80
+
81
+ # The "finished_init" callback on Demiurge items exists to allow
82
+ # items to finalize their structure relative to other items. For
83
+ # instance, containers can ensure that their list of contents is
84
+ # identical to the set of items that list the container as their
85
+ # location. This cannot be done until the container is certain
86
+ # that all items have been added to the engine. The #finished_init
87
+ # engine method calls {Demiurge::StateItem#finished_init} on any
88
+ # items that respond to that callback. Normally
89
+ # {Demiurge::Engine#finished_init} should be called when a new
90
+ # Engine is created, but not when restoring one from a state
91
+ # dump. This method should not be called multiple times, and the
92
+ # Engine will try not to allow multiple calls.
93
+ #
94
+ # @return [void]
95
+ # @since 0.0.1
96
+ def finished_init
97
+ raise("Duplicate finished_init call to engine!") if @finished_init
98
+ @state_items.values.each { |obj| obj.finished_init() if obj.respond_to?(:finished_init) }
99
+ @finished_init = true
100
+ end
101
+
102
+ # This method dumps the Engine's state in {Demiurge::StateItem}
103
+ # structured array format. This method is how one would normally
104
+ # collect a full state dump of the engine suitable for later
105
+ # restoration.
106
+ #
107
+ # @param [Hash] options Options for dumping state
108
+ # @option options [Boolean] copy If true, copy the serialized state rather than allowing any links into StateItem objects. This reduces performance but increases security.
109
+ # @return [Array] The engine's state in {Demiurge::StateItem} structured array format
110
+ # @since 0.0.1
111
+ # @see #load_state_from_dump
112
+ def structured_state(options = { "copy" => false })
113
+ dump = @state_items.values.map { |item| item.get_structure }
114
+ if options["copy"]
115
+ dump = deepcopy(dump) # Make sure it doesn't share state...
116
+ end
117
+ dump
118
+ end
119
+
120
+ # Get a StateItem by its registered unique name.
121
+ #
122
+ # @param name [String] The name registered with the {Demiurge::Engine} for this item
123
+ # @return [StateItem, nil] The StateItem corresponding to this name or nil
124
+ # @since 0.0.1
125
+ def item_by_name(name)
126
+ @state_items[name]
127
+ end
128
+
129
+ # Get an Array of StateItems that are top-level {Demiurge::Zone} items.
130
+ #
131
+ # @since 0.0.1
132
+ # @return [Array<Demiurge::StateItem>] All registered StateItems that are treated as {Demiurge::Zone} items
133
+ def zones
134
+ @zones
135
+ end
136
+
137
+ # Get an array of all registered names for all items.
138
+ #
139
+ # @return [Array<String>] All registered item names for this {Demiurge::Engine}
140
+ # @since 0.0.1
141
+ def all_item_names
142
+ @state_items.keys
143
+ end
144
+
145
+ # Get an intention ID which is guaranteed to never be returned by
146
+ # this method again. It's not important that it be consecutive or
147
+ # otherwise special, just that it be unique.
148
+ #
149
+ # @return [Integer] The new intention ID
150
+ # @api private
151
+ # @since 0.2.0
152
+ def get_intention_id
153
+ @state_items["admin"].state["intention_id"] += 1
154
+ return @state_items["admin"].state["intention_id"]
155
+ end
156
+
157
+ # Add an intention to the Engine's Intention queue. If this method
158
+ # is called during the Intention phase of a tick, the intention
159
+ # should be excecuted during this tick in standard order. If the
160
+ # method is called outside the Intention phase of a tick, the
161
+ # Intention will normally be executed during the soonest upcoming
162
+ # Intention phase. Queued intentions are subject to approval,
163
+ # cancellation and other normal operations that Intentions undergo
164
+ # before being executed.
165
+ #
166
+ # @param intention [Demiurge::Intention] The intention to be queued
167
+ # @return [void]
168
+ # @since 0.0.1
169
+ def queue_intention(intention)
170
+ @queued_intentions.push intention
171
+ nil
172
+ end
173
+
174
+ # Queue all Intentions for all registered items for the current tick.
175
+ #
176
+ # @return [void]
177
+ # @since 0.0.1
178
+ def queue_item_intentions()
179
+ next_step_intentions.each { |i| queue_intention(i) }
180
+ end
181
+
182
+ # Calculate the intentions for the next round of the Intention
183
+ # phase of a tick. This is not necessarily the same as all
184
+ # Intentions for the next tick - sometimes an executed
185
+ # {Demiurge::Intention} will queue more {Demiurge::Intention}s to
186
+ # run during the same phase of the same tick.
187
+ #
188
+ # @return [void]
189
+ # @since 0.0.1
190
+ def next_step_intentions()
191
+ @zones.flat_map { |item| item.intentions_for_next_step || [] }
192
+ end
193
+
194
+ # Send a warning that something unfortunate but not
195
+ # continuity-threatening has occurred. The problem isn't bad
196
+ # enough to warrant raising an exception, but it's bad enough that
197
+ # we should collect data about the problem. The warning normally
198
+ # indicates a problem in user-supplied code, current state, or the
199
+ # Demiurge gem itself. These warnings can be subscribed to with
200
+ # the notification type {Demiurge::Notifications::AdminWarning}.
201
+ #
202
+ # @param message [String] A user-readable log message indicating the problem that occurred
203
+ # @param info [Hash] A hash of additional fields that indicate the nature of the problem being reported
204
+ # @option info [String] "name" The name of the item causing the problem
205
+ # @option info [String] "zone" The name of the zone where the problem is located, if known
206
+ # @return [void]
207
+ # @since 0.0.1
208
+ def admin_warning(message, info = {})
209
+ send_notification({"message" => message, "info" => info},
210
+ type: ::Demiurge::Notifications::AdminWarning, zone: "admin", location: nil, actor: nil, include_context: true)
211
+ end
212
+
213
+ # Send out all pending {Demiurge::Intention}s in the
214
+ # {Demiurge::Intention} queue. This will ordinarily happen at
215
+ # least once per tick in any case. Calling this method outside the
216
+ # Engine's Intention phase of a tick may cause unexpected results.
217
+ #
218
+ # @return [void]
219
+ # @since 0.0.1
220
+ def flush_intentions
221
+ state_backup = structured_state("copy" => true)
222
+
223
+ infinite_loop_detector = 0
224
+ until @queued_intentions.empty?
225
+ infinite_loop_detector += 1
226
+ if infinite_loop_detector > 20
227
+ raise ::Demiurge::Errors::TooManyIntentionLoopsError.new("Over 20 batches of intentions were dispatched in the same call! Error and die!", "final_batch" => @queued_intentions.map { |i| i.class.to_s })
228
+ end
229
+
230
+ intentions = @queued_intentions
231
+ @queued_intentions = []
232
+ begin
233
+ intentions.each do |a|
234
+ if a.cancelled?
235
+ admin_warning("Trying to apply a cancelled intention of type #{a.class}!", "inspect" => a.inspect)
236
+ else
237
+ a.try_apply
238
+ end
239
+ end
240
+ rescue ::Demiurge::Errors::RetryableError
241
+ admin_warning("Exception when updating! Throwing away speculative state!", "exception" => $_.jsonable)
242
+ load_state_from_dump(state_backup)
243
+ end
244
+ end
245
+
246
+ @state_items["admin"].state["ticks"] += 1
247
+ nil
248
+ end
249
+
250
+ # Perform all necessary operations and phases for one "tick" of
251
+ # virtual time to pass in the Engine.
252
+ #
253
+ # @return [void]
254
+ # @since 0.0.1
255
+ def advance_one_tick()
256
+ queue_item_intentions
257
+ flush_intentions
258
+ send_notification({}, type: Demiurge::Notifications::TickFinished, location: "", zone: "", actor: nil)
259
+ flush_notifications
260
+ nil
261
+ end
262
+
263
+ # Get a StateItem type that is registered with this Engine, using the registered name for that type.
264
+ #
265
+ # @param t [String] The registered type name for this class object
266
+ # @return [Class] A StateItem Class object
267
+ # @since 0.0.1
268
+ def get_type(t)
269
+ raise("Not a valid type: #{t.inspect}!") unless @klasses[t]
270
+ @klasses[t]
271
+ end
272
+
273
+ # Register a new StateItem type with a name that will be used in structured {Demiurge::StateItem} dumps.
274
+ #
275
+ # @param name [String] The name to use when registering this type
276
+ # @param klass [Class] The StateItem class to register with this name
277
+ # @return [void]
278
+ # @since 0.0.1
279
+ def register_type(name, klass)
280
+ if @klasses[name] && @klasses[name] != klass
281
+ raise "Re-registering name with different type! Name: #{name.inspect} Class: #{klass.inspect} OldClass: #{@klasses[name].inspect}!"
282
+ end
283
+ @klasses[name] ||= klass
284
+ nil
285
+ end
286
+
287
+ # StateItems are transient and can be created, recreated or
288
+ # destroyed without warning. They need to be hooked up to the
289
+ # various Ruby code for their actions. The code for actions isn't
290
+ # serialized. Instead, each action is referred to by name, and the
291
+ # engine loads up all the item-name/action-name combinations when
292
+ # it reads the Ruby World Files. This means an action can be
293
+ # referred to by its name when serialized, but the actual code
294
+ # changes any time the world files are reloaded.
295
+
296
+ def register_actions_by_item_and_action_name(item_actions)
297
+ item_actions.each do |item_name, act_hash|
298
+ if @item_actions[item_name]
299
+ act_hash.each do |action_name, opts|
300
+ existing = @item_actions[item_name][action_name]
301
+ if existing
302
+ ActionItem::ACTION_LEGAL_KEYS.each do |key|
303
+ if existing[key] && opts[key] && existing[key] != opts[key]
304
+ raise "Can't register action #{action_name.inspect} for item #{item_name.inspect}, conflict for key #{key.inspect}!"
305
+ end
306
+ end
307
+ existing.merge!(opts)
308
+ else
309
+ @item_actions[item_name][action_name] = opts
310
+ end
311
+ end
312
+ else
313
+ @item_actions[item_name] = act_hash
314
+ end
315
+ end
316
+ end
317
+
318
+ # Fetch an action for an ActionItem that's stored in the engine.
319
+ #
320
+ # @api private
321
+ # @since 0.0.1
322
+ def action_for_item(item_name, action_name)
323
+ @item_actions[item_name] ? @item_actions[item_name][action_name] : nil
324
+ end
325
+
326
+ # Fetch actions for an ActionItem that's stored in the engine.
327
+ #
328
+ # @api private
329
+ # @since 0.0.1
330
+ def actions_for_item(item_name)
331
+ @item_actions[item_name]
332
+ end
333
+
334
+ # Fetch all actions for all items in an internal format.
335
+ #
336
+ # @api private
337
+ # @since 0.0.1
338
+ def all_actions_for_all_items
339
+ @item_actions
340
+ end
341
+
342
+ # Replace actions for an item with other, potentially quite
343
+ # different, actions. This is normally done to reload engine state
344
+ # from World Files.
345
+ #
346
+ # @api private
347
+ # @return [void]
348
+ # @since 0.0.1
349
+ def replace_all_actions_for_all_items(item_action_hash)
350
+ @item_actions = item_action_hash
351
+ nil
352
+ end
353
+
354
+ # Certain context can be important to notifications, errors,
355
+ # logging and other admin-available information. For instance:
356
+ # the current zone-in-tick (if any), a current item taking an
357
+ # action and so on. This context can be attached to notifications,
358
+ # admin warnings, system logging and similar.
359
+ #
360
+ # @param context [Hash{String=>String}]
361
+ # @yield Evaluate the following block with the given context set
362
+ # @api private
363
+ # @return [void]
364
+ # @since 0.2.0
365
+ def push_context(context)
366
+ @execution_context.push(context)
367
+ yield
368
+ ensure
369
+ @execution_context.pop
370
+ end
371
+
372
+ # This method creates a new StateItem based on an existing parent
373
+ # StateItem, and will retain some level of linkage to that parent
374
+ # StateItem afterward as well. This provides a simple form of
375
+ # action inheritance and data inheritance.
376
+ #
377
+ # The new child item will begin with a copy of the parent's state
378
+ # which can then be overridden. There will not be a longer-term
379
+ # link to the parent's state, and any later state changes in the
380
+ # parent or child will not affect each other.
381
+ #
382
+ # There *will* be a longer-term link to the parent's actions, and
383
+ # any action not overridden in the child item will fall back to
384
+ # the parent's action of the same name.
385
+ #
386
+ # @param name [String] The new item name to register with the engine
387
+ # @param parent [Demiurge::StateItem] The parent StateItem
388
+ # @param extra_state [Hash] Additional state data for the child
389
+ # @return [Demiurge::StateItem] The newly-created StateItem which has been registered with the engine
390
+ # @since 0.0.1
391
+ def instantiate_new_item(name, parent, extra_state = {})
392
+ parent = item_by_name(parent) unless parent.is_a?(StateItem)
393
+ ss = parent.get_structure
394
+
395
+ # The new instantiated item is different from the parent because
396
+ # it has its own name, and because it can get named actions from
397
+ # the parent as well as itself. The latter is important because
398
+ # we can't easily make new action procs without an associated
399
+ # World File of some kind.
400
+ ss[1] = name
401
+ ss[2] = deepcopy(ss[2])
402
+ ss[2].merge!(extra_state)
403
+ ss[2]["parent"] = parent.name
404
+
405
+ child = register_state_item(StateItem.from_name_type(self, *ss))
406
+ if @finished_init && child.respond_to?(:finished_init)
407
+ child.finished_init
408
+ end
409
+ child
410
+ end
411
+
412
+ # Determine whether the item name is basically allowable.
413
+ #
414
+ # @param name String The item name to check
415
+ # @return [Boolean] Whether the item name is valid, just in terms of its characters.
416
+ # @since 0.0.1
417
+ def valid_item_name?(name)
418
+ !!(name =~ /\A[-_ 0-9a-zA-Z]+\Z/)
419
+ end
420
+
421
+ # Register a new StateItem
422
+ #
423
+ # @param item [Demiurge::StateItem]
424
+ # @return [Demiurge::StateItem] The same item
425
+ # @since 0.0.1
426
+ def register_state_item(item)
427
+ name = item.name
428
+ if @state_items[name]
429
+ raise "Duplicate item name: #{name}! Failing!"
430
+ end
431
+ @state_items[name] = item
432
+ if item.zone?
433
+ @zones.push(item)
434
+ end
435
+ if @finished_init
436
+ send_notification(type: ::Demiurge::Notifications::NewItem, zone: item.zone_name, location: item.location_name, actor: name)
437
+ end
438
+ item
439
+ end
440
+
441
+ # This method unregisters a StateItem from the engine. The method
442
+ # assumes other items don't refer to the item being
443
+ # unregistered. {#unregister_state_item} will try to perform basic
444
+ # cleanup, but calling it *can* leave dangling references.
445
+ #
446
+ # @param [Demiurge::StateItem] item The item to unregister
447
+ # @return [void]
448
+ # @since 0.0.1
449
+ def unregister_state_item(item)
450
+ loc = item.location
451
+ loc.ensure_does_not_contain(item.name)
452
+ zone = item.zone
453
+ zone.ensure_does_not_contain(item.name)
454
+ @state_items.delete(item.name)
455
+ @zones -= [item]
456
+ nil
457
+ end
458
+
459
+ private
460
+ # This sets the Engine's internal state from a structured array of
461
+ # items. It is normally used via load_state_from_dump.
462
+ def state_from_structured_array(arr)
463
+ @finished_init = false
464
+ @state_items = {}
465
+ @zones = []
466
+
467
+ unless arr.any? { |type, name, state| name == "admin" }
468
+ register_state_item(StateItem.from_name_type(self, "InertStateItem", "admin", {}))
469
+ @state_items["admin"].state["ticks"] ||= 0
470
+ @state_items["admin"].state["notification_id"] ||= 0
471
+ @state_items["admin"].state["intention_id"] ||= 0
472
+ end
473
+
474
+ arr.each do |type, name, state|
475
+ register_state_item(StateItem.from_name_type(self, type.freeze, name.to_s.freeze, state))
476
+ end
477
+
478
+ nil
479
+ end
480
+ public
481
+
482
+ # This loads the Engine's state from structured
483
+ # {Demiurge::StateItem} state that has been serialized. This
484
+ # method handles reinitializing, signaling and whatnot. Use this
485
+ # method to restore state from a JSON dump or a hypothetical
486
+ # scenario that didn't work out.
487
+ #
488
+ # Note that this does *not* update code from Ruby files or
489
+ # otherwise handle any changes in the World Files. For that, use
490
+ # {#reload_from_dsl_files} or {#reload_from_dsl_text}.
491
+ #
492
+ # You can only reload state or World Files for a running engine,
493
+ # never both. If you want to reload *both*, make a new engine and
494
+ # use it, or reload the two in an order of your choice. Keep in
495
+ # mind that some World File changes can rename state items.
496
+ #
497
+ # @see Demiurge::DSL.engine_from_dsl_files
498
+ # @see Demiurge::DSL.engine_from_dsl_text
499
+ # @see Demiurge::Engine#reload_from_dsl_files
500
+ # @see Demiurge::Engine#reload_from_dsl_text
501
+ # @param arr [Array] {Demiurge::StateItem} structured state in the form of Ruby objects
502
+ # @return [void]
503
+ # @since 0.0.1
504
+ def load_state_from_dump(arr)
505
+ send_notification(type: Demiurge::Notifications::LoadStateStart, actor: nil, location: nil, zone: "admin", include_context: true)
506
+ state_from_structured_array(arr)
507
+ finished_init
508
+ send_notification(type: Demiurge::Notifications::LoadStateEnd, actor: nil, location: nil, zone: "admin", include_context: true)
509
+ flush_notifications
510
+ end
511
+
512
+ # Internal methods used by subscribe_to_notifications for notification matching.
513
+ private
514
+ def notification_spec(s)
515
+ return s if s == :all
516
+ if s.respond_to?(:each)
517
+ return s.map { |s| notification_entity(s) }
518
+ end
519
+ return [notification_entity(s)]
520
+ end
521
+
522
+ def notification_entity(s)
523
+ s = s.to_s if s.is_a?(Symbol)
524
+ s = s.name if s.respond_to?(:name) # Demiurge Entities should be replaced by their names
525
+ raise "Unrecognized notification entity: #{s.inspect}!" unless s.is_a?(String)
526
+ s
527
+ end
528
+ public
529
+
530
+ # This method 'subscribes' a block to various types of
531
+ # notifications. The block will be called with the notifications
532
+ # when they occur. A "specifier" for this method means either the
533
+ # special symbol +:all+ or an Array of Symbols or Strings to show
534
+ # what values a notification may have for the given field. For
535
+ # fields that might indicate Demiurge items such as "zone" or
536
+ # "actor" the value should be the Demiurge item *name*, not the
537
+ # item itself.
538
+ #
539
+ # When a notification occurs that matches the subscription, the
540
+ # given block will be called with a hash of data about that
541
+ # notification.
542
+ #
543
+ # The tracker is supplied to allow later unsubscribes. Pass a
544
+ # unique tracker object (usually a String or Symbol) when
545
+ # subscribing, then pass in the same one when unsubscribing.
546
+ #
547
+ # At a minimum, subscriptions should include a zone to avoid
548
+ # subscribing to all such notifications everywhere in the engine.
549
+ # Engine-wide subscriptions become very inefficient very quickly.
550
+ #
551
+ # Notifications only have a few mandatory fields - type, actor,
552
+ # zone and location. The location and/or actor can be nil in some
553
+ # cases, and the zone can be "admin" for engine-wide events. If
554
+ # you wish to subscribe based on other properties of the
555
+ # notification then you'll need to pass a custom predicate to
556
+ # check each notification. A "predicate" just means it's a proc
557
+ # that returns true or false, depending whether a notification
558
+ # matches.
559
+ #
560
+ # @example Subscribe to all notification types at a particular location
561
+ # subscribe_to_notifications(zone: "my zone name", location: "my location") { |h| puts "Got #{h.inspect}!" }
562
+ #
563
+ # @example Subscribe to all "say" notifications in my same zone
564
+ # subscribe_to_notifications(zone: "my zone", type: "say") { |h| puts "Somebody said something!" }
565
+ #
566
+ # @example Subscribe to all move_to notifications for a specific actor, with a tracker for future unsubscription
567
+ # subscribe_to_notifications(zone: ["one", "two", "three"], type: "move_to", actor: "bozo the clown", tracker: "bozo move tracker") { |h| bozo_move(h) }
568
+ #
569
+ # @example Subscribe to notifications matching a custom predicate
570
+ # subscribe_to_notifications(zone: "green field", type: "joyous dance", predicate: proc { |h| h["info"]["subtype"] == "butterfly wiggle" }) { |h| process(h) }
571
+ #
572
+ # @param type [:all, String, Array<Symbol>, Array<String>] A specifier for what Demiurge notification names to subscribe to
573
+ # @param zone [:all, String, Array<Symbol>, Array<String>] A specifier for what Zone names match this subscription
574
+ # @param location [:all, String, Array<Symbol>, Array<String>] A specifier for what location names match this subscription
575
+ # @param predicate [Proc, nil] Call this proc on each notification to see if it matches this subscription
576
+ # @param actor [:all, String, Array<Symbol>, Array<String>] A specifier for what Demiurge item name(s) must be the actor in a notification to match this subscription
577
+ # @param tracker [Object, nil] To unsubscribe from this notification later, pass in the same tracker to {#unsubscribe_from_notifications}, or another object that is +==+ to this tracker. A tracker is most often a String or Symbol. If the tracker is nil, you can't ever unsubscribe.
578
+ # @return [void]
579
+ # @since 0.0.1
580
+ def subscribe_to_notifications(type: :all, zone: :all, location: :all, predicate: nil, actor: :all, tracker: nil, &block)
581
+ sub_structure = {
582
+ type: notification_spec(type),
583
+ zone: notification_spec(zone),
584
+ location: notification_spec(location),
585
+ actor: notification_spec(actor),
586
+ predicate: predicate,
587
+ tracker: tracker,
588
+ block: block,
589
+ }
590
+ @subscriptions_by_tracker[tracker] ||= []
591
+ @subscriptions_by_tracker[tracker].push(sub_structure)
592
+ nil
593
+ end
594
+
595
+ # When you subscribe to a notification with
596
+ # {#subscribe_to_notifications}, you may optionally pass a non-nil
597
+ # tracker with the subscription. If you pass that tracker to this
598
+ # method, it will unsubscribe you from that notification. Multiple
599
+ # subscriptions can use the same tracker and they will all be
600
+ # unsubscribed at once. For that reason, you should use a unique
601
+ # tracker if you do *not* want other code to be able to
602
+ # unsubscribe you from notifications.
603
+ #
604
+ # @param tracker [Object] The tracker from which to unsubscribe
605
+ # @return [void]
606
+ # @since 0.0.1
607
+ def unsubscribe_from_notifications(tracker)
608
+ raise "Tracker must be non-nil!" if tracker.nil?
609
+ @subscriptions_by_tracker.delete(tracker)
610
+ end
611
+
612
+ # Queue a notification to be sent later by the engine. The
613
+ # notification must include at least type, zone, location and
614
+ # actor and may include a hash of additional data, which should be
615
+ # serializable to JSON (i.e. use only basic data types.)
616
+ #
617
+ # @param type [String] The notification type of this notification
618
+ # @param zone [String, nil] The zone name for this notification. The special "admin" zone name is used for engine-wide events
619
+ # @param location [String, nil] The location name for this notification, or nil if no location
620
+ # @param actor [String, nil] The name of the acting item for this notification, or nil if no item is acting
621
+ # @param data [Hash] Additional data about this notification; please use String keys for the Hash
622
+ # @return [void]
623
+ # @since 0.0.1
624
+ def send_notification(data = {}, type:, zone:, location:, actor:, include_context:false)
625
+ raise "Notification type must be a String, not #{type.class}!" unless type.is_a?(String)
626
+ raise "Location must be a String, not #{location.class}!" unless location.is_a?(String) || location.nil?
627
+ raise "Zone must be a String, not #{zone.class}!" unless zone.is_a?(String)
628
+ raise "Acting item must be a String or nil, not #{actor.class}!" unless actor.is_a?(String) || actor.nil?
629
+
630
+ @state_items["admin"].state["notification_id"] += 1
631
+
632
+ cleaned_data = {}
633
+ cleaned_data["context"] = @execution_context if include_context
634
+ data.each do |key, val|
635
+ # TODO: verify somehow that this is JSON-serializable?
636
+ cleaned_data[key.to_s] = val
637
+ end
638
+ cleaned_data.merge!("type" => type, "zone" => zone, "location" => location, "actor" => actor,
639
+ "notification_id" => @state_items["admin"].state["notification_id"])
640
+
641
+ @queued_notifications.push(cleaned_data)
642
+ end
643
+
644
+ # Send out any pending notifications that have been queued. This
645
+ # will normally happen at least once per tick in any case, but may
646
+ # happen more often. If this occurs during the Engine's tick,
647
+ # certain ordering issues may occur. Normally it's best to let the
648
+ # Engine call this method during the tick, and to only call it
649
+ # manually when no tick is occurring.
650
+ #
651
+ # @return [void]
652
+ # @since 0.0.1
653
+ def flush_notifications
654
+ infinite_loop_detector = 0
655
+ # Dispatch the queued notifications. Then, dispatch any
656
+ # notifications that resulted from them. Then, keep doing that
657
+ # until the queue is empty.
658
+ until @queued_notifications.empty?
659
+ infinite_loop_detector += 1
660
+ if infinite_loop_detector > 20
661
+ raise ::Demiurge::Errors::TooManyNotificationLoopsError.new("Over 20 batches of notifications were dispatched in the same call! Error and die!", "last batch" => @queued_notifications.map { |n| n.class.to_s })
662
+ end
663
+
664
+ current_notifications = @queued_notifications
665
+ @queued_notifications = []
666
+ current_notifications.each do |cleaned_data|
667
+ @subscriptions_by_tracker.each do |tracker, sub_structures|
668
+ sub_structures.each do |sub_structure|
669
+ next unless sub_structure[:type] == :all || sub_structure[:type].include?(cleaned_data["type"])
670
+ next unless sub_structure[:zone] == :all || sub_structure[:zone].include?(cleaned_data["zone"])
671
+ next unless sub_structure[:location] == :all || sub_structure[:location].include?(cleaned_data["location"])
672
+ next unless sub_structure[:actor] == :all || sub_structure[:actor].include?(cleaned_data["actor"])
673
+ next unless sub_structure[:predicate] == nil || sub_structure[:predicate] == :all || sub_structure[:predicate].call(cleaned_data)
674
+
675
+ sub_structure[:block].call(cleaned_data)
676
+ end
677
+ end
678
+ end
679
+ end
680
+ end
681
+ end
682
+
683
+ # A StateItem encapsulates a chunk of state. It provides behavior to
684
+ # the bare data. Note that the {Demiurge::ActionItem} child class
685
+ # makes StateItem easier to use by providing a simple block DSL
686
+ # instead of requiring raw calls with the engine API.
687
+
688
+ # Items a user would normally think about (zones, locations, agents,
689
+ # etc) inherit from StateItem, often indirectly. The StateItem
690
+ # itself might be highly abstract, and might not correspond to a
691
+ # user's idea of a specific thing in a specific place. For example,
692
+ # a global weather pattern across many zones is not a single
693
+ # "normal" item. But it could be a single StateItem as the weather
694
+ # changes and potentially reacts over time.
695
+
696
+ # StateItems are transient and can be created, recreated or
697
+ # destroyed without warning. They need to be hooked up to the
698
+ # various Ruby code for their actions. The code for actions isn't
699
+ # serialized. Instead, each action is referred to by a name scoped
700
+ # to the item's registered name. All item-name/action-name
701
+ # combinations are registed in the {Demiurge::Engine} when the
702
+ # {Demiurge::DSL} reads the Ruby World Files. This means an action
703
+ # can be referred to by its name when serialized, but the actual
704
+ # code changes any time the world files are reloaded.
705
+
706
+ # For items with more convenient behavior to them see ActionItem,
707
+ # and/or specific classes like Agent, Zone, Location and so on.
708
+
709
+ # StateItems can be serialized at any time to "structured array"
710
+ # format. That format consists of a set of Plain Old Ruby Objects
711
+ # (POROs) which are guaranteed to be serializable as JSON, and thus
712
+ # consist of only basic data structures like Strings, Arrays,
713
+ # Booleans and Hashes. A single StateItem will serialize itself to a
714
+ # short Array of this form: ["ObjectType", "item name",
715
+ # state_hash]. The ObjectType is the type registered with the
716
+ # engine, such as "ActionItem". The "item name" is the object-unique
717
+ # item name. And the state_hash is a JSON-serializable Ruby Hash
718
+ # with the object's current state. A dump of multiple StateItems
719
+ # will be an Array of these Arrays.
720
+
721
+ class StateItem
722
+ # @return [String] The unique, registered or registerable name of this {Demiurge::StateItem}
723
+ # @since 0.0.1
724
+ attr_reader :name
725
+
726
+ # @return [Demiurge::Engine] The Engine this item is part of
727
+ attr_reader :engine
728
+
729
+ # @return [String] The default StateItem type of this item. Can be overridden by child classes.
730
+ # @since 0.0.1
731
+ def state_type
732
+ self.class.name.split("::")[-1]
733
+ end
734
+
735
+ # The constructor. This does not register the StateItem with the
736
+ # Engine. For that, see {Demiurge::Engine#register_state_item}.
737
+ #
738
+ # @param name [String] The Engine-unique item name
739
+ # @param engine [Demiurge::Engine] The Engine this item is part of
740
+ # @param state [Hash] State data to initialize from
741
+ # @see Demiurge::Engine#register_state_item
742
+ # @return [void]
743
+ # @since 0.0.1
744
+ def initialize(name, engine, state)
745
+ @name = name
746
+ @engine = engine
747
+ @state = state
748
+ end
749
+
750
+ # This method determines whether the item will be treated as a
751
+ # top-level zone. Inheriting from Demiurge::Zone will cause that
752
+ # to occur. So will redefining the zone? method to return true.
753
+ # Whether zone? returns true should not depend on state, which may
754
+ # not be set when this method is called.
755
+ #
756
+ # @return [Boolean] Whether this item is considered a Zone.
757
+ # @since 0.0.1
758
+ def zone?
759
+ false
760
+ end
761
+
762
+ # This method determines whether the item will be treated as an
763
+ # agent. Inheriting from Demiurge::Agent will cause that to
764
+ # occur. So will redefining the agent? method to return true.
765
+ # Whether agent? returns true should not depend on state, which
766
+ # may not be set when this method is called.
767
+ #
768
+ # @return [Boolean] Whether this item is considered an Agent.
769
+ # @since 0.0.1
770
+ def agent?
771
+ false
772
+ end
773
+
774
+ # Return this StateItem's current state in a JSON-serializable
775
+ # form.
776
+ #
777
+ # @return [Object] The JSON-serializable state, usually a Hash
778
+ # @since 0.0.1
779
+ def state
780
+ @state
781
+ end
782
+
783
+ # The StateItem's serialized state in structured array format.
784
+ #
785
+ # @see Demiurge::StateItem
786
+ # @return [Array] The serialized state
787
+ # @since 0.0.1
788
+ def get_structure()
789
+ [state_type, @name, @state]
790
+ end
791
+
792
+ # Create a single StateItem from structured array format
793
+ #
794
+ # @see Demiurge::StateItem
795
+ # @return [Demiurge::StateItem] The deserialized StateItem
796
+ # @since 0.0.1
797
+ def self.from_name_type(engine, type, name, state)
798
+ engine.get_type(type).new(name, engine, state)
799
+ end
800
+
801
+ # Get this StateItem's Intentions for the upcoming tick, with its
802
+ # current internal state. Note that this can change over the
803
+ # course of a tick as the internal state changes, but it should
804
+ # *not* change if the state doesn't.
805
+ #
806
+ # @return [Array<Demiurge::Intention>] The intentions for the next tick
807
+ # @since 0.0.1
808
+ def intentions_for_next_step(*args)
809
+ raise "StateItem must be subclassed to be used!"
810
+ end
811
+ end
812
+ end