fix(hiscores): clear bank snapshots when a bank empties

Withdrawn items stayed ranked because empty banks skipped the update.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
max bittkerandClaude Opus 5.5 committed 2026-10-12 14:30:11 -04:00
1 parent ce65d8e745
commit c06f209270
3 files changed
+79 -14

No files matched your search

+11 -13
View File
@@ -213,7 +213,7 @@ async function updateQuestHiscore(account: HiscoreAccount, player: Player, profi
// 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.
async function updateWealthHiscores(account: HiscoreAccount, player: Player, profile: string) {
export async function updateWealthHiscores(account: HiscoreAccount, player: Player, profile: string) {
// Update outfit hiscore
const worn = player.getInventory(InvType.WORN);
if (worn) {
@@ -266,18 +266,16 @@ async function updateWealthHiscores(account: HiscoreAccount, player: Player, pro
}
}
if (bankItems.length > 0) {
// Sort by value descending so the most valuable items appear first
bankItems.sort((a, b) => b.value - a.value);
const bankItemsJson = JSON.stringify(bankItems);
const existingBank = await db.selectFrom('hiscore_bank').select(['value', 'items']).where('account_id', '=', account.id).where('profile', '=', profile).executeTakeFirst();
if (existingBank && existingBank.value === bankTotalValue && existingBank.items === bankItemsJson) {
// unchanged
} else if (existingBank) {
await db.updateTable('hiscore_bank').set({ value: bankTotalValue, items: bankItemsJson, date: toDbDate(new Date()) }).where('account_id', '=', account.id).where('profile', '=', profile).execute();
} else {
await db.insertInto('hiscore_bank').values({ account_id: account.id, profile, value: bankTotalValue, items: bankItemsJson }).execute();
}
// Empty banks must also replace old snapshots, otherwise withdrawn items remain ranked.
bankItems.sort((a, b) => b.value - a.value);
const bankItemsJson = JSON.stringify(bankItems);
const existingBank = await db.selectFrom('hiscore_bank').select(['value', 'items']).where('account_id', '=', account.id).where('profile', '=', profile).executeTakeFirst();
if (existingBank && existingBank.value === bankTotalValue && existingBank.items === bankItemsJson) {
// unchanged
} else if (existingBank) {
await db.updateTable('hiscore_bank').set({ value: bankTotalValue, items: bankItemsJson, date: toDbDate(new Date()) }).where('account_id', '=', account.id).where('profile', '=', profile).execute();
} else if (bankItems.length > 0) {
await db.insertInto('hiscore_bank').values({ account_id: account.id, profile, value: bankTotalValue, items: bankItemsJson }).execute();
}
}
}
+19 -1
View File
@@ -1,5 +1,5 @@
import { expect, test } from 'bun:test';
import { mkdtempSync, rmSync } from 'node:fs';
import { copyFileSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { parseBankSnapshots, rankMatchingBanks } from '../src/web/pages/BankHiscores.js';
@@ -7,6 +7,24 @@ import { parseBankSnapshots, rankMatchingBanks } from '../src/web/pages/BankHisc
const item = (name: string, value: number, count = 100) => ({ name, value, count });
const row = (username: string, items: ReturnType<typeof item>[], value = 1000000) => ({ username, value, items: JSON.stringify(items) });
test('bank snapshots clear withdrawn items and empty banks without rewriting unchanged rows', () => {
const dir = mkdtempSync(join(tmpdir(), 'bank-snapshot-update-'));
try {
mkdirSync(join(dir, 'data/config'), { recursive: true });
copyFileSync(join(import.meta.dir, '../data/config/private.pem'), join(dir, 'data/config/private.pem'));
copyFileSync(join(import.meta.dir, '../data/config/public.pem'), join(dir, 'data/config/public.pem'));
const result = Bun.spawnSync([process.execPath, join(import.meta.dir, 'fixtures/bank-snapshot-update.ts')], {
cwd: dir,
env: { ...process.env, DB_BACKEND: 'sqlite', GE_ENABLED: 'false', BANK_TEST_CACHE: join(import.meta.dir, '../data/pack') },
stdout: 'pipe', stderr: 'pipe'
});
if (result.exitCode !== 0) throw new Error(result.stdout.toString() + result.stderr.toString());
expect(result.stdout.toString()).toContain('Bank snapshot update checks passed');
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test('matches literal substrings ignoring case and sums full stack values once', () => {
const banks = parseBankSnapshots([row('wealthy', [item('Lobster', 1000000), item('Shrimps', 20)]), row('shrimp-holder', [item('Raw shrimps', 400), item('Shrimps', 600), item('Lobster', 100)], 1100), row('unrelated', [item('Lobster', 5000000)])]);
const matches = rankMatchingBanks(banks, ' ShRiMp ');
+49
View File
@@ -0,0 +1,49 @@
import assert from 'node:assert/strict';
import { mock } from 'bun:test';
import { Database } from 'bun:sqlite';
import { EventEmitter } from 'node:events';
class TestWorker extends EventEmitter { postMessage() {} }
mock.module('worker_threads', () => ({ Worker: TestWorker }));
await import('../../src/engine/World.js');
const { default: InvType } = await import('../../src/cache/config/InvType.js');
const { default: ObjType } = await import('../../src/cache/config/ObjType.js');
const { default: Player } = await import('../../src/engine/entity/Player.js');
const { updateWealthHiscores } = await import('../../src/server/login/LoginServer.js');
const { db } = await import('../../src/db/query.js');
InvType.load(process.env.BANK_TEST_CACHE!);
ObjType.load(process.env.BANK_TEST_CACHE!);
const database = new Database('db.sqlite');
database.exec('CREATE TABLE hiscore_bank (account_id INTEGER, profile TEXT, value INTEGER, items TEXT, date TEXT)');
const oldItems = JSON.stringify([{ id: 1042, name: 'Blue partyhat', value: 2, count: 2 }]);
database.query('INSERT INTO hiscore_bank VALUES (1, ?, 2, ?, ?)').run('main', oldItems, 'old');
database.query('INSERT INTO hiscore_bank VALUES (1, ?, 2, ?, ?)').run('iron', oldItems, 'old');
const account = { id: 1, staffmodlevel: 0, banned_until: null };
const player = new Player('snapshot', 1n, 1n);
const bank = InvType.getId('bank');
try {
// A save with no bank inventory must clear an old row, preserving other profiles.
await updateWealthHiscores(account, player, 'main');
assert.deepEqual(database.query('SELECT value,items FROM hiscore_bank WHERE profile=?').get('main'), { value: 0, items: '[]' });
assert.equal((database.query('SELECT items FROM hiscore_bank WHERE profile=?').get('iron') as { items: string }).items, oldItems);
database.query('UPDATE hiscore_bank SET date=? WHERE profile=?').run('unchanged', 'main');
await updateWealthHiscores(account, player, 'main');
assert.equal((database.query('SELECT date FROM hiscore_bank WHERE profile=?').get('main') as { date: string }).date, 'unchanged');
// Refill, then withdraw the final item: quantity and value must follow the real bank.
player.invAdd(bank, 1042, 2);
await updateWealthHiscores(account, player, 'main');
assert.deepEqual(database.query('SELECT value,items FROM hiscore_bank WHERE profile=?').get('main'), { value: 2, items: oldItems });
player.invDel(bank, 1042, 2);
await updateWealthHiscores(account, player, 'main');
assert.deepEqual(database.query('SELECT value,items FROM hiscore_bank WHERE profile=?').get('main'), { value: 0, items: '[]' });
await updateWealthHiscores({ ...account, id: 2 }, new Player('newempty', 2n, 2n), 'main');
assert.equal(database.query('SELECT count(*) AS n FROM hiscore_bank WHERE account_id=2').get()?.n, 0);
console.log('Bank snapshot update checks passed');
} finally {
database.close();
await db.destroy();
}
// World imports initialize recurring timers; this isolated fixture has completed.
process.exit(0);