feat(hiscores): rank by the tick a level was reached, not logout

Skill and total hiscore rows were only written at logout, so the playtime
tiebreak recorded when a player next logged out after a level-up - hours
late for anyone who kept playing. Stamp player.playtime per stat on each
level-up, persist it in the save (SAV_VERSION 9), and use the stamp when
writing hiscore rows. Total level uses the latest stat stamp. Saves from
before the stamps existed fall back to logout playtime.

Note: an engine older than this rejects v9 saves, so rolling back past this
commit locks out anyone who logged out on it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
max bittkerandClaude Opus 5.5 committed 2026-10-18 22:31:46 -04:00
1 parent ac6ef5e42c
commit 55fa26ccaa
5 files changed
+120 -7

No files matched your search

@@ -303,6 +303,10 @@ export default class Player extends PathingEntity {
sav.p4(item.count);
}
for (let i = 0; i < 21; i++) {
sav.p4(this.levelPlaytime[i]);
}
sav.p4(Packet.getcrc(sav.data, 0, sav.pos));
const bytes = sav.data.subarray(0, sav.pos);
if (checkpoint && playerSaveStore()) World.checkpointPlayers(this, bytes);
@@ -330,6 +334,9 @@ export default class Player extends PathingEntity {
lastRunEnergy: number = -1;
runweight: number = 0;
playtime: number = 0;
// rs-sdk: playtime tick each base level was reached at, so hiscores rank by when the level
// happened rather than when the player next logged out. 0 = reached before this was tracked.
levelPlaytime: Int32Array = new Int32Array(21);
stats: Int32Array = new Int32Array(21);
levels: Uint8Array = new Uint8Array(21);
vars: Int32Array;
@@ -1895,6 +1902,8 @@ export default class Player extends PathingEntity {
this.baseLevels[stat] = getLevelByExp(this.stats[stat]);
if (this.baseLevels[stat] > before) {
this.levelPlaytime[stat] = this.playtime;
if (this.levels[stat] < before) {
// replenish stat
this.levels[stat] += this.baseLevels[stat] - before;
@@ -11,7 +11,7 @@ import { fromBase37, toBase37 } from '#/util/JString.js';
export class PlayerLoading {
public static readonly SAV_MAGIC: number = 0x2004;
public static readonly SAV_VERSION: number = 8;
public static readonly SAV_VERSION: number = 9;
static verify(sav: Packet) {
if (sav.g2() !== PlayerLoading.SAV_MAGIC) {
@@ -180,6 +180,12 @@ export class PlayerLoading {
player.recoverItems();
}
if (version >= 9) {
for (let i = 0; i < 21; i++) {
player.levelPlaytime[i] = sav.g4s();
}
}
player.combatLevel = player.getCombatLevel();
return player;
+12 -6
View File
@@ -98,7 +98,7 @@ function hiscoreEligible(account: HiscoreAccount | undefined): account is Hiscor
return true;
}
async function updateHiscores(account: HiscoreAccount | undefined, player: Player, profile: string) {
export async function updateHiscores(account: HiscoreAccount | undefined, player: Player, profile: string) {
if (!hiscoreEligible(account)) return;
const insert = [];
@@ -106,6 +106,7 @@ async function updateHiscores(account: HiscoreAccount | undefined, player: Playe
let totalXp = 0;
let totalLevel = 0;
let totalPlaytime = 0;
for (let i = 0; i < player.stats.length; i++) {
if (!PlayerStatEnabled[i]) {
continue;
@@ -113,11 +114,15 @@ async function updateHiscores(account: HiscoreAccount | undefined, player: Playe
totalXp += player.stats[i];
totalLevel += player.baseLevels[i];
totalPlaytime = Math.max(totalPlaytime, player.levelPlaytime[i]);
}
// rs-sdk: playtime is the tiebreak (level desc, playtime asc), so it's only stamped when the
// level changes - it records when the player reached that level. Restamping it on every
// logout pushed maxed players down the board for continuing to play.
// logout pushed maxed players down the board for continuing to play. The stamp is the tick
// of the level-up itself; saves from before that was tracked fall back to logout playtime.
// Total level only moves on a level-up, so it was reached at the latest stat's stamp.
const reachedTotal = totalPlaytime || player.playtime;
const existing = await db.selectFrom('hiscore_large').select('type').select('level').select('value').select('playtime').where('account_id', '=', account.id).where('type', '=', 0).where('profile', '=', profile).executeTakeFirst();
if (existing && (existing.value !== totalXp || existing.level !== totalLevel)) {
await db
@@ -126,7 +131,7 @@ async function updateHiscores(account: HiscoreAccount | undefined, player: Playe
type: 0,
level: totalLevel,
value: totalXp,
playtime: existing.level === totalLevel ? existing.playtime : player.playtime,
playtime: existing.level === totalLevel ? existing.playtime : reachedTotal,
date: toDbDate(new Date())
})
.where('account_id', '=', account.id)
@@ -142,7 +147,7 @@ async function updateHiscores(account: HiscoreAccount | undefined, player: Playe
type: 0,
level: totalLevel,
value: totalXp,
playtime: player.playtime
playtime: reachedTotal
})
.execute();
}
@@ -154,6 +159,7 @@ async function updateHiscores(account: HiscoreAccount | undefined, player: Playe
if (player.baseLevels[stat] >= 15) {
const hiscoreType = stat + 1;
const reached = player.levelPlaytime[stat] || player.playtime;
// todo: can we upsert in kysely?
const existing = await db.selectFrom('hiscore').select('type').select('level').select('value').select('playtime').where('account_id', '=', account.id).where('type', '=', hiscoreType).where('profile', '=', profile).executeTakeFirst();
@@ -162,7 +168,7 @@ async function updateHiscores(account: HiscoreAccount | undefined, player: Playe
type: hiscoreType,
level: player.baseLevels[stat],
value: player.stats[stat],
playtime: existing.level === player.baseLevels[stat] ? existing.playtime : player.playtime,
playtime: existing.level === player.baseLevels[stat] ? existing.playtime : reached,
date: toDbDate(new Date())
});
} else if (!existing) {
@@ -172,7 +178,7 @@ async function updateHiscores(account: HiscoreAccount | undefined, player: Playe
type: hiscoreType,
level: player.baseLevels[stat],
value: player.stats[stat],
playtime: player.playtime
playtime: reached
});
}
}
+70
View File
@@ -0,0 +1,70 @@
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: VarPlayerType } = await import('../../src/cache/config/VarPlayerType.js');
const { default: Player } = await import('../../src/engine/entity/Player.js');
const { PlayerLoading } = await import('../../src/engine/entity/PlayerLoading.js');
const { PlayerStat } = await import('../../src/engine/entity/PlayerStat.js');
const { default: Packet } = await import('../../src/io/Packet.js');
const { updateHiscores } = 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!);
VarPlayerType.load(process.env.BANK_TEST_CACHE!);
const database = new Database('db.sqlite');
database.exec('CREATE TABLE hiscore (account_id INTEGER, profile TEXT, type INTEGER, level INTEGER, value INTEGER, playtime INTEGER DEFAULT 0, date TEXT)');
database.exec('CREATE TABLE hiscore_large (account_id INTEGER, profile TEXT, type INTEGER, level INTEGER, value INTEGER, playtime INTEGER DEFAULT 0, date TEXT)');
database.exec('CREATE TABLE hiscore_outfit (account_id INTEGER, profile TEXT, value INTEGER, items TEXT, appearance TEXT, date TEXT)');
database.exec('CREATE TABLE hiscore_bank (account_id INTEGER, profile TEXT, value INTEGER, items TEXT, date TEXT)');
const account = { id: 1, staffmodlevel: 0, banned_until: null };
const row = (type: number) => database.query('SELECT level, playtime FROM hiscore WHERE account_id=1 AND type=?').get(type) as { level: number; playtime: number };
const total = () => database.query('SELECT level, playtime FROM hiscore_large WHERE account_id=1 AND type=0').get() as { level: number; playtime: number };
const reload = (player: InstanceType<typeof Player>) => PlayerLoading.load(player.username, new Packet(player.save(false)), null);
try {
const player = reload(new Player('stamper', 1n, 1n));
player.playtime = 1000;
player.addXp(PlayerStat.WOODCUTTING, 10_000_000, false);
assert.equal(player.levelPlaytime[PlayerStat.WOODCUTTING], 1000);
player.playtime = 1500;
player.addXp(PlayerStat.FISHING, 10_000_000, false);
player.playtime = 9999; // keeps playing; logs out much later
// The stamps survive a save round-trip and the boards record the level-up ticks, not logout.
let saved = reload(player);
assert.equal(saved.levelPlaytime[PlayerStat.WOODCUTTING], 1000);
assert.equal(saved.levelPlaytime[PlayerStat.FISHING], 1500);
await updateHiscores(account, saved, 'main');
assert.equal(row(PlayerStat.WOODCUTTING + 1).playtime, 1000);
assert.equal(row(PlayerStat.FISHING + 1).playtime, 1500);
assert.equal(total().playtime, 1500);
// XP without a level-up keeps the earlier stamps.
player.playtime = 20000;
player.addXp(PlayerStat.WOODCUTTING, 1, false);
await updateHiscores(account, reload(player), 'main');
assert.equal(row(PlayerStat.WOODCUTTING + 1).playtime, 1000);
assert.equal(total().playtime, 1500);
// Saves from before stamps existed fall back to logout playtime.
const legacy = reload(new Player('legacy', 2n, 2n));
legacy.baseLevels[PlayerStat.MINING] = 20;
legacy.playtime = 4242;
await updateHiscores({ ...account, id: 2 }, legacy, 'main');
assert.equal((database.query('SELECT playtime FROM hiscore WHERE account_id=2 AND type=?').get(PlayerStat.MINING + 1) as { playtime: number }).playtime, 4242);
assert.equal((database.query('SELECT playtime FROM hiscore_large WHERE account_id=2').get() as { playtime: number }).playtime, 4242);
console.log('Level playtime checks passed');
} finally {
database.close();
await db.destroy();
}
// World imports initialize recurring timers; this isolated fixture has completed.
process.exit(0);
+22
View File
@@ -0,0 +1,22 @@
import { expect, test } from 'bun:test';
import { copyFileSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
test('hiscores record the tick a level was reached, not the logout tick', () => {
const dir = mkdtempSync(join(tmpdir(), 'level-playtime-'));
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/level-playtime.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('Level playtime checks passed');
} finally {
rmSync(dir, { recursive: true, force: true });
}
});