demiurge 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,338 @@
1
+ module Demiurge
2
+
3
+ # Agents correspond roughly to "mobiles" in many games. An agent
4
+ # isn't particularly different from other Demiurge objects, but it's
5
+ # useful to have some helper classes for things like pathfinding.
6
+ # Also, humans expect agents to have some finite ability to perform
7
+ # actions over time, so it's nice to regulate how much an agent can
8
+ # get done and how "busy" it is. This keeps an AI agent from just
9
+ # queueing up 30 move intentions and crossing the room in a single
10
+ # tick, for instance. It does *not* keep that same AI from having an
11
+ # intentional 30-square move that works in a single tick, but it
12
+ # slows the rate of actions. Agents get a single "real" intention,
13
+ # unlike, say, rooms, which can have lots going on at once.
14
+ #
15
+ # @since 0.0.1
16
+ class Agent < ActionItem
17
+
18
+ def initialize(*args)
19
+ super
20
+ state["queued_actions"] ||= []
21
+ state["queue_number"] ||= 0
22
+ end
23
+
24
+ def finished_init
25
+ super
26
+ @agent_maintenance = AgentInternal::AgentMaintenanceIntention.new(engine, @name)
27
+ state["busy"] ||= 0 # By default, start out idle.
28
+ end
29
+
30
+ # An Agent is, indeed, an Agent.
31
+ #
32
+ # @return [Boolean] Return true for Agent and its subclasses.
33
+ # @since 0.0.1
34
+ def agent?
35
+ true
36
+ end
37
+
38
+ # This method will move the agent and notify about that change. It
39
+ # doesn't use an intention or an agent's action queue, and it
40
+ # doesn't wait for a tick to happen. It just does it. The method
41
+ # *does* handle exits and generally allows the location to
42
+ # respond. But it's assumed that the offer cycle, if it needs to
43
+ # happen, has happened already.
44
+ #
45
+ # @param pos [String] A position string to move to
46
+ # @return [void]
47
+ # @since 0.0.1
48
+ def move_to_position(pos)
49
+ old_pos = self.position
50
+ old_loc = self.location_name
51
+ old_zone_name = self.zone_name
52
+ expected_new_loc = pos.split("#")[0]
53
+
54
+ if expected_new_loc == old_loc
55
+ self.location.item_change_position(self, old_pos, pos)
56
+ else
57
+ # This also handles zone changes.
58
+ self.location.item_change_location(self, old_pos, pos)
59
+ end
60
+ # We're not guaranteed to wind up where we expected, so get the
61
+ # new location *after* item_change_location or
62
+ # item_change_position.
63
+ new_loc = self.location_name
64
+
65
+ @engine.send_notification({ old_position: old_pos, old_location: old_loc, new_position: self.position, new_location: new_loc },
66
+ type: Demiurge::Notifications::MoveFrom, zone: old_zone_name, location: old_loc, actor: @name, include_context: true)
67
+ @engine.send_notification({ old_position: old_pos, old_location: old_loc, new_position: self.position, new_location: new_loc },
68
+ type: Demiurge::Notifications::MoveTo, zone: self.zone_name, location: self.location_name, actor: @name, include_context: true)
69
+ end
70
+
71
+ # Calculate the agent's intentions for the following tick. These
72
+ # Intentions can potentially trigger other Intentions.
73
+ #
74
+ # @return [Array<Intention>] The Intentions for the (first part of the) following tick.
75
+ # @since 0.0.1
76
+ def intentions_for_next_step
77
+ agent_action = AgentInternal::AgentActionIntention.new(@name, engine)
78
+ super + [@agent_maintenance, agent_action]
79
+ end
80
+
81
+ # Queue an action to be run after previous actions are complete,
82
+ # and when the agent is no longer busy from taking them. The
83
+ # action queue entry is assigned a unique-per-agent queue number,
84
+ # which is returned from this action.
85
+ #
86
+ # @param action_name [String] The name of the action to take when possible
87
+ # @param args [Array] Additional arguments to pass to the action's code block
88
+ # @return [Integer] Returns the queue number for this action - note that queue numbers are only unique per-agent
89
+ # @since 0.0.1
90
+ def queue_action(action_name, *args)
91
+ raise ::Demiurge::Errors::NoSuchActionError.new("Not an action: #{action_name.inspect}!", { "action_name" => action_name },
92
+ execution_context: @engine.execution_context) unless get_action(action_name)
93
+ state["queue_number"] += 1
94
+ state["queued_actions"].push([action_name, args, state["queue_number"]])
95
+ state["queue_number"]
96
+ end
97
+
98
+ # Any queued actions waiting to occur will be discarded.
99
+ #
100
+ # @return [void]
101
+ # @since 0.0.1
102
+ def clear_intention_queue
103
+ state.delete "queued_actions"
104
+ nil
105
+ end
106
+ end
107
+
108
+ # Code objects internal to the Agent implementation
109
+ # @api private
110
+ module AgentInternal; end
111
+
112
+ # The AgentMaintenanceIntention reduces the level of "busy"-ness of
113
+ # the agent on each tick.
114
+ # @todo Merge this with the AgentActionIntention used for taking queued actions
115
+ #
116
+ # @api private
117
+ class AgentInternal::AgentMaintenanceIntention < Intention
118
+ # Constructor. Takes an engine and agent name.
119
+ def initialize(engine, name)
120
+ @name = name
121
+ super(engine)
122
+ end
123
+
124
+ # Normally, the agent's maintenance intention can't be blocked,
125
+ # cancelled or modified.
126
+ def offer
127
+ end
128
+
129
+ # An AgentMaintenanceIntention is always considered to be allowed.
130
+ def allowed?
131
+ true
132
+ end
133
+
134
+ # Reduce the amount of busy-ness.
135
+ def apply
136
+ agent = @engine.item_by_name(@name)
137
+ agent.state["busy"] -= 1 if agent.state["busy"] > 0
138
+ end
139
+ end
140
+
141
+ # An AgentActionIntention is how the agent takes queued actions each
142
+ # tick.
143
+ #
144
+ # @note There is a bit of weirdness in how this intention handles
145
+ # {#allowed?} and {#offer}. We want to be able to queue an action
146
+ # on the same tick that we execute it if the agent is idle. So we
147
+ # count this intention as #allowed? even if the queue is empty,
148
+ # then silent-cancel the intention during {#offer} if nobody has
149
+ # added anything to it. If you see a lot of cancel notifications
150
+ # from this object with "silent" set, now you know why.
151
+ #
152
+ # @api private
153
+ class AgentInternal::AgentActionIntention < ActionItemInternal::ActionIntention
154
+ # @return [StateItem] The agent to whom this Intention applies
155
+ attr_reader :agent
156
+ # @return [String] The queued action name this Intention will next take
157
+ attr_reader :action_name
158
+
159
+ # Constructor. Takes an agent name and an engine
160
+ def initialize(name, engine)
161
+ super(engine, name, "")
162
+ @agent = engine.item_by_name(name)
163
+ raise ::Demiurge::Errors::NoSuchAgentError.new("No such agent as #{name.inspect} found in AgentActionIntention!", "agent" => name,
164
+ execution_context: engine.execution_context) unless @agent
165
+ end
166
+
167
+ # An action being pulled from the action queue is offered normally.
168
+ def offer
169
+ # Don't offer the action if it's going to be a no-op.
170
+ if @agent.state["busy"] > 0
171
+ # See comment on "silent" in #allowed? below.
172
+ self.cancel "Agent #{@name.inspect} was too busy to act (#{@agent.state["busy"]}).", "silent" => "true"
173
+ return
174
+ elsif @agent.state["queued_actions"].empty?
175
+ self.cancel "Agent #{@name.inspect} had no actions during the 'offer' phase.", "silent" => "true"
176
+ return
177
+ end
178
+ # Now offer the agent's action via the usual channels
179
+ action = @agent.state["queued_actions"][0]
180
+ @action_name, @action_args, @action_queue_number = *action
181
+ @action_struct = @agent.get_action(@action_name)
182
+ super
183
+ end
184
+
185
+ # This action is allowed if the agent is not busy, or will become not-busy soon
186
+ def allowed?
187
+ # If the agent's busy state will clear this turn, this action
188
+ # could happen. We intentionally don't send a "disallowed"
189
+ # notification for the action. It's not cancelled, nor is it
190
+ # dispatched successfully. It's just waiting for a later tick to
191
+ # do one of those two things.
192
+ return false if @agent.state["busy"] > 1
193
+
194
+ # A dilemma: if we cancel now when no actions are queued, then
195
+ # any action queued this turn (e.g. from an
196
+ # EveryXActionsIntention) won't be executed -- we said this
197
+ # intention wasn't happening. If we *don't* return false in the
198
+ # "allowed?" phase then we'll wind up sending out a cancel
199
+ # notice every turn when there are no actions. So we add a
200
+ # "silent" info option to the normal-every-turn cancellations,
201
+ # but we *do* allow-then-cancel even in perfectly normal
202
+ # circumstances.
203
+ true
204
+ end
205
+
206
+ # If the agent can do so, take the action in question.
207
+ def apply
208
+ unless agent.state["busy"] > 0 || agent.state["queued_actions"].empty?
209
+ # Pull the first entry off the action queue
210
+ queue = @agent.state["queued_actions"]
211
+ if queue && queue.size > 0
212
+ if @action_queue_number != queue[0][2]
213
+ @engine.admin_warning("Somehow the agent's action queue has gotten screwed up mid-offer!", "agent" => @name)
214
+ else
215
+ queue.shift # Remove the queue entry
216
+ end
217
+ end
218
+ agent.run_action(@action_name, *@action_args, current_intention: self)
219
+ agent.state["busy"] += (@action_struct["busy"] || 1)
220
+ end
221
+ end
222
+
223
+ # Send out a notification to indicate this ActionIntention was
224
+ # cancelled. If "silent" is set to true in the cancellation info,
225
+ # no notification will be sent.
226
+ #
227
+ # @return [void]
228
+ # @since 0.2.0
229
+ def cancel_notification
230
+ return if @cancelled_info && @cancelled_info["silent"]
231
+ @engine.send_notification({
232
+ reason: @cancelled_reason,
233
+ by: @cancelled_by,
234
+ id: @intention_id,
235
+ intention_type: self.class.to_s,
236
+ info: @cancelled_info,
237
+ queue_number: @action_queue_number,
238
+ action_name: @action_name,
239
+ action_args: @action_args,
240
+ },
241
+ type: Demiurge::Notifications::IntentionCancelled,
242
+ zone: @item.zone_name,
243
+ location: @item.location_name,
244
+ actor: @item.name,
245
+ include_context: true)
246
+ nil
247
+ end
248
+
249
+ # Send out a notification to indicate this ActionIntention was
250
+ # applied.
251
+ #
252
+ # @return [void]
253
+ # @since 0.2.0
254
+ def apply_notification
255
+ @engine.send_notification({
256
+ id: @intention_id,
257
+ intention_type: self.class.to_s,
258
+ queue_number: @action_queue_number,
259
+ action_name: @action_name,
260
+ action_args: @action_args,
261
+ },
262
+ type: Demiurge::Notifications::IntentionApplied,
263
+ zone: @item.zone_name,
264
+ location: @item.location_name,
265
+ actor: @item.name,
266
+ include_context: true)
267
+ nil
268
+ end
269
+ end
270
+
271
+ # This agent will wander around. A simple way to make a decorative
272
+ # mobile. Do we want this longer term, or should it be merged into
273
+ # the normal agent?
274
+ class WanderingAgent < Agent
275
+ # Constructor
276
+ def initialize(name, engine, state)
277
+ super
278
+ state["wander_counter"] ||= 0
279
+ end
280
+
281
+ # If we're in a room but don't know where, pick a legal location.
282
+ def finished_init
283
+ super
284
+ @wander_intention = AgentInternal::WanderIntention.new(engine, name)
285
+ unless state["position"] && state["position"]["#"]
286
+ # Move to legal position. If this is a TMX location or similar, it will assign a specific position.
287
+ if self.location.respond_to?(:any_legal_position)
288
+ state["position"] = self.location.any_legal_position
289
+ end
290
+ end
291
+ end
292
+
293
+ # Get intentions for the next upcoming tick
294
+ def intentions_for_next_step
295
+ super + [@wander_intention]
296
+ end
297
+ end
298
+
299
+ # This is a simple Wandering agent for use with TmxLocations and similar grid-based maps.
300
+ #
301
+ # @api private
302
+ class AgentInternal::WanderIntention < ActionItemInternal::ActionIntention
303
+ # Constructor
304
+ def initialize(engine, name, *args)
305
+ @name = name
306
+ super(engine, name, "", *args)
307
+ end
308
+
309
+ # Always allowed
310
+ def allowed?
311
+ true
312
+ end
313
+
314
+ # For now, WanderIntention is unblockable. That's not perfect, but
315
+ # otherwise we have to figure out how to offer an action without
316
+ # an action name.
317
+ def offer
318
+ end
319
+
320
+ # Actually wander to an adjacent position, chosen randomly
321
+ def apply
322
+ agent = @engine.item_by_name(@name)
323
+ agent.state["wander_counter"] += 1
324
+ wander_every = agent.state["wander_every"] || 3
325
+ return if agent.state["wander_counter"] < wander_every
326
+ next_coords = agent.zone.adjacent_positions(agent.position)
327
+ if next_coords.empty?
328
+ @engine.admin_warning("Oh no! Wandering agent #{@name.inspect} is stuck and can't get out!",
329
+ "zone" => agent.zone_name, "location" => agent.location_name, "agent" => @name)
330
+ return
331
+ end
332
+ chosen = next_coords.sample
333
+ pos = "#{agent.location_name}##{chosen.join(",")}"
334
+ agent.move_to_position(pos)
335
+ agent.state["wander_counter"] = 0
336
+ end
337
+ end
338
+ end
@@ -0,0 +1,194 @@
1
+ # A Container may contain other items. Common examples include Zones and Locations.
2
+
3
+ module Demiurge
4
+ # Container is the parent class of Locations, Zones and other items that can contain items.
5
+ class Container < ActionItem
6
+ # Constructor - set up contents
7
+ #
8
+ # @param name [String] The Engine-unique item name
9
+ # @param engine [Demiurge::Engine] The Engine this item is part of
10
+ # @param state [Hash] State data to initialize from
11
+ # @return [void]
12
+ # @since 0.0.1
13
+ def initialize(name, engine, state)
14
+ super
15
+ state["contents"] ||= []
16
+ end
17
+
18
+ # Gets the array of item names of all items contained in this
19
+ # container.
20
+ #
21
+ # @return [Array<String>] The array of item names
22
+ # @since 0.0.1
23
+ def contents_names
24
+ state["contents"]
25
+ end
26
+
27
+ # The finished_init hook is called after all items are loaded. For
28
+ # containers, this makes sure all items set as contents of this
29
+ # container also have it correctly set as their position.
30
+ #
31
+ # @return [void]
32
+ # @since 0.0.1
33
+ def finished_init
34
+ # Can't move all items inside until they all exist, which isn't guaranteed until init is finished.
35
+ state["contents"].each do |item|
36
+ move_item_inside(@engine.item_by_name(item))
37
+ end
38
+ nil
39
+ end
40
+
41
+ # This makes sure the given item name is listed in the container's
42
+ # contents. It does *not* make sure that the item currently
43
+ # exists, or that its position is set to this container.
44
+ #
45
+ # @see #move_item_inside
46
+ # @see #ensure_does_not_contain
47
+ # @param item_name [String] The item name to ensure is listed in the container
48
+ # @return [void]
49
+ # @since 0.0.1
50
+ def ensure_contains(item_name)
51
+ raise("Pass only item names to ensure_contains!") unless item_name.is_a?(String)
52
+ @state["contents"] |= [item_name]
53
+ nil
54
+ end
55
+
56
+ # This makes sure the given item name is *not* listed in the
57
+ # container's contents. It does not make sure the item exists, nor
58
+ # do anything with the item's position.
59
+ #
60
+ # @see #ensure_contains
61
+ # @see #move_item_inside
62
+ # @param item_name [String] The item name
63
+ # @return [void]
64
+ # @since 0.0.1
65
+ def ensure_does_not_contain(item_name)
66
+ raise("Pass only item names to ensure_does_not_contain!") unless item_name.is_a?(String)
67
+ @state["contents"] -= [item_name]
68
+ nil
69
+ end
70
+
71
+ # This makes sure the given StateItem is contained in this
72
+ # container. It sets the item's position to be in this container,
73
+ # and if there is an old location it attempts to properly remove
74
+ # the item from it.
75
+ #
76
+ # @see Demiurge::Agent#move_to_position
77
+ # @param item [Demiurge::StateItem] The item to be moved into this container
78
+ # @return [void]
79
+ # @since 0.0.1
80
+ def move_item_inside(item)
81
+ old_pos = item.position
82
+ if old_pos
83
+ old_loc_name = old_pos.split("#")[0]
84
+ old_loc = @engine.item_by_name(old_loc_name)
85
+ old_loc.ensure_does_not_contain(item.name)
86
+ end
87
+
88
+ @state["contents"] |= [ item.name ]
89
+ nil
90
+ end
91
+
92
+ # This is a callback to indicate that an item has changed
93
+ # position, but remains inside this location. Other than changing
94
+ # the item's position state variable, this may not require any
95
+ # changes. A different callback is called when the item changes
96
+ # from one location to another.
97
+ #
98
+ # @see #item_change_location
99
+ # @param item [Demiurge::StateItem] The item changing position
100
+ # @param old_pos [String] The pre-movement position, which is current when this is called
101
+ # @param new_pos [String] The post-movement position, which should be current when this method completes
102
+ # @return [void]
103
+ # @since 0.0.1
104
+ def item_change_position(item, old_pos, new_pos)
105
+ item.state["position"] = new_pos
106
+ nil
107
+ end
108
+
109
+ # This is a callback to indicate that an item has changed from one
110
+ # location to another. This will normally require removing the
111
+ # item from its first location and adding it to a new location. A
112
+ # different callback is called when the item changes position
113
+ # within a single location.
114
+ #
115
+ # @see #item_change_position
116
+ # @param item [Demiurge::StateItem] The item changing position
117
+ # @param old_pos [String] The pre-movement position, which is current when this is called
118
+ # @param new_pos [String] The post-movement position, which should be current when this method completes
119
+ # @return [void]
120
+ # @since 0.0.1
121
+ def item_change_location(item, old_pos, new_pos)
122
+ old_loc = old_pos.split("#")[0]
123
+ old_loc_item = @engine.item_by_name(old_loc)
124
+ old_loc_item.ensure_does_not_contain(item.name)
125
+ new_loc = new_pos.split("#")[0]
126
+ new_loc_item = @engine.item_by_name(new_loc)
127
+ new_loc_item.ensure_contains(item.name)
128
+ item.state["position"] = new_pos
129
+
130
+ old_zone = old_loc_item.zone_name
131
+ new_zone = new_loc_item.zone_name
132
+ if new_zone != old_zone
133
+ item.state["zone"] = new_zone
134
+ end
135
+ nil
136
+ end
137
+
138
+ # When an item has an {Demiurge::Intention}, that Intention is
139
+ # offered in order to be potentially modified or cancelled by
140
+ # environmental effects. For instance, a room might have a muddy
141
+ # floor that slows walking or prevents running, or an icy floor
142
+ # that causes sliding around. That offer is normally coordinated
143
+ # through the item's location. The location will receive this
144
+ # callback ({#receive_offer}) and make appropriate modifications
145
+ # to the Intention. Any other items or agents that want to modify
146
+ # the Intention will have to coordinate with the appropriate item
147
+ # location.
148
+ #
149
+ # @see file:CONCEPTS.md
150
+ # @param action_name [String] The name of the action for this Intention.
151
+ # @param intention [Demiurge::Intention] The Intention being offered
152
+ # @return [void]
153
+ # @since 0.0.1
154
+ def receive_offer(action_name, intention)
155
+ # Run handlers, if any
156
+ on_actions = @state["on_action_handlers"]
157
+ if on_actions && (on_actions[action_name] || on_actions["all"])
158
+ run_action(on_actions["all"], intention, current_intention: intention) if on_actions["all"]
159
+ run_action(on_actions[action_name], intention, current_intention: intention) if on_actions[action_name]
160
+ end
161
+ nil
162
+ end
163
+
164
+ # This method determines if a given agent can exist at the
165
+ # specified position inside this container. By default, a
166
+ # container can accomodate anyone or anything. Subclass to change
167
+ # this behavior. This should take into account the size, shape and
168
+ # current condition of the agent, and might take into account
169
+ # whether the agent has certain movement abilities.
170
+ #
171
+ # @param agent [Demiurge::Agent] The agent being checked
172
+ # @param position [String] The position being checked within this container
173
+ # @return [Boolean] Whether the agent can exist at that position
174
+ # @since 0.0.1
175
+ def can_accomodate_agent?(agent, position)
176
+ true
177
+ end
178
+
179
+ # This determines the intentions for the next tick for this
180
+ # container and for all items inside it.
181
+ #
182
+ # @return [Array<Intention>] The array of intentions for next tick
183
+ # @since 0.0.1
184
+ def intentions_for_next_step
185
+ intentions = super
186
+ @state["contents"].each do |item_name|
187
+ item = @engine.item_by_name(item_name)
188
+ intentions += item.intentions_for_next_step
189
+ end
190
+ intentions
191
+ end
192
+
193
+ end
194
+ end