Class: Lich::Gemstone::Combat::Recorder
- Inherits:
-
Object
- Object
- Lich::Gemstone::Combat::Recorder
- Defined in:
- documented/gemstone/combat/recorder.rb
Overview
Persists the combat observer event stream to a SQLite database.
Maintains a relational schema of sessions, creatures, attacks, resolutions, flares, hits, and statuses. Handles both live auto-sessioned recording and explicit session control for replay/headless analysis. Manages spawn-tree linkage (root/parent attack relationships), creature lifecycle tracking (kills, wounds), and attack attribution of status effects via a window heuristic.
Thread-safe: serializes all public entry points on a non-reentrant mutex to coordinate between the AsyncProcessor worker thread (record) and the consuming script's event loop (check_idle!, close, start_session).
Constant Summary collapse
- HANDLER_NAME =
'combat_recorder'- SCHEMA =
SQLite schema: seven-table relational design for combat events.
Tables: sessions (hunt unit), creatures (subject registry), attacks (swing/cast/inbound), resolutions (roll facts), flares (proc damage), hits (damage events with crit denormalization), statuses (effect stream).
Sources of truth: attacks/resolutions/flares/hits unpack the :attack payload only (derived :damage/:wound/:fatal_crit/:amputation events are not subscribed). Statuses come from :status/:stun/:roundtime/:spell_loss /:ucs emits only.
Spawn-tree closure: root_attack_id points every event at the initiating shot; parent_attack_id/parent_confidence record the immediate spawner only when game-declared (blink bracket) - ambiguous echoes are unparented.
<<~SQL CREATE TABLE IF NOT EXISTS sessions ( id INTEGER PRIMARY KEY, character TEXT, source TEXT, started_at REAL NOT NULL, ended_at REAL ); CREATE TABLE IF NOT EXISTS creatures ( id INTEGER PRIMARY KEY, session_id INTEGER NOT NULL REFERENCES sessions(id), exist_id INTEGER, noun TEXT, name TEXT, first_seen REAL, last_seen REAL, killed_at REAL, killed_by_attack_id INTEGER, UNIQUE (session_id, exist_id) ); CREATE TABLE IF NOT EXISTS attacks ( id INTEGER PRIMARY KEY, session_id INTEGER NOT NULL REFERENCES sessions(id), seq INTEGER NOT NULL, -- monotonic within session occurred_at REAL NOT NULL, name TEXT NOT NULL, -- def name (:unknown = orphan sink) parent TEXT, -- spawned-cast lineage (flare name) parent_weapon TEXT, -- the weapon whose flare spawned it root_attack_id INTEGER REFERENCES attacks(id), -- initiating shot of this blob's spawn tree (self for a root) parent_attack_id INTEGER REFERENCES attacks(id), -- immediate spawner, ONLY when asserted (blink bracket); NULL when ambiguous parent_confidence TEXT, -- 'bracket' (game-declared) | NULL (unproven); reserved: 'count' via TEXT, -- gesture wrapper (:cast) creature_id INTEGER REFERENCES creatures(id), -- NULL: inbound/self/foreign/orphan target_kind TEXT NOT NULL, -- creature|self|foreign|none attacker TEXT, -- inbound: who attacked us attacker_exist_id INTEGER, weapon TEXT, outcome TEXT, -- first outcome (miss/evade/warded/...) outcomes_all TEXT, -- comma-joined when >1 aimed INTEGER NOT NULL DEFAULT 0, ambush INTEGER NOT NULL DEFAULT 0, inbound INTEGER NOT NULL DEFAULT 0, orphan INTEGER NOT NULL DEFAULT 0, foreign_caster INTEGER NOT NULL DEFAULT 0, -- a nearby player's attack (observed, not ours) unowned INTEGER NOT NULL DEFAULT 0, -- effect tick, no owning cast: applied to creature, not our deal redirected_from TEXT -- guardian redirect: noun of the creature we struck AT; the row's creature is the guardian that took it ); CREATE INDEX IF NOT EXISTS idx_attacks_session ON attacks(session_id, seq); CREATE INDEX IF NOT EXISTS idx_attacks_creature ON attacks(creature_id); CREATE INDEX IF NOT EXISTS idx_attacks_name ON attacks(session_id, name); CREATE INDEX IF NOT EXISTS idx_attacks_root ON attacks(root_attack_id); -- attacker_stat/defender_stat/modifier by type: -- as_ds: AS / DS / AvD cs_td: CS / TD / CvA -- uaf_udf:UAF / UDF / MM fear: FS / FD / FvP -- smr/ssr/maneuver_roll/activation: NULLs + roll/bonus/penalty/result CREATE TABLE IF NOT EXISTS resolutions ( id INTEGER PRIMARY KEY, attack_id INTEGER NOT NULL REFERENCES attacks(id), flare_id INTEGER REFERENCES flares(id), -- NULL = the attack's own roll seq INTEGER NOT NULL, -- order within the attack type TEXT NOT NULL, attacker_stat INTEGER, defender_stat INTEGER, modifier INTEGER, roll INTEGER, -- the die, kept separate: bonus INTEGER, -- result - roll = margin penalty INTEGER, result INTEGER, total REAL -- UCS pre-MM total (fractional) ); CREATE INDEX IF NOT EXISTS idx_resolutions_attack ON resolutions(attack_id); CREATE TABLE IF NOT EXISTS flares ( id INTEGER PRIMARY KEY, attack_id INTEGER NOT NULL REFERENCES attacks(id), seq INTEGER NOT NULL, name TEXT NOT NULL, damaging INTEGER NOT NULL DEFAULT 0, creature_id INTEGER REFERENCES creatures(id), -- AoE: may differ from swing weapon TEXT, outcome TEXT ); CREATE INDEX IF NOT EXISTS idx_flares_attack ON flares(attack_id); CREATE INDEX IF NOT EXISTS idx_flares_name ON flares(name); CREATE TABLE IF NOT EXISTS hits ( id INTEGER PRIMARY KEY, attack_id INTEGER NOT NULL REFERENCES attacks(id), flare_id INTEGER REFERENCES flares(id), -- NULL = attack's own damage session_id INTEGER NOT NULL REFERENCES sessions(id), creature_id INTEGER REFERENCES creatures(id), -- NULL = inbound/self/orphan seq INTEGER NOT NULL, damage INTEGER NOT NULL, location TEXT, -- CritRanks location body_part TEXT, -- mapped injury-doll part crit_type TEXT, crit_rank INTEGER, -- CritRanks :rank (0-9): crit severity wound_rank INTEGER, -- CritRanks :wound_rank (0-3): wound left on the creature fatal INTEGER NOT NULL DEFAULT 0, amputated INTEGER NOT NULL DEFAULT 0, secondary_location TEXT, secondary_rank INTEGER -- secondary wound severity (:wound_rank) ); CREATE INDEX IF NOT EXISTS idx_hits_attack ON hits(attack_id); CREATE INDEX IF NOT EXISTS idx_hits_creature ON hits(session_id, creature_id); CREATE INDEX IF NOT EXISTS idx_hits_location ON hits(location); CREATE INDEX IF NOT EXISTS idx_hits_crit ON hits(crit_rank); -- kind: status (action add/remove), stun (value=rounds), -- roundtime (value=seconds), spell_loss (spell/spell_name/cause), -- ucs (status=position/tierup/..., value=tier) CREATE TABLE IF NOT EXISTS statuses ( id INTEGER PRIMARY KEY, session_id INTEGER NOT NULL REFERENCES sessions(id), creature_id INTEGER REFERENCES creatures(id), -- NULL = self or unresolved subject TEXT, -- name as printed ('self' for us) attack_id INTEGER REFERENCES attacks(id), -- window attribution occurred_at REAL NOT NULL, kind TEXT NOT NULL, status TEXT, action TEXT, value INTEGER, spell INTEGER, spell_name TEXT, cause TEXT, source TEXT -- 'direct' | 'window' ); CREATE INDEX IF NOT EXISTS idx_statuses_creature ON statuses(session_id, creature_id); CREATE INDEX IF NOT EXISTS idx_statuses_attack ON statuses(attack_id); SQL
- STAT_KEYS =
parsed-resolution key -> generic column, per roll family
{ as: :attacker_stat, cs: :attacker_stat, uaf: :attacker_stat, fs: :attacker_stat, ds: :defender_stat, td: :defender_stat, udf: :defender_stat, fd: :defender_stat, avd: :modifier, cva: :modifier, mm: :modifier, fvp: :modifier, mods: :modifier, roll: :roll, bonus: :bonus, penalty: :penalty, result: :result, total: :total }.freeze
Instance Attribute Summary collapse
-
#db ⇒ Object
readonly
Returns the value of attribute db.
-
#session_id ⇒ Object
readonly
Returns the value of attribute session_id.
Instance Method Summary collapse
-
#check_idle!(now = Time.now) ⇒ Object
Close the current session if the idle gap has elapsed - ended_at is the LAST EVENT's time, not now, so town time never pads a hunt.
-
#check_idle_locked!(now = Time.now) ⇒ void
private
Internal variant of check_idle!; does not acquire the mutex.
-
#close ⇒ Object
Finish any open session and release the database.
-
#finish_session(at: Time.now) ⇒ Integer?
The id of the session just closed.
-
#finish_session_locked(at: Time.now) ⇒ Integer?
private
Internal variant of finish_session; does not acquire the mutex.
-
#initialize(db_path, character: nil, source: 'live', idle_timeout: nil) ⇒ Recorder
constructor
idle_timeout (seconds): enables auto-sessioning - a session opens on the first recorded event and closes once this long passes without one (see check_idle!).
-
#record(type, data) ⇒ void
Record a combat event from the observer feed.
-
#record_locked(type, data) ⇒ void
private
Internal variant of record; does not acquire the mutex.
-
#start_session(character: nil, source: nil, at: Time.now) ⇒ Integer
Open a new session, finishing any open session first.
-
#start_session_locked(character: nil, source: nil, at: Time.now) ⇒ Integer
private
Internal variant of start_session; does not acquire the mutex.
-
#subscribe! ⇒ Object
Hook the live observer feed.
-
#txt(value) ⇒ Object
Text from the game stream arrives as ASCII-8BIT (binary) strings, and the sqlite3 gem binds a binary string as a BLOB.
-
#unsubscribe! ⇒ void
Unregister the named handler from the observer feed.
Constructor Details
#initialize(db_path, character: nil, source: 'live', idle_timeout: nil) ⇒ Recorder
idle_timeout (seconds): enables auto-sessioning - a session opens on the first recorded event and closes once this long passes without one (see check_idle!). nil = explicit start_session/finish_session.
285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 |
# File 'documented/gemstone/combat/recorder.rb', line 285 def initialize(db_path, character: nil, source: 'live', idle_timeout: nil) @db = SQLite3::Database.new(db_path) @db.busy_timeout = 5_000 @db.execute('PRAGMA journal_mode = WAL') @db.execute('PRAGMA synchronous = NORMAL') @db.execute_batch(SCHEMA) migrate! @seq = 0 @open_attack = nil # { id:, creature_ids: Set, inbound: bool } @chunk_rows = {} # per-chunk _uid -> attack row id, for spawn-tree links @pending_cache = nil # creature-cache entries staged during a txn (see in_txn) @pending_chunk_rows = nil # chunk-row entries staged during a txn (see in_txn) @character = character @source = source @idle_timeout = idle_timeout @last_event_at = nil # record fires on the AsyncProcessor worker thread; check_idle!/close # are documented as driven from the consuming script's own periodic # loop. Both touch @db, so every public entry point serialises on one # non-reentrant mutex. Internal helpers that run while the lock is # already held (start_session/finish_session called from inside # record) must NOT re-acquire it - hence the unsynchronised _locked # variants below. @mutex = Mutex.new end |
Instance Attribute Details
#db ⇒ Object (readonly)
Returns the value of attribute db.
280 281 282 |
# File 'documented/gemstone/combat/recorder.rb', line 280 def db @db end |
#session_id ⇒ Object (readonly)
Returns the value of attribute session_id.
280 281 282 |
# File 'documented/gemstone/combat/recorder.rb', line 280 def session_id @session_id end |
Instance Method Details
#check_idle!(now = Time.now) ⇒ Object
Close the current session if the idle gap has elapsed - ended_at is the LAST EVENT's time, not now, so town time never pads a hunt. Cheap; call it from a periodic loop so a finished hunt closes even when no further event ever arrives.
412 413 414 |
# File 'documented/gemstone/combat/recorder.rb', line 412 def check_idle!(now = Time.now) @mutex.synchronize { check_idle_locked!(now) } end |
#check_idle_locked!(now = Time.now) ⇒ 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.
Internal variant of check_idle!; does not acquire the mutex.
Assumes the caller already holds the lock. Finishes the current session if idle_timeout was set, a session is open, an event has been recorded, and the gap since the last event exceeds idle_timeout. Uses the last event's timestamp as the session end (town time never pads a hunt).
426 427 428 429 430 431 |
# File 'documented/gemstone/combat/recorder.rb', line 426 def check_idle_locked!(now = Time.now) return unless @idle_timeout && @session_id && @last_event_at return unless now - @last_event_at > @idle_timeout finish_session_locked(at: @last_event_at) end |
#close ⇒ Object
Finish any open session and release the database.
434 435 436 437 438 439 440 |
# File 'documented/gemstone/combat/recorder.rb', line 434 def close unsubscribe! @mutex.synchronize do finish_session_locked(at: @last_event_at || Time.now) if @session_id @db.close end end |
#finish_session(at: Time.now) ⇒ Integer?
Returns the id of the session just closed.
366 367 368 |
# File 'documented/gemstone/combat/recorder.rb', line 366 def finish_session(at: Time.now) @mutex.synchronize { finish_session_locked(at: at) } end |
#finish_session_locked(at: Time.now) ⇒ Integer?
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.
Internal variant of finish_session; does not acquire the mutex.
Assumes the caller already holds the lock. Updates the current session's ended_at timestamp and clears session and attack state. Returns the closed session's ID, or nil if no session was open.
379 380 381 382 383 384 385 386 387 |
# File 'documented/gemstone/combat/recorder.rb', line 379 def finish_session_locked(at: Time.now) return unless @session_id @db.execute('UPDATE sessions SET ended_at = ? WHERE id = ?', [at.to_f, @session_id]) finished = @session_id @session_id = nil @open_attack = nil finished end |
#record(type, data) ⇒ void
Errors are logged via Lich.log (if available) and never re-raised
This method returns an undefined value.
Record a combat event from the observer feed.
Public entry point called by the named observer handler. Acquires the serialization mutex and dispatches to record_locked. Rescue catches and logs any error so a payload bug never crashes the processor.
453 454 455 |
# File 'documented/gemstone/combat/recorder.rb', line 453 def record(type, data) @mutex.synchronize { record_locked(type, data) } end |
#record_locked(type, data) ⇒ 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.
Internal variant of record; does not acquire the mutex.
Assumes the caller already holds the lock. If idle_timeout is set, checks for session expiry, auto-opens a new session if needed, and updates the last-event timestamp. Then dispatches by type to the appropriate handler (record_attack or record_status variants). Returns early if no session is open.
470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 |
# File 'documented/gemstone/combat/recorder.rb', line 470 def record_locked(type, data) if @idle_timeout now = Time.now check_idle_locked!(now) start_session_locked(character: @character, source: @source, at: now) unless @session_id @last_event_at = now end return unless @session_id case type when :attack then record_attack(data) when :status then record_status(kind: 'status', id: data[:id], name: data[:name], status: data[:status].to_s, action: data[:action].to_s) when :stun then record_status(kind: 'stun', id: data[:id], name: data[:name], status: 'stunned', action: 'add', value: data[:rounds].to_i) when :roundtime then record_status(kind: 'roundtime', id: data[:id], name: data[:name], status: 'roundtime', action: 'add', value: data[:seconds].to_i) when :spell_loss then record_status(kind: 'spell_loss', id: data[:id], name: data[:name], action: 'remove', spell: data[:spell], spell_name: data[:spell_name], cause: data[:cause]&.to_s) when :ucs then record_status(kind: 'ucs', id: data[:id], name: data[:name], status: data[:kind].to_s, value: ucs_value(data)) end rescue StandardError => e # Never raise into the processor; surfaced via Lich.log when present. msg = "CombatRecorder #{type}: #{e.}" defined?(Lich) && Lich.respond_to?(:log) ? Lich.log("error: #{msg}") : warn(msg) end |
#start_session(character: nil, source: nil, at: Time.now) ⇒ Integer
Open a new session, finishing any open session first.
Acquires the serialization mutex; calls the internal _locked variant. Sessions record hunts bounded by a 5-minute gap without combat events (inbound or outbound). With idle_timeout set at initialization, the recorder opens lazily on the first event and closes once that gap elapses. For explicit control (e.g., replay), omit idle_timeout and call this and finish_session directly.
327 328 329 |
# File 'documented/gemstone/combat/recorder.rb', line 327 def start_session(character: nil, source: nil, at: Time.now) @mutex.synchronize { start_session_locked(character: character, source: source, at: at) } end |
#start_session_locked(character: nil, source: nil, at: Time.now) ⇒ Integer
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.
Internal variant of start_session; does not acquire the mutex.
Assumes the caller already holds the lock. Closes any existing session, inserts a new session row, resets sequence counter and creature cache, and returns the new session ID.
355 356 357 358 359 360 361 362 363 |
# File 'documented/gemstone/combat/recorder.rb', line 355 def start_session_locked(character: nil, source: nil, at: Time.now) finish_session_locked if @session_id @db.execute('INSERT INTO sessions (character, source, started_at) VALUES (?, ?, ?)', [txt(character), txt(source), at.to_f]) @session_id = @db.last_insert_row_id @seq = 0 @creature_cache = {} @session_id end |
#subscribe! ⇒ Object
Hook the live observer feed. Idempotent via the named-handler contract; per the Observers contract the callback stays cheap (single WAL transaction) and never sends game commands.
392 393 394 395 396 |
# File 'documented/gemstone/combat/recorder.rb', line 392 def subscribe! obs = Lich::Gemstone::Combat::Observers obs.on(:attack, :status, :stun, :roundtime, :spell_loss, :ucs, name: HANDLER_NAME) { |type, data| record(type, data) } end |
#txt(value) ⇒ Object
Text from the game stream arrives as ASCII-8BIT (binary) strings, and
the sqlite3 gem binds a binary string as a BLOB. Every noun, creature
name, attacker and weapon was landing typed blob (real db 2026-09-07:
creatures.noun blob=196, text=0), so WHERE noun = 'berserker' never
matched and text functions saw bytes, not words. Re-tag as UTF-8 at
the boundary; game text is ASCII/Latin-1 so scrub only guards junk.
337 338 339 340 341 342 |
# File 'documented/gemstone/combat/recorder.rb', line 337 def txt(value) return nil if value.nil? s = value.to_s s.encoding == Encoding::ASCII_8BIT ? s.dup.force_encoding('UTF-8').scrub('?') : s end |
#unsubscribe! ⇒ void
This method returns an undefined value.
Unregister the named handler from the observer feed.
Stops listening to :attack, :status, :stun, :roundtime, :spell_loss, :ucs events. Safe to call when not subscribed.
404 405 406 |
# File 'documented/gemstone/combat/recorder.rb', line 404 def unsubscribe! Lich::Gemstone::Combat::Observers.off(HANDLER_NAME) end |