Class: Lich::Common::XMLParser

Inherits:
Object
  • Object
show all
Defined in:
documented/common/xmlparser.rb

Overview

Parses the game server's XML stream and extracts game state: character stats, room contents, inventory, active spells, and combat data.

Implements the Ox SAX parser interface (start_element, attr, attrs_done, end_element, cdata, error) to process the server stream in real time. Tracks creature status, item containers, and collision-free room roster updates. Maintains dual-game support (GemStone IV and DragonRealms) with game-specific adaptations.

Constant Summary collapse

DECADE =

Number of seconds in a decade (10 years); used as the expiry duration for infinite-duration spell effects.

10 * 31_536_000
PSM_3_DIALOG_IDS =

The four dialog types tracked in PSM 3.0 (player status module).

These strings appear as tags and partition active effects into Buffs (positive), Active Spells (combat spells), Debuffs (negative), and Cooldowns (ability recovery times).

["Buffs", "Active Spells", "Debuffs", "Cooldowns"]
ASSESS_RANGES =

assess stream parsing

{ 'melee' => :melee, 'pole weapon' => :pole, 'missile' => :missile }.freeze
ASSESS_RELATION =

Pattern that matches the positional relationship between a creature and its target in an assess stream line.

Captures the relation (e.g. "flanking", "facing") and the target name. Used by #parse_assess_line to extract tactical positioning from the combat situation stream.

Examples:

"flanking a merchant" =~ ASSESS_RELATION
#=> Regexp::MatchData with relation="flanking", target="a merchant"

See Also:

/^(?<relation>moving to flank|flanking|facing|behind|in front of|beside|advancing on|next to|to (?:the )?(?:left|right) of)\s+(?<target>.+)$/.freeze
@@warned_deprecated_spellfront =
0

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initializevoid

Initializes the parser with empty buffers and default state.

All instance variables are initialized to their zero/empty equivalents: numeric stats (health, mana, stamina) to 0; strings to empty String; collections to empty Array or Hash. This allows safe reads before the first game tag arrives. Server time is set to the current time; server_time_offset will be computed when the first tag arrives.



50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
# File 'documented/common/xmlparser.rb', line 50

def initialize
  @buffer = String.new
  # @unescape = { 'lt' => '<', 'gt' => '>', 'quot' => '"', 'apos' => "'", 'amp' => '&' }
  @bold = false
  @active_tags = Array.new
  @active_ids = Array.new
  @last_tag = String.new
  @last_id = String.new
  @current_stream = String.new
  @current_style = String.new
  @stow_container_id = nil
  @obj_location = nil
  @obj_exist = nil
  @obj_noun = nil
  @obj_before_name = nil
  @obj_name = nil
  @obj_after_name = nil
  @pc = nil
  @pc_status = nil
  @last_obj = nil
  @last_npc = nil
  @in_stream = false
  @player_status = nil
  @fam_mode = String.new
  @room_window_disabled = false
  @wound_gsl = String.new
  @scar_gsl = String.new
  @send_fake_tags = false
  @prompt = String.new
  @nerve_tracker_num = 0
  @nerve_tracker_active = 'no'
  @server_time = Time.now.to_i
  @server_time_offset = 0.0
  @roundtime_end = 0
  @cast_roundtime_end = 0
  @last_pulse = Time.now.to_i
  @level = 0
  @next_level_value = 0
  @next_level_text = String.new
  @current_target_ids = Array.new
  @pending_crtr_status = Hash.new
  # DragonRealms stream-order name backfill: bold room-objs names and the
  # <crtrStatus> batch ids are both captured in order, then paired at the
  # following <prompt> - but only when their counts match exactly (an
  # all-or-nothing gate; see the prompt handler). DR-only; unused by
  # GemStone, which carries the name inline on the bold <a> tag.
  @dr_room_npc_names = []
  @dr_crtr_ids = []

  @room_count = 0
  @room_title = String.new
  @room_name = String.new
  @room_description = String.new
  @room_exits = Array.new
  @room_exits_string = String.new
  @room_climate = 0
  @room_terrain = 0
  @room_weather = 0
  @room_bonfire = 0
  @room_inside = 0
  @room_water = 0
  @room_sanctuary = 0
  @room_realm = 0

  @familiar_room_title = String.new
  @familiar_room_description = String.new
  @familiar_room_exits = Array.new

  @bounty_task = String.new
  @society_task = String.new

  @dr_active_spells = Hash.new
  @dr_active_spells_clear = false
  @dr_active_spells_tmp = Hash.new
  @dr_active_spell_tracking = false
  @dr_active_spells_stellar_percentage = 0
  @dr_active_spells_slivers = false
  @name = String.new
  @game = String.new
  @player_id = String.new
  @mana = 0
  @max_mana = 0
  @health = 0
  @max_health = 0
  @spirit = 0
  @max_spirit = 0
  @last_spirit = nil
  @stamina = 0
  @max_stamina = 0
  @concentration = 0
  @max_concentration = 0
  @stance_text = String.new
  @stance_value = 0
  @mind_text = String.new
  @mind_value = 0
  @field_exp = 0
  @max_field_exp = 0
  @ascension_exp = 0
  @exp = 0
  @until_next = 0
  @fashlonae = nil
  @lumnis = nil
  @rpa = nil
  @prepared_spell = 'None'
  @encumbrance_text = String.new
  @encumbrance_full_text = String.new
  @encumbrance_value = 0
  @indicator = Hash.new
  @injuries = { 'back' => { 'scar' => 0, 'wound' => 0 }, 'leftHand' => { 'scar' => 0, 'wound' => 0 }, 'rightHand' => { 'scar' => 0, 'wound' => 0 }, 'head' => { 'scar' => 0, 'wound' => 0 }, 'rightArm' => { 'scar' => 0, 'wound' => 0 }, 'abdomen' => { 'scar' => 0, 'wound' => 0 }, 'leftEye' => { 'scar' => 0, 'wound' => 0 }, 'leftArm' => { 'scar' => 0, 'wound' => 0 }, 'chest' => { 'scar' => 0, 'wound' => 0 }, 'leftFoot' => { 'scar' => 0, 'wound' => 0 }, 'rightFoot' => { 'scar' => 0, 'wound' => 0 }, 'rightLeg' => { 'scar' => 0, 'wound' => 0 }, 'neck' => { 'scar' => 0, 'wound' => 0 }, 'leftLeg' => { 'scar' => 0, 'wound' => 0 }, 'nsys' => { 'scar' => 0, 'wound' => 0 }, 'rightEye' => { 'scar' => 0, 'wound' => 0 } }
  @injury_mode = 0

  # psm 3.0 dialogdata updates
  @dialogs = {}

  # real id updates
  # Default 0 (not nil) so a read before the first <nav> tag is a valid "no UID" value
  # rather than a NoMethodError on room_id.zero? (DR) or room_id > N (GS) in the map layer.
  @room_id = 0
  @previous_nav_rm = nil
  # True once <nav> has supplied a real (non-zero) UID for the current
  # arrival and the streamWindow subtitle has not yet consumed it. Keeps a
  # ShowRoomID-on subtitle from overwriting an authoritative nav UID in the
  # same arrival (see the streamWindow handler).
  @nav_uid_pending = false
  # True when the game itself embedded the RealID marker in the room title
  # this arrival (i.e. the DragonRealms ShowRoomID flag is ON), e.g. the
  # subtitle " - [Room] (230008)" or the no-UID " - [Room] (**)". Distinct
  # from a UID merely known via <nav>: it records whether the *game* chose
  # to display it, so Lich's room-name/subtitle rewrite can preserve that
  # choice instead of second-guessing the flag (see the DR streamWindow
  # branch and Lich::DragonRealms::GameInstance#modify_room_display).
  @show_room_id = false

  # Lich::Claim update
  @arrival_pcs = []
  @check_obvious_hiding = false
  @room_player_hidden = false

  # assess (combat situation) stream tracking
  @assess = []
  @assess_buffer = nil
  @assess_ids = []

  # Ox SAX bridge state (see start_element/attr/attrs_done/error below):
  # the element/attributes being accumulated and parse errors for the
  # fragment currently being parsed.
  @sax_element = nil
  @sax_attributes = {}
  @sax_parse_errors = []
end

Instance Attribute Details

#arrival_pcsObject (readonly)

Returns the value of attribute arrival_pcs.



22
23
24
# File 'documented/common/xmlparser.rb', line 22

def arrival_pcs
  @arrival_pcs
end

#ascension_expObject (readonly)

Returns the value of attribute ascension_exp.



22
23
24
# File 'documented/common/xmlparser.rb', line 22

def ascension_exp
  @ascension_exp
end

#assessObject (readonly)

Returns the value of attribute assess.



22
23
24
# File 'documented/common/xmlparser.rb', line 22

def assess
  @assess
end

#bounty_taskObject (readonly)

Returns the value of attribute bounty_task.



22
23
24
# File 'documented/common/xmlparser.rb', line 22

def bounty_task
  @bounty_task
end

#cast_roundtime_endObject (readonly)

Returns the value of attribute cast_roundtime_end.



22
23
24
# File 'documented/common/xmlparser.rb', line 22

def cast_roundtime_end
  @cast_roundtime_end
end

#concentrationObject (readonly)

Returns the value of attribute concentration.



22
23
24
# File 'documented/common/xmlparser.rb', line 22

def concentration
  @concentration
end

#current_target_idObject (readonly)

Returns the value of attribute current_target_id.



22
23
24
# File 'documented/common/xmlparser.rb', line 22

def current_target_id
  @current_target_id
end

#current_target_idsObject (readonly)

Returns the value of attribute current_target_ids.



22
23
24
# File 'documented/common/xmlparser.rb', line 22

def current_target_ids
  @current_target_ids
end

#dialogsObject (readonly)

Returns the value of attribute dialogs.



22
23
24
# File 'documented/common/xmlparser.rb', line 22

def dialogs
  @dialogs
end

#dr_active_spellsObject (readonly)

Returns the value of attribute dr_active_spells.



