feat(engine): opt-in JSONL kill and trade ledgers

KILL_LEDGER_FILE records every NPC death at the NPC_DEL chokepoint with the
credited killer and full damage split. TRADE_LEDGER_FILE records completed
player trades at the moment the exchange executes. Both are off by default.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
max bittkerandClaude Fable 5.1 committed 2026-09-07 11:57:26 -04:00
1 parent 56b73e08fc
commit eef5964755
4 files changed
+140 -1

No files matched your search

@@ -0,0 +1,54 @@
import fs from 'fs';
import path from 'path';
// Append-only JSONL ledger of NPC deaths credited to players, written at the
// NPC_DEL opcode (the single chokepoint every scripted death path funnels
// through) while the npc's heroPoints damage table is still intact. One line
// per death: the credited killer (highest damage — the same findHero() rule
// the engine uses to award loot) plus the full damage split, so team kills
// can be attributed and audited independently of any client/SDK session.
//
// Off by default: set KILL_LEDGER_FILE to enable. The RuneBench images point
// it at /logs/tracking/kill_ledger.jsonl so benchmark watchers/verifiers can
// count boss kills authoritatively.
const LEDGER_FILE = process.env.KILL_LEDGER_FILE || '';
export type KillLedgerEntry = {
ts: string;
tick: number;
/** npc config id (e.g. 50 = king_dragon) */
npcId: number;
/** npc debugname when present, else display name */
npcName: string | null;
/** login username of the highest-damage player; null if no hero recorded */
killer: string | null;
/** full damage split, highest first */
contributors: Array<{ username: string | null; damage: number }>;
x: number;
z: number;
level: number;
};
let dirReady = false;
let warned = false;
export function recordKill(entry: Omit<KillLedgerEntry, 'ts'>): void {
if (!LEDGER_FILE) {
return;
}
try {
if (!dirReady) {
const dir = path.dirname(LEDGER_FILE);
if (dir && dir !== '.' && !fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
dirReady = true;
}
fs.appendFileSync(LEDGER_FILE, JSON.stringify({ ts: new Date().toISOString(), ...entry }) + '\n');
} catch (err) {
if (!warned) {
warned = true;
console.error(`[kill-ledger] append to ${LEDGER_FILE} failed:`, err);
}
}
}
@@ -0,0 +1,52 @@
import fs from 'fs';
import path from 'path';
import { WealthEventItem } from '#/engine/entity/tracking/WealthEvent.js';
// Append-only JSONL ledger of completed player<->player trades, written at the
// moment the engine executes the exchange (BOTH_MOVEINV from the trade-confirm
// script). One line per completed trade, both sides included - the single
// authoritative record of who traded what with whom, independent of any
// client/SDK session surviving to report it.
//
// Off by default: set TRADE_LEDGER_FILE to enable. The RuneBench images point
// it at /logs/tracking/trade_ledger.jsonl so the benchmark's market watcher
// can fold it into its tracking data.
const LEDGER_FILE = process.env.TRADE_LEDGER_FILE || '';
export type TradeLedgerEntry = {
ts: string;
tick: number;
/** login username (not display name) of the side whose offer this event moved */
from: string;
to: string;
fromItems: WealthEventItem[];
toItems: WealthEventItem[];
/** ObjType.cost totals of each side's items */
fromValue: number;
toValue: number;
};
let dirReady = false;
let warned = false;
export function recordTrade(entry: Omit<TradeLedgerEntry, 'ts'>): void {
if (!LEDGER_FILE) {
return;
}
try {
if (!dirReady) {
const dir = path.dirname(LEDGER_FILE);
if (dir && dir !== '.' && !fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
dirReady = true;
}
fs.appendFileSync(LEDGER_FILE, JSON.stringify({ ts: new Date().toISOString(), ...entry }) + '\n');
} catch (err) {
if (!warned) {
warned = true;
console.error(`[trade-ledger] append to ${LEDGER_FILE} failed:`, err);
}
}
}
@@ -6,6 +6,7 @@ import { ObjDelayedRequest } from '#/engine/entity/ObjDelayedRequest.js';
import { EntityLifeCycle } from '#/engine/entity/EntityLifeCycle.js';
import Obj from '#/engine/entity/Obj.js';
import Player from '#/engine/entity/Player.js';
import { recordTrade } from '#/engine/entity/tracking/TradeLedger.js';
import { WealthEventItem } from '#/engine/entity/tracking/WealthEvent.js';
import { Inventory } from '#/engine/Inventory.js';
import { ScriptOpcode } from '#/engine/script/ScriptOpcode.js';
@@ -487,6 +488,15 @@ const InvOps: CommandHandlers = {
recipient_items: toItems,
recipient_value: toTotal
});
recordTrade({
tick: World.currentTick,
from: fromPlayer.username,
to: toPlayer.username,
fromItems,
toItems,
fromValue: fromTotal,
toValue: toTotal
});
}
}
}),
@@ -13,6 +13,7 @@ import { NpcIteratorType } from '#/engine/entity/NpcIteratorType.js';
import { NpcMode } from '#/engine/entity/NpcMode.js';
import { NpcStat } from '#/engine/entity/NpcStat.js';
import Obj from '#/engine/entity/Obj.js';
import { recordKill } from '#/engine/entity/tracking/KillLedger.js';
import { NpcHuntAllCommandIterator, NpcIterator } from '#/engine/script/ScriptIterators.js';
import { ScriptOpcode } from '#/engine/script/ScriptOpcode.js';
import ScriptPointer, { ActiveNpc, ActivePlayer, checkedHandler } from '#/engine/script/ScriptPointer.js';
@@ -91,7 +92,29 @@ const NpcOps: CommandHandlers = {
}),
[ScriptOpcode.NPC_DEL]: checkedHandler(ActiveNpc, state => {
World.removeNpc(state.activeNpc, check(state.activeNpc.type, NpcTypeValid).respawnrate);
const npc = state.activeNpc;
const type = check(npc.type, NpcTypeValid);
// Kill ledger (KILL_LEDGER_FILE): npc_del at 0 hp is a death, not a
// scripted despawn. heroPoints is still intact here — it's only
// cleared on respawn — so the loot-credit rule (findHero) and the
// full damage split are recordable at this chokepoint.
if (npc.levels[NpcStat.HITPOINTS] === 0) {
const contributors = [...npc.heroPoints]
.filter(h => h && h.hash64 !== -1n && h.points > 0)
.sort((a, b) => b.points - a.points)
.map(h => ({ username: World.getPlayerByHash64(h.hash64)?.username ?? null, damage: h.points }));
recordKill({
tick: World.currentTick,
npcId: type.id,
npcName: type.debugname ?? type.name,
killer: contributors[0]?.username ?? null,
contributors,
x: npc.x,
z: npc.z,
level: npc.level
});
}
World.removeNpc(npc, type.respawnrate);
}),
[ScriptOpcode.NPC_DELAY]: checkedHandler(ActiveNpc, state => {