Class: Lich::Gemstone::Combat::Recorder

Inherits:
Object
  • Object
show all
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).

Examples:

Record a live hunt

rec = Combat::Recorder.new('/tmp/combat.db', character: Char.name, idle_timeout: 300)
rec.subscribe!
loop { rec.check_idle! }

Explicit session (replay)

rec = Combat::Recorder.new('/tmp/combat.db')
rec.start_session(character: 'Nisugi', source: 'log.txt', at: t0)
feed_events_to(rec)
rec.finish_session(at: t1)

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

Instance Method Summary collapse

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).

Parameters:

  • now (Time) (defaults to: Time.now) —

    current time for comparison, defaults to Time.now



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.

Returns:

  • (Integer, nil) —

    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.

Parameters:

  • at (Time) (defaults to: Time.now) —

    timestamp for session end, defaults to Time.now

Returns:

  • (Integer, nil) —

    the closed session 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

Note:

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.

Parameters:

  • type (Symbol) —

    event type: :attack, :status, :stun, :roundtime, :spell_loss, or :ucs

  • data (Hash) —

    event payload



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.

Parameters:

  • type (Symbol) —

    event type: :attack, :status, :stun, :roundtime, :spell_loss, or :ucs

  • data (Hash) —

    event payload



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.message}"
  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.

Examples:

Start a replay session

rec.start_session(character: 'Nisugi', source: '/logs/hunt.txt', at: Time.at(1000))

Parameters:

  • character (String, nil) (defaults to: nil) —

    character name, defaults to nil

  • source (String, nil) (defaults to: nil) —

    origin description ("live", log file path, etc.), defaults to nil

  • at (Time) (defaults to: Time.now) —

    timestamp for session start, defaults to Time.now

Returns:

  • (Integer) —

    the row ID of the newly created session



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.

Parameters:

  • character (String, nil) (defaults to: nil) —

    character name

  • source (String, nil) (defaults to: nil) —

    origin description

  • at (Time) (defaults to: Time.now) —

    timestamp for session start

Returns:

  • (Integer) —

    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