22
23
24
# File 'documented/common/xmlparser.rb', line 22

def dr_active_spells
  @dr_active_spells
end

#dr_active_spells_sliversObject (readonly)

Returns the value of attribute dr_active_spells_slivers.



22
23
24
# File 'documented/common/xmlparser.rb', line 22

def dr_active_spells_slivers
  @dr_active_spells_slivers
end

#dr_active_spells_stellar_percentageObject (readonly)

Returns the value of attribute dr_active_spells_stellar_percentage.



22
23
24
# File 'documented/common/xmlparser.rb', line 22

def dr_active_spells_stellar_percentage
  @dr_active_spells_stellar_percentage
end

#encumbrance_full_textObject (readonly)

Returns the value of attribute encumbrance_full_text.



22
23
24
# File 'documented/common/xmlparser.rb', line 22

def encumbrance_full_text
  @encumbrance_full_text
end

#encumbrance_textObject (readonly)

Returns the value of attribute encumbrance_text.



22
23
24
# File 'documented/common/xmlparser.rb', line 22

def encumbrance_text
  @encumbrance_text
end

#encumbrance_valueObject (readonly)

Returns the value of attribute encumbrance_value.



22
23
24
# File 'documented/common/xmlparser.rb', line 22

def encumbrance_value
  @encumbrance_value
end

#expObject (readonly)

Returns the value of attribute exp.



22
23
24
# File 'documented/common/xmlparser.rb', line 22

def exp
  @exp
end

#familiar_room_descriptionObject (readonly)

Returns the value of attribute familiar_room_description.



22
23
24
# File 'documented/common/xmlparser.rb', line 22

def familiar_room_description
  @familiar_room_description
end

#familiar_room_exitsObject (readonly)

Returns the value of attribute familiar_room_exits.



22
23
24
# File 'documented/common/xmlparser.rb', line 22

def familiar_room_exits
  @familiar_room_exits
end

#familiar_room_titleObject (readonly)

Returns the value of attribute familiar_room_title.



22
23
24
# File 'documented/common/xmlparser.rb', line 22

def familiar_room_title
  @familiar_room_title
end

#fashlonaeObject (readonly)

Returns the value of attribute fashlonae.



22
23
24
# File 'documented/common/xmlparser.rb', line 22

def fashlonae
  @fashlonae
end

#field_expObject (readonly)

Returns the value of attribute field_exp.



22
23
24
# File 'documented/common/xmlparser.rb', line 22

def field_exp
  @field_exp
end

#gameObject (readonly)

Returns the value of attribute game.



22
23
24
# File 'documented/common/xmlparser.rb', line 22

def game
  @game
end

#healthObject (readonly)

Returns the value of attribute health.



22
23
24
# File 'documented/common/xmlparser.rb', line 22

def health
  @health
end

#in_streamObject (readonly)

Returns the value of attribute in_stream.



22
23
24
# File 'documented/common/xmlparser.rb', line 22

def in_stream
  @in_stream
end

#indicatorObject (readonly)

Returns the value of attribute indicator.



22
23
24
# File 'documented/common/xmlparser.rb', line 22

def indicator
  @indicator
end

#injuriesObject (readonly)

Returns the value of attribute injuries.



22
23
24
# File 'documented/common/xmlparser.rb', line 22

def injuries
  @injuries
end

#injury_modeObject (readonly)

Returns the value of attribute injury_mode.



22
23
24
# File 'documented/common/xmlparser.rb', line 22

def injury_mode
  @injury_mode
end

#last_pulseObject (readonly)

Returns the value of attribute last_pulse.



22
23
24
# File 'documented/common/xmlparser.rb', line 22

def last_pulse
  @last_pulse
end

#last_spiritObject (readonly)

Returns the value of attribute last_spirit.



22
23
24
# File 'documented/common/xmlparser.rb', line 22

def last_spirit
  @last_spirit
end

#levelObject (readonly)

Returns the value of attribute level.



22
23
24
# File 'documented/common/xmlparser.rb', line 22

def level
  @level
end

#lumnisObject (readonly)

Returns the value of attribute lumnis.



22
23
24
# File 'documented/common/xmlparser.rb', line 22

def lumnis
  @lumnis
end

#manaObject (readonly)

Returns the value of attribute mana.



22
23
24
# File 'documented/common/xmlparser.rb', line 22

def mana
  @mana
end

#max_concentrationObject (readonly)

Returns the value of attribute max_concentration.



22
23
24
# File 'documented/common/xmlparser.rb', line 22

def max_concentration
  @max_concentration
end

#max_field_expObject (readonly)

Returns the value of attribute max_field_exp.



22
23
24
# File 'documented/common/xmlparser.rb', line 22

def max_field_exp
  @max_field_exp
end

#max_healthObject (readonly)

Returns the value of attribute max_health.



22
23
24
# File 'documented/common/xmlparser.rb', line 22

def max_health
  @max_health
end

#max_manaObject (readonly)

Returns the value of attribute max_mana.



22
23
24
# File 'documented/common/xmlparser.rb', line 22

def max_mana
  @max_mana
end

#max_spiritObject (readonly)

Returns the value of attribute max_spirit.



22
23
24
# File 'documented/common/xmlparser.rb', line 22

def max_spirit
  @max_spirit
end

#max_staminaObject (readonly)

Returns the value of attribute max_stamina.



22
23
24
# File 'documented/common/xmlparser.rb', line 22

def max_stamina
  @max_stamina
end

#mind_textObject (readonly)

Returns the value of attribute mind_text.



22
23
24
# File 'documented/common/xmlparser.rb', line 22

def mind_text
  @mind_text
end

#mind_valueObject (readonly)

Returns the value of attribute mind_value.



22
23
24
# File 'documented/common/xmlparser.rb', line 22

def mind_value
  @mind_value
end

#nameObject (readonly)

Returns the value of attribute name.



22
23
24
# File 'documented/common/xmlparser.rb', line 22

def name
  @name
end

#next_level_textObject (readonly)

Returns the value of attribute next_level_text.



22
23
24
# File 'documented/common/xmlparser.rb', line 22

def next_level_text
  @next_level_text
end

#next_level_valueObject (readonly)

Returns the value of attribute next_level_value.



22
23
24
# File 'documented/common/xmlparser.rb', line 22

def next_level_value
  @next_level_value
end

#player_idObject (readonly)

Returns the value of attribute player_id.



22
23
24
# File 'documented/common/xmlparser.rb', line 22

def player_id
  @player_id
end

#prepared_spellObject (readonly)

Returns the value of attribute prepared_spell.



22
23
24
# File 'documented/common/xmlparser.rb', line 22

def prepared_spell
  @prepared_spell
end

#previous_nav_rmObject (readonly)

Returns the value of attribute previous_nav_rm.



22
23
24
# File 'documented/common/xmlparser.rb', line 22

def previous_nav_rm
  @previous_nav_rm
end

#promptObject (readonly)

Returns the value of attribute prompt.



22
23
24
# File 'documented/common/xmlparser.rb', line 22

def prompt
  @prompt
end

#room_bonfireObject (readonly)

Returns the value of attribute room_bonfire.



22
23
24
# File 'documented/common/xmlparser.rb', line 22

def room_bonfire
  @room_bonfire
end

#room_climateObject (readonly)

Returns the value of attribute room_climate.



22
23
24
# File 'documented/common/xmlparser.rb', line 22

def room_climate
  @room_climate
end

#room_countObject (readonly)

Returns the value of attribute room_count.



22
23
24
# File 'documented/common/xmlparser.rb', line 22

def room_count
  @room_count
end

#room_descriptionObject (readonly)

Returns the value of attribute room_description.



22
23
24
# File 'documented/common/xmlparser.rb', line 22

def room_description
  @room_description
end

#room_exitsObject (readonly)

Returns the value of attribute room_exits.



22
23
24
# File 'documented/common/xmlparser.rb', line 22

def room_exits
  @room_exits
end

#room_exits_stringObject (readonly)

Returns the value of attribute room_exits_string.



22
23
24
# File 'documented/common/xmlparser.rb', line 22

def room_exits_string
  @room_exits_string
end

#room_idObject (readonly)

Returns the value of attribute room_id.



22
23
24
# File 'documented/common/xmlparser.rb', line 22

def room_id
  @room_id
end

#room_insideObject (readonly)

Returns the value of attribute room_inside.



22
23
24
# File 'documented/common/xmlparser.rb', line 22

def room_inside
  @room_inside
end

#room_nameObject (readonly)

Returns the value of attribute room_name.



22
23
24
# File 'documented/common/xmlparser.rb', line 22

def room_name
  @room_name
end

#room_player_hiddenObject (readonly)

Returns the value of attribute room_player_hidden.



22
23
24
# File 'documented/common/xmlparser.rb', line 22

def room_player_hidden
  @room_player_hidden
end

#room_realmObject (readonly)

Returns the value of attribute room_realm.



22
23
24
# File 'documented/common/xmlparser.rb', line 22

def room_realm
  @room_realm
end

#room_sanctuaryObject (readonly)

Returns the value of attribute room_sanctuary.



22
23
24
# File 'documented/common/xmlparser.rb', line 22

def room_sanctuary
  @room_sanctuary
end

#room_terrainObject (readonly)

Returns the value of attribute room_terrain.



22
23
24
# File 'documented/common/xmlparser.rb', line 22

def room_terrain
  @room_terrain
end

#room_titleObject (readonly)

Returns the value of attribute room_title.



22
23
24
# File 'documented/common/xmlparser.rb', line 22

def room_title
  @room_title
end

#room_waterObject (readonly)

Returns the value of attribute room_water.



22
23
24
# File 'documented/common/xmlparser.rb', line 22

def room_water
  @room_water
end

#room_weatherObject (readonly)

Returns the value of attribute room_weather.



22
23
24
# File 'documented/common/xmlparser.rb', line 22

def room_weather
  @room_weather
end

#room_window_disabledObject (readonly)

Returns the value of attribute room_window_disabled.



22
23
24
# File 'documented/common/xmlparser.rb', line 22

