feat(hiscores): add a quest points leaderboard

/hiscores?category=100 ranks players by quest points, ties broken by the
playtime they reached that total at. Linked from the hiscores sidebar and
shown as a row on player profiles. Stored in the hiscore table under type
100, so no migration.

The qp varp was temp-scoped, so it never reached the save file the login
server reads. Make it scope=perm (the login trigger still recounts it from
quest progress every login), and load VarPlayerType in the login server so
saves load with their varps. The login server warns at startup if qp ever
stops being perm.

Updated on logout and autosave; autosave only writes when the total changes.
Existing players appear after their first save on this build.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
max bittkerandClaude Opus 5.5 committed 2026-10-07 14:59:24 -04:00
1 parent 4be9a63b53
commit 74b79e56a0
4 files changed
+60 -9

No files matched your search

@@ -105,6 +105,7 @@ scope=perm
[qp]
transmit=yes
protect=no
scope=perm
[poison]
scope=perm
+31 -2
View File
@@ -12,10 +12,12 @@ import { PlayerStatEnabled } from '#/engine/entity/PlayerStat.js';
import Packet from '#/io/Packet.js';
import Environment from '#/util/Environment.js';
import { toSafeName } from '#/util/JString.js';
import { printInfo } from '#/util/Logger.js';
import { printInfo, printWarning } from '#/util/Logger.js';
import { startManagementWeb } from '#/web.js';
import { QUEST_POINTS_HISCORE_TYPE } from '#/web/utils.js';
import InvType from '#/cache/config/InvType.js';
import ObjType from '#/cache/config/ObjType.js';
import VarPlayerType from '#/cache/config/VarPlayerType.js';
import { outfitAppearance } from '#/server/login/OutfitAppearance.js';
// bcrypt-ts is pure JS: one compare pins this thread for ~50-200ms. Prod outage
@@ -182,9 +184,29 @@ async function updateHiscores(account: HiscoreAccount | undefined, player: Playe
await db.updateTable('hiscore').set(update[i]).where('account_id', '=', account.id).where('type', '=', update[i].type).where('profile', '=', profile).execute();
}
await updateQuestHiscore(account, player, profile);
await updateWealthHiscores(account, player, profile);
}
// rs-sdk: quest points board. %qp is recounted from quest progress on every login and is
// scope=perm so it reaches the save. Playtime is only stamped when the total changes, so ties
// go to whoever reached it first - and autosave only writes when someone finishes a quest.
async function updateQuestHiscore(account: HiscoreAccount, player: Player, profile: string) {
const qp = player.vars[VarPlayerType.getId('qp')] ?? 0;
if (qp < 1) {
return;
}
const existing = await db.selectFrom('hiscore').select('level').where('account_id', '=', account.id).where('type', '=', QUEST_POINTS_HISCORE_TYPE).where('profile', '=', profile).executeTakeFirst();
if (existing && existing.level === qp) {
// unchanged
} else if (existing) {
await db.updateTable('hiscore').set({ level: qp, value: qp, playtime: player.playtime, date: toDbDate(new Date()) }).where('account_id', '=', account.id).where('type', '=', QUEST_POINTS_HISCORE_TYPE).where('profile', '=', profile).execute();
} else {
await db.insertInto('hiscore').values({ account_id: account.id, profile, type: QUEST_POINTS_HISCORE_TYPE, level: qp, value: qp, playtime: player.playtime }).execute();
}
}
// 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.
@@ -332,6 +354,11 @@ export default class LoginServer {
InvType.load('data/pack');
ObjType.load('data/pack');
// Player.vars is sized from VarPlayerType, so saves load without varps until this is loaded
VarPlayerType.load('data/pack');
if (VarPlayerType.getByName('qp')?.scope !== VarPlayerType.SCOPE_PERM) {
printWarning('qp varp is not scope=perm - the quest points hiscore will not update');
}
this.server = new WebSocketServer({ port: Environment.login.port, host: '0.0.0.0' }, () => {
printInfo(`Login server listening on port ${Environment.login.port}`);
@@ -723,7 +750,9 @@ export default class LoginServer {
try {
const account = await db.selectFrom('account').select(['id', 'staffmodlevel', 'banned_until']).where('username', '=', username).executeTakeFirst();
if (hiscoreEligible(account)) {
await updateWealthHiscores(account, PlayerLoading.load(username, new Packet(raw), null), profile);
const player = PlayerLoading.load(username, new Packet(raw), null);
await updateQuestHiscore(account, player, profile);
await updateWealthHiscores(account, player, profile);
}
} catch (err) {
console.error(username, 'autosave hiscore update failed', err);
+25 -7
View File
@@ -1,7 +1,7 @@
import { db, toDbDate } from '#/db/query.js';
import Environment from '#/util/Environment.js';
import { tryParseInt } from '#/util/TryParse.js';
import { escapeHtml, SKILL_NAMES, ENABLED_SKILLS } from '../utils.js';
import { escapeHtml, SKILL_NAMES, ENABLED_SKILLS, QUEST_POINTS_HISCORE_TYPE } from '../utils.js';
import { itemSpriteUrl, playerSpriteUrl } from '#/web/sprites/SpriteRenderer.js';
const hiddenNames = Environment.HISCORES_HIDDEN_NAMES;
@@ -143,7 +143,8 @@ function hiscoreTableLinks(profile: string): string {
return `<tr><td><a href="/hiscores?category=${s.id}&profile=${profile}" class="c">${icon}${s.name}</a></td></tr>`;
})
.join('\n') +
`\n<tr><td>&nbsp;</td></tr>\n<tr><td><a href="/hiscores/outfit?profile=${profile}" class="c text-orange">Equipment</a></td></tr>` +
`\n<tr><td>&nbsp;</td></tr>\n<tr><td><a href="/hiscores?category=${QUEST_POINTS_HISCORE_TYPE}&profile=${profile}" class="c text-orange">Quest Points</a></td></tr>` +
`\n<tr><td><a href="/hiscores/outfit?profile=${profile}" class="c text-orange">Equipment</a></td></tr>` +
`\n<tr><td><a href="/hiscores/bank?profile=${profile}" class="c text-orange">Bank</a></td></tr>` +
`\n<tr><td><a href="/hiscores/runite?profile=${profile}" class="c text-orange">Lava Maze Runite</a></td></tr>`
);
@@ -217,6 +218,21 @@ export async function handleHiscoresPlayerPage(url: URL): Promise<Response | nul
`);
}
const questStat = skillStats.find(s => s.type === QUEST_POINTS_HISCORE_TYPE);
let questRank = '-';
if (questStat) {
const r = rankIn(await getRankedList(profile, QUEST_POINTS_HISCORE_TYPE), account.username);
questRank = r ? String(r) : '-';
}
skillRows.push(`
<tr>
<td><a href="/hiscores?category=${QUEST_POINTS_HISCORE_TYPE}&profile=${profile}" class="c text-orange">Quest Points</a></td>
<td align="right">${questRank}</td>
<td align="right">${questStat ? questStat.level.toLocaleString() : '-'}</td>
<td align="right">${questStat ? formatPlaytime(questStat.playtime) : '-'}</td>
</tr>
`);
const html = `<!DOCTYPE html>
<html>
<head>
@@ -322,6 +338,9 @@ export async function handleHiscoresPage(url: URL): Promise<Response | null> {
let rows: { rank: number; username: string; level: number; playtime: number }[] = [];
let selectedSkill = 'Overall';
// quest points rank by points, then by the playtime they reached that total at
const isQuestBoard = category === QUEST_POINTS_HISCORE_TYPE;
const levelLabel = isQuestBoard ? 'Points' : 'Level';
let searchedPlayer: { rank: number; username: string; level: number; playtime: number } | null = null;
if (category === -1 || category === 0) {
@@ -346,9 +365,8 @@ export async function handleHiscoresPage(url: URL): Promise<Response | null> {
}
selectedSkill = 'Overall';
} else {
// Individual skill - query hiscore
const skillIndex = category - 1;
const skillName = SKILL_NAMES[skillIndex];
// Individual skill (or quest points) - query hiscore
const skillName = isQuestBoard ? 'Quest Points' : SKILL_NAMES[category - 1];
if (skillName) {
const allResults = await getRankedList(profile, category);
@@ -490,7 +508,7 @@ export async function handleHiscoresPage(url: URL): Promise<Response | null> {
</td>
<td>&nbsp;</td>
<td valign="top">
<b>Level</b><br>
<b>${levelLabel}</b><br>
${levelCol}
</td>
<td>&nbsp;</td>
@@ -566,7 +584,7 @@ export async function handleHiscoresPage(url: URL): Promise<Response | null> {
<b>Search Result</b><br>
Rank: ${searchedPlayer.rank} |
<a href="/hiscores/player/${encodeURIComponent(searchedPlayer.username)}?profile=${profile}" class="c">${escapeHtml(searchedPlayer.username)}</a> |
Level: ${searchedPlayer.level.toLocaleString()} |
${levelLabel}: ${searchedPlayer.level.toLocaleString()} |
Time: ${formatPlaytime(searchedPlayer.playtime)}
</center>
</td>
+3
View File
@@ -86,3 +86,6 @@ export const SKILL_NAMES = [
export const ENABLED_SKILLS = SKILL_NAMES
.map((name, i) => name ? { id: i, name } : null)
.filter(Boolean) as { id: number; name: string }[];
// rs-sdk: quest points share the hiscore table under a type no stat id reaches (level = value = qp)
export const QUEST_POINTS_HISCORE_TYPE = 100;