Skip to main content

Drop tracking

Measure what a farm run actually yields. This bolts onto any farming plugin — a few lines on one you already have — and writes a JSONL file you can mine afterwards.

The API reference is Drops; this page is the working setup. The shipped farm_sm.lua uses it for real, and is worth reading alongside this page.

Before you start

  • A farming plugin already running, or the farm loop set up.
  • require('drops') at the top of your plugin. That is what makes c.drops exist.
  • Somewhere to write. The logger opens a file when you start it; the default location is under your Kebab root and is created for you.

The logger reads the game's chat log. It records what the game tells you you received, so a drop that produces no system chat line is not recorded, and neither is anything that happened before you started it.

The plugin

---@type KebabPlugin
plugin = { name = 'drop_tracking', version = '1.0.0' }

require('drops')

local run = 0

plugin.on_load = function()
run = 0
for _, c in ipairs(clients:Connected()) do
local ok, err = c.drops:Start()
if not ok then utils.Log('drops: ' .. tostring(err)) end
end
utils.Log('drop logging started')
end

plugin.on_combat_exit = function(data)
local c = clients:Get(data.client_id)
if not c then return end

run = run + 1
c.drops:SetRun(run)

local s = c.drops:Stats()
utils.LogThrottled('drops', string.format(
'run %d: %d events, %d gold, %d xp',
run, s.lines, s.by_name['Gold'] or 0, s.by_name['Experience'] or 0), 30000)
end

plugin.on_stop = function()
for _, c in ipairs(clients:Connected()) do
c.drops:Stop()
end
end

Four things worth copying:

Start goes in on_load, not on_tick. It is idempotent, so calling it repeatedly is harmless, but there is nothing to gain from it either — the file is opened once.

Every hook gets an event payload, not a client. on_combat_exit(data) receives a table carrying client_id; turn that into a client with clients:Get(data.client_id). This trips people up because the argument looks like it should be the client.

SetRun is bumped where a lap begins. Here a "run" is one fight. The shipped farm_sm instead bumps it as combat is entered, c.drops:SetRun(state.fights + 1), so the number lines up with its own fight counter. The number is yours; the logger only stamps it onto each record.

LogThrottled rather than Log. A farm loop that logs on every fight will flush the console scrollback over a long session. The third argument is the throttle in milliseconds30000 is one line every thirty seconds.

Watching for one item

Farming for something specific? Do not poll for it — react to it:

local found = false

plugin.on_load = function()
local c = clients:First()
if not c then return end
c.drops:Start()

c.drops:Listen(
function(item) return item.name == 'Deer Knight' end,
function(item)
utils.Log('GOT IT after ' .. run .. ' runs')
found = true
end)
end

plugin.on_tick = function()
if found then
utils.Log('stopping: target dropped')
-- stop your farm loop here
return
end
-- ...normal loop
end

The first function is a predicate — it answers yes or no for one drop — and the callback runs only when it says yes. Both receive the same table Recent() returns, so you can match on anything in the record:

-- everything from one fight the character won
function(item) return item.source ~= nil and item.source.victory end

-- gear only, ignoring reagents and gold
function(item)
local gear = { hat = true, robe = true, shoes = true, athame = true }
return gear[item.kind] == true
end

-- anything the parser did not recognise, which is how a game update shows up
function(item) return item.kind == 'unknown' end

Note shoes, not boots — the kind names are listed in Drops, and an unrecognised one silently matches nothing.

Listen and on_drop are mutually exclusive

Listen installs the plugin's on_drop hook for you. Assigning plugin.on_drop yourself, or calling events.On('drop', ...), replaces that dispatcher and disables every registered listener. Pick one style per plugin.

Listeners themselves stack — call Listen twice and you get two. A predicate or callback that errors is logged and skipped rather than taking the others down with it.

If you just want a running tally instead, Count still works. It returns a quantity, so Count('Gold') is an amount of gold, not a number of gold drops. The same is true of the by_kind and by_name tables in Stats(); only lines is an event count.

One file per session

The default file is shared by every run that starts on a given day from the same process. To keep a session separate:

c.drops:Configure{
file = { dir = '/home/me/farmlogs', name = 'sm-$date-$time.jsonl' },
}
c.drops:Start()

$time makes the name unique per start, so each session gets its own file without you having to remember to move the last one. $date, $timestamp, $pid and $revision are also available, and the name is always reduced to a bare filename, so a template cannot write outside dir.

Configure before Start. The file is opened by Start, so setting it on a running logger is an error rather than a silent no-op — and Configure returns ok, err like everything else, so check it.

append defaults to true. Setting append = false truncates an existing file of that name.

Keeping the file small

A long session records every reagent and every gold line. If you only care about gear:

c.drops:Configure{
kinds = { 'hat', 'robe', 'shoes', 'athame', 'ring', 'amulet', 'deck', 'wand' },
}

fields narrows each record the same way — farm_sm records only seeds, keeping just the source and run_id fields:

c.drops:Configure{
kinds = { 'seed' },
fields = { 'source', 'run_id' },
file = { name = 'cp.jsonl', append = true },
}
Narrowing is lossy

This changes what reaches disk, not just what is shown. Excluded kinds and fields are recorded nowhere and cannot be recovered afterwards — including from the in-memory counters. Leave it off unless file size is a real problem; the default records everything.

Reading the file afterwards

~/.kebab/drops/<game revision>/<date>-<pid>.jsonl

One JSON object per line. The drop itself is nested under drop, with the run number, zone, character and fight attribution beside it:

{"v":1,"seq":41,"t":"2026-08-28T11:04:07Z","session":"...","zone":"...","run_id":12,
"drop":{"name":"Gold","kind":"gold","quantity":355},
"source":{"duel_id":9912,"victory":true,"since_ms":1400}}

So the usual tools work:

# every distinct item, most frequent first
jq -r '.drop.name' *.jsonl | sort | uniq -c | sort -rn | head -20

# total gold
jq 'select(.drop.kind == "gold") | .drop.quantity' *.jsonl | paste -sd+ | bc

# drops from a specific run
jq 'select(.run_id == 12) | .drop.name' *.jsonl

# how many runs produced nothing but gold and xp
jq -r 'select(.drop.kind != "gold" and .drop.kind != "xp") | .run_id' *.jsonl | sort -u | wc -l

run_id is only present if you called SetRun and did not exclude it with fields.

By default each record also carries drop.line, the raw chat line it was parsed out of. That is what you want when a drop looks misclassified, and it is the first thing to drop (fields without raw_line) if the file gets large.

From the console

You do not need a plugin at all to try it:

drops start
drops stats
drops recent 20
drops stop

drops reset clears the in-memory counters and the recent buffer. It does not truncate the session file, which keeps appending.

Checking it still works after a game update

The logger parses chat text, so a game update can change the markup it reads. From the console:

drops stats

unparsed counts system chat lines that carried the drop marker but yielded no drop. If unparsed is climbing while lines stays flat, the format moved. A sudden run of records with kind: "unknown" means the opposite — the line still parses, but an item category the parser does not know has appeared; the unrecognised token is preserved in raw_kind, which is exactly what to report.