def room_window_disabled
  @room_window_disabled
end

#roundtime_endObject (readonly)

Returns the value of attribute roundtime_end.



22
23
24
# File 'documented/common/xmlparser.rb', line 22

def roundtime_end
  @roundtime_end
end

#rpaObject (readonly)

Returns the value of attribute rpa.



22
23
24
# File 'documented/common/xmlparser.rb', line 22

def rpa
  @rpa
end

#sax_parse_errorsObject (readonly)

Ox reports parse problems here instead of raising, then keeps parsing, auto-balancing whatever was malformed. Collect the messages so Game.process_xml_data can tell Simu's routine almost-XML from a genuinely truncated (desynced) fragment once the parse completes. The caller clears this between fragments.



499
500
501
# File 'documented/common/xmlparser.rb', line 499

def sax_parse_errors
  @sax_parse_errors
end

#send_fake_tagsObject

Returns the value of attribute send_fake_tags.



37
38
39
# File 'documented/common/xmlparser.rb', line 37

def send_fake_tags
  @send_fake_tags
end

#server_timeObject (readonly)

Returns the value of attribute server_time.



22
23
24
# File 'documented/common/xmlparser.rb', line 22

def server_time
  @server_time
end

#server_time_offsetObject (readonly)

Returns the value of attribute server_time_offset.



22
23
24
# File 'documented/common/xmlparser.rb', line 22

def server_time_offset
  @server_time_offset
end

#show_room_idObject (readonly)

Returns the value of attribute show_room_id.



22
23
24
# File 'documented/common/xmlparser.rb', line 22

def show_room_id
  @show_room_id
end

#society_taskObject (readonly)

Returns the value of attribute society_task.



22
23
24
# File 'documented/common/xmlparser.rb', line 22

def society_task
  @society_task
end

#spiritObject (readonly)

Returns the value of attribute spirit.



22
23
24
# File 'documented/common/xmlparser.rb', line 22

def spirit
  @spirit
end

#staminaObject (readonly)

Returns the value of attribute stamina.



22
23
24
# File 'documented/common/xmlparser.rb', line 22

def stamina
  @stamina
end

#stance_textObject (readonly)

Returns the value of attribute stance_text.



22
23
24
# File 'documented/common/xmlparser.rb', line 22

def stance_text
  @stance_text
end

#stance_valueObject (readonly)

Returns the value of attribute stance_value.



22
23
24
# File 'documented/common/xmlparser.rb', line 22

def stance_value
  @stance_value
end

#stow_container_idObject (readonly)

Returns the value of attribute stow_container_id.



22
23
24
# File 'documented/common/xmlparser.rb', line 22

def stow_container_id
  @stow_container_id
end

#until_nextObject (readonly)

Returns the value of attribute until_next.



22
23
24
# File 'documented/common/xmlparser.rb', line 22

def until_next
  @until_next
end

Instance Method Details

#active_spellsObject

for backwards compatibility



202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
# File 'documented/common/xmlparser.rb', line 202

def active_spells
  z = {}
  XMLData.dialogs.sort.each do |a, b|
    b.each do |k, v|
      case a
      when /Active Spells|Buffs/
        z.merge!(k => v) if k.instance_of?(String)
      when /Cooldowns/
        if k.to_s =~ /Recovery/
          z.merge!(k => v) if k.instance_of?(String)
        else
          z.merge!("#{k} Cooldown" => v) if k.instance_of?(String)
        end
      when /Debuffs/

        # need to deal with that pesky 'Silenced' versus 'Silence' from XML
        if k == "Silenced"
          k = 'Silence'
        end
        z.merge!(k => v) if k.instance_of?(String)
      end
    end
  end
  z
end

#assess_creaturesObject

convenience: just the creatures (positive ids; excludes self and PCs)



441
442
443
# File 'documented/common/xmlparser.rb', line 441

def assess_creatures
  @assess.reject { |e| e[:self] || e[:pc] }
end

#attr(name, value) ⇒ Object

Values are left in Ox's native encoding rather than retagged: REXML handed these callbacks UTF-8 (effectively ASCII for the scrubbed game stream), so force-tagging Windows-1252 was both a divergence from the pre-Ox behavior and the source of the entity corruption. Ox runs with convert_special: false, so XmlEntities.decode restores the standard entities here.



461
462
463
# File 'documented/common/xmlparser.rb', line 461

def attr(name, value)
  @sax_attributes[name] = XmlEntities.decode(value)
end

#attrs_donevoid

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

This method returns an undefined value.

Ox SAX callback: signals the end of attribute accumulation for the current element.

Flushes the accumulated attributes and element name to tag_start, triggering handler logic. Called after start_element and all attr callbacks for a tag, before text or child elements.



473
474
475
# File 'documented/common/xmlparser.rb', line 473

def attrs_done
  tag_start(@sax_element, @sax_attributes)
end

#cdata(value) ⇒ Object

REXML routed CDATA through text handling; do the same.



490
491
492
# File 'documented/common/xmlparser.rb', line 490

def cdata(value)
  text(value)
end

#end_element(name) ⇒ void

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

This method returns an undefined value.

Ox SAX callback: invoked when an XML element closes.

Routes the close event to tag_end, which pops the tag stack and triggers end-of-element handlers (e.g., committing staged GameObj refreshes).

Parameters:

  • name (String)

    the element name (e.g. "prompt", "compass")



485
486
487
# File 'documented/common/xmlparser.rb', line 485

def end_element(name)
  tag_end(name)
end

#error(message, line, column) ⇒ void

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

This method returns an undefined value.

Ox SAX callback: collects XML parse errors.

Ox does not raise on malformed input; instead it auto-balances and reports errors here. Errors are accumulated so that the game layer can distinguish between SIMU's intentional relaxed XML and a genuinely truncated (desynced) fragment once parsing completes.

Parameters:

  • message (String)

    the error description

  • line (Integer)

    the line number in the fragment

  • column (Integer)

    the column number in the fragment



513
514
515
# File 'documented/common/xmlparser.rb', line 513

def error(message, line, column)
  @sax_parse_errors << "#{message} (line #{line}, column #{column})"
end

#make_scar_gslString

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Encodes the character's scar injuries into a binary GSL format string.

Formats 14 body parts (nsys, leftEye, rightEye, back, abdomen, chest, leftHand, rightHand, leftLeg, rightLeg, leftArm, rightArm, neck, head) into a 0b0NNNNNNNNNNNNNNNN binary string where each 2-bit field holds the scar severity (0-3). Severity 0 = no scar; higher values = worsening condition.

Returns:

  • (String)

    binary GSL string (e.g. "0b0000010001000...")

See Also:



304
305
306
# File 'documented/common/xmlparser.rb', line 304

def make_scar_gsl
  @scar_gsl = sprintf("0b0%02b%02b%02b%02b%02b%02b%02b%02b%02b%02b%02b%02b%02b%02b", @injuries['nsys']['scar'], @injuries['leftEye']['scar'], @injuries['rightEye']['scar'], @injuries['back']['scar'], @injuries['abdomen']['scar'], @injuries['chest']['scar'], @injuries['leftHand']['scar'], @injuries['rightHand']['scar'], @injuries['leftLeg']['scar'], @injuries['rightLeg']['scar'], @injuries['leftArm']['scar'], @injuries['rightArm']['scar'], @injuries['neck']['scar'], @injuries['head']['scar'])
end

#make_wound_gslString

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Encodes the character's wound injuries into a binary GSL format string.

Formats 14 body parts (nsys, leftEye, rightEye, back, abdomen, chest, leftHand, rightHand, leftLeg, rightLeg, leftArm, rightArm, neck, head) into a 0b0NNNNNNNNNNNNNNNN binary string where each 2-bit field holds the wound severity (0-3). Severity 0 = no wound; higher values = worsening condition.

Returns:

  • (String)

    binary GSL string (e.g. "0b0000100010000...")

See Also:



290
291
292
# File 'documented/common/xmlparser.rb', line 290

def make_wound_gsl
  @wound_gsl = sprintf("0b0%02b%02b%02b%02b%02b%02b%02b%02b%02b%02b%02b%02b%02b%02b", @injuries['nsys']['wound'], @injuries['leftEye']['wound'], @injuries['rightEye']['wound'], @injuries['back']['wound'], @injuries['abdomen']['wound'], @injuries['chest']['wound'], @injuries['leftHand']['wound'], @injuries['rightHand']['wound'], @injuries['leftLeg']['wound'], @injuries['rightLeg']['wound'], @injuries['leftArm']['wound'], @injuries['rightArm']['wound'], @injuries['neck']['wound'], @injuries['head']['wound'])
end

#parse_assess_line(text, ids) ⇒ Object

Parse a single reconstructed line from the 'assess' (combat situation) stream into a structured entry. ids is the ordered list of look-target ids pulled from the line's tags (subject first, then target). Returns a Hash, or nil for the header / unparseable lines.



389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
# File 'documented/common/xmlparser.rb', line 389

def parse_assess_line(text, ids)
  text = text.to_s.strip
  return nil if text.empty?
  return nil if text =~ /assess your combat situation/i

  # drop the trailing "  | F" face-hint (the F lives in a <d cmd='face #id'> tag);
  # anchored to the pipe + trailing token so a stray '|' mid-line can't eat text
  text = text.sub(/\s+\|\s+\S+\s*$/, '').strip

  m = text.match(/^(?<name>.+?)\s+\((?:(?<number>\d+):\s*)?(?<status>[^)]*)\)\s+(?:is|are)\s+(?<rest>.+?)\s+at\s+(?<range>melee|pole weapon|missile)\s+range\b/i)
  return nil unless m

  name   = m[:name].strip
  number = m[:number] && m[:number].to_i
  status = m[:status].strip
  range  = ASSESS_RANGES[m[:range].downcase]
  rest   = m[:rest].strip

  # the target may carry its own assess number, e.g. "facing a jeol moradu (2)"
  target_number = nil
  if rest =~ /\((\d+)\)\s*$/
    target_number = $1.to_i
    rest = rest.sub(/\s*\(\d+\)\s*$/, '').strip
  end

  if (rm = rest.match(ASSESS_RELATION))
    relation = rm[:relation]
    target   = rm[:target].strip
  else
    relation = rest
    target   = nil
  end
  relation = 'flanking' if relation == 'moving to flank'

  is_self = name.casecmp?('you')
  if is_self
    subject_id = nil
    target_id  = ids[0]
  else
    subject_id = ids[0]
    target_id  = (target && target =~ /^you$/i) ? nil : ids[1]
  end
  is_pc = subject_id.to_s.start_with?('-')

  {
    name: name, id: subject_id, number: number, status: status,
    relation: relation, target: target, target_id: target_id,
    target_number: target_number, range: range, self: is_self, pc: is_pc
  }
