Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 78 additions & 8 deletions scripts/ledger.lic
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
contributors: Ondreian, Tysong
game: Gemstone
tags: silver, bounty, ledger, bank
version: 1.4.1
version: 1.4.2
requirements:
- sequel gem
- terminal-table
Expand All @@ -20,6 +20,12 @@
Help Contribute: https://github.com/elanthia-online/scripts
Version Control:
Major_change.feature_addition.bugfix
v1.4.2 - 2026-05-11
- Track Account.name on each transaction (new 'account' column).
On each run, backfills NULL account values for the currently-logged-in character
in the current game, using the resolved Account.name.
estimate_loot_cap now aggregates account-wide for the current week; falls back to the
current character when the account is not resolvable (e.g. proxy mode without ;account).
v1.4.1 - 2026-05-06
- Change estimate_loot_cap from monthly to weekly window (Monday 00:00 - Sunday 23:59:59, EST/EDT).
Week boundaries are computed in America/New_York via tzinfo (DST-aware).
Expand Down Expand Up @@ -325,19 +331,23 @@ module Ledger
end

# Estimates loot cap earnings for the current week (Monday through Sunday, EST/EDT)
# across all characters on the current account.
#
# Calculates silver gained from creature kills (excluding large deposits/withdrawals)
# to estimate progress toward the weekly loot cap. The week boundary is computed
# in America/New_York (which observes EST/EDT, including DST transitions) and then
# converted to the machine's local time to match how `created_at` is stored.
# to estimate progress toward the weekly account-wide loot cap. The week boundary
# is computed in America/New_York (which observes EST/EDT, including DST transitions)
# and then converted to the machine's local time to match how `created_at` is stored.
#
# @param year [Integer] ignored, retained for backward compatibility
# @param month [Integer] ignored, retained for backward compatibility
# @param character [String] character name (default: current character)
# @param account [String, nil] account name (default: current account via AccountInfo)
# @param character [String] character name; only used when account is nil (proxy mode)
# @param game [String] game code (default: current game)
# @return [Integer] estimated loot cap earnings for the current week
# @note Filters out transactions over 1,000,000 silver (likely bank transactions)
def self.estimate_loot_cap(year: Time.now.year, month: Time.now.month, character: Char.name, game: XMLData.game)
# @note When `account` is nil and AccountInfo cannot resolve one, falls back to
# the current character only (preserves pre-1.4.2 behavior in proxy mode).
def self.estimate_loot_cap(year: Time.now.year, month: Time.now.month, account: AccountInfo.name, character: Char.name, game: XMLData.game)
_ = year; _ = month # accepted for backward compatibility, no longer used

# Compute the start of the current week (Monday 00:00) in EST/EDT,
Expand All @@ -352,8 +362,10 @@ module Ledger
monday_midnight_est = Time.utc(week_start_est_date.year, week_start_est_date.month, week_start_est_date.day)
week_start_local = tz.local_to_utc(monday_midnight_est).getlocal

identity_filter = account ? { account: account } : { character: character }

Ledger::History::Transactions
.where(type: "silver", character: character, game: game)
.where({ type: "silver", game: game }.merge(identity_filter))
.where { created_at >= week_start_local }
.where { amount < 1_000_000 }
.where { amount > 0 }
Expand Down Expand Up @@ -395,6 +407,32 @@ module Ledger
end
end

# Resolves the current Account.name, issuing an in-game ACCOUNT command
# if Lich's Account module is present but unpopulated (proxy-mode logins).
#
# @since 1.4.2
module AccountInfo
# Returns the current account name, populating it via the ACCOUNT verb if
# needed. Lich5 (Infomon) parses the ACCOUNT response and assigns
# Account.name itself; we just trigger the round-trip and read it back.
#
# @return [String, nil] account name, or nil if it cannot be resolved
def self.name
return nil unless Object.const_defined?(:Account)
return Account.name if Account.name && !Account.name.to_s.empty?

begin
fput('account')
sleep(1)
rescue StandardError => e
echo "AccountInfo: failed to resolve via ACCOUNT (#{e.message})"
return nil
end

Account.name if Account.name && !Account.name.to_s.empty?
end
Comment on lines +420 to +433
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Memoize the unresolvable case to avoid repeated ACCOUNT commands on every transaction.

When Account.name is empty (e.g., proxy-mode logins, which the changelog explicitly calls out), this method falls through to quiet_command_xml("account", ...) and waits up to 3 seconds. Because record_transaction (line 611) calls AccountInfo.name on every recorded transaction, an unresolvable account will:

  1. Block the main History.main loop for ~3s per recorded transaction, risking missed/late deposit/withdraw matches that immediately follow.
  2. Spam the in-game account command (visible server-side) every time silver/bounty changes.

