mirror of
https://github.com/bdnugget/rs-sdk.git
synced 2026-11-07 09:07:30 +00:00
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:
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;
|
||||
|
||||
@@ -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
@@ -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);
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user