end

#parse_psm3_progressbar(kind, attributes) ⇒ void

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

This method returns an undefined value.

Parses a PSM 3.0 progress bar (dialog) and stores its expiry time.

Extracts the spell/effect name and remaining duration from a tag, converting time strings ("HH:MM:SS" or "Indefinite") into absolute Time objects. Stores the entry twice: keyed by name (for display) and by numeric id (for deduplication). Indefinite effects are assigned an expiry of +1 decade; invalid or missing duration returns early without updating the dialog.

Examples:

parse_psm3_progressbar("Buffs", {"id" => "42", "text" => "Aura", "time" => "00:05:30"})

Parameters:

  • kind (String)

    the dialog kind (e.g. "Buffs", "Active Spells", "Cooldowns")

  • attributes (Hash)

    XML attributes including "id" (numeric), "text" (spell name), "time" (duration string: "HH:MM:SS", "Indefinite", "OM", or "Fading")



349
350
351
352
353
354
355
356
357
358
359
360
361
# File 'documented/common/xmlparser.rb', line 349

def parse_psm3_progressbar(kind, attributes)
  @dialogs[kind] ||= {}
  id = attributes["id"].to_i
  name = attributes["text"]
  value = attributes["time"]
  return unless name && value
  # set the expiry for a decade for infinite duration effects
  return @dialogs[kind][name] = @dialogs[kind][id] = Time.now + DECADE if value.downcase.eql?("indefinite")

  # in psm 3.0 progress bars now have second precision!
  hour, minute, second = value.split(':')
  @dialogs[kind][name] = @dialogs[kind][id] = Time.now + (hour.to_i * 3600) + (minute.to_i * 60) + second.to_i
end

#resetvoid

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

This method returns an undefined value.

Clears parser state after a malformed or truncated fragment.

Resets tag/id stacks, the current stream buffer, and any in-flight state (pending creature status, room NPC names, collected creature ids) that could cause mismatches or data corruption if applied to the next fragment. Discards staged GameObj container refreshes that would be published as authoritative by the next tag. Called automatically by the error handler and may be called directly to recover from severe parsing issues.



239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
# File 'documented/common/xmlparser.rb', line 239

def reset
  @active_tags = Array.new
  @active_ids = Array.new
  @current_stream = String.new
  @current_style = String.new
  @sax_parse_errors = []
  # Any staged GameObj refresh still open here is incomplete. Left in
  # place, an interrupted container fill would be published as
  # authoritative by the next <prompt>, so drop the in-flight buffers and
  # keep the previously published snapshot visible instead.
  GameObj.discard_staged_refreshes
  # A <crtrStatus> tag can be fully parsed and cached here while the
  # matching bold <a> text is still in an as-yet-unparsed remainder of
  # the fragment. If a malformed/truncated fragment forces a reset in
  # that window, an uncleared entry would sit here indefinitely and
  # could misapply to an unrelated creature that later reuses the same
  # exist id (ids are recycled - see Creature.targets' notes).
  @pending_crtr_status.clear
  # A reset mid-fragment invalidates the room-objs<->crtrStatus pairing, so
  # drop any captured names and collected ids.
  @dr_room_npc_names = []
  @dr_crtr_ids = []
end

#safe_to_respond?Boolean

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Tests whether the parser is in a safe state to send a game command.

For DragonRealms, returns false if the server stream is actively being received (in_stream), or if bold text or a style tag is open - these indicate that game output is mid-stream and a command response may collide with it. GemStone IV always returns true (the game maintains command/response ordering).

Returns:

  • (Boolean)

    true if safe to send a command now; false if mid-stream (DR only)



272
273
274
275
276
277
278
# File 'documented/common/xmlparser.rb', line 272

def safe_to_respond?
  if @game =~ /^DR/
    !in_stream && !@bold && (!@current_style || @current_style.empty?)
  else
    return true
  end
end

#spellfrontObject

here for backwards compatibility, but spellfront xml isn't sent by the game anymore



1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
# File 'documented/common/xmlparser.rb', line 1474

def spellfront
  if (Time.now.to_i - @@warned_deprecated_spellfront) > 300
    @@warned_deprecated_spellfront = Time.now.to_i
    unless (script_name = Script.current.name)
      script_name = 'unknown script'
    end
    respond "--- warning: #{script_name} is using deprecated method XMLData.spellfront; this method will be removed in a future version of Lich"
  end
  @active_spells.keys
end

#start_element(name) ⇒ Object

Ox::Sax interface: Ox parses the server stream directly into XMLData (see Game.process_xml_data). Ox fires start_element, then attr per attribute, then attrs_done, then text/children, then end_element. Attributes are accumulated and flushed to tag_start (matching the old REXML-style single tag_start(name, hash) call). The game stream is Windows-1252, so byte content is tagged with that encoding; names are ASCII.



451
452
453
454
# File 'documented/common/xmlparser.rb', line 451

def start_element(name)
  @sax_element = name
  @sax_attributes = {}
end

#tag_end(name) ⇒ void

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Note:

Ignores closes with no matching open tag (see comment on line 1233).

This method returns an undefined value.

Processes a closing XML tag and triggers end-of-element handlers.

Pops the tag and id stacks when a close matches the currently-open tag (ignoring stray synthetic closes). Commits staged GameObj refresh buffers (room objects, NPCs, exits) when their components close. Sends GSL format updates and room count increments at the appropriate boundaries (compass close, stream close). Handles DR compass double-pulse and disabled-room-window branches. Catches errors and resets on failure.

Parameters:

  • name (String)

    the element tag name being closed



1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
# File 'documented/common/xmlparser.rb', line 1396

def tag_end(name)
  # Called once per element close. Ox synthesizes an end for a stray closing
  # tag -- a close with no matching open (e.g. a desynced </prompt> whose
  # <prompt> was in a prior, truncated read). tag_start never pushed it (Ox
  # fires no attrs_done for a synthetic start), so popping here would remove
  # the wrong element and the inv/compass end-handlers would fire spuriously.
  # Ignore any close that does not match the currently-open tag.
  return unless @active_tags.last == name

  begin
    if @game =~ /^DR/
      if name == 'compass' and $nav_seen
        $nav_seen = false
        @second_compass = true
      end
      if name == 'compass' and @second_compass
        @second_compass = false
        @room_count += 1
        $room_count += 1
      end
    end

    if name == 'inv'
      if @obj_exist == @obj_location
        if @obj_after_name == 'is closed.'
          GameObj.delete_container(@stow_container_id)
        end
      elsif @obj_exist
        GameObj.new_inv(@obj_exist, @obj_noun, @obj_name, @obj_location, @obj_before_name, @obj_after_name)
      end
    elsif @send_fake_tags and (@active_ids.last == 'room exits')
      gsl_exits = String.new
      @room_exits.each { |exit| gsl_exits.concat(DIRMAP[SHORTDIR[exit]].to_s) }
      $_CLIENT_.puts "\034GSj#{sprintf('%-20s', gsl_exits)}\r\n"
      gsl_exits = nil
    elsif @room_window_disabled and (name == 'compass')
      if defined?(Lich::Claim) && Lich::Claim::Lock.owned?
        if @room_id == 0
          @room_id = Digest::MD5.hexdigest([@room_title, @room_description, @room_exits_string].to_s).to_i(16)
        end
        if @room_player_hidden
          @arrival_pcs.push(:hidden)
          @room_player_hidden = false
        end
        @check_obvious_hiding = false
        Lich::Claim.parser_handle(@room_id, @arrival_pcs)
        Lich::Claim.unlock
      end
      @room_description = @room_description.strip
      @room_exits_string.concat " #{@room_exits.join(', ')}" unless @room_exits.empty?
      if @send_fake_tags
        gsl_exits = String.new
        @room_exits.each { |exit| gsl_exits.concat(DIRMAP[SHORTDIR[exit]].to_s) }
        $_CLIENT_.puts "\034GSj#{sprintf('%-20s', gsl_exits)}\r\n"
        gsl_exits = nil
      end
      @room_count += 1
      $room_count += 1
    end
    # Commit a staged room component when it closes. @active_ids.last is
    # the component's own id at this point (inner <a> ids already popped).
    if (name == 'component') or (name == 'compDef')
      case @active_ids.last
      when 'room objs'    then GameObj.commit_room_objs
      when 'room players' then GameObj.commit_room_players
      when 'room desc'    then GameObj.commit_room_desc
      end
    end
    @last_tag = @active_tags.pop
    @last_id = @active_ids.pop
  rescue
    Lich.log "error: XMLParser.tag_end: #{$!}\n\t#{$!.backtrace.join("\n\t")}"
    sleep 0.1
    reset
  end
end

#tag_start(name, attributes) ⇒ void

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

Note:

Automatically called by attrs_done after attribute accumulation completes.

This method returns an undefined value.

Processes an opening XML tag and its attributes.

Core handler for all incoming game state tags (nav, compass, progressBar, etc.). Updates instance variables (room_id, stats, containers, creature status) and triggers cascading state changes (clearing room roster on nav, committing containers on prompt). Handles game-specific logic branches for GemStone IV vs DragonRealms. Routes creature data to Creature.register or defers status flags for later application. Catches errors, logs them, resets parser state, and continues.

Parameters:

  • name (String)

    the element tag name

  • attributes (Hash)

    the element's attributes as key-value pairs



