diff --git a/scripts/Killtracker.lic b/scripts/killtracker.lic
similarity index 81%
rename from scripts/Killtracker.lic
rename to scripts/killtracker.lic
index d81b12379..ce8cf828d 100644
--- a/scripts/Killtracker.lic
+++ b/scripts/killtracker.lic
@@ -13,9 +13,15 @@
contributors: Nisugi
game: Gemstone
tags: hunting, combat, tracking, gemstones, jewels, dust, klocks, data
- version: 2.11
+ version: 2.12
Change Log:
+ v2.12 (2026-05-24)
+ - Added separate codex-eligible search counter (weekly_codex_searches)
+ - Gem and codex eligibility are gated independently
+ - Added codex tracking for onslaught creatures (10/week cap)
+ - Added ;kt codex report command and codex section in summary
+ - Codex finds submitted to external spreadsheet when enabled
v2.11 (2026-05-24)
- Fix for capitalization in dust found messaging
v2.10 (2025-09-21)
@@ -90,6 +96,10 @@ module Killtracker
if File.exist?(@filename)
begin
$killtracker = YAML.load_file(@filename)
+ unless $killtracker.is_a?(Hash)
+ log_data_warning("#{@filename} did not load as a Hash; starting with empty data")
+ $killtracker = {}
+ end
# Only create initial_load backup if it doesn't exist
initial_backup = File.join(@backup_dir, "initial_load.yaml")
@@ -122,6 +132,10 @@ module Killtracker
$killtracker[:weekly_counts] ||= {}
$killtracker[:jewel_found_this_week] ||= false
$killtracker[:debug_eligibility] ||= false
+ $killtracker[:codex_found] ||= {}
+ $killtracker[:weekly_codex] ||= 0
+ $killtracker[:weekly_codex_searches] ||= 0
+ $killtracker[:searches_since_codex] ||= 0
end
def self.create_backup(reason = "manual")
@@ -153,6 +167,10 @@ module Killtracker
latest_backup = backups.last
begin
$killtracker = YAML.load_file(latest_backup)
+ unless $killtracker.is_a?(Hash)
+ log_data_warning("#{latest_backup} did not load as a Hash; restore aborted")
+ return false
+ end
echo "Successfully restored from backup: #{File.basename(latest_backup)}"
save(true)
return true
@@ -162,6 +180,10 @@ module Killtracker
end
end
+ def self.log_data_warning(message)
+ Lich.log("Killtracker data warning: #{message}") rescue nil
+ end
+
def self.save(force = false)
@last_save_hash ||= 0
@next_save_time ||= 0
@@ -206,7 +228,7 @@ module Killtracker
# Check for old :searches key that needs to be renamed
if $killtracker[:dust_found]
$killtracker[:dust_found].each do |_, ev|
- if ev.key?(:searches)
+ if ev.is_a?(Hash) && ev.key?(:searches)
needs_migration = true
break
end
@@ -232,7 +254,7 @@ module Killtracker
end
$killtracker[:dust_found].each do |_key, ev|
- if ev.key?(:searches)
+ if ev.is_a?(Hash) && ev.key?(:searches)
ev[:searches_since] = ev.delete(:searches)
end
end
@@ -248,6 +270,7 @@ module Killtracker
end
events.each do |_, ev|
+ next unless ev.is_a?(Hash)
ev[:searches_week] = ev[:searches_week].to_i if ev[:searches_week]
ev[:searches_since] = ev[:searches_since].to_i if ev[:searches_since]
ev[:room] = ev[:room].to_s if ev[:room]
@@ -330,16 +353,22 @@ module Killtracker
total_this_week = $killtracker[:weekly_ascension_searches].to_i
weekly_dust = $killtracker[:weekly_dust].to_i
weekly_gemstone = $killtracker[:weekly_gemstone].to_i
+ weekly_codex = $killtracker[:weekly_codex].to_i
+ weekly_codex_searches = $killtracker[:weekly_codex_searches].to_i
$killtracker[:weekly_counts][:"week_#{finished_week}_ascension_searches"] = total_this_week
$killtracker[:weekly_counts][:"week_#{finished_week}_dust"] = weekly_dust
$killtracker[:weekly_counts][:"week_#{finished_week}_gemstone"] = weekly_gemstone
+ $killtracker[:weekly_counts][:"week_#{finished_week}_codex"] = weekly_codex
+ $killtracker[:weekly_counts][:"week_#{finished_week}_codex_searches"] = weekly_codex_searches
$killtracker[:monthly_ascension_searches] += total_this_week
$killtracker[:weekly_ascension_searches] = 0
$killtracker[:weekly_gemstone] = 0
$killtracker[:weekly_dust] = 0
+ $killtracker[:weekly_codex] = 0
+ $killtracker[:weekly_codex_searches] = 0
$killtracker[:jewel_found_this_week] = false
$killtracker[:last_week_reset] = $killtracker[:cached_reset_time]
@@ -406,6 +435,8 @@ module Killtracker
$killtracker[:weekly_gemstone] = 0
$killtracker[:monthly_gemstones] = 0
$killtracker[:weekly_dust] = 0
+ $killtracker[:weekly_codex] = 0
+ $killtracker[:weekly_codex_searches] = 0
$killtracker[:monthly_ascension_searches] = 0
$killtracker[:jewel_found_this_week] = false
@@ -432,6 +463,14 @@ module Killtracker
end
end
+ $killtracker[:codex_found].each do |key, _|
+ next unless key.is_a?(Integer)
+ local = tz.to_local(Time.at(key))
+ if local.strftime("%U").to_i == current_week && local.year == current_year
+ $killtracker[:weekly_codex] += 1
+ end
+ end
+
$killtracker[:weekly_counts].each do |week_key, searches|
next unless week_key.to_s.include?('ascension_searches')
next unless searches.is_a?(Integer)
@@ -448,6 +487,7 @@ module Killtracker
update_eligibility
respond("Counters successfully recalculated.")
respond(" Weekly gems: #{$killtracker[:weekly_gemstone]}, Monthly gems: #{$killtracker[:monthly_gemstones]}")
+ respond(" Weekly codex: #{$killtracker[:weekly_codex]}")
respond(" Jewel found this week: #{$killtracker[:jewel_found_this_week]}")
rescue => e
respond("Error during backfill: #{e.message}")
@@ -465,6 +505,7 @@ module Killtracker
respond(";kt gemstones report - find report broken down by week")
respond(";kt jewel report - find report of all jewels")
respond(";kt dust report - find report of all dust")
+ respond(";kt codex report - find report of all codex")
respond(";kt fix find count - fixes monthly/weekly gemstone count in summary")
respond(";kt save - force search data to be saved to file")
respond(";kt backup - create a manual backup of current data")
@@ -480,15 +521,19 @@ module Killtracker
begin
gems_total = $killtracker[:jewel_found].size
dust_total = $killtracker[:dust_found].size
+ codex_total = $killtracker[:codex_found].size
weekly_searches = $killtracker[:weekly_ascension_searches].to_i
monthly_searches = calculate_monthly_eligible_searches
gems_this_week = $killtracker[:weekly_gemstone].to_i
dust_this_week = $killtracker[:weekly_dust].to_i
+ codex_this_week = $killtracker[:weekly_codex].to_i
+ codex_searches_this_week = $killtracker[:weekly_codex_searches].to_i
gems_this_month = $killtracker[:monthly_gemstones].to_i
since_last_gem = $killtracker[:searches_since_jewel].to_i
since_last_dust = $killtracker[:searches_since_dust].to_i
+ since_last_codex = $killtracker[:searches_since_codex].to_i
total_searches_for_gems = $killtracker[:jewel_found].values
.map { |ev| ev[:searches_week].to_i }
@@ -498,10 +543,18 @@ module Killtracker
.map { |ev| ev[:searches_week].to_i }
.sum
- avg_per_gem = gems_total > 0 ? (total_searches_for_gems.to_f / gems_total).round : 0
- avg_per_dust = dust_total > 0 ? (total_searches_for_dust.to_f / dust_total).round : 0
+ total_searches_for_codex = $killtracker[:codex_found].values
+ .map { |ev| ev[:searches_week].to_i }
+ .sum
- remaining_gems = [0, 3 - gems_this_month].max
+ avg_per_gem = gems_total > 0 ? (total_searches_for_gems.to_f / gems_total).round : 0
+ avg_per_dust = dust_total > 0 ? (total_searches_for_dust.to_f / dust_total).round : 0
+ avg_per_codex = codex_total > 0 ? (total_searches_for_codex.to_f / codex_total).round : 0
+
+ remaining_gems = [0, 3 - gems_this_month].max
+ remaining_codex = [0, CODEX_WEEKLY_CAP - codex_this_week].max
+
+ codex_status = codex_currently_eligible? ? "Eligible (#{codex_this_week}/#{CODEX_WEEKLY_CAP})" : "Ineligible (#{codex_this_week}/#{CODEX_WEEKLY_CAP})"
# Determine weekly status with gem number
if currently_eligible?
@@ -535,8 +588,16 @@ module Killtracker
[" This Week", dust_this_week],
[" Avg Searches/Dust", avg_per_dust.with_commas],
[],
+ ["Codex Found (all)", codex_total.with_commas],
+ [" This Week", codex_this_week],
+ [" Codex Searches This Week", codex_searches_this_week.with_commas],
+ [" Remaining This Week", remaining_codex],
+ [" Status", codex_status],
+ [" Avg Searches/Codex", avg_per_codex.with_commas],
+ [],
["Since Last Gem", since_last_gem.with_commas],
["Since Last Dust", since_last_dust.with_commas],
+ ["Since Last Codex", since_last_codex.with_commas],
]
table = Terminal::Table.new(
@@ -607,6 +668,35 @@ module Killtracker
end
end
+ def self.codex_report
+ begin
+ events = $killtracker[:codex_found].sort_by { |key, _| key.to_i }
+
+ total_searches_for_codex = events.map { |_, ev| ev[:searches_week].to_i }.sum
+
+ rows = events.map do |key, ev|
+ time_str = format_time_eastern(key.to_i)
+ searches = (ev[:searches_week] || 0).with_commas
+ since_last = (ev[:searches_since] || 0).with_commas
+ creature = ev[:creature] || ""
+ room = ev[:room] || ""
+ name = ev[:name] || ""
+ [time_str, searches, since_last, creature, room, name]
+ end
+
+ title = "Detailed Codex Report: #{$killtracker[:codex_found].size} Codex over #{total_searches_for_codex.with_commas} Eligible Searches"
+ table = Terminal::Table.new(
+ title: title,
+ headings: ["Time", "Week Searches", "Since Last Codex", "Creature", "Room", "Name"],
+ rows: rows
+ )
+
+ respond table.to_s
+ rescue => e
+ respond "Error generating codex report: #{e.message}"
+ end
+ end
+
def self.gemstones_report(weeks_back = nil)
begin
tz = get_eastern_tz
@@ -629,6 +719,14 @@ module Killtracker
since: ev[:searches_since]
)
end
+ $killtracker[:codex_found].each do |key, ev|
+ next unless key.is_a?(Integer) && ev.is_a?(Hash)
+ combined << ev.merge(
+ timestamp: key,
+ type: "Codex",
+ since: ev[:searches_since]
+ )
+ end
events_by_week = combined.group_by do |ev|
tz.to_local(Time.at(ev[:timestamp])).strftime("%U").to_i
@@ -693,6 +791,10 @@ module Killtracker
f.flock(File::LOCK_SH)
content = f.read
@jewel_eligibility = content.empty? ? {} : YAML.load(content) || {}
+ unless @jewel_eligibility.is_a?(Hash)
+ log_data_warning("#{eligibility_file} did not load as a Hash; using empty eligibility data")
+ @jewel_eligibility = {}
+ end
end
else
@jewel_eligibility = {}
@@ -751,6 +853,8 @@ module Killtracker
ev = $killtracker[:dust_found][key]
when "jewel"
ev = $killtracker[:jewel_found][key]
+ when "codex"
+ ev = $killtracker[:codex_found][key]
end
return unless ev
@@ -801,6 +905,16 @@ module Killtracker
end
respond(" Sent #{sent_dust} dust records")
+ respond("Sending found codex...")
+ sent_codex = 0
+ $killtracker[:codex_found].each do |timestamp, _|
+ next unless timestamp.is_a?(Integer)
+ if send_to_sheet("codex", timestamp)
+ sent_codex += 1
+ end
+ end
+ respond(" Sent #{sent_codex} codex records")
+
respond("Sending complete.")
respond("View the data at: https://docs.google.com/spreadsheets/d/1IOLs8AGRR45Kr6Y9nz6CXlMVBKYR7cHLaz0jjAbjMv0")
respond("")
@@ -902,6 +1016,18 @@ module Killtracker
eligible
end
+ def self.codex_currently_eligible?
+ eligible = $killtracker[:weekly_codex].to_i < CODEX_WEEKLY_CAP
+
+ if $killtracker[:debug_eligibility]
+ echo "Codex Eligibility Check:"
+ echo " Weekly codex: #{$killtracker[:weekly_codex]} / #{CODEX_WEEKLY_CAP}"
+ echo " Eligible: #{eligible}"
+ end
+
+ eligible
+ end
+
def self.update_eligibility
begin
@eligibility_file = File.join(DATA_DIR, XMLData.game, "jewel_eligibility.yaml")
@@ -914,6 +1040,10 @@ module Killtracker
f.flock(File::LOCK_SH)
content = f.read
@jewel_eligibility = content.empty? ? {} : YAML.load(content) || {}
+ unless @jewel_eligibility.is_a?(Hash)
+ log_data_warning("#{@eligibility_file} did not load as a Hash; using empty eligibility data")
+ @jewel_eligibility = {}
+ end
end
end
@@ -967,13 +1097,14 @@ module Killtracker
errors = []
[:weekly_ascension_searches, :monthly_ascension_searches, :searches_since_jewel,
- :searches_since_dust, :monthly_gemstones, :weekly_gemstone, :weekly_dust].each do |key|
+ :searches_since_dust, :monthly_gemstones, :weekly_gemstone, :weekly_dust,
+ :weekly_codex, :weekly_codex_searches, :searches_since_codex].each do |key|
if $killtracker[key] && $killtracker[key] < 0
errors << "#{key} is negative: #{$killtracker[key]}"
end
end
- [$killtracker[:jewel_found], $killtracker[:dust_found]].each do |events|
+ [$killtracker[:jewel_found], $killtracker[:dust_found], $killtracker[:codex_found]].each do |events|
next unless events
events.each do |timestamp, _data|
unless timestamp.is_a?(Integer)
@@ -1000,6 +1131,10 @@ module Killtracker
errors << "Weekly gemstone exceeds maximum: #{$killtracker[:weekly_gemstone]}"
end
+ if $killtracker[:weekly_codex].to_i > CODEX_WEEKLY_CAP
+ errors << "Weekly codex exceeds maximum: #{$killtracker[:weekly_codex]}"
+ end
+
errors
end
@@ -1016,6 +1151,8 @@ module Killtracker
FOUND_DUST = %r{You notice a scintillating mote of gemstone dust on the ground and gather it quickly\.}i
FOUND_GEMSTONE = %r{ \*\* A glint of light catches your eye, and you notice an? (?[^<]+) at your feet! \*\*}
+ FOUND_CODEX = %r{Among the remains, you find an? (?[^<]+), which falls alongside your feet\.}
+ CODEX_WEEKLY_CAP = 10 unless const_defined?(:CODEX_WEEKLY_CAP)
ASCENSION_CREATURES = Regexp.union(
%r{armored battle mastodon},
%r{black valravn},
@@ -1064,6 +1201,16 @@ module Killtracker
%r{merrow oracle}
)
+ ONSLAUGHT_CREATURES = Regexp.union(
+ %r{battle-worn Empyrean captain},
+ %r{branded goliath diviner},
+ %r{burly goliath engineer},
+ %r{haze-shrouded goliath diviner},
+ %r{masked goliath plunderer},
+ %r{radiant-eyed goliath auramancer},
+ %r{tawny armor-clad pegasus}
+ )
+
def self.parse_downstream(line)
case line
when FOUND_GEMSTONE
@@ -1089,6 +1236,26 @@ module Killtracker
REPORT_QUEUE.push(report)
REPORT_QUEUE.push(["send jewel report", key]) if $killtracker[:submit_finds]
+ when FOUND_CODEX
+ key = Time.now.to_i
+ name = Regexp.last_match[:n]
+ room = Room.current.id.to_s
+
+ create_backup("pre_codex_find")
+
+ $killtracker[:weekly_codex] += 1
+ $killtracker[:codex_found][key] = {
+ searches_since: $killtracker[:searches_since_codex],
+ searches_week: $killtracker[:weekly_ascension_searches],
+ name: name,
+ room: room,
+ creature: $killtracker[:creature]
+ }
+ report = ['found codex', $killtracker[:creature], $killtracker[:weekly_codex], $killtracker[:searches_since_codex]]
+ $killtracker[:searches_since_codex] = 0
+ REPORT_QUEUE.push(report)
+ REPORT_QUEUE.push(["send codex report", key]) if $killtracker[:submit_finds]
+
when FOUND_DUST
key = Time.now.to_i
room = Room.current.id.to_s
@@ -1110,7 +1277,14 @@ module Killtracker
maybe_reset_monthly_counter
name = Regexp.last_match[:creature]
$killtracker[:creature] = name
- if ASCENSION_CREATURES.match?(name)
+
+ is_ascension = ASCENSION_CREATURES.match?(name)
+ is_onslaught = ONSLAUGHT_CREATURES.match?(name)
+
+ # Ascension and onslaught creatures both drop gems and dust.
+ # Codex only drops from onslaught creatures.
+ # Gem and codex eligibility are gated independently.
+ if is_ascension || is_onslaught
# Always count dust searches (dust has no limits)
$killtracker[:searches_since_dust] += 1
@@ -1124,6 +1298,16 @@ module Killtracker
elsif $killtracker[:debug_eligibility]
echo "Search not counted for gems - currently ineligible (#{name})"
end
+
+ # Onslaught creatures additionally contribute to codex tracking
+ if is_onslaught
+ if codex_currently_eligible?
+ $killtracker[:weekly_codex_searches] += 1
+ $killtracker[:searches_since_codex] += 1
+ elsif $killtracker[:debug_eligibility]
+ echo "Search not counted for codex - currently ineligible (#{name})"
+ end
+ end
end
end
line
@@ -1173,10 +1357,16 @@ module Killtracker
Lich::Messaging.stream_window("Found a gemstone in #{week} searches. (#{creature}) - Since last Jewel: (#{jewel})", "speech")
save
update_eligibility
+ when "found codex"
+ _, creature, weekly_codex, since = report
+ Lich::Messaging.stream_window("Found a codex after #{since} searches. (#{creature}) - Week Total: (#{weekly_codex}/#{CODEX_WEEKLY_CAP})", "speech")
+ save
when /send dust report/
send_to_sheet("dust", report[1])
when /send jewel report/
send_to_sheet("jewel", report[1])
+ when /send codex report/
+ send_to_sheet("codex", report[1])
end
end
@@ -1210,6 +1400,8 @@ module Killtracker
Killtracker.jewel_report
when /dust report/
Killtracker.dust_report
+ when /codex report/
+ Killtracker.codex_report
when /gemstones? report(?:\s+(\d+))?$/
weeks = $1 ? $1.to_i : nil
Killtracker.gemstones_report(weeks)