mirror of
https://github.com/bdnugget/rs-sdk.git
synced 2026-11-07 09:07:30 +00:00
fix(hiscores): backfill equipment-board appearances from saves
Outfit rows only gained an appearance on the player's next save, and most of the board's top players are long offline, so the sprites never showed. tools/server/backfill-outfit-appearance.ts rebuilds it once from each save (via the exchange checkpoint when present, like the login server), filling only still-empty rows whose listed items match the save. outfitAppearance() moves to its own module so the tool can share it with the login server. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
aa4bb5dbbf
commit
822af40abf
3 files changed
+110
-32
No files matched your search
@@ -16,6 +16,7 @@ import { printInfo } from '#/util/Logger.js';
|
||||
import { startManagementWeb } from '#/web.js';
|
||||
import InvType from '#/cache/config/InvType.js';
|
||||
import ObjType from '#/cache/config/ObjType.js';
|
||||
import { outfitAppearance } from '#/server/login/OutfitAppearance.js';
|
||||
|
||||
// bcrypt-ts is pure JS: one compare pins this thread for ~50-200ms. Prod outage
|
||||
// 2026-08-17: a reconnect stampede after a redeploy (~5 auths/s across fleets and
|
||||
@@ -184,38 +185,6 @@ async function updateHiscores(account: HiscoreAccount | undefined, player: Playe
|
||||
await updateWealthHiscores(account, player, profile);
|
||||
}
|
||||
|
||||
// rs-sdk: the outfit as it renders on the player, for the equipment board's /sprite/player
|
||||
// images: 12 appearance-protocol slots (0 = empty, 0x100+idk = identity kit, 0x200+obj = worn
|
||||
// item), mirroring Player.generateAppearance. Resolved here because the wearpos2/3 masking
|
||||
// needs obj config the web layer doesn't load.
|
||||
function outfitAppearance(player: Player): string {
|
||||
const worn = player.getInventory(InvType.WORN);
|
||||
const hidden = new Set<number>();
|
||||
if (worn) {
|
||||
for (let slot = 0; slot < worn.capacity; slot++) {
|
||||
const item = worn.get(slot);
|
||||
if (item) {
|
||||
const config = ObjType.get(item.id);
|
||||
if (config.wearpos2 !== -1) hidden.add(config.wearpos2);
|
||||
if (config.wearpos3 !== -1) hidden.add(config.wearpos3);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const slots: number[] = [];
|
||||
for (let slot = 0; slot < 12; slot++) {
|
||||
const item = worn?.get(slot);
|
||||
if (hidden.has(slot)) {
|
||||
slots.push(0);
|
||||
} else if (item) {
|
||||
slots.push(0x200 + item.id);
|
||||
} else {
|
||||
slots.push(player.getAppearanceInSlot(slot));
|
||||
}
|
||||
}
|
||||
return JSON.stringify({ gender: player.gender, colors: player.colors, slots });
|
||||
}
|
||||
|
||||
// rs-sdk: outfit + bank boards are also refreshed on autosave (not just logout), so players who
|
||||
// stay online for days don't sit on a stale value. Autosave posts every online player in one
|
||||
// burst, so writes are skipped when the row hasn't changed - most banks don't move in 15 min.
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import InvType from '#/cache/config/InvType.js';
|
||||
import ObjType from '#/cache/config/ObjType.js';
|
||||
import type Player from '#/engine/entity/Player.js';
|
||||
|
||||
// rs-sdk: the outfit as it renders on the player, for the equipment board's /sprite/player
|
||||
// images: 12 appearance-protocol slots (0 = empty, 0x100+idk = identity kit, 0x200+obj = worn
|
||||
// item), mirroring Player.generateAppearance. Resolved here because the wearpos2/3 masking
|
||||
// needs obj config the web layer doesn't load.
|
||||
export function outfitAppearance(player: Player): string {
|
||||
const worn = player.getInventory(InvType.WORN);
|
||||
const hidden = new Set<number>();
|
||||
if (worn) {
|
||||
for (let slot = 0; slot < worn.capacity; slot++) {
|
||||
const item = worn.get(slot);
|
||||
if (item) {
|
||||
const config = ObjType.get(item.id);
|
||||
if (config.wearpos2 !== -1) hidden.add(config.wearpos2);
|
||||
if (config.wearpos3 !== -1) hidden.add(config.wearpos3);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const slots: number[] = [];
|
||||
for (let slot = 0; slot < 12; slot++) {
|
||||
const item = worn?.get(slot);
|
||||
if (hidden.has(slot)) {
|
||||
slots.push(0);
|
||||
} else if (item) {
|
||||
slots.push(0x200 + item.id);
|
||||
} else {
|
||||
slots.push(player.getAppearanceInSlot(slot));
|
||||
}
|
||||
}
|
||||
return JSON.stringify({ gender: player.gender, colors: player.colors, slots });
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
// One-off: fill hiscore_outfit.appearance for rows written before the column existed, from each
|
||||
// player's save (the login server fills it on every save after that). Only touches rows that are
|
||||
// still empty, and skips rows whose listed items no longer match the save's worn items so the
|
||||
// sprite never disagrees with the icons beside it.
|
||||
//
|
||||
// Run from server/engine with the world's GE_DATABASE (PlayerLoading prefers the exchange
|
||||
// checkpoint over the .sav, same as the login server):
|
||||
// GE_DATABASE=/opt/server/data/market.sqlite bun tools/server/backfill-outfit-appearance.ts [--dry-run]
|
||||
import fs from 'fs';
|
||||
|
||||
import InvType from '#/cache/config/InvType.js';
|
||||
import ObjType from '#/cache/config/ObjType.js';
|
||||
import { db } from '#/db/query.js';
|
||||
import { PlayerLoading } from '#/engine/entity/PlayerLoading.js';
|
||||
import Packet from '#/io/Packet.js';
|
||||
import { outfitAppearance } from '#/server/login/OutfitAppearance.js';
|
||||
|
||||
const dryRun = process.argv.includes('--dry-run');
|
||||
|
||||
InvType.load('data/pack');
|
||||
ObjType.load('data/pack');
|
||||
|
||||
const rows = await db
|
||||
.selectFrom('hiscore_outfit')
|
||||
.innerJoin('account', 'account.id', 'hiscore_outfit.account_id')
|
||||
.select(['hiscore_outfit.account_id', 'hiscore_outfit.profile', 'hiscore_outfit.items', 'account.username'])
|
||||
.where('hiscore_outfit.appearance', 'is', null)
|
||||
.execute();
|
||||
|
||||
let filled = 0;
|
||||
let noSave = 0;
|
||||
let stale = 0;
|
||||
let failed = 0;
|
||||
for (const row of rows) {
|
||||
const file = `data/players/${row.profile}/${row.username}.sav`;
|
||||
if (!fs.existsSync(file)) {
|
||||
noSave++;
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const player = PlayerLoading.load(row.username, new Packet(fs.readFileSync(file)), null);
|
||||
|
||||
// same item selection as LoginServer.updateWealthHiscores (ammo slot excluded)
|
||||
const worn = player.getInventory(InvType.WORN);
|
||||
const wornIds: number[] = [];
|
||||
for (let slot = 0; worn && slot < worn.capacity; slot++) {
|
||||
const item = worn.get(slot);
|
||||
if (slot !== 13 && item) wornIds.push(item.id);
|
||||
}
|
||||
const listedIds = (JSON.parse(row.items) as { id: number }[]).map(i => i.id);
|
||||
if (wornIds.join() !== listedIds.join()) {
|
||||
stale++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!dryRun) {
|
||||
await db
|
||||
.updateTable('hiscore_outfit')
|
||||
.set({ appearance: outfitAppearance(player) })
|
||||
.where('account_id', '=', row.account_id)
|
||||
.where('profile', '=', row.profile)
|
||||
.where('appearance', 'is', null)
|
||||
.execute();
|
||||
}
|
||||
filled++;
|
||||
} catch (err) {
|
||||
failed++;
|
||||
console.error(row.username, err instanceof Error ? err.message : err);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`${dryRun ? '[dry run] ' : ''}${rows.length} rows without appearance: ${filled} filled, ${stale} stale (items changed since), ${noSave} without a save, ${failed} failed`);
|
||||
process.exit(0);
|
||||
Reference in new issue
Block a user