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:
| Call | Returns |
|---|---|
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 line | Recorded as |
|---|---|
<image;Reagent> Nickel | name 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 Prism | name 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.
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
sourceis alwaysniland everything else still works. source.enemy_templatesholds 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' },
}
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.
Configure call replaces the whole policyIt 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:
| Token | Expands to |
|---|---|
$date | 2026-08-28 |
$time | 15-04-05 |
$timestamp | Unix seconds |
$pid | Console process id |
$revision | Game build hash |
Two rules worth knowing:
nameis always reduced to a bare file name, so a template cannot write outsidedir.- The file is opened by
Start, so settingfileon a running logger returnsfalse, errrather than quietly applying on some later start.
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.