mirror of
https://github.com/bdnugget/rs-sdk.git
synced 2026-11-07 09:07:30 +00:00
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:
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 => {
|
||||
|
||||
Reference in new issue
Block a user