mirror of
https://github.com/bdnugget/rs-sdk.git
synced 2026-11-07 09:07:30 +00:00
Reduce tick stalls and move leaderboard refreshes off the game thread
This commit is contained in:
1 parent
e504d6adb3
commit
2012ab67f6
24 files changed
+22050
-90
No files matched your search
File diff suppressed because it is too large.
Load diff
+1
-1
@@ -6,7 +6,7 @@
|
||||
"docs:api": "bun sdk/generate-api-docs.ts",
|
||||
"docs:api:check": "bun sdk/generate-api-docs.ts --check",
|
||||
"fetch-collision-data": "bun sdk/fetch-collision-data.ts",
|
||||
"test": "bun test sdk/test server/webclient/src/bot server/webclient/src/util server/webclient/src/lite server/webclient/src/mapview server/engine/test/market.test.ts server/engine/test/market-web.test.ts server/engine/test/item-search.test.ts server/engine/test/file-stream.test.ts server/engine/test/get-ip.test.ts server/engine/test/bank-hiscores.test.ts server/engine/test/reconnect.test.ts server/engine/test/worldfeed.test.ts server/gateway/state-fanout.test.ts",
|
||||
"test": "bun test sdk/test server/webclient/src/bot server/webclient/src/util server/webclient/src/lite server/webclient/src/mapview server/engine/test/market.test.ts server/engine/test/market-web.test.ts server/engine/test/item-search.test.ts server/engine/test/file-stream.test.ts server/engine/test/get-ip.test.ts server/engine/test/bank-hiscores.test.ts server/engine/test/reconnect.test.ts server/engine/test/worldfeed.test.ts server/gateway/state-fanout.test.ts server/engine/test/hiscores-performance.test.ts",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"typecheck:webclient": "tsc --noEmit -p server/webclient/tsconfig.json",
|
||||
"test:market:integration": "cd server/engine && bun test/market-integration.ts && bun test/market-recovery.ts && bun test/market-protocol.ts && bun test/market-disabled.ts"
|
||||
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
CREATE INDEX `hiscore_rank` ON `hiscore`(`profile`, `type`, `level` DESC, `playtime`, `account_id`);
|
||||
CREATE INDEX `hiscore_large_rank` ON `hiscore_large`(`profile`, `type`, `level` DESC, `playtime`, `account_id`);
|
||||
@@ -55,6 +55,7 @@ model hiscore {
|
||||
date DateTime @default(now()) @updatedAt
|
||||
|
||||
@@id([profile, type, account_id])
|
||||
@@index([profile, type, level(sort: Desc), playtime, account_id], map: "hiscore_rank")
|
||||
}
|
||||
|
||||
model hiscore_large {
|
||||
@@ -68,6 +69,7 @@ model hiscore_large {
|
||||
date DateTime @default(now()) @updatedAt
|
||||
|
||||
@@id([profile, type, account_id])
|
||||
@@index([profile, type, level(sort: Desc), playtime, account_id], map: "hiscore_large_rank")
|
||||
}
|
||||
|
||||
model hiscore_outfit {
|
||||
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
CREATE INDEX "hiscore_rank" ON "hiscore"("profile", "type", "level" DESC, "playtime", "account_id");
|
||||
CREATE INDEX "hiscore_large_rank" ON "hiscore_large"("profile", "type", "level" DESC, "playtime", "account_id");
|
||||
@@ -55,6 +55,7 @@ model hiscore {
|
||||
date DateTime @default(now()) @updatedAt
|
||||
|
||||
@@id([profile, type, account_id])
|
||||
@@index([profile, type, level(sort: Desc), playtime, account_id], map: "hiscore_rank")
|
||||
}
|
||||
|
||||
model hiscore_large {
|
||||
@@ -68,6 +69,7 @@ model hiscore_large {
|
||||
date DateTime @default(now()) @updatedAt
|
||||
|
||||
@@id([profile, type, account_id])
|
||||
@@index([profile, type, level(sort: Desc), playtime, account_id], map: "hiscore_large_rank")
|
||||
}
|
||||
|
||||
model hiscore_outfit {
|
||||
|
||||
@@ -825,6 +825,9 @@ class World {
|
||||
|
||||
private processLogouts(): void {
|
||||
const start: number = Date.now();
|
||||
const started = performance.now();
|
||||
const budgetMs = Math.max(1, Math.min(50, this.tickRate / 6));
|
||||
let removed = 0;
|
||||
|
||||
for (const player of this.playerLoop.all()) {
|
||||
let force = false;
|
||||
@@ -852,6 +855,9 @@ class World {
|
||||
}
|
||||
|
||||
if (player.loggingOut && (force || this.currentTick >= player.preventLogoutUntil)) {
|
||||
// Keep loggingOut set so deferred accounts are retried next tick.
|
||||
// Each removal performs a durable whole-world checkpoint.
|
||||
if (!this.shutdown && (removed >= 10 || (removed > 0 && performance.now() - started >= budgetMs))) continue;
|
||||
player.closeModal();
|
||||
|
||||
let queueDiscardable = true;
|
||||
@@ -878,6 +884,7 @@ class World {
|
||||
ScriptRunner.execute(state);
|
||||
|
||||
this.removePlayer(player);
|
||||
removed++;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -898,6 +905,11 @@ class World {
|
||||
|
||||
private processLogins(): void {
|
||||
const start: number = Date.now();
|
||||
const started = performance.now();
|
||||
// One checkpoint cannot be interrupted, but a reconnect burst must not run
|
||||
// hundreds of whole-world saves in one tick. Leave the rest queued.
|
||||
const budgetMs = Math.max(1, Math.min(50, this.tickRate / 6));
|
||||
let processed = 0;
|
||||
|
||||
// counted once per tick; sessions admitted below increment it so a burst of
|
||||
// logins from one IP can't all slip under the cap in the same tick
|
||||
@@ -912,7 +924,13 @@ class World {
|
||||
}
|
||||
}
|
||||
|
||||
player: for (const player of this.newPlayers) {
|
||||
player: for (const queuedPlayer of this.newPlayers) {
|
||||
if (processed >= 10 || (processed > 0 && performance.now() - started >= budgetMs)) break;
|
||||
this.newPlayers.delete(queuedPlayer);
|
||||
processed++;
|
||||
let player = queuedPlayer;
|
||||
if (player instanceof NetworkPlayer && (!isClientConnected(player) || player.client.state < 0)) continue;
|
||||
let transferred = false;
|
||||
// prevent logging in if a player save is being flushed
|
||||
if (this.logoutRequests.has(player.username)) {
|
||||
player.addSessionLog(LoggerEventType.ENGINE, 'Tried to log in - old session is mid-logout');
|
||||
@@ -993,7 +1011,6 @@ class World {
|
||||
const client = isClientConnected(player) ? player.client : null;
|
||||
|
||||
// Re-create the new player from the existing player's current state
|
||||
this.newPlayers.delete(player);
|
||||
const transferredPlayer = PlayerLoading.load(player.username, new Packet(existingSave), client);
|
||||
|
||||
// Preserve login metadata from the original login response
|
||||
@@ -1004,18 +1021,20 @@ class World {
|
||||
transferredPlayer.members = player.members;
|
||||
transferredPlayer.messageCount = player.messageCount;
|
||||
|
||||
this.newPlayers.add(transferredPlayer);
|
||||
|
||||
// Remove the old player WITHOUT flushing to disk
|
||||
// State is already transferred to the new player, so no need to save
|
||||
// This avoids a race condition where the old logout could interfere with the new player's save
|
||||
if (other instanceof NetworkPlayer && ipCounts) {
|
||||
const oldIp = other.client.remoteAddress;
|
||||
ipCounts.set(oldIp, Math.max(0, (ipCounts.get(oldIp) ?? 0) - 1));
|
||||
}
|
||||
this.removePlayerWithoutSave(other);
|
||||
|
||||
// Skip the rest of this iteration - transferredPlayer will be processed
|
||||
// on the next iteration of the outer loop. Without this, the original
|
||||
// player would continue to the normal login process and get added to
|
||||
// this.players, causing an infinite loop when transferredPlayer is processed.
|
||||
continue player;
|
||||
// Finish admission in this iteration. Deferring the transferred
|
||||
// player behind the backlog would leave the account out of the world.
|
||||
player = transferredPlayer;
|
||||
transferred = true;
|
||||
break;
|
||||
}
|
||||
|
||||
// prevent logging in when the server is shutting down
|
||||
@@ -1028,12 +1047,12 @@ class World {
|
||||
continue;
|
||||
}
|
||||
|
||||
// per-IP connection cap - reconnects and session transfers never reach here,
|
||||
// so this only gates net-new sessions
|
||||
// Session transfers replace an existing slot; only net-new sessions
|
||||
// are subject to the per-IP connection cap.
|
||||
if (player instanceof NetworkPlayer && ipCounts) {
|
||||
const ip = player.client.remoteAddress;
|
||||
const count = ipCounts.get(ip) ?? 0;
|
||||
if (count >= World.PLAYER_MAX_PER_IP) {
|
||||
if (!transferred && count >= World.PLAYER_MAX_PER_IP) {
|
||||
player.addSessionLog(LoggerEventType.ENGINE, `Tried to log in - too many connections from ${ip}`);
|
||||
this.forceLogout(player, 7);
|
||||
continue;
|
||||
@@ -1124,7 +1143,6 @@ class World {
|
||||
staffLvl: player.staffModLevel
|
||||
});
|
||||
}
|
||||
this.newPlayers.clear();
|
||||
this.cycleStats[WorldStat.LOGIN] = Date.now() - start;
|
||||
}
|
||||
|
||||
@@ -1987,6 +2005,7 @@ class World {
|
||||
tick: this.currentTick,
|
||||
tickRate: this.tickRate,
|
||||
players: this.getTotalPlayers(),
|
||||
pendingLogins: this.newPlayers.size,
|
||||
npcs: this.getTotalNpcs(),
|
||||
zonesTracking: this.zonesTracking.size,
|
||||
window: n,
|
||||
|
||||
@@ -12,6 +12,7 @@ export default class BuildArea {
|
||||
readonly mapsquares: Set<number>;
|
||||
|
||||
lastBuild: number = -1;
|
||||
private zoneKey = '';
|
||||
|
||||
constructor(player: Player) {
|
||||
this.player = player;
|
||||
@@ -22,6 +23,7 @@ export default class BuildArea {
|
||||
|
||||
clear(reconnecting: boolean): void {
|
||||
if (!reconnecting) {
|
||||
this.zoneKey = '';
|
||||
this.activeZones.clear();
|
||||
this.loadedZones.clear();
|
||||
this.mapsquares.clear();
|
||||
@@ -29,14 +31,15 @@ export default class BuildArea {
|
||||
}
|
||||
|
||||
rebuildZones(): void {
|
||||
// update any newly tracked zones
|
||||
this.activeZones.clear();
|
||||
|
||||
const centerX = CoordGrid.zone(this.player.x);
|
||||
const centerZ = CoordGrid.zone(this.player.z);
|
||||
|
||||
const originX: number = CoordGrid.zone(this.player.originX);
|
||||
const originZ: number = CoordGrid.zone(this.player.originZ);
|
||||
const key = `${centerX}:${centerZ}:${originX}:${originZ}:${this.player.level}`;
|
||||
if (key === this.zoneKey) return;
|
||||
this.zoneKey = key;
|
||||
this.activeZones.clear();
|
||||
|
||||
const leftX = originX - 6;
|
||||
const rightX = originX + 6;
|
||||
|
||||
@@ -306,6 +306,8 @@ export class NetworkPlayer extends Player {
|
||||
// update active zones
|
||||
for (const zoneIndex of activeZones) {
|
||||
const zone: Zone = World.gameMap.getZoneIndex(zoneIndex);
|
||||
// Loaded zones only need processing when the engine tracked a change.
|
||||
if (loadedZones.has(zoneIndex) && !World.zonesTracking.has(zone)) continue;
|
||||
if (!loadedZones.has(zone.index)) {
|
||||
zone.writeFullFollows(this);
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import { handleBugReport } from './pages/bug-report.js';
|
||||
import { handleDisclaimerPage, handleMapviewPage, handlePublicFiles } from './pages/static.js';
|
||||
import { WebSocketData, handleWebSocketUpgrade, handleGatewayEndpointGet, websocketHandlers } from './websocket.js';
|
||||
import { getIp } from './utils.js';
|
||||
import { getPlayerPositionsJson } from './worldfeed.js';
|
||||
|
||||
export type { WebSocketData };
|
||||
|
||||
@@ -82,6 +83,7 @@ export async function startWeb() {
|
||||
}
|
||||
|
||||
async function handleRequest(req: Request, server: Bun.Server, url: URL): Promise<Response | undefined> {
|
||||
if (/^\/hi(?:gh)?scores(?:\/|$)/.test(url.pathname)) server.timeout(req, 60);
|
||||
// Handle WebSocket upgrades first
|
||||
const wsResponse = handleWebSocketUpgrade(req, server, url);
|
||||
if (wsResponse !== undefined) {
|
||||
@@ -129,11 +131,7 @@ async function handleRequest(req: Request, server: Bun.Server, url: URL): Promis
|
||||
|
||||
// Player positions endpoint
|
||||
if (url.pathname === '/playerpositions' || url.pathname === '/playerpositions/') {
|
||||
const players: {x: number, z: number, level: number, name: string}[] = [];
|
||||
for (const player of World.playerLoop.all()) {
|
||||
players.push({ x: player.x, z: player.z, level: player.level, name: player.displayName });
|
||||
}
|
||||
return new Response(JSON.stringify(players), {
|
||||
return new Response(getPlayerPositionsJson(), {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Access-Control-Allow-Origin': '*'
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import { Worker } from 'node:worker_threads';
|
||||
|
||||
export type RankedRow = { username: string; level: number; playtime: number };
|
||||
type Job = { resolve: (rows: RankedRow[]) => void; reject: (error: Error) => void; timer: ReturnType<typeof setTimeout>; request: { profile: string; category: number } };
|
||||
let worker: Worker | null = null;
|
||||
let workerReady = false;
|
||||
let nextId = 0;
|
||||
const jobs = new Map<number, Job>();
|
||||
|
||||
// Only immutable leaderboard rows cross threads; the worker never touches the world.
|
||||
export function loadRankedList(profile: string, category: number): Promise<RankedRow[]> {
|
||||
if (!worker) {
|
||||
const current = new Worker(new URL('./RankedHiscoresWorker.ts', import.meta.url));
|
||||
worker = current;
|
||||
workerReady = false;
|
||||
const fail = (error: Error) => {
|
||||
if (worker !== current) return;
|
||||
worker = null;
|
||||
void current.terminate();
|
||||
for (const job of jobs.values()) { clearTimeout(job.timer); job.reject(error); }
|
||||
jobs.clear();
|
||||
};
|
||||
current.on('error', fail);
|
||||
current.on('exit', code => fail(new Error(`Leaderboard worker exited (${code})`)));
|
||||
current.on('message', ({ id, rows, error, ready }) => {
|
||||
if (worker !== current) return;
|
||||
if (ready) {
|
||||
workerReady = true;
|
||||
for (const [id, job] of jobs) current.postMessage({ id, ...job.request });
|
||||
return;
|
||||
}
|
||||
const job = jobs.get(id);
|
||||
if (!job) return;
|
||||
jobs.delete(id);
|
||||
clearTimeout(job.timer);
|
||||
if (error) job.reject(new Error(error)); else job.resolve(rows);
|
||||
if (!jobs.size) current.unref();
|
||||
});
|
||||
current.unref();
|
||||
}
|
||||
const current = worker;
|
||||
return new Promise((resolve, reject) => {
|
||||
const id = ++nextId;
|
||||
const timer = setTimeout(() => {
|
||||
// Restart on a stuck query so later refreshes don't accumulate behind it.
|
||||
if (worker === current) { worker = null; void current.terminate(); }
|
||||
for (const job of jobs.values()) { clearTimeout(job.timer); job.reject(new Error('Leaderboard refresh timed out')); }
|
||||
jobs.clear();
|
||||
}, 30_000);
|
||||
jobs.set(id, { resolve, reject, timer, request: { profile, category } });
|
||||
current.ref();
|
||||
if (workerReady) current.postMessage({ id, profile, category });
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { parentPort } from 'node:worker_threads';
|
||||
import { db } from '#/db/query.js';
|
||||
import Environment from '#/util/Environment.js';
|
||||
|
||||
// The general SQLite adapter logs errors and returns empty rows. Rankings must
|
||||
// propagate failures instead, so an unavailable DB cannot erase a good snapshot.
|
||||
const sqlite = Environment.db.backend !== 'sqlite' ? null : typeof Bun !== 'undefined'
|
||||
? new (await import('bun:sqlite')).Database('db.sqlite', { readonly: true })
|
||||
: new (await import('node:sqlite')).DatabaseSync('db.sqlite', { readOnly: true });
|
||||
sqlite?.exec('PRAGMA busy_timeout = 5000');
|
||||
|
||||
parentPort!.on('message', async ({ id, profile, category }) => {
|
||||
try {
|
||||
const table = category === 0 ? 'hiscore_large' : 'hiscore';
|
||||
let query = db.selectFrom(table).innerJoin('account', 'account.id', `${table}.account_id`)
|
||||
.select(['account.username', `${table}.level`, `${table}.playtime`])
|
||||
.where(`${table}.profile`, '=', profile).where(`${table}.type`, '=', category)
|
||||
.where('account.staffmodlevel', '<=', 1)
|
||||
.orderBy(`${table}.level`, 'desc').orderBy(`${table}.playtime`, 'asc');
|
||||
if (Environment.HISCORES_HIDDEN_NAMES.length) {
|
||||
query = query.where(eb => eb.not(eb(eb.fn('lower', ['account.username']), 'in', Environment.HISCORES_HIDDEN_NAMES)));
|
||||
}
|
||||
const compiled = query.compile();
|
||||
const rows = sqlite ? sqlite.prepare(compiled.sql).all(...compiled.parameters as any[]) : await query.execute();
|
||||
parentPort!.postMessage({ id, rows });
|
||||
} catch (error) {
|
||||
parentPort!.postMessage({ id, error: String(error) });
|
||||
}
|
||||
});
|
||||
// Bun may drop messages sent while top-level async imports are still loading.
|
||||
parentPort!.postMessage({ ready: true });
|
||||
@@ -5,6 +5,7 @@ import { escapeHtml, SKILL_NAMES, ENABLED_SKILLS, QUEST_POINTS_HISCORE_TYPE } fr
|
||||
import { itemSpriteUrl, playerSpriteUrl } from '#/web/sprites/SpriteRenderer.js';
|
||||
import { parseBankSnapshots, rankMatchingBanks } from './BankHiscores.js';
|
||||
import type { BankSnapshot } from './BankHiscores.js';
|
||||
import { loadRankedList, type RankedRow } from './RankedHiscores.js';
|
||||
|
||||
const hiddenNames = Environment.HISCORES_HIDDEN_NAMES;
|
||||
|
||||
@@ -66,73 +67,50 @@ function formatPlaytime(ticks: number): string {
|
||||
return `${minutes}m`;
|
||||
}
|
||||
|
||||
// rs-sdk: ranked lists are served from a short-lived cache. Every hiscores request used to
|
||||
// materialise the whole hiscore/hiscore_large table (160k+ rows) on the tick thread; a burst
|
||||
// of page loads was enough to freeze the world for seconds. The list changes on player save
|
||||
// (autosave is 15 min), so a 60s TTL is invisible to users.
|
||||
type RankedRow = { username: string; level: number; playtime: number };
|
||||
const RANKED_TTL_MS = Number(process.env.HISCORES_CACHE_MS ?? 60_000);
|
||||
const rankedCache = new Map<string, { at: number; rows: RankedRow[]; pending: Promise<RankedRow[]> | null }>();
|
||||
// Serve complete snapshots while the read worker refreshes rankings off the tick thread.
|
||||
const RANKED_TTL_MS = Number(process.env.HISCORES_CACHE_MS ?? 300_000);
|
||||
type RankedCache = { at: number; rows: RankedRow[] | null; pending: Promise<RankedRow[]> | null };
|
||||
const rankedCache = new Map<string, RankedCache>();
|
||||
|
||||
// category 0 = overall (hiscore_large type 0), otherwise hiscore type = category
|
||||
async function getRankedList(profile: string, category: number): Promise<RankedRow[]> {
|
||||
export async function getRankedList(profile: string, category: number): Promise<RankedRow[]> {
|
||||
const key = `${profile}:${category}`;
|
||||
const now = Date.now();
|
||||
const cached = rankedCache.get(key);
|
||||
if (cached && now - cached.at < RANKED_TTL_MS) {
|
||||
return cached.rows;
|
||||
}
|
||||
if (cached?.pending) {
|
||||
return cached.pending;
|
||||
}
|
||||
const load = (async () => {
|
||||
let rows: RankedRow[];
|
||||
if (category === 0) {
|
||||
let query = db
|
||||
.selectFrom('hiscore_large')
|
||||
.innerJoin('account', 'account.id', 'hiscore_large.account_id')
|
||||
.select(['account.username', 'hiscore_large.level', 'hiscore_large.playtime'])
|
||||
.where('hiscore_large.type', '=', 0)
|
||||
.where('hiscore_large.profile', '=', profile)
|
||||
.where('account.staffmodlevel', '<=', 1)
|
||||
.orderBy('hiscore_large.level', 'desc')
|
||||
.orderBy('hiscore_large.playtime', 'asc');
|
||||
if (hiddenNames.length > 0) {
|
||||
query = query.where(eb => eb.not(eb(eb.fn('lower', ['account.username']), 'in', hiddenNames)));
|
||||
}
|
||||
rows = await query.execute();
|
||||
} else {
|
||||
let query = db
|
||||
.selectFrom('hiscore')
|
||||
.innerJoin('account', 'account.id', 'hiscore.account_id')
|
||||
.select(['account.username', 'hiscore.level', 'hiscore.playtime'])
|
||||
.where('hiscore.type', '=', category)
|
||||
.where('hiscore.profile', '=', profile)
|
||||
.where('account.staffmodlevel', '<=', 1)
|
||||
.orderBy('hiscore.level', 'desc')
|
||||
.orderBy('hiscore.playtime', 'asc');
|
||||
if (hiddenNames.length > 0) {
|
||||
query = query.where(eb => eb.not(eb(eb.fn('lower', ['account.username']), 'in', hiddenNames)));
|
||||
}
|
||||
rows = await query.execute();
|
||||
}
|
||||
rankedCache.set(key, { at: Date.now(), rows, pending: null });
|
||||
if (cached?.pending) return cached.rows ?? cached.pending;
|
||||
if (cached?.rows && Date.now() - cached.at < RANKED_TTL_MS) return cached.rows;
|
||||
const entry: RankedCache = { at: cached?.at ?? 0, rows: cached?.rows ?? null, pending: null };
|
||||
const load = loadRankedList(profile, category).then(rows => {
|
||||
entry.rows = rows;
|
||||
entry.at = Date.now();
|
||||
return rows;
|
||||
})();
|
||||
rankedCache.set(key, { at: cached?.at ?? 0, rows: cached?.rows ?? [], pending: load });
|
||||
try {
|
||||
return await load;
|
||||
} catch (err) {
|
||||
}).catch(error => {
|
||||
if (entry.rows) {
|
||||
// Keep a useful snapshot and back off for 30s after a failed refresh.
|
||||
entry.at = Date.now() - RANKED_TTL_MS + 30_000;
|
||||
console.warn('[hiscores] refresh failed:', error);
|
||||
return entry.rows;
|
||||
}
|
||||
rankedCache.delete(key);
|
||||
throw err;
|
||||
}
|
||||
throw error;
|
||||
}).finally(() => { entry.pending = null; });
|
||||
entry.pending = load;
|
||||
rankedCache.set(key, entry);
|
||||
return entry.rows ?? load;
|
||||
}
|
||||
|
||||
// rank of a player in a ranked list (1-based), or null when absent
|
||||
// One index per cached snapshot; player pages otherwise scan every ranked list.
|
||||
const rankIndexes = new WeakMap<RankedRow[], Map<string, number>>();
|
||||
function rankIn(rows: RankedRow[], username: string): number | null {
|
||||
const lower = username.toLowerCase();
|
||||
const idx = rows.findIndex(r => r.username.toLowerCase() === lower);
|
||||
return idx === -1 ? null : idx + 1;
|
||||
let ranks = rankIndexes.get(rows);
|
||||
if (!ranks) {
|
||||
ranks = new Map();
|
||||
rows.forEach((row, index) => {
|
||||
const name = row.username.toLowerCase();
|
||||
if (!ranks!.has(name)) ranks!.set(name, index + 1);
|
||||
});
|
||||
rankIndexes.set(rows, ranks);
|
||||
}
|
||||
return ranks.get(username.toLowerCase()) ?? null;
|
||||
}
|
||||
|
||||
// "Select hiscore table" sidebar shared by the list pages
|
||||
|
||||
@@ -50,7 +50,7 @@ import type { WebSocketData } from './websocket.js';
|
||||
const WORLD_FEED_VERSION = 1;
|
||||
const MAX_RECT_SIZE = 384;
|
||||
const MAX_BUFFERED_BYTES = 1 << 20;
|
||||
const ROSTER_INTERVAL_TICKS = 5;
|
||||
const ROSTER_INTERVAL_TICKS = 10;
|
||||
const MAX_CLIENT_MESSAGE_BYTES = 1024;
|
||||
const SLOW_FEED_MS = 20;
|
||||
|
||||
@@ -591,3 +591,16 @@ function send(ws: ServerWebSocket<WebSocketData>, msg: FeedRecord | string): voi
|
||||
subscribers.delete(ws);
|
||||
}
|
||||
}
|
||||
|
||||
// Map dashboards can share one immutable JSON snapshot for a second.
|
||||
let positionsAt = -Infinity;
|
||||
let positionsJson = '[]';
|
||||
export function getPlayerPositionsJson(now = Date.now()): string {
|
||||
if (now - positionsAt >= 1000 || now < positionsAt) {
|
||||
positionsJson = JSON.stringify([...World.playerLoop.all()].map(player => ({
|
||||
x: player.x, z: player.z, level: player.level, name: player.displayName
|
||||
})));
|
||||
positionsAt = now;
|
||||
}
|
||||
return positionsJson;
|
||||
}
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { Database } from 'bun:sqlite';
|
||||
import { db } from '../../src/db/query.js';
|
||||
import { loadRankedList } from '../../src/web/pages/RankedHiscores.js';
|
||||
import { handleHiscoresPlayerPage, getRankedList } from '../../src/web/pages/hiscores.js';
|
||||
|
||||
const database = new Database('db.sqlite');
|
||||
database.exec(`CREATE TABLE account(id INTEGER PRIMARY KEY,username TEXT,staffmodlevel INTEGER);
|
||||
CREATE TABLE hiscore(profile TEXT,account_id INTEGER,type INTEGER,level INTEGER,value INTEGER,playtime INTEGER);
|
||||
CREATE TABLE hiscore_large(profile TEXT,account_id INTEGER,type INTEGER,level INTEGER,value INTEGER,playtime INTEGER);
|
||||
INSERT INTO account VALUES(1,'Alice',0),(2,'Bob',0),(3,'hidden',0),(4,'staff',2);
|
||||
INSERT INTO hiscore_large VALUES('main',1,0,100,1,200),('main',2,0,100,1,100),('main',3,0,999,1,1),('main',4,0,999,1,1),('iron',1,0,200,1,1),('iron',2,0,100,1,1);
|
||||
INSERT INTO hiscore SELECT profile,account_id,1,level,value,playtime FROM hiscore_large;`);
|
||||
|
||||
const rank = async (name: string, profile = 'main') => {
|
||||
const response = await handleHiscoresPlayerPage(new URL(`http://localhost/hiscores/player/${name}?profile=${profile}`));
|
||||
assert.ok(response);
|
||||
const html = await response.text();
|
||||
return { status: response.status, rank: html.match(/>Overall<\/a><\/td>\s*<td align="right">(\d+)<\/td>/)?.[1] };
|
||||
};
|
||||
try {
|
||||
assert.equal((await rank('Alice')).rank, '2');
|
||||
assert.equal((await rank('Bob')).rank, '1');
|
||||
assert.equal((await rank('Alice', 'iron')).rank, '1');
|
||||
assert.equal((await rank('hidden')).status, 404);
|
||||
assert.equal((await rank('staff')).status, 404);
|
||||
database.exec("UPDATE hiscore_large SET level=300 WHERE account_id=1 AND profile='main'");
|
||||
assert.equal((await rank('Alice')).rank, '2', 'Existing cached snapshot remains consistent');
|
||||
const now = Date.now;
|
||||
const future = now() + 60001;
|
||||
try {
|
||||
Date.now = () => future;
|
||||
assert.equal((await rank('Alice')).rank, '2', 'Serve the old complete snapshot during refresh');
|
||||
for (let i = 0; i < 100 && (await rank('Alice')).rank !== '1'; i++) await Bun.sleep(10);
|
||||
assert.equal((await rank('Alice')).rank, '1', 'Background refresh builds a fresh rank index');
|
||||
assert.equal((await rank('Bob')).rank, '2');
|
||||
database.exec('DROP TABLE hiscore_large');
|
||||
await assert.rejects(loadRankedList('main', 0));
|
||||
Date.now = () => future + 60001;
|
||||
assert.equal((await getRankedList('main', 0))[0].username, 'Alice', 'Refresh failure retains the complete snapshot');
|
||||
await assert.rejects(loadRankedList('main', 0));
|
||||
assert.equal((await getRankedList('main', 0))[1].username, 'Bob');
|
||||
} finally {
|
||||
Date.now = now;
|
||||
}
|
||||
} finally {
|
||||
database.close();
|
||||
await db.destroy();
|
||||
}
|
||||
+143
@@ -17,11 +17,16 @@ const { default: NullClientSocket } = await import('../../src/server/NullClientS
|
||||
const { default: WSClientSocket } = await import('../../src/server/ws/WSClientSocket.js');
|
||||
const { default: TcpServer } = await import('../../src/server/tcp/TcpServer.js');
|
||||
const { websocketHandlers } = await import('../../src/web/websocket.js');
|
||||
const { PlayerLoading } = await import('../../src/engine/entity/PlayerLoading.js');
|
||||
const { default: ScriptProvider } = await import('../../src/engine/script/ScriptProvider.js');
|
||||
const { default: ScriptRunner } = await import('../../src/engine/script/ScriptRunner.js');
|
||||
const world = World as any;
|
||||
|
||||
afterEach(() => {
|
||||
for (const player of World.playerLoop.all()) player.unlink();
|
||||
World.newPlayers.clear();
|
||||
world.players.fill(undefined);
|
||||
world.shutdownTick = -1;
|
||||
world.loginThread.messages.length = 0;
|
||||
world.friendThread.messages.length = 0;
|
||||
});
|
||||
@@ -33,6 +38,144 @@ function player(client: ClientSocket) {
|
||||
return { p, logs };
|
||||
}
|
||||
|
||||
function queuedReconnects(count: number, onSave = () => {}) {
|
||||
const entries = [];
|
||||
for (let index = 0; index < count; index++) {
|
||||
const old = websocket();
|
||||
const next = websocket();
|
||||
old.client.state = next.client.state = 1;
|
||||
const name = `budget${index}`;
|
||||
const active = new NetworkPlayer(name, BigInt(index + 1), 1n, old.client);
|
||||
const pending = new NetworkPlayer(name, BigInt(index + 1), 1n, next.client);
|
||||
active.save = () => { onSave(); return Uint8Array.of(index); };
|
||||
active.onReconnect = () => {};
|
||||
active.addSessionLog = pending.addSessionLog = () => {};
|
||||
pending.reconnecting = true;
|
||||
World.playerLoop.add(1n, active);
|
||||
World.newPlayers.add(pending);
|
||||
entries.push({ active, pending, next });
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
test('a slow reconnect yields after one save and the remaining accounts drain without loss', () => {
|
||||
let elapsed = 0;
|
||||
const clock = spyOn(performance, 'now').mockImplementation(() => elapsed);
|
||||
const entries = queuedReconnects(185, () => { elapsed += 120; });
|
||||
const initialRate = World.tickRate;
|
||||
World.tickRate = 300;
|
||||
try {
|
||||
for (let remaining = 184; remaining >= 0; remaining--) {
|
||||
const before = elapsed;
|
||||
world.processLogins();
|
||||
expect(World.newPlayers.size).toBe(remaining);
|
||||
expect(elapsed - before).toBe(120);
|
||||
}
|
||||
expect(world.loginThread.messages).toHaveLength(185);
|
||||
for (const { active, next } of entries) {
|
||||
expect(active.client).toBe(next.client);
|
||||
expect(next.client.player).toBe(active);
|
||||
expect(next.sent).toEqual([[15]]);
|
||||
}
|
||||
} finally {
|
||||
World.tickRate = initialRate;
|
||||
clock.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
test('a logout burst retains deferred accounts and runs at most one slow checkpoint per phase', () => {
|
||||
let elapsed = 0;
|
||||
const clock = spyOn(performance, 'now').mockImplementation(() => elapsed);
|
||||
const entries = queuedReconnects(3);
|
||||
World.newPlayers.clear();
|
||||
for (const { active } of entries) {
|
||||
active.loggingOut = true;
|
||||
active.canAccess = () => true;
|
||||
active.closeModal = () => {};
|
||||
}
|
||||
const trigger = spyOn(ScriptProvider, 'getByTriggerSpecific').mockReturnValue({} as any);
|
||||
const init = spyOn(ScriptRunner, 'init').mockReturnValue({ pointerAdd() {} } as any);
|
||||
const execute = spyOn(ScriptRunner, 'execute').mockImplementation(() => {});
|
||||
const remove = spyOn(World, 'removePlayer').mockImplementation(p => { elapsed += 120; p.unlink(); });
|
||||
try {
|
||||
for (let remaining = 2; remaining >= 0; remaining--) {
|
||||
world.processLogouts();
|
||||
expect([...World.playerLoop.all()]).toHaveLength(remaining);
|
||||
for (const p of World.playerLoop.all()) expect(p.loggingOut).toBe(true);
|
||||
}
|
||||
expect(remove).toHaveBeenCalledTimes(3);
|
||||
const shutdownEntries = queuedReconnects(3);
|
||||
World.newPlayers.clear();
|
||||
for (const { active } of shutdownEntries) {
|
||||
active.canAccess = () => true;
|
||||
active.closeModal = () => {};
|
||||
}
|
||||
world.shutdownTick = 0;
|
||||
world.processLogouts();
|
||||
expect([...World.playerLoop.all()]).toHaveLength(0);
|
||||
expect(remove).toHaveBeenCalledTimes(6);
|
||||
} finally {
|
||||
clock.mockRestore(); trigger.mockRestore(); init.mockRestore(); execute.mockRestore(); remove.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
test('cheap reconnects respect the count limit and closed queued sockets are discarded', () => {
|
||||
const clock = spyOn(performance, 'now').mockReturnValue(0);
|
||||
const entries = queuedReconnects(12);
|
||||
entries[0].next.close();
|
||||
try {
|
||||
world.processLogins();
|
||||
expect(World.newPlayers.size).toBe(2);
|
||||
expect(world.loginThread.messages).toHaveLength(9);
|
||||
expect(entries[0].next.sent).toEqual([]);
|
||||
world.processLogins();
|
||||
expect(World.newPlayers.size).toBe(0);
|
||||
expect(world.loginThread.messages).toHaveLength(11);
|
||||
} finally {
|
||||
clock.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
test('a slow session takeover finishes admission before yielding, even at the IP cap', () => {
|
||||
let elapsed = 0;
|
||||
const clock = spyOn(performance, 'now').mockImplementation(() => elapsed);
|
||||
const entries = queuedReconnects(2);
|
||||
entries[0].pending.reconnecting = false;
|
||||
entries[0].active.slot = 1;
|
||||
world.players[1] = entries[0].active;
|
||||
const events: string[] = [];
|
||||
entries[0].active.closeModal = () => { events.push('close modal'); };
|
||||
entries[0].active.save = () => { events.push('save'); elapsed += 120; return Uint8Array.of(42); };
|
||||
entries[0].pending.staffModLevel = 1;
|
||||
const loaded = new NetworkPlayer(entries[0].active.username, entries[0].active.username37, 1n, entries[0].next.client);
|
||||
loaded.onLogin = () => { events.push('login'); };
|
||||
loaded.addSessionLog = () => {};
|
||||
const load = spyOn(PlayerLoading, 'load').mockImplementation((_name, packet) => {
|
||||
expect([...packet.data]).toEqual([42]);
|
||||
return loaded;
|
||||
});
|
||||
const remove = spyOn(World, 'removePlayerWithoutSave').mockImplementation(p => {
|
||||
events.push('remove');
|
||||
delete world.players[p.slot];
|
||||
p.unlink();
|
||||
});
|
||||
const zone = spyOn(World.gameMap, 'getZone').mockReturnValue({ enter() {} } as any);
|
||||
try {
|
||||
world.processLogins();
|
||||
expect(events).toEqual(['close modal', 'save', 'remove', 'login']);
|
||||
expect([...World.playerLoop.all()]).toContain(loaded);
|
||||
expect(world.players[loaded.slot]).toBe(loaded);
|
||||
expect(loaded.staffModLevel).toBe(1);
|
||||
expect(World.newPlayers.size).toBe(1);
|
||||
expect(entries[0].next.sent).toEqual([[2, 1, 1]]);
|
||||
world.processLogins();
|
||||
expect(World.newPlayers.size).toBe(0);
|
||||
expect(entries[1].next.sent).toEqual([[15]]);
|
||||
} finally {
|
||||
clock.mockRestore(); load.mockRestore(); remove.mockRestore(); zone.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
function websocket() {
|
||||
const client = new WSClientSocket();
|
||||
const sent: number[][] = [];
|
||||
|
||||
+62
-1
@@ -11,13 +11,14 @@ const { default: World } = await import('../../src/engine/World.js');
|
||||
const { NetworkPlayer } = await import('../../src/engine/entity/NetworkPlayer.js');
|
||||
const { default: NullClientSocket } = await import('../../src/server/NullClientSocket.js');
|
||||
const { Visibility } = await import('../../src/network/rsbuf/visibility.js');
|
||||
const { checkWorldFeedUpgrade, cycleWorldFeed, onWorldFeedClose, onWorldFeedMessage, onWorldFeedOpen } = await import('../../src/web/worldfeed.js');
|
||||
const { checkWorldFeedUpgrade, cycleWorldFeed, onWorldFeedClose, onWorldFeedMessage, onWorldFeedOpen, getPlayerPositionsJson } = await import('../../src/web/worldfeed.js');
|
||||
|
||||
type Message = { t: string; [key: string]: any };
|
||||
|
||||
const viewers: { close: () => void }[] = [];
|
||||
|
||||
afterEach(() => {
|
||||
World.zonesTracking.clear();
|
||||
for (const v of viewers.splice(0)) {
|
||||
v.close();
|
||||
}
|
||||
@@ -125,3 +126,63 @@ test('roster lists visible players every few ticks', () => {
|
||||
test('upgrade checks the token when one is configured', () => {
|
||||
expect(checkWorldFeedUpgrade(new URL('http://localhost/worldfeed'))).toBeUndefined();
|
||||
});
|
||||
|
||||
|
||||
test('map snapshots are shared for a second and roster work runs every ten ticks', () => {
|
||||
const player = addPlayer(22, 3210, 3210);
|
||||
const first = getPlayerPositionsJson(1000);
|
||||
player.x++;
|
||||
expect(getPlayerPositionsJson(1999)).toBe(first);
|
||||
expect(JSON.parse(getPlayerPositionsJson(2000))[0].x).toBe(3211);
|
||||
const v = viewer();
|
||||
onWorldFeedMessage(v.ws, JSON.stringify({ t: 'roster', on: true }));
|
||||
World.currentTick = 15;
|
||||
cycleWorldFeed();
|
||||
expect(v.sent.some(s => JSON.parse(s).t === 'roster')).toBe(false);
|
||||
World.currentTick = 20;
|
||||
cycleWorldFeed();
|
||||
expect(v.last().t).toBe('roster');
|
||||
});
|
||||
|
||||
test('zone membership stays cached until movement, map origin, plane or clear changes it', () => {
|
||||
const player = addPlayer(23, 3210, 3210);
|
||||
player.originX = player.x; player.originZ = player.z;
|
||||
const area = player.buildArea;
|
||||
let clears = 0;
|
||||
const clear = area.activeZones.clear.bind(area.activeZones);
|
||||
area.activeZones.clear = () => { clears++; clear(); };
|
||||
area.rebuildZones(); area.rebuildZones();
|
||||
player.x++; area.rebuildZones();
|
||||
expect(clears).toBe(1);
|
||||
player.x += 8; area.rebuildZones();
|
||||
player.originX += 8; area.rebuildZones();
|
||||
player.level++; area.rebuildZones();
|
||||
expect(clears).toBe(4);
|
||||
area.clear(false); area.rebuildZones();
|
||||
expect(clears).toBe(6);
|
||||
expect(area.activeZones.size).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('loaded unchanged zones skip writes, new zones and private events retain delivery', () => {
|
||||
const player = addPlayer(24, 3210, 3210);
|
||||
const zone = World.gameMap.getZone(3210, 3210, 0);
|
||||
player.buildArea.activeZones.add(zone.index);
|
||||
const written: any[] = [];
|
||||
player.write = message => { written.push(message); };
|
||||
player.updateZones();
|
||||
expect(written.length).toBeGreaterThan(0); // First sight always sends a full update.
|
||||
written.length = 0;
|
||||
player.updateZones();
|
||||
expect(written).toEqual([]);
|
||||
const mine = { marker: 'mine' }, theirs = { marker: 'theirs' };
|
||||
// ZoneEventType.FOLLOWS = 1; use actual zone filtering with capture-only output.
|
||||
(zone as any).events.add({ type: 1, receiver64: player.hash64, message: mine });
|
||||
(zone as any).events.add({ type: 1, receiver64: 999n, message: theirs });
|
||||
World.trackZone(zone);
|
||||
player.updateZones();
|
||||
expect(written).toContain(mine);
|
||||
expect(written).not.toContain(theirs);
|
||||
zone.reset();
|
||||
player.buildArea.activeZones.clear(); player.updateZones();
|
||||
expect(player.buildArea.loadedZones.size).toBe(0);
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
import { expect, test } from 'bun:test';
|
||||
import { Database } from 'bun:sqlite';
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
test('ranking indexes preserve results and avoid scanning other skills and sorting', () => {
|
||||
const db = new Database(':memory:');
|
||||
try {
|
||||
db.exec(`CREATE TABLE account(id INTEGER PRIMARY KEY, username TEXT, staffmodlevel INTEGER);
|
||||
CREATE TABLE hiscore(profile TEXT, account_id INTEGER, type INTEGER, level INTEGER, playtime INTEGER, PRIMARY KEY(profile,account_id,type));
|
||||
CREATE TABLE hiscore_large(profile TEXT, account_id INTEGER, type INTEGER, level INTEGER, playtime INTEGER, PRIMARY KEY(profile,account_id,type));
|
||||
INSERT INTO account VALUES(1,'slower',0),(2,'faster',0),(3,'lower',0),(4,'staff',2);
|
||||
INSERT INTO hiscore VALUES('main',1,1,99,200),('main',2,1,99,100),('main',3,1,50,1),('main',4,1,100,1),('main',1,2,100,1),('iron',3,1,100,1);
|
||||
INSERT INTO hiscore_large SELECT * FROM hiscore;`);
|
||||
const query = (table: string) => `SELECT account.username, h.level, h.playtime FROM ${table} h JOIN account ON account.id=h.account_id WHERE h.profile='main' AND h.type=1 AND account.staffmodlevel<=1 ORDER BY h.level DESC,h.playtime ASC`;
|
||||
const before = ['hiscore', 'hiscore_large'].map(table => db.query(query(table)).all());
|
||||
db.exec(readFileSync(join(import.meta.dir, '../prisma/singleworld/migrations/20261011000000_hiscore_rank_indexes/migration.sql'), 'utf8'));
|
||||
for (const [index, table] of ['hiscore', 'hiscore_large'].entries()) {
|
||||
expect(db.query(query(table)).all()).toEqual(before[index]);
|
||||
const plan = JSON.stringify(db.query('EXPLAIN QUERY PLAN ' + query(table)).all());
|
||||
expect(plan).toContain(`COVERING INDEX ${table}_rank`);
|
||||
expect(plan).toContain('profile=? AND type=?');
|
||||
expect(plan).not.toContain('TEMP B-TREE');
|
||||
}
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('cached ranks remain correct across profiles, exclusions and cache refresh', () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'ranked-hiscores-'));
|
||||
try {
|
||||
const result = Bun.spawnSync([process.execPath, join(import.meta.dir, 'fixtures/ranked-hiscores.ts')], {
|
||||
cwd: dir,
|
||||
env: { ...process.env, DB_BACKEND: 'sqlite', HISCORES_HIDDEN_NAMES: 'hidden', HISCORES_CACHE_MS: '60000' },
|
||||
});
|
||||
if (result.exitCode !== 0) throw new Error(result.stdout.toString() + result.stderr.toString());
|
||||
expect(result.exitCode).toBe(0);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
test('ranking requests wait for a worker with delayed startup to install its handler', () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'ranked-startup-'));
|
||||
try {
|
||||
writeFileSync(join(dir, 'RankedHiscores.ts'), readFileSync(join(import.meta.dir, '../src/web/pages/RankedHiscores.ts')));
|
||||
writeFileSync(join(dir, 'RankedHiscoresWorker.ts'), `
|
||||
import { parentPort } from 'node:worker_threads';
|
||||
let listening = false;
|
||||
parentPort.on('message', ({ id, profile }) => {
|
||||
if (listening) parentPort.postMessage({ id, rows: [{ username: profile, level: 1, playtime: 0 }] });
|
||||
});
|
||||
setTimeout(() => { listening = true; parentPort.postMessage({ ready: true }); }, 100);
|
||||
`);
|
||||
writeFileSync(join(dir, 'check.ts'), `
|
||||
import assert from 'node:assert/strict';
|
||||
import { loadRankedList } from './RankedHiscores.ts';
|
||||
const timeout = setTimeout(() => process.exit(1), 2000);
|
||||
const rows = await Promise.all([loadRankedList('a', 0), loadRankedList('b', 1)]);
|
||||
assert.deepEqual(rows.map(r => r[0].username), ['a', 'b']);
|
||||
clearTimeout(timeout);
|
||||
`);
|
||||
const result = Bun.spawnSync([process.execPath, join(dir, 'check.ts')]);
|
||||
expect(result.exitCode).toBe(0);
|
||||
} finally { rmSync(dir, { recursive: true, force: true }); }
|
||||
});
|
||||
@@ -11,7 +11,7 @@ test('reconnect transfers ownership and stale socket closes cannot detach the re
|
||||
copyFileSync(join(import.meta.dir, '../data/config/private.pem'), join(dir, 'data/config/private.pem'));
|
||||
const result = Bun.spawnSync([process.execPath, 'test', join(import.meta.dir, 'fixtures/reconnect-lifecycle.ts')], {
|
||||
cwd: dir,
|
||||
env: { ...process.env, GE_DATABASE: join(dir, 'market.sqlite') }
|
||||
env: { ...process.env, GE_DATABASE: join(dir, 'market.sqlite'), MAX_PLAYERS_PER_IP: '1' }
|
||||
});
|
||||
if (result.exitCode !== 0) {
|
||||
throw new Error(result.stdout.toString() + result.stderr.toString());
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { expect, test } from 'bun:test';
|
||||
import { loginJitterMs } from './LoginBackoff.js';
|
||||
|
||||
test('fleet delays spread attempts and respect disabled, invalid and capped settings', () => {
|
||||
expect([0, 0.25, 0.75].map(value => loginJitterMs(5000, () => value))).toEqual([0, 1250, 3750]);
|
||||
expect(loginJitterMs(0, () => 0.5)).toBe(0);
|
||||
expect(loginJitterMs(-1, () => 0.5)).toBe(0);
|
||||
expect(loginJitterMs(NaN, () => 0.5)).toBe(2500);
|
||||
expect(loginJitterMs(1e9, () => 0.5)).toBe(30000);
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
/** Bounded jitter shared by initial remote logins and fleet retry loops. */
|
||||
export function loginJitterMs(maxMs: number, random = Math.random): number {
|
||||
const bounded = Number.isFinite(maxMs) ? Math.max(0, Math.min(60_000, maxMs)) : 5000;
|
||||
return Math.floor(random() * bounded);
|
||||
}
|
||||
@@ -13,6 +13,7 @@
|
||||
// quiet without closing. A single bad packet is not an ending; see handlePacket.
|
||||
|
||||
import './dom-shim.js';
|
||||
import { loginJitterMs } from './LoginBackoff.js';
|
||||
|
||||
import { invalidateCache, loadCache } from './cache.js';
|
||||
import { CLIENT_VERSION, GameConnection, LoginError, OutdatedClientError } from './net/GameConnection.js';
|
||||
@@ -33,6 +34,8 @@ const KEEPALIVE_CYCLES = 50;
|
||||
const IDLE_CYCLES = 3000; // ~60s, i.e. ~200 missed server ticks
|
||||
|
||||
export interface SessionOptions {
|
||||
/** Spread remote fleet starts over 5s by default; 0 disables the delay. */
|
||||
loginJitterMs?: number;
|
||||
host: string;
|
||||
username: string;
|
||||
password: string;
|
||||
@@ -115,14 +118,16 @@ export async function startSession(opts: SessionOptions): Promise<LiteSession> {
|
||||
let { checksums } = await loadCache({ origin, cacheDir, members: opts.members, quiet: opts.quiet });
|
||||
let locIndex = await LocIndex.load({ origin, cacheDir, versionlistCrc: checksums[5], quiet: opts.quiet });
|
||||
|
||||
const connect = () =>
|
||||
GameConnection.connect({
|
||||
const connect = async () => {
|
||||
await Bun.sleep(loginJitterMs(opts.loginJitterMs ?? Number(process.env.LITE_LOGIN_JITTER_MS ?? (secured ? 5000 : 0))));
|
||||
return GameConnection.connect({
|
||||
host: opts.host,
|
||||
secured,
|
||||
username: opts.username,
|
||||
password: opts.password,
|
||||
checksums
|
||||
});
|
||||
};
|
||||
|
||||
let connection: GameConnection;
|
||||
try {
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
// of the gold, and the report marks anything that is currently offline.
|
||||
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { loginJitterMs } from './LoginBackoff.js';
|
||||
|
||||
import { OutdatedClientError } from './net/GameConnection.js';
|
||||
import { startSession, type LiteSession, type SessionEnd } from './session.js';
|
||||
@@ -399,7 +400,7 @@ function exitIfOutdated(e: unknown): void {
|
||||
async function relogin(bot: PickpocketBot): Promise<void> {
|
||||
let delay = RELOGIN_MS;
|
||||
while (!shuttingDown) {
|
||||
await Bun.sleep(delay);
|
||||
await Bun.sleep(RELOGIN_MS + loginJitterMs(Math.max(1000, delay - RELOGIN_MS)));
|
||||
if (shuttingDown) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -47,7 +47,8 @@ export class MapView extends GameShell {
|
||||
playerPositions: {x: number, z: number, level: number, name: string}[] = [];
|
||||
playerTrails: Map<string, {x: number, z: number, time: number}[]> = new Map();
|
||||
lastPlayerFetch: number = 0;
|
||||
readonly playerPollInterval: number = 750;
|
||||
readonly playerPollInterval: number = 1500;
|
||||
private playerFetchPending = false;
|
||||
readonly maxTrailLength: number = 2000;
|
||||
readonly maxTrailAge: number = 1800000;
|
||||
|
||||
@@ -837,7 +838,7 @@ export class MapView extends GameShell {
|
||||
|
||||
// custom: poll player positions
|
||||
const now: number = performance.now();
|
||||
if (MapView.shouldDrawPlayers && now - this.lastPlayerFetch > this.playerPollInterval) {
|
||||
if (!document.hidden && !this.playerFetchPending && MapView.shouldDrawPlayers && now - this.lastPlayerFetch > this.playerPollInterval) {
|
||||
this.lastPlayerFetch = now;
|
||||
this.fetchPlayerPositions();
|
||||
}
|
||||
@@ -2041,14 +2042,16 @@ export class MapView extends GameShell {
|
||||
|
||||
// custom: fetch player positions from server
|
||||
fetchPlayerPositions(): void {
|
||||
fetch('/playerpositions')
|
||||
this.playerFetchPending = true;
|
||||
fetch('/playerpositions', { signal: AbortSignal.timeout(5000) })
|
||||
.then(res => res.json())
|
||||
.then((data: {x: number, z: number, level: number, name: string}[]) => {
|
||||
this.playerPositions = data;
|
||||
this.updateTrails(data);
|
||||
this.redraw = true;
|
||||
})
|
||||
.catch(() => {});
|
||||
.catch(() => {})
|
||||
.finally(() => { this.playerFetchPending = false; });
|
||||
}
|
||||
|
||||
// custom: fetch and pre-render long-term anonymous traces
|
||||
|
||||
Reference in new issue
Block a user