diff --git a/server/engine/src/engine/entity/Player.ts b/server/engine/src/engine/entity/Player.ts index aedbcb5d6..d087fa34a 100644 --- a/server/engine/src/engine/entity/Player.ts +++ b/server/engine/src/engine/entity/Player.ts @@ -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; diff --git a/server/engine/src/engine/entity/PlayerLoading.ts b/server/engine/src/engine/entity/PlayerLoading.ts index fba68c9ee..8d759498e 100644 --- a/server/engine/src/engine/entity/PlayerLoading.ts +++ b/server/engine/src/engine/entity/PlayerLoading.ts @@ -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; diff --git a/server/engine/src/server/login/LoginServer.ts b/server/engine/src/server/login/LoginServer.ts index 330d04980..577fe39e8 100644 --- a/server/engine/src/server/login/LoginServer.ts +++ b/server/engine/src/server/login/LoginServer.ts @@ -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 }); } } diff --git a/server/engine/test/fixtures/level-playtime.ts b/server/engine/test/fixtures/level-playtime.ts new file mode 100644 index 000000000..6ef05cca3 --- /dev/null +++ b/server/engine/test/fixtures/level-playtime.ts @@ -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) => 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); diff --git a/server/engine/test/level-playtime.test.ts b/server/engine/test/level-playtime.test.ts new file mode 100644 index 000000000..4a3ed5a05 --- /dev/null +++ b/server/engine/test/level-playtime.test.ts @@ -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 }); + } +});