mirror of
https://github.com/bdnugget/rs-sdk.git
synced 2026-11-07 09:07:30 +00:00
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:
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 => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[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`);
|
||||
@@ -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 }));
|
||||
Reference in new issue
Block a user