demiurge 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,170 @@
1
+ module Demiurge;end
2
+
3
+ # The Errors module exists to scope errors out of the top-level namespace.
4
+ module Demiurge::Errors
5
+ # Demiurge::Errors::Exception is the parent class of all Demiurge-specific
6
+ # Exceptions.
7
+ #
8
+ # @since 0.0.1
9
+ class Exception < ::RuntimeError
10
+ # @return [Hash] Additional specific data about this exception.
11
+ # @since 0.0.1
12
+ attr_reader :info
13
+
14
+ # @return [Hash{String=>String}] Context about where and how the error occurred
15
+ # @since 0.2.0
16
+ attr_reader :execution_context
17
+
18
+ # Optionally add a hash of extra data, called info, to this
19
+ # exception. You can also add the engine's execution context, if
20
+ # available.
21
+ #
22
+ # @param msg [String] The message for this Exception
23
+ # @since 0.0.1
24
+ def initialize(msg, info = {}, execution_context: nil)
25
+ super(msg)
26
+ @info = info
27
+ @execution_context = execution_context ? execution_context.dup : nil
28
+ end
29
+
30
+ def backtrace_chain
31
+ bt_chain = []
32
+ cur_cause = self.cause
33
+ while cur_cause
34
+ bt_chain.push(self.backtrace)
35
+ cur_cause = cur_cause.cause
36
+ end
37
+ bt_chain
38
+ end
39
+
40
+ # Serialize this exception to a JSON-serializable PORO.
41
+ #
42
+ # @return [Hash] The serialized {Demiurge::Errors::Exception} data
43
+ # @since 0.0.1
44
+ def jsonable()
45
+ bt = backtrace_chain.inject { |a, b| a + [ "... Caused by ..." ] + b }
46
+ {
47
+ "message" => self.message,
48
+ "info" => self.info,
49
+ "execution_context" => self.execution_context,
50
+ "backtrace" => bt
51
+ }
52
+ end
53
+
54
+ def formatted
55
+ bt = backtrace_chain.map { |t| t.join("\n") }.join("\n... Caused by ...\n")
56
+ <<FORMATTED_BLOCK
57
+ #{self.message}
58
+ Error info: #{info.inspect}
59
+ Context: #{execution_context.inspect}
60
+ #{bt}
61
+ FORMATTED_BLOCK
62
+ end
63
+ end
64
+
65
+ # A RetryableError or its subclasses normally indicate an error that
66
+ # is likely to be transient, and where retrying the tick is a
67
+ # reasonable attempt at a solution.
68
+ #
69
+ # @since 0.0.1
70
+ class RetryableError < ::Demiurge::Errors::Exception; end
71
+
72
+ # A BadScriptError will normally not benefit from retrying. Instead,
73
+ # one or more scripts associated with this error is presumed to be
74
+ # bad or outdated. The primary thing to do with BadScriptErrors is
75
+ # to accumulate and count them and possibly to deactivate one or
76
+ # more bad scripts. Error counting can allow an administrator to
77
+ # locate (or guess) the bad script in question and disable one or
78
+ # more scripts to remove the problem. While it's technically
79
+ # possible to disable bad scripts automatically, that may sometimes
80
+ # have distressing side effects when a script was intended to run
81
+ # and didn't. It may also have false positives where a misbehaving
82
+ # script "frames" a correct script by causing errors downstream.
83
+ #
84
+ # @since 0.0.1
85
+ class BadScriptError < ::Demiurge::Errors::Exception; end
86
+
87
+ # An AssetError means that there's a problem in the format of a TMX
88
+ # file, image, JSON file or other game asset. It is unlikely to be
89
+ # retryable, but it isn't normally the result of bad admin-written
90
+ # code.
91
+ #
92
+ # @since 0.0.1
93
+ class AssetError < ::Demiurge::Errors::Exception; end
94
+
95
+ # A ReloadError is a result of state that doesn't match perfectly on
96
+ # reload. Deleting non-transient objects, renaming objects, giving
97
+ # objects a new type and changing an object's state format can all
98
+ # give a ReloadError in certain circumstances.
99
+ #
100
+ # @since 0.0.1
101
+ class ReloadError < ::Demiurge::Errors::Exception; end
102
+
103
+
104
+
105
+ # This exception occurs when trying to use an action that doesn't
106
+ # exist, such as from {Demiurge::ActionItem#run_action} or
107
+ # {Demiurge::Agent#queue_action}.
108
+ #
109
+ # @since 0.0.1
110
+ class NoSuchActionError < BadScriptError; end
111
+
112
+ # This exception occurs when trying to use an agent name that
113
+ # doesn't belong to any registered agent.
114
+ #
115
+ # @since 0.0.1
116
+ class NoSuchAgentError < BadScriptError; end
117
+
118
+ # This occurs when trying to use a nonexistent state key in a way
119
+ # that isn't allowed. This exception normally refers to object
120
+ # state, when accessed from a script.
121
+ #
122
+ # @since 0.0.1
123
+ class NoSuchStateKeyError < BadScriptError; end
124
+
125
+ # This occurs when trying to modify or cancel an intention when
126
+ # there isn't one.
127
+ #
128
+ # @since 0.0.1
129
+ class NoCurrentIntentionError < BadScriptError; end
130
+
131
+ # This occurs if intentions queue other, new intentions too many
132
+ # times in the same tick. The exception exists to prevent infinite
133
+ # loops of queued intentions. If your script wants to queue lots of
134
+ # intentions, consider queueing them during *later* ticks instead of
135
+ # immediately.
136
+ #
137
+ # @since 0.0.1
138
+ class TooManyIntentionLoopsError < BadScriptError; end
139
+
140
+ # This occurs if notifications queue other, new notifications too
141
+ # many times in the same tick. The exception exists to prevent
142
+ # infinite loops of queued notifications. If your script wants to
143
+ # queue lots of notifications, consider queueing them after the tick
144
+ # has finished, perhaps on a later tick.
145
+ #
146
+ # @since 0.0.1
147
+ class TooManyNotificationLoopsError < BadScriptError; end
148
+
149
+ # This occurs if there's a problem in the TMX file or in some kind
150
+ # of file convention (such as using "Fringe" for a hardcoded layer)
151
+ # in a specific subformat like ManaSource.
152
+ #
153
+ # @since 0.0.1
154
+ class TmxFormatError < AssetError; end
155
+
156
+ # When loading or reloading, we got an exception when parsing
157
+ # WorldFile code.
158
+ #
159
+ # @since 0.0.1
160
+ class CannotLoadWorldFiles < ReloadError; end
161
+
162
+ # When reloading, this error or a subclass can be raised if the new
163
+ # state structure or StateItems don't seem to match the old one in
164
+ # illegal ways. "Illegal" can vary, depending how conservative the
165
+ # reloading options are set.
166
+ #
167
+ # @since 0.0.1
168
+ class NonMatchingStateError < ReloadError; end
169
+
170
+ end
@@ -0,0 +1,21 @@
1
+ module Demiurge
2
+
3
+ # Sometimes you just want state that sits and does nothing unless
4
+ # you mess with it. Player password hashes? Top-level game settings?
5
+ # Heck, even something sort-of-active like bank inventory can make
6
+ # sense to model this way since it will never do anything on its
7
+ # own. This is especially good for things that will never interact
8
+ # with the engine cycle - something that ignores ticks, intentions,
9
+ # notifications, etc.
10
+ #
11
+ # @since 0.0.1
12
+ class InertStateItem < StateItem
13
+ # An InertStateItem doesn't intend anything, ever.
14
+ #
15
+ # @return [Array<Intention>] This array will always be empty for an InertStateItem
16
+ # @since 0.0.1
17
+ def intentions_for_next_step(*args)
18
+ []
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,164 @@
1
+ module Demiurge
2
+
3
+ # An Intention is an unresolved event. Some part of the simulated
4
+ # world "wishes" to take an action. This need not be a sentient
5
+ # being - any change to the world should occur with an Intention
6
+ # then being resolved into changes in state and events -- or
7
+ # not. It's also possible for an intention to resolve to nothing at
8
+ # all. For instance, an intention to move in an impossible direction
9
+ # could simply resolve with no movement, no state change and no event.
10
+ #
11
+ # Intentions go through verification, resolution and eventually
12
+ # notification.
13
+ #
14
+ # Intentions are not, in general, serializable. They normally
15
+ # persist for only a single tick. To persist an intention for most StateItems,
16
+ # consider persisting action names instead.
17
+ #
18
+ # For more details about Intention, see {file:CONCEPTS.md}.
19
+ #
20
+ # @since 0.0.1
21
+
22
+ class Intention
23
+ # Subclasses of intention can require all sorts of constructor arguments to
24
+ # specify what the intention is. But the engine should always be supplied.
25
+ #
26
+ # @param engine [Demiurge::Engine] The engine this Intention is part of.
27
+ # @since 0.0.1
28
+ def initialize(engine)
29
+ @cancelled = false
30
+ @engine = engine
31
+ @intention_id = engine.get_intention_id
32
+ end
33
+
34
+ # This cancels the intention, and gives the reason for the
35
+ # cancellation.
36
+ #
37
+ # @param reason [String] A human-readable reason this action was cancelled
38
+ # @param info [Hash] A String-keyed Hash of additional information about the cancellation
39
+ # @return [void]
40
+ # @since 0.0.1
41
+ def cancel(reason, info = {})
42
+ @cancelled = true
43
+ @cancelled_by = caller(1, 1)
44
+ @cancelled_reason = reason
45
+ @cancelled_info = info
46
+ cancel_notification
47
+ nil
48
+ end
49
+
50
+ # Most intentions will send a cancellation notice when they are
51
+ # cancelled. By default, this will include who cancelled the
52
+ # intention and why.
53
+ #
54
+ # If the cancellation info Hash includes "silent" with a true
55
+ # value, by default no notification will be sent out. This is to
56
+ # avoid an avalache of notifications for common cancelled
57
+ # intentions that happen nearly every tick. Examples include an
58
+ # agent's action queue being empty so it cancels its intention.
59
+ # These are normal operation and nobody is likely to need
60
+ # notification every tick that they didn't ask to do anything so
61
+ # they didn't.
62
+ #
63
+ # {#cancel_notification} can be overridden by child classes
64
+ # for more specific cancel notifications.
65
+ #
66
+ # @return [void]
67
+ # @since 0.0.1
68
+ def cancel_notification
69
+ return if @cancelled_info && @cancelled_info["silent"]
70
+ @engine.send_notification({
71
+ :reason => @cancelled_reason,
72
+ :by => @cancelled_by,
73
+ :id => @intention_id,
74
+ :intention_type => self.class.to_s,
75
+ :info => @cancelled_info
76
+ },
77
+ type: Demiurge::Notifications::IntentionCancelled, zone: "admin", location: nil, actor: nil,
78
+ include_context: true)
79
+ nil
80
+ end
81
+
82
+ # Intentions should send an apply notice when they are
83
+ # successfully applied. {#apply_notification} can be overridden by
84
+ # child classes to send more specific information.
85
+ #
86
+ # @return [void]
87
+ # @since 0.2.0
88
+ def apply_notification
89
+ @engine.send_notification({
90
+ :id => @intention_id,
91
+ :intention_type => self.class.to_s,
92
+ :info => @cancelled_info
93
+ },
94
+ type: Demiurge::Notifications::IntentionApplied, zone: "admin", location: nil, actor: nil,
95
+ include_context: true)
96
+ nil
97
+ end
98
+
99
+ # This returns whether this intention has been cancelled.
100
+ #
101
+ # @return [Boolean] Whether the notification is cancelled.
102
+ def cancelled?
103
+ @cancelled
104
+ end
105
+
106
+ # This method allows child classes of Intention to check whether
107
+ # they should happen at all. If this method returns false, the
108
+ # intention will self-cancel without sending a notification and
109
+ # quietly not occur. The method exists primarily to allow
110
+ # "illegal" intentions like walking through a wall or drinking
111
+ # nonexistent water to quietly not happen without the rest of the
112
+ # simulation responding to them in any way.
113
+ #
114
+ # @return [Boolean] If this method returns false, the Intention will quietly self-cancel before the offer phase.
115
+ # @since 0.0.1
116
+ def allowed?
117
+ raise "Unimplemented 'allowed?' for intention: #{self.inspect}!"
118
+ end
119
+
120
+ # This method tells the Intention that it has successfully
121
+ # occurred and it should modify StateItems accordingly. Normally
122
+ # this will only be called after {#allowed?} and {#offer} have
123
+ # completed, and other items have had a chance to modify or cancel
124
+ # this Intention.
125
+ #
126
+ # @return [void]
127
+ # @since 0.0.1
128
+ def apply
129
+ raise "Unimplemented 'apply' for intention: #{self.inspect}!"
130
+ end
131
+
132
+ # When an Intention is "offered", that means appropriate other
133
+ # entities have a chance to modify or cancel the intention. For
134
+ # instance, a movement action in a room should be offered to that
135
+ # room, which may trigger a special action (e.g. trap) or change
136
+ # the destination of the action (e.g. exits, slippery ice,
137
+ # spinning spaces.)
138
+ #
139
+ # @see file:CONCEPTS.md
140
+ # @return [void]
141
+ # @since 0.0.1
142
+ # @note This method changed signature in 0.2.0 to stop taking an intention ID.
143
+ def offer
144
+ raise "Unimplemented 'offer' for intention: #{self.inspect}!"
145
+ end
146
+
147
+ # This is a normally-private part of the Tick cycle. It checks the
148
+ # {#allowed?} and {#offer} phases for this one specific Intention.
149
+ #
150
+ # @return [void]
151
+ # @since 0.0.1
152
+ # @api private
153
+ # @note This method changed signature in 0.2.0 to stop taking an intention ID.
154
+ def try_apply
155
+ return unless allowed?
156
+ offer
157
+ return if cancelled? # Notification should already have been sent out
158
+ apply
159
+ return if cancelled? # Notification should already have been sent out
160
+ apply_notification
161
+ nil
162
+ end
163
+ end
164
+ end
@@ -0,0 +1,85 @@
1
+ module Demiurge
2
+ # A Location is generally found inside a Zone. It may contain items
3
+ # and agents.
4
+ class Location < Container
5
+ # Constructor - set up exits
6
+ #
7
+ # @param name [String] The Engine-unique item name
8
+ # @param engine [Demiurge::Engine] The Engine this item is part of
9
+ # @param state [Hash] State data to initialize from
10
+ # @return [void]
11
+ # @since 0.0.1
12
+ def initialize(name, engine, state)
13
+ super
14
+ state["exits"] ||= []
15
+ end
16
+
17
+ def finished_init
18
+ super
19
+ # Make sure we're in our zone.
20
+ zone.ensure_contains(@name)
21
+ end
22
+
23
+ # A Location isn't located "inside" somewhere else. It is located in/at itself.
24
+ def location_name
25
+ @name
26
+ end
27
+
28
+ # A Location isn't located "inside" somewhere else. It is located in/at itself.
29
+ def location
30
+ self
31
+ end
32
+
33
+ def zone_name
34
+ @state["zone"]
35
+ end
36
+
37
+ def zone
38
+ @engine.item_by_name(@state["zone"])
39
+ end
40
+
41
+ # By default, the location can accomodate any agent number, size
42
+ # or shape, as long as it's in this location itself. Subclasses
43
+ # of location may have different abilities to accomodate different
44
+ # sizes or shapes of agent, and at different positions.
45
+ def can_accomodate_agent?(agent, position)
46
+ position == @name
47
+ end
48
+
49
+ # Return a legal position of some kind within this Location. By
50
+ # default there is only one position, which is just the Location's
51
+ # name. More complicated locations (e.g. tilemaps or procedural
52
+ # areas) may have more interesting positions inside them.
53
+ def any_legal_position
54
+ @name
55
+ end
56
+
57
+ # Is this position valid in this location?
58
+ def valid_position?(pos)
59
+ pos == @name
60
+ end
61
+
62
+ def add_exit(from:any_legal_position, to:, to_location: nil, properties:{})
63
+ to_loc, to_coords = to.split("#",2)
64
+ if to_location == nil
65
+ to_location = @engine.item_by_name(to_loc)
66
+ end
67
+ raise("'From' position #{from.inspect} is invalid when adding exit to #{@name.inspect}!") unless valid_position?(from)
68
+ raise("'To' position #{to.inspect} is invalid when adding exit to #{@name.inspect}!") unless to_location.valid_position?(to)
69
+ exit_obj = { "from" => from, "to" => to, "properties" => properties }
70
+ @state["exits"] ||= []
71
+ @state["exits"].push(exit_obj)
72
+ exit_obj
73
+ end
74
+
75
+ # This isn't guaranteed to be in a particular format for all
76
+ # Locations everywhere. Sometimes exits in this form don't even
77
+ # make sense. So: this is best-effort when queried from a random
78
+ # Location, and a known format only if you know the specific
79
+ # subclass of Location you're dealing with.
80
+ def exits
81
+ @state["exits"]
82
+ end
83
+
84
+ end
85
+ end
@@ -0,0 +1,93 @@
1
+ module Demiurge
2
+ # Notifications use string identifiers as names. It's legal to use
3
+ # strings directly, but then typos can go undetected. This also
4
+ # serves as a list of what notifications are normally
5
+ # available. Application-specific notifications should define their
6
+ # own notification constants, either in this module or another one.
7
+ #
8
+ # @since 0.2.0
9
+ module Notifications
10
+
11
+ # This notification is sent when something is misconfigured, but
12
+ # not in a continuity-threatening way.
13
+ #
14
+ # @since 0.2.0
15
+ AdminWarning = "admin_warning"
16
+
17
+ # This notification indicates that a tick has completed.
18
+ #
19
+ # @since 0.2.0
20
+ TickFinished = "tick_finished"
21
+
22
+ # This notification indicates that a new item has been registered
23
+ # by the engine.
24
+ #
25
+ # @since 0.2.0
26
+ NewItem = "new_item"
27
+
28
+ # This notification means that state loading has begun into an
29
+ # initialized engine.
30
+ #
31
+ # @since 0.2.0
32
+ LoadStateStart = "load_state_start"
33
+
34
+ # This notification means that state loading has finished in an
35
+ # initialized engine.
36
+ #
37
+ # @since 0.2.0
38
+ LoadStateEnd = "load_state_end"
39
+
40
+ # This notification is sent when a World File reload is preparing
41
+ # to start. This will be sent on verify-only reloads as well as
42
+ # normal reloads.
43
+ #
44
+ # @since 0.2.0
45
+ LoadWorldVerify = "load_world_verify"
46
+
47
+ # This notification is sent when a World File reload has
48
+ # successfully verified and has begun loading. Verify-only reloads
49
+ # do not send this signal.
50
+ #
51
+ # @since 0.2.0
52
+ LoadWorldStart = "load_world_start"
53
+
54
+ # This notification is sent when a World File reload has
55
+ # successfully completed. Verify-only reloads do not send this
56
+ # signal.
57
+ #
58
+ # @since 0.2.0
59
+ LoadWorldEnd = "load_world_end"
60
+
61
+ # This notification is sent when an agent moves between positions,
62
+ # locations and/or zones. This notification goes out at the old
63
+ # location and zone, which may be the same as the new.
64
+ #
65
+ # Fields: new_position (String), old_position (String), new_location (String), old_location (String), zone (String)
66
+ #
67
+ # @since 0.2.0
68
+ MoveFrom = "move_from"
69
+
70
+ # This notification is sent when an agent moves between positions,
71
+ # locations and/or zones. This notification goes out at the new
72
+ # location and zone, which may be the same as the old.
73
+ #
74
+ # Fields: new_position (String), old_position (String), new_location (String), old_location (String), zone (String)
75
+ #
76
+ # @since 0.2.0
77
+ MoveTo = "move_to"
78
+
79
+ # This notification is sent when an intention has been cancelled.
80
+ #
81
+ # Fields: reason (String), by (Array<String>), id (Integer), intention_type (String), info (Hash)
82
+ #
83
+ # @since 0.2.0
84
+ IntentionCancelled = "intention_cancelled"
85
+
86
+ # This notification is sent when an intention is successfully applied.
87
+ #
88
+ # Fields: id (Integer), intention_type (String), info (Hash)
89
+ #
90
+ # @since 0.2.0
91
+ IntentionApplied = "intention_applied"
92
+ end
93
+ end