-
Notifications
You must be signed in to change notification settings - Fork 37
Creature Module Developer Reference
Audience: Lich core developers, script authors writing against the runtime creature API, and LLM agents that need a complete, unambiguous model of the module. This page documents everything in the creature module and is explicit about what is game-agnostic, what is DragonRealms-specific, and what is GemStone-specific.
This is the single canonical creature article. It absorbed the former "Using the Creature and Combat Systems in Lich Scripts (GemStone IV)" guide, which was GemStone-only; that page has been removed and its GemStone-specific material — including the whole
Combat::Trackeranalytics layer and theGameObjmigration — now lives in §6 (GemStone-specific layer) below. It is written as a reference, not a tutorial; scripters new to the API can read §6.5–§6.7 top-to-bottom for a worked GemStone walk-through.
Scope note: This page documents the runtime creature layer introduced by the
CreatureBaseextraction (the shared mixin) and the DragonRealms creature tracking added on top of it (PR #1485). GemStone's bestiary/UCS/HP layer predates the extraction and was refactored onto the shared base at the same time.
- Mental model: three layers
- File map
- Which game is loaded
-
Game-agnostic layer —
Lich::Common::CreatureBase - DragonRealms-specific layer
- GemStone-specific layer
- XML parser integration (the feed plumbing)
- The debug system (
$creature_debug) - Registry lifecycle & capacity
- Agnostic vs DR vs GS — comparison matrix
- Gotchas, edge cases & known issues
- Cheat sheets & inline
;esnippets
The creature module is deliberately structured in three layers, mirroring
the MapBase convention used elsewhere in Lich:
┌─────────────────────────────────────────────────────────────────┐
│ FACADE (game-specific module) │
│ Lich::Gemstone::Creature Lich::DragonRealms::Creature │
│ - thin module of self.* methods; the public API scripts call │
│ - delegates almost everything to the CreatureInstance class │
│ - adds the game's feed entry points (GS: none; DR: sync, │
│ feed_assess) and game-specific stats │
└───────────────────────────────┬───────────────────────────────────┘
│ delegates to
┌───────────────────────────────▼───────────────────────────────────┐
│ INSTANCE CLASS (game-specific class) │
│ Lich::Gemstone::CreatureInstance │
│ Lich::DragonRealms::CreatureInstance │
│ - one object per live creature, keyed by server `exist` id │
│ - includes CreatureBase (gets class + instance methods) │
│ - defines new(id, noun, name), initialize_status_tracking call, │
│ valid_target?, created_at │
│ - GS adds: HP/injuries/UCS/templates. DR adds: assess backfill. │
└───────────────────────────────┬───────────────────────────────────┘
│ include Lich::Common::CreatureBase
┌───────────────────────────────▼───────────────────────────────────┐
│ BASE MIXIN (GAME-AGNOSTIC) │
│ Lich::Common::CreatureBase │
│ - self.included wires ClassMethods (extend) + InstanceMethods │
│ - id-keyed registry, current-room roster, targets/in_room query │
│ - <crtrStatus> flag vocabulary + reconciliation, status timers │
└─────────────────────────────────────────────────────────────────┘
Key architectural facts:
-
CreatureBaseis a mixin module, not a data holder. EchoingLich::Common::CreatureBaseshows you the module/its constants — never a list of creatures. All roster/registry state lives in per-class instance variables on the includingCreatureInstanceclass. -
Each game keeps an independent registry. Because state lives in
@instances,@current_room_ids, etc. on the extending class, GemStone and DragonRealms never share a roster even though they run identical code. In practice only one game's classes are ever loaded per session anyway. -
The
<crtrStatus>flag vocabulary is identical across both games. Status reconciliation, the registry, the room roster, and target filtering are all shared. Only the feed shape (how names/ids arrive) and thevalid_target?rule differ per game.
| Path | Layer | Game |
|---|---|---|
lib/common/creature/creature_base.rb |
Base mixin | Agnostic |
lib/dragonrealms/creature.rb |
Instance class + facade | DR |
lib/gemstone/creature.rb |
Instance class + facade + bestiary | GS |
lib/gemstone/creatures/*.rb |
Bestiary template data files | GS |
lib/common/xmlparser.rb |
Feed plumbing (drives all of the above) | Agnostic dispatcher, game-branched |
lib/dragonrealms/drinfomon/drvariables.rb |
DR_BALANCE_VALUES (assess balance parsing) |
DR |
spec/lib/common/creature/creature_base_spec.rb |
Tests | Agnostic |
spec/lib/dragonrealms/creature_spec.rb |
Tests | DR |
spec/lib/gemstone/creature_spec.rb, spec/lib/gemstone/creature_data_integrity_spec.rb
|
Tests | GS |
Only one game's creature classes are loaded per Lich session. Throughout the
XML parser the branch is XMLData.game =~ /^GS/ vs XMLData.game =~ /^DR/, and
every call into a game facade is guarded with defined?(...):
Lich::Gemstone::Creature.clear_room if defined?(Lich::Gemstone::Creature)
Lich::DragonRealms::Creature.clear_room if defined?(Lich::DragonRealms::Creature)Practical consequence for scripts and agents: write against the facade for
your game (Lich::DragonRealms::Creature or Lich::Gemstone::Creature), and
guard with defined? if the script is meant to run cross-game. Do not reference
Lich::Common::CreatureBase directly for data — it holds none.
File: lib/common/creature/creature_base.rb. Everything in this section is
shared by both games verbatim.
module Lich::Common::CreatureBase
def self.included(base)
base.extend(ClassMethods) # class-level registry/roster/queries
base.include(InstanceMethods) # per-instance status tracking
end
endA host CreatureInstance class that includes CreatureBase must honor
this contract, or the shared code raises at runtime:
| Requirement | Why | Used by |
|---|---|---|
Constructor new(id, noun, name)
|
Registry instantiates via this exact positional signature | ClassMethods#register |
Call initialize_status_tracking from initialize
|
Sets up @status, @status_timestamps, @crtr_flags
|
all InstanceMethods
|
Expose valid_target? predicate |
The game supplies its own attackability rule | ClassMethods#targets |
Expose created_at reader |
Age-based eviction | ClassMethods#cleanup_old |
Expose @name / @id ivars |
Debug headers only | InstanceMethods#debug_header |
Both games satisfy this (see §5.2 and §6.1).
All constants are frozen and live on Lich::Common::CreatureBase.
Maps a canonical status name to an auto-expiry duration. A nil value means the
status has a reliable server-sent removal message and therefore must not
be auto-expired on a timer.
STATUS_DURATIONS = {
'breeze' => 6, 'bind' => 10, 'web' => 8,
'entangle' => 10, 'hypnotism' => 12, 'calm' => 15,
'mass_calm' => 15, 'sleep' => 8,
# nil = has a reliable removal message, no timer:
'stunned' => nil, 'immobilized' => nil, 'prone' => nil, 'blind' => nil,
'sunburst' => nil, 'webbed' => nil, 'poisoned' => nil, 'hidden' => nil
}.freezeTransient combat statuses. Note the vocabulary reconciliation (e.g. XML
immobile → canonical immobilized) so XML- and message-based detection land
in the same status entry.
CRTR_STATUS_FLAGS = {
'immobile' => 'immobilized', 'webbed' => 'webbed', 'sleeping' => 'sleeping',
'disoriented' => 'disoriented', 'stunned' => 'stunned', 'rooted' => 'rooted',
'calmed' => 'calm', 'kneeling' => 'kneeling', 'prone' => 'prone',
'sitting' => 'sitting', 'flying' => 'flying', 'hovering' => 'hovering',
'hidden' => 'hidden'
}.freezeRelationship/classification facts (not transient statuses), stored separately
and read via crtr_flag?.
CRTR_CLASSIFICATION_FLAGS = {
'hostile' => :hostile, 'disengaged' => :disengaged, 'dead' => :dead,
'sympathetic' => :sympathetic, 'ascended' => :ascended, 'inferior' => :inferior,
'AscensionBoss' => :ascension_boss, 'MiniBoss' => :mini_boss,
'challenging' => :challenging, 'rider' => :rider, 'mount' => :mount
}.freezeCRTR_STATUS_FLAGS.merge(CRTR_CLASSIFICATION_FLAGS) — used for debug snapshots
and (in DR) to exclude crtrStatus flag-words from assess-parsed conditions
(see §5.5).
The complete filter vocabulary accepted by targets/in_room:
KNOWN_FLAG_NAMES = (CRTR_STATUS_FLAGS.values +
STATUS_DURATIONS.keys +
CRTR_CLASSIFICATION_FLAGS.values.map(&:to_s)).uniq.freezeA filter name outside this set is "unknown" and matches nothing — even when
negated with a not_ prefix (see §4.5).
These become class methods on the including CreatureInstance (and are
re-exposed through the facade). State is held in per-class ivars: @instances
(the id => instance hash), @current_room_ids (the live room roster),
@max_size, @auto_register.
| Method | Signature | Returns | Notes |
|---|---|---|---|
register |
register(name, id, noun = nil) |
instance or nil
|
Marks the id in-room first (always), then registers. Room-marking happens on every call — even when auto-register is off — because the triggering feed event is proof of presence. Returns nil if auto-register is disabled or the registry is full after cleanup. |
mark_in_room |
mark_in_room(id) |
Boolean |
true if the id was newly added to the roster. |
clear_room |
clear_room |
void | Empties the room roster only. The persistent registry is untouched. |
current_room_ids |
current_room_ids |
Array<Integer> |
Defensive copy (.dup) of the roster — callers can mutate it freely. |
[] |
[](id) |
instance or nil
|
Lookup by id (id.to_i). |
all |
all |
Array<instance> |
Every registered instance (not just in-room). |
clear |
clear |
void | Wipes instances and roster (session reset). |
cleanup_old |
cleanup_old(max_age_seconds = 600) |
Integer |
Removes instances whose created_at < now - max_age. Returns count removed. Positional arg (see §6.4 caveat). |
configure |
configure(max_size: 1000, auto_register: true) |
void | Every call resets omitted options to defaults. Pass all options you mean to keep. |
auto_register? |
Boolean |
Default true. |
|
max_size |
Integer |
Default 1000. |
|
size |
Integer |
Current instance count. | |
full? |
Boolean |
size >= max_size. |
|
targets |
targets(*filters) |
Array<instance> |
In-room creatures where valid_target? && crtr_flag?(:hostile), then ANDed filters. |
in_room |
in_room(*filters) |
Array<instance> |
All in-room creatures (no hostility/validity requirement), then ANDed filters. |
debug_on |
debug_on(level = :changes) |
the level | Sets global $creature_debug. |
Private helpers: apply_filters, known_flag?, instances, room_roster.
register capacity escalation. When the registry is full?, register
runs progressively more aggressive age-based cleanup before giving up:
[7200, 6300, 5400, 4500, 3600, 2700, 1800, 900].each do |age_threshold|
cleanup_old(age_threshold)
break unless full?
end
return nil if full? # still full after every attempt → refuse to registerSo a creature can silently fail to register under sustained pressure. It
still gets mark_in_room'd (presence tracked), but no instance is created, so
it won't appear in targets/in_room.
Mixed into each instance. Drives the transient status (@status +
@status_timestamps) and classification (@crtr_flags) state.
| Method | Signature | Notes |
|---|---|---|
initialize_status_tracking |
Initializes @status = [], @status_timestamps = {}, @crtr_flags = {}. Host must call this from initialize.
|
|
add_status |
add_status(status, duration = nil) |
Normalizes to String (symbol or string OK). No-op if already present. Duration falls back to STATUS_DURATIONS; if a duration resolves, an expiry timestamp is stored, else the status has no auto-expiry. |
remove_status |
remove_status(status) |
Deletes the status and its timestamp. |
cleanup_expired_statuses |
Drops timed statuses past expiry. Called implicitly by the readers below. | |
has_status? |
has_status?(status) |
Runs expiry cleanup first, then membership check. |
statuses |
Copy of currently-active statuses (after expiry). | |
sync_crtr_status |
sync_crtr_status(attrs) |
Full-snapshot reconciliation from a <crtrStatus> tag (see below). |
crtr_flag? |
crtr_flag?(key) |
Classification flag, Symbol/String key. Always boolean (unseen → false, never nil). |
flag_active? |
flag_active?(key) |
`has_status?(key) |
Private helpers: debug_level (true → :changes), debug_header,
debug_log, report_crtr_snapshot.
sync_crtr_status is a full snapshot, not a delta. A <crtrStatus> tag
reports everything currently active. A missing known flag, or a flag set to
"0", means inactive — so absent known flags are cleared, not ignored:
def sync_crtr_status(attrs)
CRTR_STATUS_FLAGS.each do |xml_name, status|
if attrs[xml_name] == '1' then add_status(status)
elsif @status.include?(status) then remove_status(status)
end
end
CRTR_CLASSIFICATION_FLAGS.each do |xml_name, key|
@crtr_flags[key] = (attrs[xml_name] == '1')
end
report_crtr_snapshot(attrs) if %i[all active].include?(debug_level)
endNote the asymmetry: statuses are timer-eligible (they may auto-expire before
the next snapshot); classification flags are always-sent booleans (unseen ⇒
false).
Both targets(*filters) and in_room(*filters) pass filters through
apply_filters. Rules:
-
Filters are ANDed.
in_room(:hostile, :prone)returns creatures that are both. -
not_prefix negates.in_room(:not_dead)returns non-dead. Implemented asflag_active?(key) != negate. -
Unknown filters match nothing — by design. A filter whose base name (after stripping
not_) isn't inKNOWN_FLAG_NAMEScollapses the whole result to[]:return [] unless known_flag?(key)
This guard is deliberate. Without it, a negated typo (
:not_prnoe) would invert "matches nothing" into "matches everything," silently widening a query. Because filters are ANDed, one unknown filter empties the result. -
A filter matches via
flag_active?, so it can name either a status (:prone,:stunned,:webbed,:hidden, …) or a classification key (:hostile,:dead,:mount, …). The two vocabularies are intentionally disjoint.
Accepted filter names = KNOWN_FLAG_NAMES =
- canonical statuses:
immobilized,webbed,sleeping,disoriented,stunned,rooted,calm,kneeling,prone,sitting,flying,hovering,hidden - timer statuses:
breeze,bind,web,entangle,hypnotism,mass_calm,sleep,blind,sunburst,poisoned - classification keys (as strings):
hostile,disengaged,dead,sympathetic,ascended,inferior,ascension_boss,mini_boss,challenging,rider,mount
(All usable with a not_ prefix.)
File: lib/dragonrealms/creature.rb. Additive: it does not touch DRRoom
or the name-string roster the ~120 existing DR scripts rely on. It introduces
ID-based creature tracking that DR previously lacked.
Unlike GemStone's single structured room-object tag, DR delivers creature info across three streams:
-
<crtrStatus exist="…" hostile="1" immobile="1"/>— id-keyed full snapshot of combat status + classification flags. Emitted automatically with room refreshes, but name-less and batched after the room-objs component closes. → drivesCreature.sync(creates instances id-first). -
room-objs bold text (
<pushBold/>a jeol moradu<popBold/>) — names only, noexistid. Consumed for the stream-order name backfill (zip Nth bold name to Nth crtrStatus id, at the next<prompt>, only when counts match). -
the
assesscombat stream (<d cmd='look #NNN'>A jeol moradu</d> (1: …) is behind you at melee range) — the only feed that ties anexistid to a name, plus assess number, relation, range, target. → drivesCreature.feed_assess(authoritative name backfill + positional data).
So a DR CreatureInstance is born id-first (flags, no name) from <crtrStatus>
and gets its name/position/range backfilled later.
class Lich::DragonRealms::CreatureInstance; include Lich::Common::CreatureBase.
Constructor initialize(id, noun, name) sets all fields to their empty defaults
and calls initialize_status_tracking.
| Reader | Type | Source | Meaning |
|---|---|---|---|
id |
Integer | crtrStatus | server exist id |
noun |
String / nil | derived | trailing word of name, e.g. "a jeol moradu" → "moradu"; drop-in for attack <noun>
|
name |
String / nil | room-objs or assess | display name (accessor: attr_accessor) |
created_at |
Time | ctor | registration time |
assess_number |
Integer / nil | assess | 1-based list number |
relation |
String / nil | assess | e.g. "behind you", "flanking"
|
assess_status |
String / nil | assess | raw parenthetical, e.g. "cursed and solidly balanced"
|
range |
Symbol / nil | assess |
:melee, :pole, or :missile
|
target_id |
String / nil | assess | id of who this creature is engaging |
target |
String / nil | assess | name of who it's engaging ("you", "Holdigor", "a plague spawn") |
target_number |
Integer / nil | assess | engaged target's assess number |
balance |
String / nil | assess | a DR_BALANCE_VALUES descriptor ("solidly", "off", …); nil until an assess with a balance phrase |
conditions |
Array | assess | assess-only afflictions crtrStatus does NOT carry (["cursed"], ["friendly","cursed"]); crtrStatus states live in crtr_flag?/has_status?, not here |
enriched_at |
Time / nil | assess | when assess enrichment last landed; nil until first assess |
Internal ivar @name_from_assess (bool) marks the assess-derived name as
authoritative so a later room-objs backfill can't overwrite it.
| Method | Notes |
|---|---|
feed_assess(entry) |
Backfills from a parsed assess entry (:name, :number, :status, :relation, :range, :target, :target_id, :target_number). Downcases the assess-capitalized name to match room-objs vocabulary, re-derives noun, sets @name_from_assess = true, parses balance/conditions, stamps enriched_at. |
apply_room_name(name) |
Applies a room-objs (stream-order) name + derives noun. No-op if name is nil or @name_from_assess is set (assess wins). |
valid_target? |
DR rule: !crtr_flag?(:dead). DR has no UCS/appendage decoys to exclude. |
enriched? |
!@enriched_at.nil? — whether any assess enrichment has landed. |
off_balance? |
true if balance is worse than "solidly" in DR_BALANCE_VALUES order. false when balance unknown. |
condition?(name) |
@conditions.include?(name.to_s). |
cursed? |
condition?('cursed'). |
friendly? |
condition?('friendly') — temporary empath manipulation, NOT a permanent classification (see §5.5 / §11). |
self.balance_pattern |
Lazily-built regex matching a DR_BALANCE_VALUES descriptor + balanced?/imbalanced?. Union ordered longest-first so "somewhat off" resolves before "off". |
parse_assess_status(status) (private) |
Splits the parenthetical into [balance, conditions]; drops any word present in ALL_CRTR_FLAGS (crtrStatus tracks those fresh). |
derive_noun(name) (private) |
name.scan(/[A-Za-z'-]+/).last — trailing word, robust to trailing whitespace/punctuation. |
DR_BALANCE_VALUES (from lib/dragonrealms/drinfomon/drvariables.rb), worst →
best: completely, hopelessly, extremely, very badly, badly,
somewhat off, off, slightly off, solidly, nimbly, adeptly,
incredibly.
module Lich::DragonRealms::Creature — all self.*:
| Method | Delegates / does |
|---|---|
sync(id, flags) |
DR feed entry point. CreatureInstance.register(nil, id) (id-first, name-less) then sync_crtr_status(flags). Returns instance or nil. |
feed_assess(entry) |
DR feed entry point. Registers by entry[:id] then calls the instance's feed_assess. Returns nil if no id. |
[](id) |
Lookup by id. |
targets(*filters) |
Hostile attackable in-room. |
in_room(*filters) |
All in-room. |
clear_room |
Empty roster. |
register(name, id, noun = nil) |
Manual registration. |
configure(**options) |
Registry config. |
stats |
{instances:, max_size:, auto_register:} (no templates — DR has no bestiary). |
clear |
Wipe instances + roster. |
cleanup_old(max_age_seconds = 600) |
Age eviction (positional). |
all |
Every instance. |
debug_on(level = :changes) |
Sets $creature_debug. |
DR has two overlapping information sources, and the module keeps them strictly separated by trust model:
-
<crtrStatus>flags are a push source: always sent, always fresh, reconciled as a full snapshot. Read viacrtr_flag?(:hostile)/has_status?('immobilized'). States like prone/sleeping/stunned/immobile/ hidden live here. -
assess fields (
range,balance,conditions,relation,target) are a pull snapshot: only current as of the lastassess, and they go stale. Pollassessbefore relying on one; checkenriched?/enriched_atfor freshness.
parse_assess_status deliberately drops any parenthetical word that also
appears in ALL_CRTR_FLAGS (e.g. immobile) from conditions, because that
state is tracked fresh via the push source; keeping it would duplicate and could
disagree. Only assess-only afflictions (cursed, poisoned, friendly, …)
remain in conditions.
friendly? deserves special care: it is a temporary, manipulated state (an
Empath ability makes a creature "consider you friend, not foe"). crtrStatus
keeps reporting hostile="1" throughout, and it wears off. Do not treat it
as a durable friend/foe flag or as equivalent to a summon/pet.
File: lib/gemstone/creature.rb. GemStone predates the DR feed model: its
creatures arrive through a single structured room-object tag carrying inline
exist id + noun + name, plus an inline (or @pending_crtr_status-deferred)
<crtrStatus>. GS adds three big concerns on top of the shared base: HP/
damage/injuries, UCS (Unarmed Combat System) tracking, and the
bestiary (CreatureTemplate).
Critical: much of the GS HP/injury/UCS state is populated by the GemStone Combat tracker (
Lich::Gemstone::Combat::Tracker), which parses combat text. The creature module stores it; the tracker fills it. The tracker is OFF by default — until youCombat::Tracker.enable!,hp_percent,injured_locations,fatal_crit?, UCS values, and message-driven statuses stay at their empty defaults (full HP, no wounds). See §6.5 for the full tracker reference and the per-field "needs tracker?" matrix.
Populated by the Combat tracker. The HP/damage, injury, and UCS fields below only carry meaningful values once
Combat::Tracker.enable!has run (§6.5). With the tracker off,damage_takenis0(sohp_percentreads 100 anddead?is always false),injuriesis empty, and UCS readers returnnil. Identity,crtr_flag?, XMLhas_status?,valid_target?, andtemplatedo not need the tracker.
class Lich::Gemstone::CreatureInstance; include Lich::Common::CreatureBase.
Accessors: id, noun, name, status, injuries, health, damage_taken, created_at, fatal_crit, status_timestamps, ucs_smote, ucs_updated (attr_accessor), plus
ucs_position, ucs_tierup (attr_writer, with custom readers that apply TTL).
Constants:
-
BODY_PARTS— 17 valid injury locations (abdomen,back,chest,head,leftArm,leftEye, …rightLeg). -
UCS_TTL = 120— UCS data expires after 2 minutes. -
UCS_SMITE_TTL = 15— smite (crimson mist) expires after 15 seconds.
Templates:
| Method | Notes |
|---|---|
template |
Memoized CreatureTemplate[@name]. |
has_template? |
!template.nil?. |
UCS (Unarmed Combat System):
| Method | Notes |
|---|---|
position_to_tier(pos) |
"decent"/1 → 1, "good"/2 → 2, "excellent"/3 → 3, else nil. |
set_ucs_position(position) |
Clears @ucs_tierup if tier changed; stamps ucs_updated. |
set_ucs_tierup(attack_type) |
Records tierup vulnerability. |
smite! / smote? / clear_smote
|
Crimson-mist tracking; smote? self-expires past UCS_SMITE_TTL. |
ucs_expired? |
true if never updated or older than UCS_TTL. |
ucs_position / ucs_tierup (readers) |
Return nil once ucs_expired?. |
HP / injuries / damage:
| Method | Notes |
|---|---|
add_injury(body_part, amount = 1) |
Raises ArgumentError for a part not in BODY_PARTS. |
injured?(location, threshold = 1) |
|
injured_locations(threshold = 1) |
|
add_damage(amount) / reset_damage
|
|
mark_fatal_crit! / fatal_crit?
|
|
max_hp |
Template HP → else Combat::Tracker.fallback_hp (guarded) → else 400. |
current_hp |
[max_hp - damage_taken, 0].max. |
hp_percent |
0–100, rounded to 0.1. |
low_hp?(threshold = 25) |
|
dead? |
current_hp == 0 (HP-based, distinct from the crtrStatus :dead flag). |
essential_data |
Hash snapshot of id/noun/name/status/injuries/health/damage/HP/template/UCS. |
Predicates that combine base + GS state:
-
valid_target?(GS rule) — excludes crtrStatus-dead or HP-dead, plus regex decoy/appendage exclusions matchingGameObj.targets:return false if crtr_flag?(:dead) || dead? return false if @name =~ /^animated\b/i && @name !~ /^animated slush/i return false if @noun =~ /^(?:arm|appendage|claw|limb|pincer|tentacle)s?$|^(?:palpus|palpi)$/i && @name !~ /(?:amaranthine|ghostly|grizzled|ancient) kraken tentacle/i true
-
muckled?— creature-side analog of the player'sStatus.muckled?. Deliberately narrow: only statuses that prevent acting (webbed, dead,stunned,sleeping,immobilized,rooted) — excludes penalty-only (disoriented), positional (prone/kneeling/sitting/flying/hovering), andcalm.
Static, ID-less reference data (the GS "bestiary"). Loaded once from
lib/gemstone/creatures/*.rb, keyed by normalized name.
Readers: name, url, picture, level, family, type, undead, otherclass, areas, bcs, max_hp, speed, height, size, attack_attributes, defense_attributes, treasure, messaging, special_other, abilities, alchemy.
Tri-state predicates (true/false/nil = uncatalogued): has_blood?,
has_bones?, muggable?.
Class methods:
| Method | Notes |
|---|---|
load_all(dir = …/creatures) |
Idempotent (@@loaded guard). Skips _creature_template.rb. Reads each file, evals it (load_template_data via binding.eval, validated as a Hash), normalizes the name, warns on lookup-key collisions (debug only). Rescues StandardError, ScriptError per-file so one bad template can't abort the whole load. |
[](name) |
Auto-loads; exact-match, then fix_template_name (strip boon adjectives) fallback. |
all |
Auto-loads; unique template list. |
fix_template_name(name) |
Downcase + strip leading boon adjective (BOON_REGEX from BOON_ADJECTIVES) + strip. |
Name handling is subtle: the file's own :name wins over the filename-derived
fallback, so names with characters a slugified filename can't represent
(hyphens, apostrophes) still round-trip for lookup.
All in lib/gemstone/creature.rb, all GS-only:
-
SpecialAbility—name,note. -
Treasure— booleanized loot facts:has_coins?,has_gems?,has_boxes?,has_skin?,blunt_required?,to_h. -
Messaging— arrival/flee/death/attack/etc. message templates with{placeholder}support;display(field, subs)andmatch(field, str). UsesPLACEHOLDER_MAP(Pronoun/pronoun/direction/weapon) andPlaceholderTemplate. -
DefenseAttributes— ASG + per-attack/per-CS TDs (melee,ranged,bolt,udf, and the*_tdwarding values), immunities, defensive spells/abilities. Range strings like"10..20"are parsed toRangewithout eval. -
PlaceholderTemplate— compiles message templates to display strings (to_display) or regexes (to_regex, cached) and matches (match).
module Lich::Gemstone::Creature — same shared surface as DR
([], targets, in_room, clear_room, register, configure, clear,
cleanup_old, all, debug_on), plus GS extras:
| Method | Notes |
|---|---|
stats |
{instances:, templates:, max_size:, auto_register:} — includes templates count (DR's does not). |
damage_report(**options) |
Delegates to CreatureInstance.damage_report. |
print_damage_report(**options) |
Delegates to CreatureInstance.print_damage_report. |
cleanup_old is intentionally positional (max_age_seconds = 600) to match
the base method and the positional call from Combat::Tracker#cleanup_creatures;
a keyword-only signature previously raised ArgumentError on every scheduled
cleanup (swallowed by the tracker's rescue), so registry cleanup silently never
ran.
GS has no feed entry points on the facade (no sync/feed_assess) — GS
creatures are registered directly by the XML parser's room-objs text path (see
§7), which calls Creature.register + sync_crtr_status.
File: lib/gemstone/combat/tracker.rb (+ parser.rb, processor.rb,
async_processor.rb, defs/*.rb). Module: Lich::Gemstone::Combat::Tracker.
GemStone-only. Requires Lich 5.19.0+ (that release wired the tracker into
the current Creature system).
The tracker is the companion to the GS Creature module and the source of every
"how the fight is going" field. It does not build its own creature list — it
watches downstream combat text, resolves each attack/wound/effect to a creature
by its exact exist id, and writes onto the same CreatureInstance objects
Creature.in_room/.targets already return.
It is OFF by default. Reading hp_percent/injured_locations/UCS with the
tracker disabled returns empty defaults (full HP, no wounds). Enable once — the
state persists per character and re-enables on next login:
Combat = Lich::Gemstone::Combat unless defined?(Combat)
Combat::Tracker.enable! unless Combat::Tracker.enabled?| Value | Source | Needs tracker? |
|---|---|---|
Creature.in_room / .targets / [] / .all
|
XML room data | No |
id, noun, name
|
XML | No |
crtr_flag?(:hostile/:dead/…) |
XML <crtrStatus>
|
No |
has_status? for a <crtrStatus> status |
XML | No |
valid_target? |
XML (dead flag + name/noun) | No |
template and its fields |
bestiary files | No |
hp_percent, current_hp, damage_taken, low_hp?, dead?
|
tracker | Yes |
injured?, injured_locations, injuries
|
tracker | Yes |
fatal_crit? |
tracker | Yes |
ucs_position, ucs_tierup, smote?
|
tracker | Yes |
message-driven statuses (bind, web, entangle, hypnotism, sleep, blind, sunburst, poisoned) |
tracker | Yes |
| Method | Notes |
|---|---|
enable! |
Start tracking; adds the downstream hook; persists enabled: true. No-op if already on. |
disable! |
Stop tracking; persists enabled: false. |
enabled? |
@enabled && settings[:enabled]. |
configure(new_settings = {}) |
Merges into current settings (unlike the base Creature.configure, which resets) and persists. |
set_fallback_hp(n) / fallback_hp
|
Shortcut for configure(fallback_max_hp: n) / read it back. |
enable_debug! / disable_debug! / debug?
|
Toggle verbose [Combat] logging (enable_debug! also forces enabled: true). |
stats |
Live Hash: enabled state, buffer size, settings in effect, thread activity. |
process(chunk) / combat_relevant?(line)
|
Internal — the downstream hook calls these; scripts don't. |
There is no public
Combat::Tracker.settingsreader (the removed GS guide listed one; it does not exist). Read the effective settings viaCombat::Tracker.stats.
Combat::Tracker.configure(track_wounds: true, track_ucs: true, max_threads: 2)| Setting | Default | Meaning |
|---|---|---|
enabled |
false |
Master on/off (enable! sets true). |
track_damage |
true |
Record damage → drives HP estimates. |
track_wounds |
true |
Record critical wounds by body part (severity via CritRanks). |
track_statuses |
true |
Record message-driven status effects. |
track_ucs |
true |
Record UCS position/tierup/smite. |
max_threads |
2 |
1 = synchronous; >1 = background pool (concurrent gem). |
buffer_size |
200 |
Max game lines buffered between prompts. |
fallback_max_hp |
350 |
Max HP assumed when a creature has no template (final fallback in CreatureInstance#max_hp before the hardcoded 400). |
cleanup_interval |
100 |
Prune the creature registry every N processed chunks. |
cleanup_max_age |
600 |
During cleanup, drop creatures older than N seconds. |
At enable! the tracker installs a persistent downstream-text hook. It buffers
lines and, at each game prompt, processes the buffered chunk only if it
contains creatures (combat_relevant?). Processing runs on a small thread pool
so it never stalls the game, and the registry is pruned every cleanup_interval
chunks. File roles: tracker.rb (public interface), parser.rb (recognizes
combat lines, extracts the target from its bolded <a exist noun> link — same id
the room roster keys on, which is why they always line up), processor.rb
(attack → damage → crit look-ahead state machine), async_processor.rb (thread
pool), defs/attacks.rb/damage.rb/statuses.rb/ucs.rb (pattern libraries).
Creature.debug_on(:changes) # Creature registration/status/flag changes (see §8)
Combat::Tracker.enable_debug! # Combat parsing/application details ([Combat] …)The GS Creature module supersedes GameObj.npcs / GameObj.targets for combat.
| Goal | Old (GameObj) |
New (Creature / Combat) |
|---|---|---|
| Living creatures in room |
GameObj.npcs (can be nil) |
Creature.in_room (always an Array) |
| Attackable hostiles |
GameObj.targets (client target list) |
Creature.targets (room roster, hostile + valid) |
| First thing to attack | GameObj.npcs.find { |n| n.status.nil? } |
Creature.targets.first |
| Dead creatures in room | GameObj.dead |
Creature.in_room(:dead) |
| One creature by id | GameObj.npcs.find { |n| n.id == id } |
Creature[id] |
| Is it dead (server flag)? | npc.status.to_s =~ /dead/ |
c.crtr_flag?(:dead) |
| Is it stunned? | parse the status string | c.has_status?("stunned") |
| Can it act? | (none) | c.muckled? |
| How hurt is it? | (none) |
c.hp_percent (needs tracker) |
| Where wounded? | (none) |
c.injured_locations (needs tracker) |
| UCS position | (none) |
c.ucs_position (needs tracker) |
| Does it skin? | (none) | c.template&.treasure&.has_skin? |
Key behavioral differences that bite migrations:
-
GameObj.npcscan benil;Creature.in_room/.targetsnever are (empty Array on an empty room) —Creature.in_room.each { }is always safe. -
Creature#idis anInteger;GameObj#idis aString.creature.id == gameobj.idis always false (12345 == "12345"). Bridge withcreature.id.to_s == gameobj.id. (Creature[id]itself accepts either — it doesid.to_i.) -
No single status string.
GameObj#statusis one string (nil= alive); aCreatureInstancecan hold several statuses at once — usedead?,has_status?,crtr_flag?, orstatuses, never a regex on one string. -
Creature.targetsignores the client's selected-target list, so it doesn't go stale after a kill or a room change the wayGameObj.targetscan. -
dead?(HP-0) needs the tracker;crtr_flag?(:dead)is the authoritative server flag and works tracker-off. A creature can also die by fatal crit (fatal_crit?) without HP reaching 0.
Before / after — "attack everything, wounded first":
# Old — no HP awareness
loop do
target = (GameObj.npcs || []).find { |n| n.status.nil? }
break unless target
waitrt?; fput "attack ##{target.id}"; pause 1
end
# New — HP-aware (tracker enabled)
Combat::Tracker.enable! unless Combat::Tracker.enabled?
while (foe = Creature.targets.min_by { |c| c.hp_percent || 100 })
waitrt?; fput "attack ##{foe.id}"; pause 1 # `|| 100` attacks unknown-HP last
endEnables tracking, clears the room's hostiles most-wounded-first, then skins the corpses the bestiary marks skinnable. Illustrative (no movement/healing/bounty):
Creature = Lich::Gemstone::Creature unless defined?(Creature)
Combat = Lich::Gemstone::Combat unless defined?(Combat)
# Guard: GemStone only, and only on a Lich new enough to have this API.
unless XMLData.game =~ /^GS/ && defined?(Creature) && Creature.respond_to?(:targets)
respond "Needs GemStone IV on Lich 5.19.0+."; exit
end
Combat::Tracker.enable! unless Combat::Tracker.enabled? # persists per character
# Phase 1: clear the room. targets already excludes dead + decoys and requires
# hostile, so the loop ends naturally when the room is clear.
while (foe = Creature.targets.min_by { |c| c.hp_percent || 100 })
waitrt?
wounds = foe.injured_locations
respond "Attacking #{foe.name} — #{foe.hp_percent}% HP" +
(wounds.any? ? ", wounds: #{wounds.inspect}" : "")
fput "attack ##{foe.id}" # exact-id targeting avoids noun ambiguity
pause 1
end
# Phase 2: skin skinnable corpses. crtr_flag?(:dead) is the server flag, so this
# works tracker-off too.
Creature.in_room(:dead).each do |corpse|
next unless corpse.template&.treasure&.has_skin?
waitrt?; respond "Skinning #{corpse.name}"; fput "skin ##{corpse.id}"
end
respond "Room clear."File: lib/common/xmlparser.rb. This is where the raw server stream becomes
registry state. The dispatcher is agnostic but branches by game.
On navigation and on a room-objs refresh, the roster is cleared and rebuilt:
Lich::Gemstone::Creature.clear_room if defined?(Lich::Gemstone::Creature)
Lich::DragonRealms::Creature.clear_room if defined?(Lich::DragonRealms::Creature)DR then rebuilds the roster from the <crtrStatus> batch that follows the
refresh.
crtr_id = attributes['exist']
crtr_flags = attributes.reject { |k, _| k == 'exist' }
if XMLData.game =~ /^DR/
Lich::DragonRealms::Creature.sync(crtr_id, crtr_flags) # apply now, id-first, name-less
@dr_crtr_ids << crtr_id # remember arrival order
else # GS
@pending_crtr_status[crtr_id] = crtr_flags # defer until the <a> text path
end- DR: flags are applied immediately (creature created id-first), and the id is queued for stream-order name pairing.
-
GS: flags are stashed in
@pending_crtr_statusbecause GS registration- room-marking happens on the bolded
<a>room-object text path. Applying here and skipping that path would update flags but leave the creature out of the roster after the nextclear_room(it'd sync but never reappear intargets/in_room).
- room-marking happens on the bolded
For a bold <a exist noun> room object whose id is a current target or has
pending flags:
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)
endDR room-objs bold NPC names carry no <a> tag, so they're captured in stream
order (@dr_room_npc_names) and paired to @dr_crtr_ids at the prompt —
all-or-nothing, only when the counts match exactly:
if !@dr_crtr_ids.empty? && @dr_crtr_ids.length == @dr_room_npc_names.length
@dr_crtr_ids.each_with_index { |id, i| Creature[id]&.apply_room_name(@dr_room_npc_names[i]) }
end
@dr_room_npc_names = []; @dr_crtr_ids = [] # reset every promptOn any mismatch (e.g. a bold room entity that emits no crtrStatus) naming is
skipped rather than risk a shifted mis-pair — assess still backfills names
by id later. (There is also a future-proof branch in the <a> handler for if DR
ever emits GemStone-style <a exist noun> room-objs.)
XMLData.parse_assess_line(text, ids) turns one reconstructed assess line into a
structured Hash. Supporting constants:
ASSESS_RANGES = { 'melee' => :melee, 'pole weapon' => :pole, 'missile' => :missile }-
ASSESS_RELATIONregex matchingflanking/facing/behind/advancing on/…- target.
The parser: strips the trailing | F face-hint; matches
name (number: status) is|are rest at <range> range; extracts an optional target
assess number; splits rest into relation + target; normalizes
"moving to flank" → "flanking"; resolves subject/target ids from the ordered
<d cmd='look #id'> ids (self has no subject id; negative ids ⇒ PCs). Returns a
Hash with :name, :id, :number, :status, :relation, :target, :target_id, :target_number, :range, :self, :pc — or nil for the header/unparseable lines.
At popStream for the assess stream, DR feeds it in (skipping self and PCs):
if XMLData.game =~ /^DR/ && !entry[:self] && !entry[:pc] && defined?(Lich::DragonRealms::Creature)
Lich::DragonRealms::Creature.feed_assess(entry)
endXMLData.assess_creatures returns just the creature entries
(@assess.reject { |e| e[:self] || e[:pc] }).
A single global toggles live echo of registration/status/flag changes.
Creature.debug_on(level) (or CreatureInstance.debug_on) sets it.
| Value | Effect |
|---|---|
false (default) |
Silent. |
true / :changes
|
Report changes only (registration, +/-status, flag transitions). |
:all |
On each <crtrStatus>, report every known flag and its value. |
:active |
On each <crtrStatus>, report only active (true) flags. |
report_crtr_snapshot reads straight from the tag attributes, so a snapshot
reflects exactly what the feed sent, independent of how the mutation logic
applied it. Output goes through respond (visible in your client) prefixed
--- <name> (<id>): ….
-
Birth:
register(directly in GS via the parser; viasync/feed_assessin DR). Room-marking always happens; instance creation is gated onauto_register?and capacity. -
Room presence: tracked separately in
@current_room_ids, cleared on nav / room refresh and rebuilt from the next feed.targets/in_roomread the roster, not the whole registry. -
Death: a creature confirmed dead (
crtr_flag?(:dead), or GS HP-0) stays in the registry and can still show inin_roomwhile the room still reports it. It's excluded fromtargets(viavalid_target?). Once it drops out of the room feed it leaves the roster; the instance lingers inalluntil aged out. -
Eviction:
cleanup_old(max_age_seconds)bycreated_at. Auto-invoked with escalating thresholds whenregisterhitsfull?(see §4.3). -
Capacity:
max_sizedefault 1000;full?register refuses (returns nil) after exhausting cleanup. -
ID recycling: the server reuses
existids. A stale cached flag/name could in principle misapply to an unrelated creature that reuses an id — the parser resets its pairing buffers every prompt/refresh to bound this, and reachability logic should not assume an id is unique over time.
| Concern | Agnostic (CreatureBase) |
DragonRealms | GemStone |
|---|---|---|---|
Registry / roster / []/all/clear/cleanup_old
|
✅ defines | inherits | inherits |
targets / in_room / filters |
✅ defines | inherits | inherits |
<crtrStatus> flag vocab + reconciliation |
✅ defines | inherits | inherits |
Status timers (STATUS_DURATIONS) |
✅ defines | inherits | inherits |
configure/max_size/auto_register?
|
✅ defines | inherits | inherits |
Debug ($creature_debug, snapshots) |
✅ defines | inherits + facade debug_on
|
inherits + facade debug_on
|
| Feed shape | — | 3 streams (crtrStatus id-first + room-objs names + assess) |
1 stream (room-objs <a exist noun> + inline/pending crtrStatus) |
| Facade feed entry points | — |
sync, feed_assess
|
none (parser calls register directly) |
| Name source | assess (authoritative) / room-objs (stream-order) | inline <a> name |
|
valid_target? rule |
host-supplied | !dead |
!dead && !HP0 && !animated-decoy && !appendage |
| Positional/relational data | — |
relation, range, target, balance, off_balance? (assess pull) |
— |
assess-only conditions (cursed?, friendly?) |
— | ✅ | — |
| HP / damage / injuries | — | — | ✅ |
| UCS tracking | — | — | ✅ |
Bestiary (CreatureTemplate + support classes) |
— | — | ✅ |
stats extra keys |
— | {instances,max_size,auto_register} |
+ templates |
damage_report/print_damage_report
|
— | — | facade delegates ( |
| External populator | — | XML parser | XML parser + Combat::Tracker
|
-
CreatureBaseholds no data.;e echo Lich::Common::CreatureBaseshows a module, not creatures. Use the game facade's.in_room/.all. -
Creature.damage_report/print_damage_reportare dangling delegations. The GS facade callsCreatureInstance.damage_report(**options)/CreatureInstance.print_damage_report(**options), but no such class methods are defined inlib/gemstone/creature.rb(or anywhere inlib/). Calling them will raiseNoMethodErrorunless aCombat::Tracker-side reopening provides them at runtime. Treat as unsupported / verify before use. -
configureresets omitted options.configure(max_size: 2000)silently resetsauto_registerback totrue. Always pass every option you want to keep. -
Unknown filters return
[]. A typo'd filter (:not_prnoe,:hostle) empties the whole result — it does not raise. Stick toKNOWN_FLAG_NAMES. -
sync_crtr_statusclears absent flags. It's a full snapshot; don't expect a partial tag to be a delta. -
DR assess fields go stale.
range,balance,conditions,relation,targetare pull snapshots. Re-assessand checkenriched?/enriched_atbefore relying on them. crtrStatus flags (crtr_flag?,has_status?) are fresh push data. -
DR
friendly?is temporary manipulation, not a durable ally flag. crtrStatus can still sayhostile="1"whilefriendly?is true. -
DR name pairing is all-or-nothing. If the bold-name count ≠ crtrStatus id
count for a refresh, no stream-order names are applied that refresh (assess
still names by id later). So a freshly-synced DR creature may have
name == nilbriefly. -
current_room_idsis a copy; the roster is not. Mutate the return value freely; never mutate anything the internals hand you elsewhere. -
GS
dead?(HP-0) ≠ crtrStatus:dead.valid_target?andmuckled?check both; a filter:deadchecks only the crtrStatus classification flag. -
add_injuryraises on bad body parts. OnlyBODY_PARTSare valid. -
ID recycling. Don't assume an
existid maps to one creature forever. - Registry can refuse registration when full — presence is still marked, but the creature won't appear in queries.
-
GS only: the Combat tracker is off by default.
hp_percent,injured_locations,fatal_crit?, and UCS values are empty untilCombat::Tracker.enable!runs once (persists per character). See §6.5. -
GS only:
Combat::Tracker.configuremerges; baseCreature.configureresets. Two different config methods with opposite semantics — don't confuse them. -
GS
CreatureInstance#idisInteger;GameObj#idisString.==between them is always false; bridge with.to_s(see §6.6).
;e <ruby>executes Ruby inline; userespond/putsto print to your client. SwapLich::DragonRealms::CreatureforLich::Gemstone::Creatureon GS.
# Everything in the room (live or dead) — the full picture
;e Lich::DragonRealms::Creature.in_room.each { |c| respond "#{c.id} name=#{c.name.inspect} noun=#{c.noun.inspect} dead=#{c.crtr_flag?(:dead)} hostile=#{c.crtr_flag?(:hostile)} range=#{c.range.inspect} balance=#{c.balance.inspect} statuses=#{c.statuses}" }
# Just IDs of dead mobs in the room
;e respond Lich::DragonRealms::Creature.in_room(:dead).map(&:id).inspect
# Just IDs of prone mobs in the room
;e respond Lich::DragonRealms::Creature.in_room(:prone).map(&:id).inspect
# Attackable hostiles only
;e Lich::DragonRealms::Creature.targets.each { |c| respond "#{c.id} #{c.noun} off_balance=#{c.off_balance?}" }
# Registry-wide (not just this room) + stats
;e respond Lich::DragonRealms::Creature.stats.inspect; Lich::DragonRealms::Creature.all.each { |c| respond "#{c.id} #{c.name.inspect} enriched=#{c.enriched?}" }
# Watch it populate live
;e Lich::DragonRealms::Creature.debug_on(:changes) # :all / :active / falseRequires Lich 5.19.0+. HP/wounds/UCS need the Combat tracker enabled.
# Turn on combat analytics (persists per character; no-op if already on)
;e Lich::Gemstone::Combat::Tracker.enable! unless Lich::Gemstone::Combat::Tracker.enabled?
# Everything in the room
;e Lich::Gemstone::Creature.in_room.each { |c| respond "#{c.id} #{c.name} hp=#{c.hp_percent}% dead=#{c.dead?} muckled=#{c.muckled?}" }
# Attackable hostiles, wounded first (needs tracker for hp_percent)
;e Lich::Gemstone::Creature.targets.sort_by { |c| c.hp_percent || 100 }.each { |c| respond "#{c.noun} #{c.hp_percent}% wounds=#{c.injured_locations.inspect}" }
# Server dead flag (works tracker-off) vs HP-0 dead? (needs tracker)
;e Lich::Gemstone::Creature.in_room(:dead).each { |c| respond "#{c.id} #{c.name} fatal_crit=#{c.fatal_crit?}" }
# UCS state (unarmed)
;e c = Lich::Gemstone::Creature.targets.first; respond c ? "pos=#{c.ucs_position.inspect} tierup=#{c.ucs_tierup.inspect} smote=#{c.smote?}" : "no target"
# Bestiary lookup
;e t = Lich::Gemstone::CreatureTemplate['kobold']; respond t ? "#{t.name} lvl #{t.level} hp #{t.max_hp} skin=#{t.treasure.has_skin?}" : "no template"
# Tracker controls
;e Lich::Gemstone::Combat::Tracker.set_fallback_hp(500)
;e respond Lich::Gemstone::Combat::Tracker.stats.inspect
;e Lich::Gemstone::Combat::Tracker.enable_debug! # verbose [Combat] logging- Status filters:
:immobilized :webbed :sleeping :disoriented :stunned :rooted :calm :kneeling :prone :sitting :flying :hovering :hidden :blind :poisoned :sunburst(plus timer names:breeze :bind :web :entangle :hypnotism :mass_calm :sleep) - Classification filters:
:hostile :disengaged :dead :sympathetic :ascended :inferior :ascension_boss :mini_boss :challenging :rider :mount - Any filter can be negated:
:not_dead,:not_prone, … - Filters are ANDed; an unknown filter returns
[].