demiurge 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
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