mirror of
https://github.com/bdnugget/rs-sdk.git
synced 2026-11-07 09:07:30 +00:00
feat(hiscores): rank bank wealth by item name substring
This commit is contained in:
1 parent
1bc50391c8
commit
0c1d35abb1
5 files changed
+246
-14
No files matched your search
+1
-1
@@ -6,7 +6,7 @@
|
||||
"docs:api": "bun sdk/generate-api-docs.ts",
|
||||
"docs:api:check": "bun sdk/generate-api-docs.ts --check",
|
||||
"fetch-collision-data": "bun sdk/fetch-collision-data.ts",
|
||||
"test": "bun test sdk/test server/webclient/src/bot server/webclient/src/util server/webclient/src/lite server/engine/test/market.test.ts server/engine/test/market-web.test.ts server/engine/test/item-search.test.ts server/engine/test/file-stream.test.ts server/engine/test/get-ip.test.ts server/gateway/state-fanout.test.ts",
|
||||
"test": "bun test sdk/test server/webclient/src/bot server/webclient/src/util server/webclient/src/lite server/engine/test/market.test.ts server/engine/test/market-web.test.ts server/engine/test/item-search.test.ts server/engine/test/file-stream.test.ts server/engine/test/get-ip.test.ts server/engine/test/bank-hiscores.test.ts server/gateway/state-fanout.test.ts",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"typecheck:webclient": "tsc --noEmit -p server/webclient/tsconfig.json",
|
||||
"test:market:integration": "cd server/engine && bun test/market-integration.ts && bun test/market-recovery.ts && bun test/market-protocol.ts && bun test/market-disabled.ts"
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
export type BankHiscoreRow = { username: string; value: number; items: string };
|
||||
export type BankItem = { id?: number; name: string; value: number; count: number };
|
||||
export type BankSnapshot = { username: string; items: BankItem[] };
|
||||
|
||||
// Parse each bank once per snapshot, rather than once per search. A damaged row should
|
||||
// not prevent other players' banks from appearing in the filtered leaderboard.
|
||||
export function parseBankSnapshots(rows: BankHiscoreRow[]): BankSnapshot[] {
|
||||
return rows.flatMap(row => {
|
||||
try {
|
||||
const items: unknown = JSON.parse(row.items);
|
||||
if (!Array.isArray(items)) return [];
|
||||
return [
|
||||
{
|
||||
username: row.username,
|
||||
items: items.filter((item): item is BankItem => item !== null && typeof item === 'object' && typeof item.name === 'string' && Number.isFinite(item.value) && item.value >= 0 && Number.isFinite(item.count) && item.count > 0)
|
||||
}
|
||||
];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function rankMatchingBanks(banks: BankSnapshot[], substring: string): BankHiscoreRow[] {
|
||||
const needle = substring.trim().toLowerCase();
|
||||
const matches = banks.flatMap(bank => {
|
||||
const items = bank.items.filter(item => item.name.toLowerCase().includes(needle));
|
||||
if (items.length === 0) return [];
|
||||
// Saved item values already include stack quantities.
|
||||
const value = items.reduce((total, item) => total + item.value, 0);
|
||||
return [{ username: bank.username, value, items }];
|
||||
});
|
||||
return matches
|
||||
.sort((a, b) => b.value - a.value || a.username.localeCompare(b.username, 'en'))
|
||||
.slice(0, 50)
|
||||
.map(row => ({ username: row.username, value: row.value, items: JSON.stringify(row.items.sort((a, b) => b.value - a.value)) }));
|
||||
}
|
||||
@@ -3,6 +3,8 @@ import Environment from '#/util/Environment.js';
|
||||
import { tryParseInt } from '#/util/TryParse.js';
|
||||
import { escapeHtml, SKILL_NAMES, ENABLED_SKILLS, QUEST_POINTS_HISCORE_TYPE } from '../utils.js';
|
||||
import { itemSpriteUrl, playerSpriteUrl } from '#/web/sprites/SpriteRenderer.js';
|
||||
import { parseBankSnapshots, rankMatchingBanks } from './BankHiscores.js';
|
||||
import type { BankSnapshot } from './BankHiscores.js';
|
||||
|
||||
const hiddenNames = Environment.HISCORES_HIDDEN_NAMES;
|
||||
|
||||
@@ -990,13 +992,7 @@ export async function handleHiscoresRunitePage(url: URL): Promise<Response | nul
|
||||
return new Response(html, { headers: { 'Content-Type': 'text/html', 'Cache-Control': 'public, max-age=30' } });
|
||||
}
|
||||
|
||||
// Bank value leaderboard handler
|
||||
export async function handleHiscoresBankPage(url: URL): Promise<Response | null> {
|
||||
const match = url.pathname.match(/^\/hi(?:gh)?scores\/bank\/?$/);
|
||||
if (!match) return null;
|
||||
|
||||
const profile = (url.searchParams.get('profile') || 'main').replace(/[^a-zA-Z0-9_-]/g, '');
|
||||
|
||||
function bankHiscoresQuery(profile: string) {
|
||||
let query = db
|
||||
.selectFrom('hiscore_bank')
|
||||
.innerJoin('account', 'account.id', 'hiscore_bank.account_id')
|
||||
@@ -1004,12 +1000,50 @@ export async function handleHiscoresBankPage(url: URL): Promise<Response | null>
|
||||
.where('hiscore_bank.profile', '=', profile)
|
||||
.where('account.staffmodlevel', '<=', 1)
|
||||
.orderBy('hiscore_bank.value', 'desc')
|
||||
.limit(50);
|
||||
.orderBy('account.username', 'asc');
|
||||
if (hiddenNames.length > 0) {
|
||||
query = query.where(eb => eb.not(eb(eb.fn('lower', ['account.username']), 'in', hiddenNames)));
|
||||
}
|
||||
|
||||
const results = await query.execute();
|
||||
return query;
|
||||
}
|
||||
|
||||
// Reuse a short-lived, parsed snapshot across arbitrary searches. Search terms are not
|
||||
// cache keys, and only the most recently searched profile's banks are held in memory.
|
||||
let bankSearchCache: { profile: string; at: number; rows: BankSnapshot[]; pending: Promise<BankSnapshot[]> | null } | null = null;
|
||||
|
||||
async function getBankSearchSnapshot(profile: string): Promise<BankSnapshot[]> {
|
||||
const cached = bankSearchCache;
|
||||
if (cached?.profile === profile) {
|
||||
if (cached.pending) return cached.pending;
|
||||
if (Date.now() - cached.at < RANKED_TTL_MS) return cached.rows;
|
||||
}
|
||||
|
||||
const entry = { profile, at: 0, rows: [] as BankSnapshot[], pending: null as Promise<BankSnapshot[]> | null };
|
||||
bankSearchCache = entry;
|
||||
entry.pending = bankHiscoresQuery(profile).execute().then(rows => {
|
||||
entry.rows = parseBankSnapshots(rows);
|
||||
entry.at = Date.now();
|
||||
return entry.rows;
|
||||
});
|
||||
try {
|
||||
return await entry.pending;
|
||||
} finally {
|
||||
entry.pending = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Bank value leaderboard handler
|
||||
export async function handleHiscoresBankPage(url: URL): Promise<Response | null> {
|
||||
const match = url.pathname.match(/^\/hi(?:gh)?scores\/bank\/?$/);
|
||||
if (!match) return null;
|
||||
|
||||
const profile = (url.searchParams.get('profile') || 'main').replace(/[^a-zA-Z0-9_-]/g, '');
|
||||
const itemSearch = (url.searchParams.get('q') || '').trim();
|
||||
const results = itemSearch
|
||||
? rankMatchingBanks(await getBankSearchSnapshot(profile), itemSearch)
|
||||
: await bankHiscoresQuery(profile).limit(50).execute();
|
||||
const title = itemSearch ? `Bank Hiscores: ${escapeHtml(itemSearch)}` : 'Bank Hiscores';
|
||||
|
||||
const rows = results.map((r, i) => {
|
||||
let itemsList = '';
|
||||
@@ -1042,7 +1076,7 @@ export async function handleHiscoresBankPage(url: URL): Promise<Response | null>
|
||||
const html = `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Bank Hiscores</title>
|
||||
<title>${title}</title>
|
||||
<style>${HISCORES_STYLES}</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -1072,7 +1106,7 @@ export async function handleHiscoresBankPage(url: URL): Promise<Response | null>
|
||||
<tr>
|
||||
<td class="e">
|
||||
<center>
|
||||
<b>Bank Hiscores</b><br>
|
||||
<b>${title}</b><br>
|
||||
<a href="/" class="c">Main menu</a> | <a href="/hiscores?profile=${profile}" class="c">All Hiscores</a>
|
||||
</center>
|
||||
</td>
|
||||
@@ -1103,6 +1137,14 @@ export async function handleHiscoresBankPage(url: URL): Promise<Response | null>
|
||||
<td width="400" valign="top">
|
||||
<center>
|
||||
<b>Bank</b><br>
|
||||
<form method="GET" action="/hiscores/bank" style="margin:8px 0">
|
||||
<input type="hidden" name="profile" value="${profile}">
|
||||
<label for="bank-item-search">Item name contains</label><br>
|
||||
<input id="bank-item-search" type="text" name="q" value="${escapeHtml(itemSearch)}" placeholder="e.g. shrimp" style="width:220px">
|
||||
<button type="submit">Search</button>
|
||||
${itemSearch ? `<a href="/hiscores/bank?profile=${profile}" class="c">Clear</a>` : ''}
|
||||
</form>
|
||||
<p style="margin:4px 0 8px;font-size:11px">${itemSearch ? `Ranked by the combined value of banked items containing “${escapeHtml(itemSearch)}” (ignoring case).` : 'Ranked by total bank value. Search to rank only matching items.'}</p>
|
||||
<table width="420" bgcolor="black" cellpadding="4">
|
||||
<tr>
|
||||
<td class="e" valign="top">
|
||||
@@ -1113,11 +1155,11 @@ export async function handleHiscoresBankPage(url: URL): Promise<Response | null>
|
||||
<td><b>#</b></td>
|
||||
<td><b>Name</b></td>
|
||||
<td align="right"><b>Value</b></td>
|
||||
<td><b>Top Items</b></td>
|
||||
<td><b>${itemSearch ? 'Matching Items' : 'Top Items'}</b></td>
|
||||
</tr>
|
||||
${rows.join('')}
|
||||
</table>`
|
||||
: '<center><br>No bank data found</center>'
|
||||
: `<center><br>${itemSearch ? `No banked items match “${escapeHtml(itemSearch)}”` : 'No bank data found'}</center>`
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { expect, test } from 'bun:test';
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { parseBankSnapshots, rankMatchingBanks } from '../src/web/pages/BankHiscores.js';
|
||||
|
||||
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('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 ');
|
||||
expect(matches.map(match => [match.username, match.value])).toEqual([
|
||||
['shrimp-holder', 1000],
|
||||
['wealthy', 20]
|
||||
]);
|
||||
expect(JSON.parse(matches[0].items).map((entry: { name: string }) => entry.name)).toEqual(['Shrimps', 'Raw shrimps']);
|
||||
expect(rankMatchingBanks(banks, 'nonexistent')).toEqual([]);
|
||||
const literal = parseBankSnapshots([row('literal', [item('100%_rare [fish]', 7)]), row('ordinary', [item('Rare fish', 8)])]);
|
||||
expect(rankMatchingBanks(literal, '%_')[0]?.username).toBe('literal');
|
||||
expect(rankMatchingBanks(literal, '[fish]')[0]?.username).toBe('literal');
|
||||
});
|
||||
|
||||
test('searches beyond the overall top 50 and sums every matching item before limiting results', () => {
|
||||
const banks = parseBankSnapshots([
|
||||
...Array.from({ length: 60 }, (_, index) => row(`rich-${index}`, [item('Logs', 1000000)])),
|
||||
row(
|
||||
'small-bank',
|
||||
Array.from({ length: 8 }, (_, index) => item(`Shrimp ${index}`, 10)),
|
||||
80
|
||||
)
|
||||
]);
|
||||
expect(rankMatchingBanks(banks, 'shrimp').map(match => [match.username, match.value])).toEqual([['small-bank', 80]]);
|
||||
const tied = parseBankSnapshots(Array.from({ length: 60 }, (_, index) => row(`user-${String(59 - index).padStart(2, '0')}`, [item('Shrimps', 10)])));
|
||||
const matches = rankMatchingBanks(tied, 'shrimp');
|
||||
expect(matches).toHaveLength(50);
|
||||
expect(matches[0].username).toBe('user-00');
|
||||
expect(matches[49].username).toBe('user-49');
|
||||
});
|
||||
|
||||
test('skips damaged banks and invalid entries while retaining zero-value matching items', () => {
|
||||
const banks = parseBankSnapshots([
|
||||
{ username: 'broken', value: 0, items: '{broken' },
|
||||
{ username: 'not-array', value: 0, items: '{}' },
|
||||
{ username: 'mixed', value: 0, items: JSON.stringify([null, 'shrimp', { name: 'Shrimp', value: 'bad', count: 1 }, item('Shrimp', 0, 1)]) }
|
||||
]);
|
||||
expect(rankMatchingBanks(banks, 'shrimp').map(match => [match.username, match.value])).toEqual([['mixed', 0]]);
|
||||
});
|
||||
|
||||
test('bank search HTTP page uses eligible banks, preserves profiles, and escapes searches', () => {
|
||||
// Isolate the real database module so the shared test process never opens a player's DB.
|
||||
const dir = mkdtempSync(join(tmpdir(), 'bank-hiscores-'));
|
||||
try {
|
||||
const result = Bun.spawnSync([process.execPath, join(import.meta.dir, 'fixtures/bank-hiscores.ts')], {
|
||||
cwd: dir,
|
||||
env: { ...process.env, DB_BACKEND: 'sqlite', HISCORES_HIDDEN_NAMES: 'private', HISCORES_CACHE_MS: '60000' },
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe'
|
||||
});
|
||||
expect(result.stderr.toString()).toBe('');
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.stdout.toString()).toContain('Bank hiscores HTTP checks passed');
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
+87
@@ -0,0 +1,87 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { Database } from 'bun:sqlite';
|
||||
import { db } from '../../src/db/query.js';
|
||||
import { handleHiscoresBankPage } from '../../src/web/pages/hiscores.js';
|
||||
|
||||
const database = new Database('db.sqlite');
|
||||
database.exec(`
|
||||
CREATE TABLE account (id INTEGER PRIMARY KEY, username TEXT, staffmodlevel INTEGER);
|
||||
CREATE TABLE hiscore_bank (account_id INTEGER, profile TEXT, value INTEGER, items TEXT);
|
||||
`);
|
||||
const item = (name: string, value: number, count = 100) => ({ name, value, count });
|
||||
let accountId = 0;
|
||||
function bank(username: string, value: number, items: ReturnType<typeof item>[], profile = 'main', staff = 0) {
|
||||
database.query('INSERT INTO account VALUES (?, ?, ?)').run(++accountId, username, staff);
|
||||
database.query('INSERT INTO hiscore_bank VALUES (?, ?, ?, ?)').run(accountId, profile, value, JSON.stringify(items));
|
||||
}
|
||||
for (let i = 0; i < 50; i++) bank(`rich${String(i).padStart(2, '0')}`, 1000000, [item('Lobster', 999980), item('Shrimps', 20)]);
|
||||
bank('shrimp-holder', 1100, [item('Raw shrimps', 400), item('Shrimps', 600), item('Lobster', 100)]);
|
||||
bank('Private', 2000000, [item('Shrimps', 2000000)]);
|
||||
bank('staff', 3000000, [item('Shrimps', 3000000)], 'main', 2);
|
||||
bank('other-profile', 4000000, [item('Shrimps', 4000000)], 'iron');
|
||||
bank('literal', 7, [item('100%_rare [fish]', 7)]);
|
||||
database.close();
|
||||
|
||||
const request = async (path: string) => {
|
||||
const response = await handleHiscoresBankPage(new URL(`http://localhost${path}`));
|
||||
assert.ok(response);
|
||||
assert.equal(response.status, 200);
|
||||
return response.text();
|
||||
};
|
||||
|
||||
try {
|
||||
const all = await request('/hiscores/bank');
|
||||
assert.ok(all.includes('name="q"'));
|
||||
assert.ok(all.includes('placeholder="e.g. shrimp"'));
|
||||
assert.ok(all.includes('rich00'));
|
||||
assert.ok(!all.includes('shrimp-holder'));
|
||||
assert.ok(!all.includes('>Private</a>'));
|
||||
assert.ok(!all.includes('>staff</a>'));
|
||||
|
||||
const filtered = await request('/hiscores/bank?q=%20ShRiMp%20');
|
||||
assert.ok(filtered.includes('value="ShRiMp"'));
|
||||
assert.ok(filtered.includes('<b>Matching Items</b>'));
|
||||
assert.ok(filtered.indexOf('>shrimp-holder</a>') < filtered.indexOf('>rich00</a>'));
|
||||
assert.ok(filtered.includes('title="1,000 gp">1,000</td>'));
|
||||
assert.ok(filtered.includes('Raw shrimps'));
|
||||
assert.ok(!filtered.includes('Lobster'));
|
||||
assert.ok(!filtered.includes('>Private</a>'));
|
||||
assert.ok(!filtered.includes('>staff</a>'));
|
||||
assert.ok(!filtered.includes('>other-profile</a>'));
|
||||
assert.equal((filtered.match(/<td align="right">\d+<\/td>/g) || []).length, 50);
|
||||
|
||||
const iron = await request('/highscores/bank/?q=shrimp&profile=iron');
|
||||
assert.ok(iron.includes('>other-profile</a>'));
|
||||
assert.ok(!iron.includes('>shrimp-holder</a>'));
|
||||
assert.ok(iron.includes('name="profile" value="iron"'));
|
||||
assert.ok(iron.includes('href="/hiscores/bank?profile=iron"'));
|
||||
|
||||
const empty = await request('/hiscores/bank?q=no-such-item');
|
||||
assert.ok(empty.includes('No banked items match “no-such-item”'));
|
||||
assert.ok(!(empty.match(/<td align="right">\d+<\/td>/g) || []).length);
|
||||
assert.ok((await request('/hiscores/bank?q=%25_')).includes('>literal</a>'));
|
||||
assert.ok((await request('/hiscores/bank?q=%20%20')).includes('<b>Top Items</b>'));
|
||||
|
||||
const malicious = '<script>alert("x")</script>';
|
||||
const escaped = await request(`/hiscores/bank?q=${encodeURIComponent(malicious)}`);
|
||||
assert.ok(!escaped.includes(malicious));
|
||||
assert.ok(escaped.includes('<script>alert("x")</script>'));
|
||||
assert.equal(await handleHiscoresBankPage(new URL('http://localhost/hiscores/outfit?q=shrimp')), null);
|
||||
|
||||
// Different searches share one snapshot; refresh it when the leaderboard TTL expires.
|
||||
const updater = new Database('db.sqlite');
|
||||
updater.query('UPDATE hiscore_bank SET items = ? WHERE account_id = ?').run(JSON.stringify([item('Raw shrimps', 5000)]), 51);
|
||||
updater.close();
|
||||
assert.ok((await request('/hiscores/bank?q=raw%20shrimp')).includes('title="400 gp">400</td>'));
|
||||
const originalNow = Date.now;
|
||||
const afterExpiry = originalNow() + 60001;
|
||||
try {
|
||||
Date.now = () => afterExpiry;
|
||||
assert.ok((await request('/hiscores/bank?q=raw%20shrimp')).includes('title="5,000 gp">5,000</td>'));
|
||||
} finally {
|
||||
Date.now = originalNow;
|
||||
}
|
||||
console.log('Bank hiscores HTTP checks passed');
|
||||
} finally {
|
||||
await db.destroy();
|
||||
}
|
||||
Reference in new issue
Block a user