Skip to main content

Drops

The drop logger watches the in-game chat log and records what you pick up. It reads window text and nothing else — no hooks, no writes to the game.

require('drops')

local c = clients:First()
local ok, err = c.drops:Start()
if not ok then utils.Log('drop logger: ' .. tostring(err)) end

Start is idempotent. Everything from that moment forward is counted in memory and appended to a JSONL file; the scrollback that was already on screen when you started is not, or every run would begin by re-reporting the last one.

The full controller:

CallReturns
c.drops:Start()true on success (including a second call), false, err otherwise
c.drops:Stop()nothing
c.drops:Reset()nothing
c.drops:SetRun(n)nothing; a negative n raises an argument error
c.drops:Stats()DropStats — a zeroed one if the client is unavailable
c.drops:Recent(n)DropRecord[], oldest first; n defaults to 20
c.drops:Count(name)integer quantity, 0 for an unknown name
c.drops:Configure(opts)true, or false, err naming the bad value
c.drops:Listen(pred, cb)true, or false, err

Count matches the name exactly — it is a lookup in Stats().by_name, not a search. Count('gold') is 0; Count('Gold') is your gold.

What counts as a drop

Four kinds of line appear in the chat log, and all four are recorded:

Chat lineRecorded as
<image;Reagent> Nickelname Nickel, kind reagent, quantity 1
You have earned 254 gold!name Gold, kind gold, quantity 254
You have received 30 experience!name Experience, kind xp, quantity 30
You received: Mass Life Prismname Mass Life Prism, kind received

Gold and experience carry real amounts, so Count('Gold') is an amount of gold while Count('Sunstone') is a number of stones. Stats().lines is the number of drop events, which is the one to use for "how many things dropped".

An item type the logger does not recognise is recorded as kind unknown with the raw token kept in raw_kind, never discarded. If Kingsisle adds a type, it shows up in your data as a countable unknown rather than as silence.

Reading the results

local s = c.drops:Stats()
print(s.lines, 'events')
print(s.by_name['Gold'], 'gold')
print(s.by_kind['reagent'], 'reagents')

for _, d in ipairs(c.drops:Recent(5)) do
print(d.kind, d.name, d.quantity, d.zone)
end

Stats() carries lines, unparsed, by_kind, by_name, and started (RFC3339, present only once the logger has run). Both maps hold quantities, not event counts, so by_kind.gold is an amount of gold.

Recent returns oldest first and is capped at the logger's buffer (256 by default). The counters are not capped, so totals stay accurate across a long session even though only the last 256 records are held.

A record carries name, kind, quantity, and time always; raw_kind, zone, character, run_id, and source appear only when they have a value. Index them defensively — d.zone or '?' — rather than assuming every record is complete.

Grouping by run

local run = 0

function plugin.on_tick()
local c = clients:First()
if c and starting_a_new_lap() then
run = run + 1
c.drops:SetRun(run)
end
end

on_tick receives no arguments — fetch the client yourself.

Every drop after SetRun(n) carries run_id = n on disk, which is what lets you compute a per-run drop rate later instead of a per-day one.

Reacting to a drop

Listen takes a predicate — a function that returns true or false for one drop — and a callback that runs only for the drops it accepts.

c.drops:Listen(
function(item) return item.kind == 'seed' end,
function(item) utils.Log('planted-to-be: ' .. item.name) end)

Both functions receive the same table Recent() returns, so match on item.name, item.kind, item.quantity — fields, not method calls.

Listeners stack. Calling Listen twice registers two; the second does not replace the first. A predicate or callback that raises an error is logged and skipped, so one broken listener cannot silence the others.

If you would rather handle every drop yourself, the same data arrives as an ordinary event:

events.On('drop', function(data)
utils.Log(data.client_id .. ' got ' .. data.name)
end)

The event payload is the record's fields inline plus client_id and seq, a per-session sequence number that the tables from Recent() do not carry. Read data.name, not data.record.name.

Pick one style

Listen installs the plugin's on_drop hook. Assigning plugin.on_drop yourself, or calling events.On('drop', …), replaces that dispatcher and silently disables every registered listener. Use one or the other, not both.

Attribution

A drop recorded within 15 seconds of a fight ending carries a source:

local recent = c.drops:Recent(1)
local d = recent[1]
if d and d.source then
print('from duel', d.source.duel_id, 'won:', tostring(d.source.victory))
end

Recent returns an empty table when nothing has dropped yet, so index it defensively. source carries duel_id, victory, since_ms (milliseconds between the duel ending and the drop), and enemy_templates.

Two limits are worth knowing:

  • Attribution needs the state watcher running. Without it source is always nil and everything else still works.
  • source.enemy_templates holds template ids, not boss names. libwiz exposes no participant name that is safe to read from a poll loop, and a template id identifies the boss exactly.

A drop that cannot be attributed gets source = nil rather than a guess. An unattributed drop is a correct answer; a wrong boss is not.

Narrowing what is recorded

c.drops:Configure{
kinds = { 'hat', 'robe', 'athame', 'ring', 'amulet', 'deck' },
fields = { 'zone', 'run_id' },
}
Narrowing is lossy

Configure controls what reaches disk, not just what is displayed. An excluded kind or field is recorded nowhere and cannot be recovered afterwards. Narrow it when a long run would otherwise write megabytes of reagent lines, not to tidy up a readout — the default records everything.

Configure returns false, err naming the offending value if you mistype a field or kind, rather than quietly recording less than you intended.

Valid fields: raw_line, source, zone, character, run_id.

Valid kinds: reagent, snack, housing, pet, shoes, seed, jewel, robe, hat, athame, weapon, wand, deck, ring, amulet, gold, crowns, xp, received, unknown. These are also the values you see in d.kind and Stats().by_kind.

Each Configure call replaces the whole policy

It does not merge with the last one. A second call that sets only file resets fields and kinds back to "record everything", and a call that sets only kinds restores every field. Pass the complete policy every time, or call it exactly once at startup.

Where the file goes

By default:

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

One JSON object per line, appended. Grouping by game revision keeps data from different client builds separable, which matters because the chat markup can change with a game update.

Choosing your own path

c.drops:Configure{ file = '/home/me/farmlogs/tonight.jsonl' }

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

A string is a complete path. The table form splits it, and name accepts these tokens:

TokenExpands to
$date2026-08-28
$time15-04-05
$timestampUnix seconds
$pidConsole process id
$revisionGame build hash

Two rules worth knowing:

  • name is always reduced to a bare file name, so a template cannot write outside dir.
  • The file is opened by Start, so setting file on a running logger returns false, err rather than quietly applying on some later start.
append = false truncates

append defaults to true. Setting it to false erases an existing file at that path when the logger starts. With a fixed name and no date token, that silently destroys the previous run. It is the only destructive option in this API.

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

From the console

drops start
drops stats
drops recent 10
drops stop

drops stats prints event counts, per-kind and per-name totals, and an unparsed counter.

When it stops working

unparsed counts chat lines that carried the system marker but produced no drop. In normal play it stays near zero. If it climbs while lines stays flat, the chat markup has changed under the parser — most likely after a game update. Capture a fresh sample with:

dump chat /tmp/chatlog.txt

and compare it against libs/libwiz/pkg/drops/testdata/README.md, which documents the format the parser expects and the capture it was written against.