Module: Lich::Gemstone::Combat::Parser

Defined in:
documented/gemstone/combat/parser.rb

Constant Summary collapse

/<a exist="(?<id>[^"]+)" noun="(?<noun>[^"]+)">(?<name>[^<]+)<\/a>/i.freeze
BOLD_WRAPPER_PATTERN =

Bold tag pattern - creatures are wrapped in bold tags Non-greedy match to avoid spanning multiple creatures Allow zero or more characters before tag (e.g., "a creature" or just "creature")

/<pushBold\/>([^<]*<a exist="[^"]+"[^>]+>[^<]+<\/a>)<popBold\/>/i.freeze
SELF_TARGET_PATTERN =

A target capture that refers to US rather than a creature. Anchored at the start of the capture: real forms observed in session logs are "you", "your " and tails like "you with its tusk" - but NOT a creature whose name merely contains the word ("a youngling"), hence the \b and the anchor.

/\A(?:you|your)\b/i.freeze
SELF_IN_PATTERN =

Some inbound defs name us in the pattern LITERAL rather than in a target capture (" springs from the shadows and strikes at you!", "... plants ... near you"). They have an attacker capture and no target capture, so without this they too fall through to the line-scan and self-attribute. A def that captures an attacker and addresses "you"/"your" in its own source is aimed at us by construction.

/\b(?:at|towards?|upon|around|near|on)\s+your?\b/i.freeze
SWING_WEAPON_PATTERN =

The weapon a swing line names, in plain text (swing lines do not link the weapon): "You swing a kelyn-edged slim short sword at ..." - used to claim pre-flares by weapon.

/You(?: take aim and)? (?:swing|fire) (?:an? |your |some )?(?<weapon>[^<]+?) at </.freeze

Class Method Summary collapse

Class Method Details

.extract_attacker_from_match(match) ⇒ Object

Attacker from a third-person def's (?...) capture. Unlike targets, player attackers have NEGATIVE exist ids, so no id sign check; plain text (pre-stripped logs) falls back to name.



187
188
189
190
191
192
193
194
195
196
197
198
# File 'documented/gemstone/combat/parser.rb', line 187

def extract_attacker_from_match(match)
  return nil unless match.names.include?('attacker')

  text = match[:attacker]
  return nil if text.nil? || text.strip.empty?

  if (link = TARGET_LINK_PATTERN.match(text))
    { id: link[:id].to_i, noun: link[:noun], name: link[:name] }
  else
    { name: strip_links(text).strip }
  end
end

.extract_creature_target(line) ⇒ Object

Extract creature target (must be wrapped in bold tags)



322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
# File 'documented/gemstone/combat/parser.rb', line 322

def extract_creature_target(line)
  # Cheap gate: the wrapper pattern can't match without the bold tag,
  # and the substring check avoids two regexes on most lines
  return nil unless line.include?('<pushBold/>')

  # Check if line contains a bolded link
  bold_match = BOLD_WRAPPER_PATTERN.match(line)
  return nil unless bold_match

  # Extract the link from within the bold tags
  link_text = bold_match[1]
  link_match = TARGET_LINK_PATTERN.match(link_text)
  return nil unless link_match

  id = link_match[:id].to_i
  return nil if id <= 0 # Skip invalid IDs

  {
    id: id,
    noun: link_match[:noun],
    name: link_match[:name]
  }
end

.extract_target_from_line(line) ⇒ Hash?

Extracts a creature target from a combat line by looking for a bolded link.

Only creature links wrapped in tags are accepted as valid targets. Non-bolded links (equipment, objects, or other non-combatants) are rejected.

Parameters:

  • line (String) —

    a combat output line

Returns:

  • (Hash, nil) —

    a hash with keys :id (Integer), :noun (String), and :name (String) if a bolded creature link is found; nil otherwise



374
375
376
377
378
# File 'documented/gemstone/combat/parser.rb', line 374

def extract_target_from_line(line)
  # ONLY accept bolded creatures as targets
  # Non-bolded links are equipment, objects, or other non-combatants
  extract_creature_target(line)
end

.extract_target_from_match(match) ⇒ Object

Try to extract target from regex match first, then from line



347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
# File 'documented/gemstone/combat/parser.rb', line 347

def extract_target_from_match(match)
  return nil unless match.names.include?('target')
  target_text = match[:target]
  return nil if target_text.nil? || target_text.strip.empty?

  # Look for creature in target text
  if (target_match = TARGET_LINK_PATTERN.match(target_text))
    id = target_match[:id].to_i
    return nil if id < 0

    return {
      id: id,
      noun: target_match[:noun],
      name: target_match[:name]
    }
  end

  nil
end

.inbound_attack?(line) ⇒ Boolean

True when this line is an attack aimed at US. Used by the processor to keep the attacker's own link out of the target-switcher (see the inbound_line note there).

Returns:

  • (Boolean)


173
174
175
176
177
178
179
180
181
182
# File 'documented/gemstone/combat/parser.rb', line 173

def inbound_attack?(line)
  return false if Definitions::Attacks.rejects?(line)

  Definitions::Attacks::ATTACK_LOOKUP.each do |pattern, _name|
    if (match = pattern.match(line))
      return self_target?(match)
    end
  end
  false
end

.parse_assault_end(line) ⇒ Hash?

Parses a single-target multi-round assault bracket closing line.

Identifies the end of assault sequences (flurry, barrage, pummel, guardant thrusts, thrash) that span multiple rounds against one target.

Parameters:

  • line (String) —

    a combat output line

Returns:

  • (Hash, nil) —

    a hash with assault metadata if the line closes an assault; nil otherwise

See Also:



261
262
263
# File 'documented/gemstone/combat/parser.rb', line 261

def parse_assault_end(line)
  Definitions::Assaults.parse_end(line)
end

.parse_assault_start(line) ⇒ Object

Single-target multi-round assault brackets (flurry, barrage, pummel, guardant thrusts, thrash) - see defs/assaults.rb



249
250
251
# File 'documented/gemstone/combat/parser.rb', line 249

def parse_assault_start(line)
  Definitions::Assaults.parse_start(line)
end

.parse_attack(line) ⇒ Object

Parse attack initiation



41
42
43
44
45
46
47
48
49
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
# File 'documented/gemstone/combat/parser.rb', line 41

def parse_attack(line)
  return nil if Definitions::Attacks.rejects?(line)

  Definitions::Attacks::ATTACK_LOOKUP.each do |pattern, name|
    if (match = pattern.match(line))
      # An inbound attack (creature -> us) names US as its target.
      # Its only creature link is the ATTACKER, so the line-scan
      # fallback below would install the attacker as its own
      # target and apply its damage/crits to itself. Resolve the
      # target as us and stop - never fall through.
      # Environmental / self-inflicted defs (SELF_INFLICTED) are
      # damage to us by construction - no attacker, no "you"
      # capture - unless this particular pattern named someone
      # else (a nearby player taking the same tick).
      self_inflicted = Definitions::Attacks::SELF_INFLICTED.include?(name) &&
                       !(match.names.include?('target') && match[:target])
      if self_target?(match) || self_inflicted
        return {
          name: name,
          target: {},
          inbound: true,
          attacker: extract_attacker_from_match(match),
          damaging: true
        }
      end

      # The fallback is only safe when the def has NO target
      # capture: then the sole creature link on the line really is
      # the target. When a def DID capture a target but it failed
      # to resolve to a creature link, scanning the line would find
      # some other creature (typically the attacker) - so don't.
      attacker = extract_attacker_from_match(match)
      target_info = if match.names.include?('target')
                      extract_target_from_match(match)
                    else
                      extract_target_from_line(line)
                    end
      # Nothing can attack itself. An untargeted def (an AoE like
      # the mastodon's tremors) has no target capture, so the
      # line-scan above returns the only link present - the
      # attacker. Drop it: the event is genuinely untargeted, and
      # the per-target lines that follow bind the real victims.
      target_info = nil if target_info && attacker &&
                           attacker[:id] && target_info[:id] == attacker[:id]
      result = {
        name: name,
        target: target_info || {},
        damaging: true
      }
      # The def NAMED a target but it is not a creature link - a
      # player ("striking Sugiin!"), or a name we cannot resolve.
      # The event is therefore bound to someone who is not a
      # creature, and must never later adopt one: the caster's own
      # bolded props (a summoned ethereal sphere) and room echoes
      # would otherwise fill the empty slot through the
      # target-switcher and take that third party's damage with
      # them (real-feed replay, GSIV-Monstr 2025-09-30).
      if target_info.nil? && match.names.include?('target') &&
         !match[:target].to_s.strip.empty?
        result[:foreign_target] = true
      end
      # Third-person defs capture the attacker (a player link with
      # a negative exist id, or a pushBold-wrapped creature link)
      result[:attacker] = attacker if attacker
      # A NEARBY PLAYER's attack on a creature we can see. When a
      # 3p attack names its actor but that actor is not us and not
      # a creature - a player link (negative id) or a bare name
      # from mid-sentence prose (the paladin weapon_infusion proc
      # names "Heavenscent", not a link) - the whole event belongs
      # to that player. Marked foreign_caster so the processor
      # emits it for observers but never lands its damage on our
      # ledger, the attacker-side mirror of foreign_target
      # (real-feed, GSIV-Nisugi 2026-09-06). Attacks a CREATURE
      # makes on us are :inbound and handled above; those keep
      # their creature-id attacker and are not foreign_caster.
      if attacker && !result[:inbound] &&
         (attacker[:id].to_i < 0 || (attacker[:id].nil? && attacker[:name]))
        result[:foreign_caster] = true
      end
      result[:weapon] = strip_links(match[:weapon]) if match.names.include?('weapon') && match[:weapon]
      # Aimed shots ("You take aim and fire...", "You make a precise
      # attempt to jab...") roll differently from unaimed ones. The
      # defs have captured this all along; it was never surfaced.
      result[:aimed] = true if match.names.include?('aimed') && match[:aimed]
      return result
    end
  end
  nil
end

.parse_damage(line) ⇒ Object

Parse damage amounts using damage definitions



206
207
208
209
# File 'documented/gemstone/combat/parser.rb', line 206

def parse_damage(line)
  result = Definitions::Damage.parse(line)
  result ? result[:damage] : nil
end

.parse_flare(line) ⇒ Object

Parse a flare announce line (weapon scripts, enchants, GEFs, flourishes). Returns name/damaging/aoe/spawns plus the flaring weapon's { id:, name: } when the line links it.



214
215
216
# File 'documented/gemstone/combat/parser.rb', line 214

def parse_flare(line)
  Definitions::Flares.parse(line)
end

.parse_outcome(line) ⇒ Object

Why a swing produced nothing: :evade, :block, :parry, :warded, :miss, :fumble, :hindrance, :confused



220
221
222
# File 'documented/gemstone/combat/parser.rb', line 220

def parse_outcome(line)
  Definitions::Outcomes.parse(line)
end

.parse_resolution(line) ⇒ Object

Roll lines: AS/DS, CS/TD, UAF/UDF, SMR - returns the full breakdown { type:, as:, ds:, avd:, roll:, result: ... }



226
227
228
# File 'documented/gemstone/combat/parser.rb', line 226

def parse_resolution(line)
  Definitions::Resolutions.parse(line)
end

.parse_sequence_end(line) ⇒ Hash?

Parses a multi-target sequence bracket closing line.

Identifies the end of action sequences (mstrike, volley, spawned casts) that span multiple targets.

Parameters:

  • line (String) —

    a combat output line

Returns:

  • (Hash, nil) —

    a hash with sequence metadata if the line closes a sequence; nil otherwise

See Also:



243
244
245
# File 'documented/gemstone/combat/parser.rb', line 243

def parse_sequence_end(line)
  Definitions::Sequences.parse_end(line)
end

.parse_sequence_start(line) ⇒ Object

Multi-TARGET action brackets (mstrike, volley, spawned casts)



231
232
233
# File 'documented/gemstone/combat/parser.rb', line 231

def parse_sequence_start(line)
  Definitions::Sequences.parse_start(line)
end

.parse_spell_loss(line) ⇒ Object

Third-person spell wear-off lines pinned to a spell number (defs/spell_losses.rb). Unlike statuses these apply to players in view as well as creatures, so the subject resolves through ANY link (negative player ids included), falling back to the stripped capture text for plain logs.



298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
# File 'documented/gemstone/combat/parser.rb', line 298

def parse_spell_loss(line)
  return nil unless Tracker.settings[:track_statuses]

  result = Definitions::SpellLosses.parse(line)
  return nil unless result

  if (link = TARGET_LINK_PATTERN.match(result[:target].to_s))
    result[:id]   = link[:id].to_i
    result[:name] = link[:name]
  else
    result[:id]   = nil
    result[:name] = strip_links(result[:target].to_s).strip
  end
  result
end

.parse_status(line) ⇒ Object

Parse status effects (optional - performance setting)



286
287
288
289
290
291
# File 'documented/gemstone/combat/parser.rb', line 286

def parse_status(line)
  return nil unless Tracker.settings[:track_statuses]

  # Return the full result including action field
  Definitions::Statuses.parse(line)
end

.parse_swing_weapon(line) ⇒ String?

Extracts the weapon name from a swing or ranged attack line.

Swing lines name the weapon in plain text (not as a link): "You swing a kelyn-edged slim short sword at ..." or "You take aim and fire a flaming arrow at ...". Used to claim pre-flares by the weapon that triggered them.

Examples:

parse_swing_weapon("You swing a bastard sword at <pushBold/>...") #=> "bastard sword"

Parameters:

  • line (String) —

    a combat output line

Returns:

  • (String, nil) —

    the weapon name, or nil if the line is not a swing



281
282
283
# File 'documented/gemstone/combat/parser.rb', line 281

def parse_swing_weapon(line)
  SWING_WEAPON_PATTERN.match(line)&.[](:weapon)
end

.parse_ucs(line) ⇒ Object

Parse UCS events (position, tierup, smite)



315
316
317
318
319
# File 'documented/gemstone/combat/parser.rb', line 315

def parse_ucs(line)
  return nil unless Tracker.settings[:track_ucs]

  Definitions::UCS.parse(line)
end

.self_target?(match) ⇒ Boolean

Note:

Returns false if the match includes a target capture but it is empty or nil.

Determines whether a regex match names the player as its target.

Handles two patterns: explicit target captures like "you" or "your ", and inbound defs without target captures that reference the player literally (e.g., "at you", "towards your leg"). Used to classify inbound attacks.

Parameters:

  • match (MatchData) —

    a Regexp match from an attack pattern, with named captures

Returns:

  • (Boolean) —

    true if the match identifies the player as target; false otherwise



156
157
158
159
160
161
162
163
164
165
166
167
168
# File 'documented/gemstone/combat/parser.rb', line 156

def self_target?(match)
  if match.names.include?('target')
    text = match[:target]
    return false if text.nil?

    return SELF_TARGET_PATTERN.match?(strip_links(text).strip)
  end

  # No target capture: inbound only if the def both identifies an
  # attacker and addresses us literally.
  match.names.include?('attacker') &&
    SELF_IN_PATTERN.match?(match.regexp.source)
end

Drop XML link/bold markup from a captured fragment



201
202
203
# File 'documented/gemstone/combat/parser.rb', line 201

def strip_links(text)
  text.gsub(/<[^>]+>/, '')
end