The same applies to the account: default on estimate_loot_cap (line 348), though that path is colder.

Cache the resolution outcome (including the negative case) so the verb is sent at most once per session, with an optional invalidation hook if needed.

🔒 Proposed fix: memoize the resolved/unresolvable state
   module AccountInfo
+    `@resolved`    = false
+    `@cached_name` = nil
+
+    # Reset the cached resolution; useful if the user runs ;account later
+    # in the session and we want to retry.
+    def self.reset!
+      `@resolved`    = false
+      `@cached_name` = nil
+    end
+
     # Returns the current account name, populating it via the ACCOUNT verb if
     # needed. Lich5 (Infomon) parses the ACCOUNT response and assigns
     # Account.name itself; we just trigger the round-trip and read it back.
     #
     # `@return` [String, nil] account name, or nil if it cannot be resolved
     def self.name
+      return `@cached_name` if `@resolved`
       return nil unless Object.const_defined?(:Account)
-      return Account.name if Account.name && !Account.name.to_s.empty?
+      if Account.name && !Account.name.to_s.empty?
+        `@resolved` = true
+        return `@cached_name` = Account.name
+      end
 
       begin
         Lich::Util.quiet_command_xml("account", /Account Name:/i, /<prompt/, false, 3)
       rescue StandardError => e
         echo "AccountInfo: failed to resolve via ACCOUNT (#{e.message})"
-        return nil
+        `@resolved` = true
+        return `@cached_name` = nil
       end
 
-      Account.name if Account.name && !Account.name.to_s.empty?
+      `@resolved` = true
+      `@cached_name` = (Account.name if Account.name && !Account.name.to_s.empty?)
     end
   end
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/ledger.lic` around lines 418 - 430, AccountInfo.name currently
re-runs Lich::Util.quiet_command_xml each time when Account.name is empty;
memoize the lookup (including the negative result) so the verb is sent at most
once per session: add a class-level cache (e.g., `@cached_account_name` and
`@cached_account_name_set`) used by AccountInfo.name to return a cached value if
set, only call Lich::Util.quiet_command_xml when the cache is unset, store
either the found name or nil into `@cached_account_name` and mark
`@cached_account_name_set`=true, and add an optional invalidation method (e.g.,
AccountInfo.invalidate_name_cache) so callers like record_transaction and
estimate_loot_cap will use the cached outcome and avoid repeated account
commands.

end

# Transaction history database and tracking logic
#
# Manages the SQLite database, defines transaction patterns, and handles
Expand Down Expand Up @@ -443,6 +481,17 @@ module Ledger
Integer :day
Integer :hour
String :game
String :account
end

# One-time migration: add `account` column to existing databases.
# Pre-existing rows remain NULL; new rows populate via record_transaction.
begin
unless Self.schema(:transactions).map(&:first).include?(:account)
Self.alter_table(:transactions) { add_column :account, String }
end
rescue => e
echo "Note: Could not add account column: #{e.message}"
end

# Add indexes for query optimization (one-time migration)
Expand All @@ -460,13 +509,33 @@ module Ledger
name: :transactions_game_type_year_index
end

# Index for account-wide queries (estimate_loot_cap)
unless Self.indexes(:transactions).key?(:transactions_account_type_index)
Self.add_index :transactions, [:account, :type],
name: :transactions_account_type_index
end

# Update query optimizer statistics
Self.run("ANALYZE transactions")
rescue => e
# Non-fatal - script continues even if indexing fails
echo "Note: Could not add indexes: #{e.message}"
end

# Backfill NULL account values for the currently-logged-in character.
# Safe because we know with certainty which account this character belongs
# to right now. Idempotent (only touches NULL rows). Silent.
begin
current_account = AccountInfo.name
if current_account && !current_account.empty?
Self[:transactions]
.where(character: Char.name, game: XMLData.game, account: nil)
.update(account: current_account)
end
rescue => e
echo "Note: Could not backfill account: #{e.message}"
end

# Alias for easier access to transactions table
Transactions = Self[:transactions]

Expand Down Expand Up @@ -556,6 +625,7 @@ module Ledger
now = Time.now
# info fields
transaction[:character] = Char.name
transaction[:account] = AccountInfo.name
transaction[:amount] = amount
transaction[:type] = type
transaction[:game] = XMLData.game
Expand Down Expand Up @@ -597,7 +667,7 @@ module Ledger
max_bar_width = 40
rows = hours.map do |hour, amount|
bar_width = max_value > 0 ? (amount.to_f / max_value * max_bar_width).round : 0
bar = '' * bar_width
bar = '|' * bar_width
formatted_amount = with_commas(amount)

[sprintf("%02d:00", hour), "#{bar} #{formatted_amount}"]
Expand Down
Loading