mirror of
https://github.com/bdnugget/rs-sdk.git
synced 2026-11-07 09:07:30 +00:00
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:
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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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> </td></tr>\n<tr><td><a href="/hiscores/outfit?profile=${profile}" class="c text-orange">Equipment</a></td></tr>` +
|
||||
`\n<tr><td> </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> </td>
|
||||
<td valign="top">
|
||||
<b>Level</b><br>
|
||||
<b>${levelLabel}</b><br>
|
||||
${levelCol}
|
||||
</td>
|
||||
<td> </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>
|
||||
|
||||
@@ -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;
|
||||
Reference in new issue
Block a user