feat(tools): add item census, owner lookup and offline bank grant tools

- item-census.ts / item-census-html.ts: count and holders per item across
  every save plus GE escrow, rendered as a sprite viewer
- find-item-owners.ts: who holds a given obj id
- grant-bank-item.ts: patch an offline player's bank, optionally waiting
  for them to log out

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
max bittkerandClaude Opus 5.5 committed 2026-10-09 00:00:55 -04:00
1 parent a6d124cec2
commit c755bbe3bd
4 files changed
+676

No files matched your search

@@ -0,0 +1,97 @@
// Census of who holds a given obj across every player save + the GE database.
//
// bun find-item-owners.ts <objId>[,objId...] [--players-dir=data/players/main] [--ge=data/market.sqlite]
//
// Scans every .sav (any SAV_VERSION >= 5; older saves lack inv sizes and are
// reported as skipped), every inventory type inside it (inv 93, worn 94,
// bank 95, ...), then the GE offers table for items sitting in escrow
// (open/partial sell offers) or in uncollected fill boxes.
import { readdirSync, readFileSync } from 'fs';
import { join } from 'path';
import { Database } from 'bun:sqlite';
const argv = process.argv.slice(2);
const flags = new Map<string, string>();
const positional: string[] = [];
for (const a of argv) {
if (a.startsWith('--')) { const [k, v] = a.slice(2).split('='); flags.set(k, v ?? 'true'); }
else positional.push(a);
}
if (!positional[0]) { console.error('usage: find-item-owners.ts <objId>[,objId...] [--players-dir=...] [--ge=...]'); process.exit(1); }
const targets = new Set(positional[0].split(',').map(s => parseInt(s, 10)));
const playersDir = flags.get('players-dir') ?? 'data/players/main';
const gePath = flags.get('ge') ?? 'data/market.sqlite';
const INV_NAMES: Record<number, string> = { 93: 'inv', 94: 'worn', 95: 'bank' };
class Reader {
pos = 0;
constructor(public buf: Uint8Array) {}
g1() { return this.buf[this.pos++]; }
g2() { return ((this.buf[this.pos++] << 8) | this.buf[this.pos++]) >>> 0; }
g4() { return ((this.buf[this.pos++] << 24) | (this.buf[this.pos++] << 16) | (this.buf[this.pos++] << 8) | this.buf[this.pos++]) | 0; }
gVarInt() {
let byte = this.buf[this.pos++]; let result = 0;
while ((byte & 0x80) !== 0) { result = (result | (byte & 0x7f)) << 7; byte = this.buf[this.pos++]; }
return (result | byte) >>> 0;
}
}
interface Hit { inv: string; slot: number; id: number; count: number }
function scan(buf: Uint8Array): { version: number; hits: Hit[] } {
const r = new Reader(buf);
if (r.g2() !== 0x2004) throw new Error('bad magic');
const version = r.g2();
if (version < 5) throw new Error(`save version ${version} < 5 (no inv sizes)`);
r.g2(); r.g2(); r.g1();
r.pos += 7 + 5 + 1;
r.g2();
if (version >= 2) r.g4(); else r.g2();
for (let i = 0; i < 21; i++) { r.g4(); r.g1(); }
const varpCount = r.g2();
if (version >= 7) for (let i = 0; i < varpCount; i++) { r.g2(); r.gVarInt(); }
else for (let i = 0; i < varpCount; i++) r.g4();
const invCount = r.g1();
const hits: Hit[] = [];
for (let i = 0; i < invCount; i++) {
const type = r.g2();
const size = r.g2();
for (let s = 0; s < size; s++) {
const id = r.g2() - 1;
if (id === -1) continue;
let c = r.g1();
if (c === 255) c = r.g4();
if (targets.has(id)) hits.push({ inv: INV_NAMES[type] ?? `inv${type}`, slot: s, id, count: c });
}
}
return { version, hits };
}
let files = 0, skipped: string[] = [], total = 0;
const rows: string[] = [];
for (const f of readdirSync(playersDir).filter(f => f.endsWith('.sav')).sort()) {
files++;
try {
const { hits } = scan(new Uint8Array(readFileSync(join(playersDir, f))));
for (const h of hits) { total += h.count; rows.push(`${f.slice(0, -4)}\t${h.inv}[${h.slot}]\tobj ${h.id}\tx${h.count}`); }
} catch (e) { skipped.push(`${f}: ${(e as Error).message}`); }
}
console.log(`scanned ${files} saves in ${playersDir}; ${rows.length} stacks, ${total} items total`);
for (const r of rows) console.log(' ' + r);
if (skipped.length) { console.log(`skipped ${skipped.length}:`); for (const s of skipped) console.log(' ' + s); }
try {
const db = new Database(gePath, { readonly: true });
const ids = [...targets].join(',');
const offers = db.query(`SELECT id, owner, slot, item, side, quantity, remaining, filled, state, items, coins FROM offers WHERE item IN (${ids}) ORDER BY id`).all() as any[];
let escrow = 0, boxed = 0;
console.log(`\nGE offers touching obj ${ids}: ${offers.length}`);
for (const o of offers) {
// sell escrow = remaining unsold; fill box = items not yet collected (buy side)
const held = o.side === 'sell' ? (o.slot >= 0 ? o.remaining : 0) : o.items;
if (o.side === 'sell' && o.slot >= 0) escrow += o.remaining; else if (o.side === 'buy') boxed += o.items;
console.log(` #${o.id} ${o.owner} ${o.side} slot=${o.slot} qty=${o.quantity} remaining=${o.remaining} filled=${o.filled} state=${o.state} itemsInBox=${o.items} coins=${o.coins} held=${held}`);
}
console.log(`sell escrow: ${escrow}, uncollected in buy boxes: ${boxed}`);
const trades = db.query(`SELECT COUNT(*) n, COALESCE(SUM(quantity),0) q FROM trades WHERE item IN (${ids})`).get() as any;
console.log(`GE trades ever: ${trades.n} (${trades.q} items)`);
} catch (e) { console.log(`\nGE db ${gePath}: ${(e as Error).message}`); }
@@ -0,0 +1,259 @@
// Grant an item into an OFFLINE player's bank by patching their .sav on disk.
//
// Usage (run on the box, from /opt/server/engine or anywhere):
// bun grant-bank-item.ts <username> <objId> [count] [--wait] [--inspect] [--players-dir=data/players/main] [--web=http://localhost:8080]
//
// A save only takes effect on a fresh login - while the world holds the
// character the in-memory state wins and the next autosave/logout overwrites
// the file (see World.removePlayerWithoutSave / savePlayers). So with --wait
// this polls /playerpositions until the player is gone, waits for the logout
// save to land (file quiet for QUIET_MS), patches, then keeps re-verifying for
// a while in case a late save overwrites the patch.
//
// Byte layout mirrors Player.save() / PlayerLoading.load() at SAV_VERSION 7.
import { existsSync, readFileSync, renameSync, statSync, writeFileSync } from 'fs';
import { join } from 'path';
const SAV_MAGIC = 0x2004;
const SAV_VERSION = 7;
const INV_BANK = 95;
const BANK_CAPACITY = 240; // ^bank_total_slots
const QUIET_MS = 15_000;
const VERIFY_MS = 90_000;
const argv = process.argv.slice(2);
const flags = new Map<string, string>();
const positional: string[] = [];
for (const a of argv) {
if (a.startsWith('--')) {
const [k, v] = a.slice(2).split('=');
flags.set(k, v ?? 'true');
} else {
positional.push(a);
}
}
const [usernameArg, objArg, countArg] = positional;
if (!usernameArg || !objArg) {
console.error('usage: grant-bank-item.ts <username> <objId> [count] [--wait] [--players-dir=...] [--web=...]');
process.exit(1);
}
const username = usernameArg.toLowerCase().replace(/[^a-z0-9]/g, '_');
const objId = parseInt(objArg, 10);
const count = Math.max(1, parseInt(countArg ?? '1', 10) || 1);
const wait = flags.get('wait') === 'true';
const playersDir = flags.get('players-dir') ?? 'data/players/main';
const web = flags.get('web') ?? 'http://localhost:8080';
const savPath = join(playersDir, `${username}.sav`);
const log = (...args: unknown[]) => console.log(new Date().toISOString(), ...args);
// ---- CRC (Packet.getcrc) ----
const CRC_TABLE = (() => {
const t = new Int32Array(256);
for (let i = 0; i < 256; i++) {
let r = i;
for (let b = 0; b < 8; b++) r = (r & 1) === 1 ? (r >>> 1) ^ 0xedb88320 : r >>> 1;
t[i] = r;
}
return t;
})();
function crc32(src: Uint8Array, len: number): number {
let crc = 0xffffffff;
for (let i = 0; i < len; i++) crc = (crc >>> 8) ^ CRC_TABLE[(crc ^ src[i]) & 0xff];
return ~crc;
}
// ---- reader ----
class Reader {
pos = 0;
constructor(public buf: Uint8Array) {}
g1() { return this.buf[this.pos++]; }
g2() { return ((this.buf[this.pos++] << 8) | this.buf[this.pos++]) >>> 0; }
g4() { return ((this.buf[this.pos++] << 24) | (this.buf[this.pos++] << 16) | (this.buf[this.pos++] << 8) | this.buf[this.pos++]) | 0; }
gVarInt() {
let byte = this.buf[this.pos++];
let result = 0;
while ((byte & 0x80) !== 0) {
result = (result | (byte & 0x7f)) << 7;
byte = this.buf[this.pos++];
}
return (result | byte) >>> 0;
}
}
type Slot = { id: number; count: number } | null;
interface Parsed {
invCountPos: number;
invCount: number;
/** byte range of the bank block [start, end), or null if the save has no bank inv */
bank: { start: number; end: number; size: number; slots: Slot[] } | null;
/** where the inv section ends (start of afk zones) */
invSectionEnd: number;
}
function parse(buf: Uint8Array): Parsed {
const r = new Reader(buf);
if (r.g2() !== SAV_MAGIC) throw new Error('not a save file');
const version = r.g2();
if (version !== SAV_VERSION) throw new Error(`save version ${version} != ${SAV_VERSION}; refusing to patch`);
const stored = ((buf[buf.length - 4] << 24) | (buf[buf.length - 3] << 16) | (buf[buf.length - 2] << 8) | buf[buf.length - 1]) | 0;
if (stored !== crc32(buf, buf.length - 4)) throw new Error('CRC mismatch on existing save');
r.g2(); r.g2(); r.g1(); // x, z, level
r.pos += 7 + 5 + 1; // body, colors, gender
r.g2(); // runenergy
r.g4(); // playtime
for (let i = 0; i < 21; i++) { r.g4(); r.g1(); }
const varpCount = r.g2();
for (let i = 0; i < varpCount; i++) { r.g2(); r.gVarInt(); }
const invCountPos = r.pos;
const invCount = r.g1();
let bank: Parsed['bank'] = null;
for (let i = 0; i < invCount; i++) {
const start = r.pos;
const type = r.g2();
const size = r.g2();
const slots: Slot[] = new Array(size).fill(null);
for (let s = 0; s < size; s++) {
const id = r.g2() - 1;
if (id === -1) continue;
let c = r.g1();
if (c === 255) c = r.g4();
slots[s] = { id, count: c };
}
if (type === INV_BANK) bank = { start, end: r.pos, size, slots };
}
return { invCountPos, invCount, bank, invSectionEnd: r.pos };
}
function encodeBank(size: number, slots: Slot[]): number[] {
const out: number[] = [];
const p1 = (v: number) => out.push(v & 0xff);
const p2 = (v: number) => out.push((v >>> 8) & 0xff, v & 0xff);
const p4 = (v: number) => out.push((v >>> 24) & 0xff, (v >>> 16) & 0xff, (v >>> 8) & 0xff, v & 0xff);
p2(INV_BANK);
p2(size);
for (let s = 0; s < size; s++) {
const o = slots[s];
if (!o) { p2(0); continue; }
p2(o.id + 1);
if (o.count >= 255) { p1(255); p4(o.count); } else p1(o.count);
}
return out;
}
/** Returns the slot holding objId, or -1. */
function bankSlotOf(buf: Uint8Array): number {
const p = parse(buf);
if (!p.bank) return -1;
return p.bank.slots.findIndex(s => s && s.id === objId);
}
function patch(buf: Uint8Array): Uint8Array {
const p = parse(buf);
let size: number, slots: Slot[], start: number, end: number, invCount = p.invCount;
if (p.bank) {
({ size, slots, start, end } = p.bank);
} else {
// never banked: append a fresh bank block at the end of the inv section
size = BANK_CAPACITY;
slots = new Array(size).fill(null);
start = end = p.invSectionEnd;
invCount++;
}
const existing = slots.findIndex(s => s && s.id === objId);
if (existing !== -1) throw new Error(`bank already has obj ${objId} in slot ${existing}`);
const free = slots.findIndex(s => !s);
if (free === -1) throw new Error('bank is full');
slots[free] = { id: objId, count };
log(`placing obj ${objId} x${count} in bank slot ${free} (bank ${slots.filter(Boolean).length}/${size} used)`);
const block = encodeBank(size, slots);
const head = buf.subarray(0, start);
const tail = buf.subarray(end, buf.length - 4);
const out = new Uint8Array(head.length + block.length + tail.length + 4);
out.set(head, 0);
out.set(block, head.length);
out.set(tail, head.length + block.length);
out[p.invCountPos] = invCount;
const crc = crc32(out, out.length - 4);
out[out.length - 4] = (crc >>> 24) & 0xff;
out[out.length - 3] = (crc >>> 16) & 0xff;
out[out.length - 2] = (crc >>> 8) & 0xff;
out[out.length - 1] = crc & 0xff;
parse(out); // round-trip sanity check
return out;
}
function writeAtomic(bytes: Uint8Array): void {
const tmp = `${savPath}.tmp`;
writeFileSync(tmp, bytes);
renameSync(tmp, savPath);
}
async function isOnline(): Promise<boolean | null> {
try {
const res = await fetch(`${web}/playerpositions`);
const players = (await res.json()) as { name: string }[];
return players.some(p => p.name.toLowerCase().replace(/[^a-z0-9]/g, '_') === username);
} catch (e) {
log('playerpositions fetch failed:', (e as Error).message);
return null;
}
}
const sleep = (ms: number) => new Promise(r => setTimeout(r, ms));
async function main() {
if (!existsSync(savPath)) throw new Error(`${savPath} does not exist`);
log(`target ${savPath}, obj ${objId} x${count}`);
const initial = parse(new Uint8Array(readFileSync(savPath)));
log(`save ok: ${initial.invCount} invs, bank ${initial.bank ? `${initial.bank.slots.filter(Boolean).length}/${initial.bank.size} used` : 'absent'}, online=${await isOnline()}`);
if (flags.get('inspect') === 'true') return;
if (bankSlotOf(new Uint8Array(readFileSync(savPath))) !== -1) {
log('already present in bank; nothing to do');
return;
}
// 1. wait for the player to leave the world
for (;;) {
const online = await isOnline();
if (online === false) break;
if (!wait) throw new Error(online ? `${username} is online; rerun with --wait` : 'cannot reach web server');
await sleep(10_000);
}
log(`${username} is offline`);
// 2. wait for the logout save to land and the file to go quiet
let mtime = statSync(savPath).mtimeMs;
let quietSince = Date.now();
while (Date.now() - quietSince < QUIET_MS) {
await sleep(1000);
const now = statSync(savPath).mtimeMs;
if (now !== mtime) { mtime = now; quietSince = Date.now(); log('save file changed, waiting for quiet'); }
}
// 3. patch; if they came back online between the check and now, go around again
if (await isOnline()) { log('came back online before patch; restarting wait'); return main(); }
const before = new Uint8Array(readFileSync(savPath));
const after = patch(before);
writeAtomic(after);
log(`wrote ${after.length} bytes (was ${before.length})`);
// 4. keep verifying for a bit - a late engine save can overwrite us
const until = Date.now() + VERIFY_MS;
while (Date.now() < until) {
await sleep(3000);
const cur = new Uint8Array(readFileSync(savPath));
if (bankSlotOf(cur) !== -1) continue;
if (await isOnline()) { log('player logged in and save was rewritten without the item; restarting wait'); return main(); }
log('save was overwritten without the item; re-patching');
writeAtomic(patch(cur));
}
log(`done: obj ${objId} is in ${username}'s bank slot ${bankSlotOf(new Uint8Array(readFileSync(savPath)))}`);
}
main().catch(e => { log('FAILED:', e.message); process.exit(1); });
@@ -0,0 +1,254 @@
// Render the JSON from item-census.ts as a standalone HTML viewer with item sprites.
// bun tools/server/item-census-html.ts <census.json> <out.html> [--sprites=https://rs-sdk-demo.fly.dev/sprite/item/] [--title=...]
// Item names come from the engine's obj config (data/pack), so run from server/engine.
import { readFileSync, writeFileSync } from 'fs';
import ObjType from '#/cache/config/ObjType.js';
const argv = process.argv.slice(2);
const flags = new Map<string, string>();
const positional: string[] = [];
for (const a of argv) {
if (a.startsWith('--')) { const [k, ...v] = a.slice(2).split('='); flags.set(k, v.join('=') || 'true'); }
else positional.push(a);
}
const [inPath, outPath] = positional;
if (!inPath || !outPath) { console.error('usage: item-census-html.ts <census.json> <out.html> [--sprites=URL] [--title=...]'); process.exit(1); }
const sprites = flags.get('sprites') ?? 'https://rs-sdk-demo.fly.dev/sprite/item/';
const title = flags.get('title') ?? 'Item census';
const date = flags.get('date') ?? new Date().toISOString().slice(0, 10);
ObjType.load('data/pack');
const census = JSON.parse(readFileSync(inPath, 'utf8')) as { files: number; skipped: number; geItems: number; items: Record<string, [number, number]> };
// [id, name, debugname, copies, holders, tradeable, cost, members]
const rows = Object.entries(census.items).map(([id, [c, h]]) => {
const o = ObjType.get(+id);
return [+id, o?.name ?? `obj${id}`, o?.debugname ?? '', c, h, o?.tradeable ? 1 : 0, o?.cost ?? 0, o?.members ? 1 : 0];
}).sort((a, b) => (a[3] as number) - (b[3] as number) || (a[4] as number) - (b[4] as number) || (a[0] as number) - (b[0] as number));
const meta = { title, date, files: census.files, skipped: census.skipped, geItems: census.geItems, distinct: rows.length, defined: ObjType.count, sprites };
const html = `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>${title} — ${date}</title>
<style>
:root {
color-scheme: light;
--surface-1: #fcfcfb; --surface-2: #f3f2ef; --border: #e4e3df;
--text-primary: #0b0b0b; --text-secondary: #52514e; --text-muted: #8a8985;
--series-1: #2a78d6; --series-1-soft: #cde2fb; --grid: #e9e8e4;
--tip-bg: #0b0b0b; --tip-fg: #fff;
}
@media (prefers-color-scheme: dark) {
:root:where(:not([data-theme="light"])) {
color-scheme: dark;
--surface-1: #1a1a19; --surface-2: #232322; --border: #333330;
--text-primary: #ffffff; --text-secondary: #c3c2b7; --text-muted: #8a8985;
--series-1: #3987e5; --series-1-soft: #184f95; --grid: #2b2b29;
--tip-bg: #f3f2ef; --tip-fg: #0b0b0b;
}
}
* { box-sizing: border-box; }
body { margin: 0; background: var(--surface-1); color: var(--text-primary); font: 14px/1.45 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
main { max-width: 1200px; margin: 0 auto; padding: 24px 20px 60px; }
h1 { font-size: 22px; font-weight: 600; margin: 0 0 4px; }
.sub { color: var(--text-secondary); margin: 0 0 20px; }
.sub b { color: var(--text-primary); font-weight: 600; }
section { margin-bottom: 28px; }
h2 { font-size: 15px; font-weight: 600; margin: 0 0 2px; }
.h2sub { color: var(--text-secondary); font-size: 13px; margin: 0 0 10px; }
.chart-wrap { position: relative; }
svg.chart { width: 100%; height: 240px; display: block; overflow: visible; }
.chart .grid line { stroke: var(--grid); stroke-width: 1; }
.chart .axis text { fill: var(--text-secondary); font-size: 11px; }
.chart .bar rect { fill: var(--series-1); cursor: pointer; }
.chart .bar rect.dim { fill: var(--series-1-soft); }
.chart .bar .hit { fill: transparent; cursor: pointer; }
.chart .bar text { fill: var(--text-secondary); font-size: 11px; text-anchor: middle; pointer-events: none; }
.tip { position: absolute; pointer-events: none; background: var(--tip-bg); color: var(--tip-fg); padding: 6px 9px; border-radius: 6px; font-size: 12px; white-space: nowrap; transform: translate(-50%, calc(-100% - 10px)); opacity: 0; transition: opacity .08s; z-index: 5; }
.tip.on { opacity: 1; }
.tip b { font-weight: 600; }
.controls { display: flex; flex-wrap: wrap; gap: 10px 16px; align-items: center; margin-bottom: 14px; }
.controls label { display: inline-flex; gap: 6px; align-items: center; color: var(--text-secondary); font-size: 13px; }
.controls input[type=search], .controls select { font: inherit; padding: 6px 9px; border: 1px solid var(--border); border-radius: 6px; background: var(--surface-2); color: var(--text-primary); }
.controls input[type=search] { min-width: 220px; }
.chip { display: inline-flex; gap: 6px; align-items: center; padding: 3px 8px 3px 10px; border-radius: 999px; background: var(--series-1-soft); color: var(--text-primary); font-size: 12px; }
.chip button { all: unset; cursor: pointer; padding: 0 4px; color: var(--text-secondary); }
.count { color: var(--text-muted); font-size: 13px; margin-left: auto; }
.view { display: inline-flex; border: 1px solid var(--border); border-radius: 6px; overflow: hidden; }
.view button { all: unset; cursor: pointer; padding: 5px 10px; font-size: 13px; color: var(--text-secondary); }
.view button[aria-pressed=true] { background: var(--surface-2); color: var(--text-primary); }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(168px, 1fr)); gap: 8px; }
.card { display: grid; grid-template-columns: 40px 1fr; gap: 8px; align-items: center; padding: 8px; border: 1px solid var(--border); border-radius: 8px; background: var(--surface-2); min-height: 58px; }
.card img { width: 36px; height: 32px; object-fit: contain; image-rendering: pixelated; display: block; }
.card .name { font-weight: 600; font-size: 13px; line-height: 1.25; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.card .dbg { color: var(--text-muted); font-size: 11px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.card .stat { color: var(--text-secondary); font-size: 12px; }
.card .stat b { color: var(--text-primary); font-weight: 600; font-variant-numeric: tabular-nums; }
.card .tags { color: var(--text-muted); font-size: 10px; letter-spacing: .02em; }
table { width: 100%; border-collapse: collapse; font-variant-numeric: tabular-nums; }
th, td { text-align: left; padding: 6px 8px; border-bottom: 1px solid var(--border); }
th { color: var(--text-secondary); font-weight: 600; font-size: 12px; position: sticky; top: 0; background: var(--surface-1); }
td.num, th.num { text-align: right; }
td img { width: 32px; height: 28px; object-fit: contain; image-rendering: pixelated; vertical-align: middle; }
.more { display: block; margin: 14px auto 0; font: inherit; padding: 8px 16px; border: 1px solid var(--border); border-radius: 6px; background: var(--surface-2); color: var(--text-primary); cursor: pointer; }
.hidden { display: none; }
footer { color: var(--text-muted); font-size: 12px; margin-top: 30px; }
</style>
</head>
<body>
<main>
<h1>${title}</h1>
<p class="sub">Scanned <b id="m-files"></b> player saves (backpack, worn, bank) plus Grand Exchange escrow on <b>${date}</b>. <b id="m-distinct"></b> of <b id="m-defined"></b> item types exist with at least one copy; items with zero copies are omitted. Ground items are not counted.</p>
<section>
<h2>Rarity histogram</h2>
<p class="h2sub">Number of distinct items by how many copies exist on the whole server. Click a bar to filter the list below.</p>
<div class="chart-wrap">
<svg class="chart" id="chart" role="img" aria-label="Histogram of distinct items per copies-on-server bucket"></svg>
<div class="tip" id="tip"></div>
</div>
</section>
<section>
<h2>Items, rarest first</h2>
<div class="controls">
<input type="search" id="q" placeholder="Search name or debug name" aria-label="Search items">
<label>Sort <select id="sort">
<option value="rare">Rarest first</option>
<option value="common">Most common first</option>
<option value="holders">Fewest holders</option>
<option value="value">Highest shop value</option>
<option value="name">Name</option>
</select></label>
<label><input type="checkbox" id="noclue" checked> Hide clue scrolls</label>
<label><input type="checkbox" id="tradeonly"> Tradeable only</label>
<span id="bucketchip" class="chip hidden"><span id="bucketlabel"></span><button id="clearbucket" aria-label="Clear bucket filter">×</button></span>
<span class="count" id="shown"></span>
<span class="view" role="group" aria-label="View">
<button id="v-grid" aria-pressed="true">Cards</button><button id="v-table" aria-pressed="false">Table</button>
</span>
</div>
<div class="grid" id="grid"></div>
<div id="tablewrap" class="hidden"><table><thead><tr><th></th><th>Item</th><th>Debug name</th><th class="num">Copies</th><th class="num">Holders</th><th class="num">Shop value</th><th>Tradeable</th></tr></thead><tbody id="tbody"></tbody></table></div>
<button class="more hidden" id="more">Show more</button>
</section>
<footer>Sprites from <code id="m-sprites"></code>. Data generated by <code>server/engine/tools/server/item-census.ts</code>; page by <code>item-census-html.ts</code>.</footer>
</main>
<script id="data" type="application/json">${JSON.stringify({ meta, rows })}</script>
<script>
const { meta, rows } = JSON.parse(document.getElementById('data').textContent);
const fmt = n => n.toLocaleString('en-US');
document.getElementById('m-files').textContent = fmt(meta.files);
document.getElementById('m-distinct').textContent = fmt(meta.distinct);
document.getElementById('m-defined').textContent = fmt(meta.defined);
document.getElementById('m-sprites').textContent = meta.sprites;
const sprite = id => meta.sprites + id + '.png';
const items = rows.map(([id, name, dbg, copies, holders, trade, cost, members]) => ({ id, name, dbg, copies, holders, trade, cost, members, clue: /^trail_clue/.test(dbg) }));
// ---- histogram ----
const BUCKETS = [[1,1],[2,2],[3,3],[4,5],[6,10],[11,25],[26,100],[101,1000],[1001,10000],[10001,100000],[100001,1000000],[1000001,Infinity]];
const blabel = ([lo, hi]) => hi === Infinity ? fmt(lo) + '+' : lo === hi ? String(lo) : fmt(lo) + '–' + fmt(hi);
const compact = n => n >= 1e6 ? (n / 1e6) + 'M' : n >= 1e3 ? (n / 1e3) + 'K' : String(n);
const bshort = ([lo, hi]) => hi === Infinity ? compact(lo - 1) + '+' : lo === hi ? String(lo) : compact(lo > 1000 ? lo - 1 : lo) + '–' + compact(hi);
const bucketOf = c => BUCKETS.findIndex(([lo, hi]) => c >= lo && c <= hi);
let activeBucket = -1;
const svg = document.getElementById('chart'), tip = document.getElementById('tip');
function drawChart() {
const counts = BUCKETS.map(() => 0);
for (const it of items) counts[bucketOf(it.copies)]++;
const W = svg.clientWidth || 1000, H = 240, padL = 44, padR = 8, padT = 14, padB = 34;
const iw = W - padL - padR, ih = H - padT - padB;
const max = Math.max(...counts);
const step = max > 200 ? 100 : max > 80 ? 50 : 20;
const top = Math.ceil(max / step) * step;
const y = v => padT + ih - (v / top) * ih;
const band = iw / BUCKETS.length, bw = Math.min(24, band * 0.6);
svg.setAttribute('viewBox', '0 0 ' + W + ' ' + H);
let s = '<g class="grid">';
for (let v = 0; v <= top; v += step) s += '<line x1="' + padL + '" x2="' + (W - padR) + '" y1="' + y(v) + '" y2="' + y(v) + '"/>';
s += '</g><g class="axis">';
for (let v = 0; v <= top; v += step) s += '<text x="' + (padL - 8) + '" y="' + (y(v) + 4) + '" text-anchor="end">' + fmt(v) + '</text>';
s += '</g>';
counts.forEach((n, i) => {
const cx = padL + band * i + band / 2, x = cx - bw / 2, yt = y(n), h = Math.max(0, y(0) - yt), r = Math.min(4, h);
const dim = activeBucket !== -1 && activeBucket !== i ? ' dim' : '';
const path = 'M' + x + ' ' + y(0) + 'V' + (yt + r) + 'a' + r + ' ' + r + ' 0 0 1 ' + r + ' -' + r + 'h' + (bw - 2 * r) + 'a' + r + ' ' + r + ' 0 0 1 ' + r + ' ' + r + 'V' + y(0) + 'Z';
s += '<g class="bar" data-i="' + i + '"><rect class="hit" x="' + (padL + band * i) + '" y="' + padT + '" width="' + band + '" height="' + (ih + padB) + '"/>' +
'<path class="' + dim.trim() + '" d="' + path + '" style="fill:' + (dim ? 'var(--series-1-soft)' : 'var(--series-1)') + '"/>' +
(n === max || activeBucket === i ? '<text x="' + cx + '" y="' + (yt - 5) + '">' + fmt(n) + '</text>' : '') +
'<text x="' + cx + '" y="' + (H - 12) + '">' + bshort(BUCKETS[i]) + '</text></g>';
});
svg.innerHTML = s;
svg.querySelectorAll('.bar').forEach(g => {
const i = +g.dataset.i;
g.addEventListener('mousemove', e => {
const rect = svg.getBoundingClientRect();
tip.innerHTML = '<b>' + fmt(counts[i]) + '</b> items with ' + blabel(BUCKETS[i]) + ' cop' + (BUCKETS[i][1] === 1 ? 'y' : 'ies');
tip.style.left = (e.clientX - rect.left) + 'px'; tip.style.top = (e.clientY - rect.top) + 'px'; tip.classList.add('on');
});
g.addEventListener('mouseleave', () => tip.classList.remove('on'));
g.addEventListener('click', () => { activeBucket = activeBucket === i ? -1 : i; drawChart(); render(); });
});
}
// ---- list ----
const q = document.getElementById('q'), sortSel = document.getElementById('sort'), noclue = document.getElementById('noclue'), tradeonly = document.getElementById('tradeonly');
const grid = document.getElementById('grid'), tbody = document.getElementById('tbody'), tablewrap = document.getElementById('tablewrap'), more = document.getElementById('more'), shown = document.getElementById('shown');
const chip = document.getElementById('bucketchip'), chipLabel = document.getElementById('bucketlabel');
let view = 'grid', limit = 200;
const SORTS = {
rare: (a, b) => a.copies - b.copies || a.holders - b.holders || a.id - b.id,
common: (a, b) => b.copies - a.copies || b.holders - a.holders,
holders: (a, b) => a.holders - b.holders || a.copies - b.copies || a.id - b.id,
value: (a, b) => b.cost - a.cost || a.copies - b.copies,
name: (a, b) => a.name.localeCompare(b.name) || a.id - b.id,
};
function filtered() {
const needle = q.value.trim().toLowerCase();
return items.filter(it =>
(!noclue.checked || !it.clue) &&
(!tradeonly.checked || it.trade) &&
(activeBucket === -1 || bucketOf(it.copies) === activeBucket) &&
(!needle || it.name.toLowerCase().includes(needle) || it.dbg.includes(needle) || String(it.id) === needle)
).sort(SORTS[sortSel.value]);
}
const esc = s => s.replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
function card(it) {
return '<div class="card" title="obj ' + it.id + '"><img loading="lazy" src="' + sprite(it.id) + '" alt="" width="36" height="32">' +
'<div><div class="name">' + esc(it.name) + '</div><div class="dbg">' + esc(it.dbg) + '</div>' +
'<div class="stat"><b>' + fmt(it.copies) + '</b> cop' + (it.copies === 1 ? 'y' : 'ies') + ' · <b>' + fmt(it.holders) + '</b> holder' + (it.holders === 1 ? '' : 's') + '</div>' +
'<div class="tags">' + (it.trade ? 'tradeable' : 'untradeable') + (it.members ? ' · members' : '') + (it.cost > 1 ? ' · ' + fmt(it.cost) + ' gp' : '') + '</div></div></div>';
}
function row(it) {
return '<tr><td><img loading="lazy" src="' + sprite(it.id) + '" alt=""></td><td>' + esc(it.name) + '</td><td style="color:var(--text-muted)">' + esc(it.dbg) + ' <span style="opacity:.6">#' + it.id + '</span></td>' +
'<td class="num">' + fmt(it.copies) + '</td><td class="num">' + fmt(it.holders) + '</td><td class="num">' + fmt(it.cost) + '</td><td>' + (it.trade ? 'yes' : 'no') + '</td></tr>';
}
function render() {
const list = filtered();
const slice = list.slice(0, limit);
if (view === 'grid') { grid.innerHTML = slice.map(card).join(''); grid.classList.remove('hidden'); tablewrap.classList.add('hidden'); }
else { tbody.innerHTML = slice.map(row).join(''); tablewrap.classList.remove('hidden'); grid.classList.add('hidden'); }
shown.textContent = 'Showing ' + fmt(slice.length) + ' of ' + fmt(list.length) + ' items';
more.classList.toggle('hidden', slice.length >= list.length);
chip.classList.toggle('hidden', activeBucket === -1);
if (activeBucket !== -1) chipLabel.textContent = blabel(BUCKETS[activeBucket]) + ' cop' + (BUCKETS[activeBucket][1] === 1 ? 'y' : 'ies');
}
for (const el of [q, sortSel, noclue, tradeonly]) el.addEventListener('input', () => { limit = 200; render(); });
more.addEventListener('click', () => { limit += 300; render(); });
document.getElementById('clearbucket').addEventListener('click', () => { activeBucket = -1; drawChart(); render(); });
document.getElementById('v-grid').addEventListener('click', () => setView('grid'));
document.getElementById('v-table').addEventListener('click', () => setView('table'));
function setView(v) { view = v; document.getElementById('v-grid').setAttribute('aria-pressed', v === 'grid'); document.getElementById('v-table').setAttribute('aria-pressed', v === 'table'); render(); }
window.addEventListener('resize', drawChart);
drawChart(); render();
</script>
</body>
</html>
`;
writeFileSync(outPath, html);
console.log(`wrote ${outPath}: ${rows.length} items, ${(html.length / 1024).toFixed(0)} KB`);
+66
View File
@@ -0,0 +1,66 @@
// Full item census: total count + distinct holders per obj across every player
// save (all inv types) and GE escrow. Prints JSON to stdout.
// bun item-census.ts [--players-dir=data/players/main] [--ge=data/market.sqlite]
import { readdirSync, readFileSync } from 'fs';
import { join } from 'path';
import { Database } from 'bun:sqlite';
const flags = new Map<string, string>();
for (const a of process.argv.slice(2)) if (a.startsWith('--')) { const [k, v] = a.slice(2).split('='); flags.set(k, v ?? 'true'); }
const playersDir = flags.get('players-dir') ?? 'data/players/main';
const gePath = flags.get('ge') ?? 'data/market.sqlite';
class Reader {
pos = 0;
constructor(public buf: Uint8Array) {}
g1() { return this.buf[this.pos++]; }
g2() { return ((this.buf[this.pos++] << 8) | this.buf[this.pos++]) >>> 0; }
g4() { return ((this.buf[this.pos++] << 24) | (this.buf[this.pos++] << 16) | (this.buf[this.pos++] << 8) | this.buf[this.pos++]) | 0; }
gVarInt() { let b = this.buf[this.pos++]; let r = 0; while ((b & 0x80) !== 0) { r = (r | (b & 0x7f)) << 7; b = this.buf[this.pos++]; } return (r | b) >>> 0; }
}
const count = new Map<number, number>();
const holders = new Map<number, Set<string>>();
const add = (id: number, c: number, who: string) => {
count.set(id, (count.get(id) ?? 0) + c);
let h = holders.get(id); if (!h) holders.set(id, (h = new Set())); h.add(who);
};
function scan(buf: Uint8Array, who: string) {
const r = new Reader(buf);
if (r.g2() !== 0x2004) throw new Error('bad magic');
const version = r.g2();
if (version < 5) throw new Error(`save version ${version} < 5`);
r.g2(); r.g2(); r.g1(); r.pos += 13; r.g2();
if (version >= 2) r.g4(); else r.g2();
for (let i = 0; i < 21; i++) { r.g4(); r.g1(); }
const varpCount = r.g2();
if (version >= 7) for (let i = 0; i < varpCount; i++) { r.g2(); r.gVarInt(); } else for (let i = 0; i < varpCount; i++) r.g4();
const invCount = r.g1();
for (let i = 0; i < invCount; i++) {
r.g2(); const size = r.g2();
for (let s = 0; s < size; s++) {
const id = r.g2() - 1; if (id === -1) continue;
let c = r.g1(); if (c === 255) c = r.g4();
add(id, c, who);
}
}
}
let files = 0, skipped = 0;
for (const f of readdirSync(playersDir)) {
if (!f.endsWith('.sav')) continue;
files++;
try { scan(new Uint8Array(readFileSync(join(playersDir, f))), f.slice(0, -4)); } catch { skipped++; }
}
let geItems = 0;
try {
const db = new Database(gePath, { readonly: true });
for (const o of db.query(`SELECT owner, item, side, slot, remaining, items FROM offers`).all() as any[]) {
const held = o.side === 'sell' ? (o.slot >= 0 ? o.remaining : 0) : o.items;
if (held > 0) { add(o.item, held, 'ge:' + o.owner); geItems += held; }
}
} catch {}
const out: Record<string, [number, number]> = {};
for (const [id, c] of count) out[id] = [c, holders.get(id)!.size];
console.log(JSON.stringify({ files, skipped, geItems, items: out }));