531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
# File 'documented/common/xmlparser.rb', line 531

def tag_start(name, attributes)
  # This is called once per element by REXML in games.rb
  # https://ruby-doc.org/stdlib-2.6.1/libdoc/rexml/rdoc/REXML/StreamListener.html
  begin
    @active_tags.push(name)
    @active_ids.push(attributes['id'].to_s)

    if name == 'nav'
      Lich::Claim.lock if defined?(Lich::Claim)
      Lich::Gemstone::Overwatch.room_with_hiders_reset if defined?(Lich::Gemstone::Overwatch)
      GameObj.clear_loot
      GameObj.clear_npcs
      GameObj.clear_pcs
      GameObj.clear_room_desc
      # Creature tracks its own room roster independently of GameObj
      # (see lib/gemstone/creature.rb / lib/dragonrealms/creature.rb).
      # Only one game's Creature is ever loaded per session.
      Lich::Gemstone::Creature.clear_room if defined?(Lich::Gemstone::Creature)
      Lich::DragonRealms::Creature.clear_room if defined?(Lich::DragonRealms::Creature)
      # Any <crtrStatus> cached for the room being left is scoped to
      # that room - don't let it survive to misapply if the id gets
      # reused elsewhere.
      @pending_crtr_status.clear
      @check_obvious_hiding = true
      # The <nav rm='NNNN'/> tag is the authoritative room UID for every game, including
      # DragonRealms, which now emits it on every arrival (a plain <nav/> with no rm
      # attribute for a room that has no UID, which yields 0 here). Capturing it on nav
      # gives early room knowledge before the room text streams, and keeps
      # @previous_nav_rm accurate for Map.previous_uid.
      @previous_nav_rm = @room_id
      @room_id = attributes['rm'].to_i
      # A real nav UID this arrival is authoritative; flag it so the
      # streamWindow subtitle below treats its own UID marker as a fallback
      # and does not overwrite this value. A bare <nav/> (no UID, room_id 0)
      # leaves the subtitle free to supply one.
      @nav_uid_pending = @room_id.positive?
      @arrival_pcs = []
      $nav_seen = true
    end

    if name == 'compass'
      if defined?(Lich::Claim) && Lich::Claim::Lock.owned?
        if @room_id == 0
          @room_id = Digest::MD5.hexdigest([@room_title, @room_description, @room_exits_string].to_s).to_i(16)
        end
        if @room_player_hidden
          @arrival_pcs.push(:hidden)
          @room_player_hidden = false
        end
        @check_obvious_hiding = false
        Lich::Claim.parser_handle(@room_id, @arrival_pcs)
        Lich::Claim.unlock
      end
      if @current_stream == 'familiar'
        @fam_mode = String.new
      elsif @room_window_disabled
        @room_exits = Array.new
      end
    end

    if (name == 'compDef') or (name == 'component')
      if attributes['id'] == 'room objs'
        GameObj.begin_room_objs
        Lich::Gemstone::Creature.clear_room if defined?(Lich::Gemstone::Creature)
        # DR rebuilds the roster from the <crtrStatus> batch that follows
        # this component; clearing here gives that batch a clean snapshot.
        Lich::DragonRealms::Creature.clear_room if defined?(Lich::DragonRealms::Creature)
        @pending_crtr_status.clear
        # Start a fresh room-objs<->crtrStatus pairing for this refresh: the
        # bold names captured below and the crtrStatus ids that follow are
        # zipped at the next <prompt>, gated on equal counts.
        @dr_room_npc_names = []
        @dr_crtr_ids = []
      elsif attributes['id'] == 'room players'
        GameObj.begin_room_players
      elsif attributes['id'] == 'room exits'
        @room_exits = Array.new
        @room_exits_string = String.new
      elsif attributes['id'] == 'room desc'
        @room_description = String.new
        GameObj.begin_room_desc
      end
    end

    if name =~ /^(?:a|right|left)$/
      @obj_exist = attributes['exist']
      @obj_noun = attributes['noun']
    end
    if name == 'crtrStatus'
      # Self-closing and self-contained (carries its own id), so it needs
      # no surrounding-tag context, unlike the bolded <a> name path below.
      # Always stash rather than applying directly even when the
      # creature is already known: Creature.register (and the room-in
      # marking it does) only ever runs from the <a> text path below, so
      # syncing here and skipping that path would update the instance's
      # flags correctly while silently leaving it out of the room
      # roster after the next clear_room - it would sync but never
      # reappear in Creature.targets/.in_room. Deferring to the text()
      # handler keeps registration/room-marking and flag application on
      # the same path for both new and already-known creatures.
      #
      # DragonRealms differs: its room-objs carry no per-creature <a exist>
      # tag, so there is no text() path to defer to. Its <crtrStatus> tags
      # arrive batched after the room-objs component and carry their own id,
      # so flags are applied immediately, id-first. The name is backfilled
      # two ways: the stream-order pairing (bold room-objs names zipped to
      # this batch's ids at the next <prompt>, gated on equal counts) and,
      # authoritatively, later from the assess stream (see
      # lib/dragonrealms/creature.rb).
      crtr_id = attributes['exist']
      if crtr_id
        crtr_flags = attributes.reject { |k, _| k == 'exist' }
        if XMLData.game =~ /^DR/
          # Apply flags now (id-first, name-less); collect the id in arrival
          # order so the prompt handler can pair it to a bold name only if
          # the counts match for this refresh.
          Lich::DragonRealms::Creature.sync(crtr_id, crtr_flags) if defined?(Lich::DragonRealms::Creature)
          @dr_crtr_ids << crtr_id
        else
          @pending_crtr_status[crtr_id] = crtr_flags
        end
      end
    end
    if name == 'inv'
      if attributes['id'] == 'stow'
        @obj_location = @stow_container_id
      else
        @obj_location = attributes['id']
      end
      @obj_exist = nil
      @obj_noun = nil
      @obj_name = nil
      @obj_before_name = nil
      @obj_after_name = nil
    end
    if name == 'dialogData' and attributes['clear'] == 't' and PSM_3_DIALOG_IDS.include?(attributes["id"])
      @dialogs[attributes["id"]] ||= {}
      @dialogs[attributes["id"]].clear
      # detect a clear board request for effects, and send to activespell
      ActiveSpell.request_update
    end
    if name == 'resource'
      nil
    end
    if name == 'roommeta'
      @room_weather = attributes['weather'].to_i if attributes['weather']
      @room_bonfire = attributes['bonfire'].to_i if attributes['bonfire']
      @room_inside = attributes['inside'].to_i if attributes['inside']
      @room_water = attributes['water'].to_i if attributes['water']
      @room_sanctuary = attributes['sanctuary'].to_i if attributes['sanctuary']
      @room_realm = attributes['realm'].to_i if attributes['realm']
      @room_climate = attributes['climate'].to_i if attributes['climate']
      @room_terrain = attributes['terrain'].to_i if attributes['terrain']
    end
    if name == 'pushStream'
      @in_stream = true
      @current_stream = attributes['id'].to_s
      if attributes['id'].to_s == 'assess'
        @assess_buffer = String.new
        @assess_ids = []
      end
      if XMLData.game =~ /^GS/
        GameObj.begin_inv if attributes['id'].to_s == 'inv'
        GameObj.begin_reserve if attributes['id'].to_s == 'reserve'
      end
    end

    if name == 'd' && @current_stream == 'assess'
      @assess_ids << $1 if attributes['cmd'].to_s =~ /look #(-?\d+)/
    end
    if name == 'popStream'
      if @current_stream == 'assess' && @assess_buffer
        entry = parse_assess_line(@assess_buffer, @assess_ids)
        if entry
          @assess << entry
          # The assess stream is DragonRealms' only tie between an exist id
          # and a creature name/position, so it backfills the id-first
          # instances created from <crtrStatus>. Self and PCs are skipped.
          if XMLData.game =~ /^DR/ && !entry[:self] && !entry[:pc] && defined?(Lich::DragonRealms::Creature)
            Lich::DragonRealms::Creature.feed_assess(entry)
          end
        end
        @assess_buffer = nil
      end
      if attributes['id'] == 'room'
        unless @room_window_disabled
          @room_count += 1
          $room_count += 1
        end
      end
      @in_stream = false
      # @current_stream still names the closing stream here; commit_* is a
      # no-op when the matching refresh was never opened (e.g. in DR).
      GameObj.commit_inv      if @current_stream == 'inv'
      GameObj.commit_reserve  if @current_stream == 'reserve'
      GameObj.commit_familiar if @current_stream == 'familiar'
      if attributes['id'] == 'bounty'
        @bounty_task.strip!
      end
      @current_stream = String.new
    end
    if name == 'pushBold'
      @bold = true
    end
    if name == 'popBold'
      @bold = false
    end
    if (name == 'streamWindow')
      if (attributes['id'] == 'main') and attributes['subtitle']
        unless attributes['subtitle'].empty? || attributes['subtitle'].nil?
          if XMLData.game =~ /^GS/
            if Lich.display_uid == false && attributes['subtitle'][3..-1] =~ / - \d+$/
              Lich.display_uid = true
            end
            @room_title = '[' + attributes['subtitle'][3..-1].gsub(/ - \d+$/, '') + ']'
          elsif XMLData.game =~ /^DR/
            # - [Bosque Deriel, Hermit's Shacks] (a trailing UID marker is present only when
            # the game's ShowRoomID flag is ON): " (230008)" for a room that has a UID, or
            # " (**)" for a room that has none. The <nav rm=.../> tag is the PRIMARY UID
            # source (see the nav handler above); this subtitle marker is only a FALLBACK
            # for arrivals where nav arrived late, was absent, or was a bare no-uid <nav/>.
            # When nav already supplied a real UID this arrival (@nav_uid_pending), leave
            # room_id alone so the subtitle never clobbers the authoritative nav value; the
            # subtitle consumes that flag either way, so the next arrival's marker (if its
            # nav is missing) is free to act as the fallback. When nav did not supply one, a
            # numeric marker sets the UID and "(**)" clears it to 0 (explicit "no UID",
            # dropping any stale id from a prior room). A ShowRoomID-OFF subtitle has no
            # marker at all, so room_id is left untouched (never write 0 blindly). The title
            # stays UID-free in every case. Guard the match: a blank/identity-less subtitle
            # (e.g. " - ") has no "[...]" and returns nil, so leave the prior title untouched
            # rather than crash.
            room = attributes['subtitle'].match(%r{(?<roomtitle>\[.*?\])(?:\s\((?<uid>\d+|\*+)\))?})
            if room
              @room_title = "[#{room[:roomtitle]}]"
              @room_id = (room[:uid] =~ /\A\d+\z/ ? room[:uid].to_i : 0) if room[:uid] && !@nav_uid_pending
              @nav_uid_pending = false
              # Record whether the game displayed the RealID this arrival (a UID
              # marker present == ShowRoomID ON) so the outbound room-name/subtitle
              # rewrite can preserve that choice GS-style. A marker-less subtitle
              # (flag OFF) leaves the previous room's flag stale, so clear it here.
              @show_room_id = !room[:uid].nil?
            end
          else
            @room_title = String.new
          end
        end
      end
    end
    if name == 'style'
      @current_style = attributes['id']
    end
    if (name == 'clearStream' && attributes['id'] == 'percWindow')
      @dr_active_spells_clear = true
    end

    if (name == 'pushStream' && attributes['id'] == 'percWindow')
      @dr_active_spell_tracking = true
      @dr_active_spells_clear = false
    end

    if name == 'prompt'
      @server_time = attributes['time'].to_i
      @server_time_offset = (Time.now.to_f - @server_time)
      $_CLIENT_.puts "\034GSq#{sprintf('%010d', @server_time)}\r\n" if @send_fake_tags

      # A prompt terminates the command burst and is the reliable close
      # signal for clearContainer/inv container fills, which have no
      # closing tag of their own. No-op when no container refresh is open.
      GameObj.commit_all_containers

      if @dr_active_spell_tracking
        @dr_active_spell_tracking = false
        @dr_active_spells_slivers = false
        @dr_active_spells = @dr_active_spells_tmp
        @dr_active_spells_tmp = {}
      elsif @dr_active_spells_clear
        @dr_active_spells = {}
      end

      # DragonRealms stream-order name backfill, applied as an
      # all-or-nothing batch at the end of the room-objs + crtrStatus
      # sequence. Only when the captured bold-name count exactly matches
      # this batch's crtrStatus id count do we pair them by position; on any
      # mismatch (e.g. a bold room entity that emits no crtrStatus) we skip
      # naming rather than risk a shifted mis-pair - assess still backfills
      # names by id. Reset after every prompt so a later lone crtrStatus
      # batch can't reuse a previous refresh's names.
      if XMLData.game =~ /^DR/ && defined?(Lich::DragonRealms::Creature)
        if !@dr_crtr_ids.empty? && @dr_crtr_ids.length == @dr_room_npc_names.length
          @dr_crtr_ids.each_with_index do |id, i|
            Lich::DragonRealms::Creature[id]&.apply_room_name(@dr_room_npc_names[i])
          end
        end
        @dr_room_npc_names = []
        @dr_crtr_ids = []
      end
    end

    if name == 'clearContainer'
      if attributes['id'] == 'stow'
        GameObj.begin_container(@stow_container_id)
      else
        GameObj.begin_container(attributes['id'])
      end
    end
    if name == 'deleteContainer'
      GameObj.delete_container(attributes['id'])
    end
    if name == 'progressBar'
      if attributes['id'] == 'pbarStance'
        @stance_text = attributes['text'].split.first
        @stance_value = attributes['value'].to_i
        $_CLIENT_.puts "\034GSg#{sprintf('%010d', @stance_value)}\r\n" if @send_fake_tags
      elsif attributes['id'] == 'mana'
        last_mana = @mana
        @mana, @max_mana = attributes['text'].scan(/-?\d+/).collect { |num| num.to_i }
        difference = @mana - last_mana
        # fixme: enhancives screw this up
        unless XMLData.name.empty?
          if (difference == noded_pulse) or (difference == unnoded_pulse) or ((@mana == @max_mana) and (last_mana + noded_pulse > @max_mana))
            @last_pulse = Time.now.to_i
            if @send_fake_tags
              $_CLIENT_.puts "\034GSZ#{sprintf('%010d', (@mana + 1))}\n"
              $_CLIENT_.puts "\034GSZ#{sprintf('%010d', @mana)}\n"
            end
          end
        end
        if @send_fake_tags
          $_CLIENT_.puts "\034GSV#{sprintf('%010d%010d%010d%010d%010d%010d%010d%010d', @max_health.to_i, @health.to_i, @max_spirit.to_i, @spirit.to_i, @max_mana.to_i, @mana.to_i, @wound_gsl, @scar_gsl)}\r\n"
        end
      elsif attributes['id'] == 'stamina'
        @stamina, @max_stamina = attributes['text'].scan(/-?\d+/).collect { |num| num.to_i }
      elsif attributes['id'] == 'mindState'
        @mind_text = attributes['text']
        @mind_value = attributes['value'].to_i
        @field_exp = attributes['field_exp'].to_i if attributes['field_exp']
        @max_field_exp = attributes['max_field_exp'].to_i if attributes['max_field_exp']
        @ascension_exp = attributes['ascension_exp'].to_i if attributes['ascension_exp']
        @exp = attributes['exp'].to_i if attributes['exp']
        @until_next = attributes['until_next'].to_i if attributes['until_next']
        # lumnis and rpa are only sent while active; fashlonae is sent
        # whenever an orb is redeemed (1 = redeemed/inactive, 2 = active).
        # All three are cleared back to nil whenever a fresh mindState
        # progressBar omits them. rpa can be fractional (e.g. 1.5), so it
        # is parsed as a float.
        @fashlonae = attributes['fashlonae'] ? attributes['fashlonae'].to_i : nil
        @lumnis = attributes['lumnis'] ? attributes['lumnis'].to_i : nil
        @rpa = attributes['rpa'] ? attributes['rpa'].to_f : nil
        $_CLIENT_.puts "\034GSr#{MINDMAP[@mind_text]}\r\n" if @send_fake_tags
      elsif attributes['id'] == 'health'
        @health, @max_health = attributes['text'].scan(/-?\d+/).collect { |num| num.to_i }
        $_CLIENT_.puts "\034GSV#{sprintf('%010d%010d%010d%010d%010d%010d%010d%010d', @max_health.to_i, @health.to_i, @max_spirit.to_i, @spirit.to_i, @max_mana.to_i, @mana.to_i, @wound_gsl, @scar_gsl)}\r\n" if @send_fake_tags
      elsif attributes['id'] == 'spirit'
        @last_spirit = @spirit if @last_spirit
        @spirit, @max_spirit = attributes['text'].scan(/-?\d+/).collect { |num| num.to_i }
        @last_spirit = @spirit unless @last_spirit
        $_CLIENT_.puts "\034GSV#{sprintf('%010d%010d%010d%010d%010d%010d%010d%010d', @max_health.to_i, @health.to_i, @max_spirit.to_i, @spirit.to_i, @max_mana.to_i, @mana.to_i, @wound_gsl, @scar_gsl)}\r\n" if @send_fake_tags
      elsif attributes['id'] == 'nextLvlPB'
        Gift.pulse unless @next_level_text == attributes['text']
        @next_level_value = attributes['value'].to_i
        @next_level_text = attributes['text']
      elsif attributes['id'] == 'encumlevel'
        @encumbrance_value = attributes['value'].to_i
        @encumbrance_text = attributes['text']
      elsif attributes['id'] == 'concentration'
        @concentration, @max_concentration = attributes['text'].scan(/-?\d+/).collect { |num| num.to_i }
      elsif PSM_3_DIALOG_IDS.include?(@active_ids[-2])
        # puts "kind=(%s) name=%s attributes=%s" % [@active_ids[-2], name, attributes]
        self.parse_psm3_progressbar(@active_ids[-2], attributes)
        # since we received an updated spell duration, let's signal infomon to update
        ActiveSpell.request_update
      end
    end
    if name == 'roundTime'
      @roundtime_end = attributes['value'].to_i
      $_CLIENT_.puts "\034GSQ#{sprintf('%010d', @roundtime_end)}\r\n" if @send_fake_tags
    end
    if name == 'castTime'
      @cast_roundtime_end = attributes['value'].to_i
    end
    if name == 'dropDownBox'
      if attributes['id'] == 'dDBTarget'
        @current_target_ids.clear
        attributes['content_value'].split(',').each { |t|
          if t =~ /^\#(\-?\d+)(?:,|$)/
            @current_target_ids.push($1)
          end
        }
        if attributes['content_value'] =~ /^\#(\-?\d+)(?:,|$)/
          @current_target_id = $1
        else
          @current_target_id = nil
        end
      end
    end
    if name == 'indicator'
      @indicator[attributes['id']] = attributes['visible']
      if @send_fake_tags
        if attributes['id'] == 'IconPOISONED'
          if attributes['visible'] == 'y'
            $_CLIENT_.puts "\034GSJ0000000000000000000100000000001\r\n"
          else
            $_CLIENT_.puts "\034GSJ0000000000000000000000000000000\r\n"
          end
        elsif attributes['id'] == 'IconDISEASED'
          if attributes['visible'] == 'y'
            $_CLIENT_.puts "\034GSK0000000000000000000100000000001\r\n"
          else
            $_CLIENT_.puts "\034GSK0000000000000000000000000000000\r\n"
          end
        else
          gsl_prompt = String.new; ICONMAP.keys.each { |icon| gsl_prompt += ICONMAP[icon] if @indicator[icon] == 'y' }
          $_CLIENT_.puts "\034GSP#{sprintf('%-30s', gsl_prompt)}\r\n"
        end
      end
    end
    if (name == 'image') and @active_ids.include?('injuries')
      if @injuries.keys.include?(attributes['id'])
        if attributes['name'] =~ /Injury/i
          @injuries[attributes['id']]['wound'] = attributes['name'].slice(/\d/).to_i
        elsif attributes['name'] =~ /Scar/i
          @injuries[attributes['id']]['wound'] = 0
          @injuries[attributes['id']]['scar'] = attributes['name'].slice(/\d/).to_i
        elsif attributes['name'] =~ /Nsys/i
          rank = attributes['name'].slice(/\d/).to_i
          if rank == 0
            @injuries['nsys']['wound'] = 0
            @injuries['nsys']['scar'] = 0
          else
            Thread.new {
              wait_while { dead? }
              action = proc { |server_string|
                if (@nerve_tracker_active == 'maybe')
                  if @nerve_tracker_active == 'maybe'
                    if server_string =~ /^You/
                      @nerve_tracker_active = 'yes'
                      @injuries['nsys']['wound'] = 0
                      @injuries['nsys']['scar'] = 0
                    else
                      @nerve_tracker_active = 'no'
                    end
                  end
                end
                if @nerve_tracker_active == 'yes'
                  if server_string =~ /<output class=['"]['"]\/>/
                    @nerve_tracker_active = 'no'
                    @nerve_tracker_num -= 1
                    DownstreamHook.remove('nerve_tracker') if @nerve_tracker_num < 1
                    $_CLIENT_.puts "\034GSV#{sprintf('%010d%010d%010d%010d%010d%010d%010d%010d', @max_health.to_i, @health.to_i, @max_spirit.to_i, @spirit.to_i, @max_mana.to_i, @mana.to_i, make_wound_gsl, make_scar_gsl)}\r\n" if @send_fake_tags
                    server_string
                  elsif server_string =~ /a case of uncontrollable convulsions/
                    @injuries['nsys']['wound'] = 3
                    nil
                  elsif server_string =~ /a case of sporadic convulsions/
                    @injuries['nsys']['wound'] = 2
                    nil
                  elsif server_string =~ /a strange case of muscle twitching/
                    @injuries['nsys']['wound'] = 1
                    nil
                  elsif server_string =~ /a very difficult time with muscle control/
                    @injuries['nsys']['scar'] = 3
                    nil
                  elsif server_string =~ /constant muscle spasms/
                    @injuries['nsys']['scar'] = 2
                    nil
                  elsif server_string =~ /developed slurred speech/
                    @injuries['nsys']['scar'] = 1
                    nil
                  end
                else
                  if server_string =~ /<output class=['"]mono['"]\/>/
                    @nerve_tracker_active = 'maybe'
                  end
                  server_string
                end
              }
              @nerve_tracker_num += 1
              DownstreamHook.add('nerve_tracker', action, persist: true) # engine-managed, toggled by the parser
              Game._puts "#{$cmd_prefix}health"
            }
          end
        else
          @injuries[attributes['id']]['wound'] = 0
          @injuries[attributes['id']]['scar'] = 0
        end
      end
      $_CLIENT_.puts "\034GSV#{sprintf('%010d%010d%010d%010d%010d%010d%010d%010d', @max_health.to_i, @health.to_i, @max_spirit.to_i, @spirit.to_i, @max_mana.to_i, @mana.to_i, make_wound_gsl, make_scar_gsl)}\r\n" if @send_fake_tags
    end
    if @room_window_disabled and (name == 'dir') and @active_tags.include?('compass')
      @room_exits.push(LONGDIR[attributes['value']])
    end
    if name == 'radio'
      if attributes['id'] == 'injrRad'
        @injury_mode = 0 if attributes['value'] == '1'
      elsif attributes['id'] == 'scarRad'
        @injury_mode = 1 if attributes['value'] == '1'
      elsif attributes['id'] == 'bothRad'
        @injury_mode = 2 if attributes['value'] == '1'
      end
    end
    if name == 'label'
      if attributes['id'] == 'yourLvl'
        @level = attributes['value'].slice(/\d+/).to_i
      elsif attributes['id'] == 'encumblurb'
        @encumbrance_full_text = attributes['value']
      end
    end
    if (name == 'container') and (attributes['id'] == 'stow')
      @stow_container_id = attributes['target'].sub('#', '')
    end
    if (name == 'clearStream')
      if attributes['id'] == 'bounty'
        @bounty_task = String.new
      elsif attributes['id'] == 'assess'
        @assess = []
      end
    end
    if (name == 'playerID')
      @player_id = attributes['id']
      unless Frontend.supports_gsl?
        if Lich.inventory_boxes(@player_id)
          DownstreamHook.remove('inventory_boxes_off')
        end
      end
    end
    if name == 'settingsInfo'
      if (game = attributes['instance'])
        if game == 'GS4'
          @game = 'GSIV'
        elsif (game == 'GSX') or (game == 'GS4X')
          @game = 'GSPlat'
        else
          @game = game # covers DR, DRT, DRF, GST, GSF
        end
      end
    end
    if (name == 'app') and (@name = attributes['char'])
      if @game.nil? or @game.empty?
        @game = 'unknown'
      end
      unless File.exist?("#{DATA_DIR}/#{@game}")
        Dir.mkdir("#{DATA_DIR}/#{@game}")
      end
      unless File.exist?("#{DATA_DIR}/#{@game}/#{@name}")
        Dir.mkdir("#{DATA_DIR}/#{@game}/#{@name}")
      end
      if Frontend.supports_gsl?
        Game._puts "#{$cmd_prefix}_flag Display Dialog Boxes 0"
        sleep 0.05
        Game._puts "#{$cmd_prefix}_injury 2"
        sleep 0.05
        # fixme: game name hardcoded as Gemstone IV; maybe doesn't make any difference to the client
        $_CLIENT_.puts "\034GSB0000000000#{attributes['char']}\r\n\034GSA#{Time.now.to_i}GemStone IV\034GSD\r\n"
        # Sending fake GSL tags to the Wizard FE is disabled until now, because it doesn't accept the tags and just gives errors until initialized with the above line
        @send_fake_tags = true
        # Send all the tags we missed out on
        $_CLIENT_.puts "\034GSV#{sprintf('%010d%010d%010d%010d%010d%010d%010d%010d', @max_health.to_i, @health.to_i, @max_spirit.to_i, @spirit.to_i, @max_mana.to_i, @mana.to_i, make_wound_gsl, make_scar_gsl)}\r\n"
        $_CLIENT_.puts "\034GSg#{sprintf('%010d', @stance_value)}\r\n"
        $_CLIENT_.puts "\034GSr#{MINDMAP[@mind_text]}\r\n"
        gsl_prompt = String.new
        @indicator.keys.each { |icon| gsl_prompt += ICONMAP[icon] if @indicator[icon] == 'y' }
        $_CLIENT_.puts "\034GSP#{sprintf('%-30s', gsl_prompt)}\r\n"
        gsl_prompt = nil
        gsl_exits = String.new
        @room_exits.each { |exit| gsl_exits.concat(DIRMAP[SHORTDIR[exit]].to_s) }
        $_CLIENT_.puts "\034GSj#{sprintf('%-20s', gsl_exits)}\r\n"
        gsl_exits = nil
        $_CLIENT_.puts "\034GSn#{sprintf('%-14s', @prepared_spell)}\r\n"
        $_CLIENT_.puts "\034GSm#{sprintf('%-45s', GameObj.right_hand.name)}\r\n"
        $_CLIENT_.puts "\034GSl#{sprintf('%-45s', GameObj.left_hand.name)}\r\n"
        $_CLIENT_.puts "\034GSq#{sprintf('%010d', @server_time)}\r\n"
        $_CLIENT_.puts "\034GSQ#{sprintf('%010d', @roundtime_end)}\r\n" if @roundtime_end > 0
      end
      Game._puts("#{$cmd_prefix}_flag Display Inventory Boxes 1")
    end
  rescue
    Lich.log "error: XMLParser.tag_start: #{$!}\n\t#{$!.backtrace.join("\n\t")}"
    sleep 0.1
    reset
  end
end

#text(text_string) ⇒ void

This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.

This method returns an undefined value.

Processes text content between XML tags.

Handles multiple state machines in parallel: game stats (mind state, stance), room/inventory content (objects, NPCs, PCs), container filling, and the assess (combat) stream. Decodes standard XML entities and routes text to the appropriate accumulator (room_description, inventory, active spells, etc.) based on the current tag/stream context. Registers creatures and items, annotates status, and handles game-specific parsing branches (DR active spell durations, familiar room tracking, etc.). Catches errors and resets on failure.

Parameters:

  • text_string (String)

    the decoded text node content



1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
# File 'documented/common/xmlparser.rb', line 1126

def text(text_string)
  # Called by Ox once per text node. Decode the standard XML entities (Ox
  # runs with convert_special: false; see Lich::Common::XmlEntities).
  text_string = XmlEntities.decode(text_string)
  begin
    # fixme: /<stream id="Spells">.*?<\/stream>/m
    # $_CLIENT_.write(text_string) unless ($frontend != 'suks') or (@current_stream =~ /^(?:spellfront|inv|bounty|society)$/) or @active_tags.any? { |tag| tag =~ /^(?:compDef|inv|component|right|left|spell)$/ } or (@active_tags.include?('stream') and @active_ids.include?('Spells')) or (text_string == "\n" and (@last_tag =~ /^(?:popStream|prompt|compDef|dialogData|openDialog|switchQuickBar|component)$/))

    # DR Active Spell tracking and handling
    if @dr_active_spell_tracking
      spell = nil
      duration = nil
      case text_string
      when /(?<spell>^[^\(]+)\((?<duration>\d+|Indefinite|OM|Fading)\s*(?:%|roisae?n)?\)/i
        # Spell with known duration remaining
        # XML looks like:
        # Hydra Hex  (Indefinite)
        # Persistence of Mana  (OM)
        # Cure Disease  (Fading)
        # Osrel Meraud  (94%)
        # Landslide (4 roisaen)
        # Khri Sagacity  (1 roisan)
        spell = Regexp.last_match[:spell]
        duration = Regexp.last_match[:duration]

        if duration.match?(/Indefinite|OM/)
          duration = 1000
        elsif duration.match?(/Fading/)
          duration = 0
        else
          duration = duration.to_i
        end
      when /(?<spell>Stellar Collector)\s+\((?<percentage>\d+)%,\s*(?<duration>\d+)?\s*(?<unit>(?:roisae?n|anlaen|fading))/
        # Stellar collector special case
        # XML looks like:
        # Stellar Collector  (0%, 4 anlaen)
        # Stellar Collector  (0%, fading)
        spell = Regexp.last_match[:spell]
        duration = Regexp.last_match[:duration].to_i
        @dr_active_spells_stellar_percentage = Regexp.last_match[:percentage].to_i
        unit = Regexp.last_match[:unit]
        duration = unit == 'anlaen' ? duration * 30 : duration
      when /(?<spell>^[^\(]+)\(.+\)/i
        # Spells with inexact duration verbiage, such as with
        # Barbarians without knowledge of Power Monger mastery
        spell = Regexp.last_match[:spell]
        duration = 1000
      when /.*orbiting sliver.*/i
        # Moon Mage slivers
        @dr_active_spells_slivers = true
      end
      spell.strip!
      if spell
        @dr_active_spells_tmp[spell] = duration
      end
    end

    if @current_style == 'roomName'
      @room_name = text_string.match(/(?<roomname>\[.*?\])/)[:roomname]
    end

    if @active_tags.include?('inv')
      if @active_tags[-1] == 'a'
        @obj_name = text_string
      elsif @obj_name.nil?
        @obj_before_name = text_string.strip
      else
        @obj_after_name = text_string.strip
      end
    elsif @active_tags.last == 'prompt'
      @prompt = text_string
    elsif @active_tags.include?('right')
      GameObj.new_right_hand(@obj_exist, @obj_noun, text_string)
      $_CLIENT_.puts "\034GSm#{sprintf('%-45s', text_string)}\r\n" if @send_fake_tags
    elsif @active_tags.include?('left')
      GameObj.new_left_hand(@obj_exist, @obj_noun, text_string)
      $_CLIENT_.puts "\034GSl#{sprintf('%-45s', text_string)}\r\n" if @send_fake_tags
    elsif @active_tags.include?('spell')
      @prepared_spell = text_string
      $_CLIENT_.puts "\034GSn#{sprintf('%-14s', text_string)}\r\n" if @send_fake_tags
    elsif @active_tags.include?('compDef') or @active_tags.include?('component')
      if @active_ids.include?('room objs')
        if @active_tags.include?('a')
          if @bold
            @last_npc = GameObj.new_npc(@obj_exist, @obj_noun, text_string)
            if XMLData.game =~ /^DR/
              # Future-proofing: DragonRealms room-objs creatures currently
              # carry no <a> tag, but if that ever changes to GemStone's
              # <a exist noun> shape, register id-first with the inline
              # name/noun via DR's own Creature (never the unqualified
              # Gemstone Creature below, which is not loaded in DR). With an
              # <a> present the stream-order capture branch is skipped, so
              # this becomes the sole name source - no double-set.
              if @obj_exist && defined?(Lich::DragonRealms::Creature)
                dr_creature = Lich::DragonRealms::Creature.register(text_string, @obj_exist, @obj_noun)
                dr_creature&.apply_room_name(text_string)
              end
            elsif XMLData.current_target_ids.include?(@obj_exist) || @pending_crtr_status.key?(@obj_exist)
              creature = Creature.register(text_string, @obj_exist, @obj_noun)
              if creature && (pending_flags = @pending_crtr_status.delete(@obj_exist))
                creature.sync_crtr_status(pending_flags)
              end
            end
          else
            GameObj.new_loot(@obj_exist, @obj_noun, text_string)
          end
        elsif @bold && XMLData.game =~ /^DR/
          # DragonRealms room-objs bold NPC names carry no <a> tag. Capture
          # them in stream order so the <crtrStatus> batch that follows can
          # pair the Nth name to the Nth id (see the crtrStatus handler).
          # Only bold runs are names; the non-bold "(dead)"/"(immobile)"
          # status runs fall through to the annotation branch below.
          @dr_room_npc_names << text_string
        elsif (text_string =~ /that (?:is|appears) ([\w\s]+)(?:,| and|\.)/) or (text_string =~ / \(([^\(]+)\)/)
          # @last_npc is nil in DragonRealms here (DR room-objs bold names
          # carry no <a> tag, so the new_npc branch above never runs and
          # never sets it), so the &. keeps this a safe no-op in DR while
          # still annotating the last GemStone npc.
          @last_npc&.status = $1
        end
      elsif @active_ids.include?('room players')
        if @active_tags.include?('a')
          if @obj_exist.to_s.start_with?('-')
            @pc = GameObj.new_pc(@obj_exist, @obj_noun, "#{@player_title}#{text_string}", @player_status)
            # Track the status we just staged for this pc. The annotation
            # branch below appends to this instead of reading it back
            # through GameObj#status, which cannot see the in-flight value.
            @pc_status = @player_status
            @arrival_pcs.push(@pc.noun) if (defined?(Lich::Claim) && Lich::Claim::Lock.owned?)
          else
            @pc = nil
            @pc_status = nil
          end
          @player_status = nil
          @player_title = nil
        else
          if @game =~ /^DR/
            GameObj.begin_room_players
            text_string.sub(/^Also here\: /, '').sub(/ and ([^,]+)\./) { ", #{$1}" }.split(', ').each { |player|
              if player =~ / who is (.+)/
                status = $1
                player.sub!(/ who is .+/, '')
              elsif player =~ / \((.+)\)/
                status = $1
                player.sub!(/ \(.+\)/, '')
              else
                status = nil
              end
              noun = player.slice(/\b[A-Z][a-z]+$/)
              if player =~ /the body of /
                player.sub!('the body of ', '')
                if status
                  status.concat ' dead'
                else
                  status = 'dead'
                end
              end
              if player =~ /a stunned /
                player.sub!('a stunned ', '')
                if status
                  status.concat ' stunned'
                else
                  status = 'stunned'
                end
              end
              GameObj.new_pc(nil, noun, player, status)
            }
            GameObj.commit_room_players
          else
            if @pc && ((text_string =~ /^ who (?:is|appears) ([\w\s]+)(?:,| and|\.|$)/) || (text_string =~ / \(([\w\s]+)\)(?: \(([\w\s]+)\))?/))
              @pc_status = @pc_status ? "#{@pc_status} #{$1}" : $1
              @pc_status = "#{@pc_status} #{$2}" if $2
              @pc.status = @pc_status
            end
            if text_string =~ /(?:^Also here: |, )(?:a )?([a-z\s]+)?([\w\s\-!\?',]+)?$/
              @player_status = ($1.strip.gsub('the body of', 'dead')) if $1
              @player_title = $2
            end
          end
        end
      elsif @active_ids.include?('room desc')
        if text_string == '[Room window disabled at this location.]'
          @room_window_disabled = true
        else
          @room_window_disabled = false
          @room_description.concat(text_string)
          if @active_tags.include?('a')
            GameObj.new_room_desc(@obj_exist, @obj_noun, text_string)
          end
        end
      elsif @active_ids.include?('room exits')
        @room_exits_string.concat(text_string)
        @room_exits.push(text_string) if @active_tags.include?('d')
      end
    elsif @current_stream == 'bounty'
      @bounty_task += text_string
    elsif @current_stream == 'society'
      @society_task = text_string
    elsif @current_stream == 'assess'
      @assess_buffer.concat(text_string) if @assess_buffer
    elsif (@current_stream == 'inv') and @active_tags.include?('a')
      GameObj.new_inv(@obj_exist, @obj_noun, text_string, nil)
    elsif (@current_stream == 'reserve') and @active_tags.include?('a')
      GameObj.new_reserve(@obj_exist, @obj_noun, text_string)
    elsif @check_obvious_hiding && text_string =~ /obvious signs of someone hiding/
      @room_player_hidden = true
    elsif @current_stream == 'familiar'
      # fixme: familiar room tracking does not (can not?) auto update, status of pcs and npcs isn't tracked at all, titles of pcs aren't tracked
      if @current_style == 'roomName'
        @familiar_room_title = text_string
        @familiar_room_description = String.new
        @familiar_room_exits = Array.new
        GameObj.begin_familiar
        @fam_mode = String.new
      elsif @current_style == 'roomDesc'
        @familiar_room_description.concat(text_string)
        if @active_tags.include?('a')
          GameObj.new_fam_room_desc(@obj_exist, @obj_noun, text_string)
        end
      elsif text_string =~ /^You also see/
        @fam_mode = 'things'
      elsif text_string =~ /^Also here/
        @fam_mode = 'people'
      elsif text_string =~ /Obvious (?:paths|exits)/
        @fam_mode = 'paths'
      elsif @fam_mode == 'things'
        if @active_tags.include?('a')
          if @bold
            GameObj.new_fam_npc(@obj_exist, @obj_noun, text_string)
          else
            GameObj.new_fam_loot(@obj_exist, @obj_noun, text_string)
          end
        end
        # puts 'things: ' + text_string
      elsif @fam_mode == 'people' and @active_tags.include?('a')
        GameObj.new_fam_pc(@obj_exist, @obj_noun, text_string)
        # puts 'people: ' + text_string
      elsif (@fam_mode == 'paths') and @active_tags.include?('a')
        @familiar_room_exits.push(text_string)
      end
    elsif @room_window_disabled
      if @current_style == 'roomDesc'
        @room_description.concat(text_string)
        if @active_tags.include?('a')
          GameObj.new_room_desc(@obj_exist, @obj_noun, text_string)
        end
      elsif text_string =~ /^Obvious (?:paths|exits): (?:none)?$/
        @room_exits_string = text_string.strip
      end
    end
  rescue
    Lich.log "error: XMLParser.text: #{$!}\n\t#{$!.backtrace.join("\n\t")}"
    sleep 0.1
    reset
  end
end