mirror of
https://github.com/bdnugget/rs-sdk.git
synced 2026-11-07 09:07:30 +00:00
feat(hiscores): replace King of the Hill with a Lava Maze runite leaderboard
Rip out KOTH (Koth.ts, the per-minute World hook, koth_capture logging and table, /hiscores/koth) for simplicity. In its place, the mining script calls a new runite_mined(coord) command whenever it gives runite ore; the engine counts only the two Lava Maze rocks (0_3059_3885, 0_3060_3884) and relays a runite_mine row through the logger pipeline. /hiscores/runite ranks ore mined (all time / week / today) under an in-game shot of the rocks. No backfill. Migration 20260926000000_runite_mine drops koth_capture. The server-side player sprites move to the Equipment board: the login server stores each outfit's resolved appearance slots in the new hiscore_outfit.appearance column, the top 10 rows show /sprite/player images, and item icons use /sprite/item instead of the in-browser viewer. Rows without an appearance get one on the player's next save. The sidebar link list shared by the hiscores pages is now one helper. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
4c2069cc0c
commit
aa4bb5dbbf
23 files changed
+237
-541
No files matched your search
+19
-10
@@ -86,15 +86,24 @@ survival) is described in the project memory; this file is the human-readable ch
|
||||
- [ ] **`[LOGOUT DEBUG]` instrumentation** — console.warn breadcrumbs in
|
||||
`NetworkPlayer.ts`, `IdleTimerHandler.ts`, `ClientCheatHandler.ts`, `PlayerOps.ts`,
|
||||
`World.ts` (and webclient `Client.ts`). Low-stakes but useful; fine to re-add lazily.
|
||||
- [ ] **King of the Hill (Demonic Ruins)** — `src/engine/Koth.ts` (rs-sdk-only file; wall
|
||||
polygon traced from `m51_60.jm2`, one capture per wall-clock minute) + hook in
|
||||
`World.ts` `cycle()` (`Koth.cycle` → `koth_capture` postMessage), relay cases in
|
||||
`LoggerThread.ts`/`LoggerClient.ts`/`LoggerServer.ts`, `koth_capture` table (both
|
||||
prisma schemas + `db/types.ts`), `/hiscores/koth` page in `web/pages/hiscores.ts`
|
||||
(registered in `web/index.ts` + `web/hiscoresServer.ts`), player-sprite renderer in
|
||||
webclient `src/viewer/ItemViewer.ts` (`renderPlayerSpriteAsImageData`). Depends on
|
||||
the logger pipeline (EASY_STARTUP or a standalone `bun run logger`) — events are
|
||||
silently dropped without it.
|
||||
- [ ] **Lava Maze runite hiscores** — content `mining.rs2` (`get_ore_normal`) calls the
|
||||
`runite_mined(loc_coord)` command (declared in `engine.rs2`) whenever it gives runite
|
||||
ore; `RUNITE_MINED` opcode (10011) in `ScriptOpcode.ts`/`ScriptOpcodePointers.ts`, handler
|
||||
in `DebugOps.ts` counts only the two Lava Maze rocks (0_3059_3885, 0_3060_3884, from
|
||||
`m47_60.jm2`) → `World.notifyRuniteMined` → `runite_mine` relay cases in
|
||||
`LoggerThread.ts`/`LoggerClient.ts`/`LoggerServer.ts`, `runite_mine` table (both prisma
|
||||
schemas + `db/types.ts`), `/hiscores/runite` page in `web/pages/hiscores.ts`
|
||||
(registered in `web/index.ts` + `web/hiscoresServer.ts`). Depends on the logger
|
||||
pipeline (EASY_STARTUP or a standalone `bun run logger`) — events are silently
|
||||
dropped without it. Replaced King of the Hill (migration `20260926000000_runite_mine`
|
||||
drops `koth_capture`).
|
||||
Verify: `grep -n "runite_mined" ../content/scripts/skill_mining/scripts/mining.rs2 && grep -n "RUNITE_MINED" src/engine/script/handlers/DebugOps.ts`
|
||||
- [ ] **Equipment hiscores sprites** — `LoginServer.ts` `outfitAppearance()` stores the worn
|
||||
outfit's resolved appearance slots (wearpos2/3 masking needs obj config) in
|
||||
`hiscore_outfit.appearance` (migration `20260926010000_hiscore_outfit_appearance`) on every
|
||||
outfit hiscore write; `/hiscores/outfit` shows a `/sprite/player` image for the top 10 and
|
||||
`/sprite/item` icons (no in-browser viewer bundle). Sprites come from `web/sprites/`
|
||||
(worker hosting webclient `src/viewer/ItemViewer.ts` `renderPlayerSprite`).
|
||||
- [ ] **Worker-thread crash hardening** — `src/server/InternalClient.ts` uses persistent
|
||||
`.on('close'/'error')` handlers instead of `.once()` (a second ws error after a
|
||||
successful open was an unhandled EventEmitter 'error' that killed the worker → every
|
||||
@@ -210,7 +219,7 @@ survival) is described in the project memory; this file is the human-readable ch
|
||||
- [ ] **`src/dash3d/LoopCycle.ts`** (rs-sdk-only) — the frame counter split out of Client;
|
||||
`Client.loopCycle` is now a static getter/setter over it and
|
||||
`ClientPlayer.ts` reads `LoopCycle.value` instead of importing Client. Required so
|
||||
the viewer bundle (which imports ClientPlayer for KOTH character sprites) doesn't
|
||||
the viewer bundle (which imports ClientPlayer for character sprites) doesn't
|
||||
drag the whole game client (and its node-only `open` dep) in and fail to build.
|
||||
|
||||
### Client.ts bot SDK surface (~1,450 added lines inside upstream's `src/client/Client.ts`)
|
||||
|
||||
@@ -1064,3 +1064,6 @@
|
||||
|
||||
// rs-sdk: physical Grand Exchange booth interaction.
|
||||
[command,ge_open](coord $counter)
|
||||
|
||||
// rs-sdk: runite ore mined from a rock; the engine counts the Lava Maze rocks toward the hiscores
|
||||
[command,runite_mined](coord $rock)
|
||||
@@ -140,6 +140,7 @@ if (random($chance) = ^true) {
|
||||
$output = perfect_gold_ore;
|
||||
}
|
||||
inv_add(inv, $output, 1);
|
||||
if ($output = runite_ore) runite_mined(loc_coord); // rs-sdk: Lava Maze runite hiscores
|
||||
stat_advance(mining, db_getfield($data, mining_table:rock_exp, 0));
|
||||
mes("You manage to mine some <db_getfield($data, mining_table:ore_name, 0)>.");
|
||||
return;
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
-- DropTable
|
||||
DROP TABLE `koth_capture`;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `runite_mine` (
|
||||
`id` INTEGER NOT NULL AUTO_INCREMENT,
|
||||
`timestamp` DATETIME(3) NOT NULL,
|
||||
`profile` VARCHAR(191) NOT NULL DEFAULT 'main',
|
||||
`username` VARCHAR(191) NOT NULL,
|
||||
|
||||
INDEX `runite_mine_timestamp_idx`(`timestamp`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE `hiscore_outfit` ADD COLUMN `appearance` TEXT NULL;
|
||||
|
||||
@@ -75,6 +75,7 @@ model hiscore_outfit {
|
||||
profile String @default("main")
|
||||
value Int
|
||||
items String
|
||||
appearance String? @db.Text
|
||||
date DateTime @default(now()) @updatedAt
|
||||
|
||||
@@id([account_id, profile])
|
||||
@@ -150,18 +151,13 @@ model session_wealth {
|
||||
recipient_value Int?
|
||||
}
|
||||
|
||||
model koth_capture {
|
||||
model runite_mine {
|
||||
id Int @id @default(autoincrement())
|
||||
|
||||
timestamp DateTime
|
||||
profile String @default("main")
|
||||
username String
|
||||
|
||||
combat_level Int
|
||||
contenders Int
|
||||
loadout String @db.Text
|
||||
|
||||
@@index([username, timestamp])
|
||||
@@index([timestamp])
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
-- DropTable
|
||||
PRAGMA foreign_keys=off;
|
||||
DROP TABLE "koth_capture";
|
||||
PRAGMA foreign_keys=on;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "runite_mine" (
|
||||
"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
|
||||
"timestamp" DATETIME NOT NULL,
|
||||
"profile" TEXT NOT NULL DEFAULT 'main',
|
||||
"username" TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "runite_mine_timestamp_idx" ON "runite_mine"("timestamp");
|
||||
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "hiscore_outfit" ADD COLUMN "appearance" TEXT;
|
||||
|
||||
@@ -75,6 +75,7 @@ model hiscore_outfit {
|
||||
account_id Int
|
||||
value Int
|
||||
items String
|
||||
appearance String?
|
||||
date DateTime @default(now()) @updatedAt
|
||||
|
||||
@@id([profile, account_id])
|
||||
@@ -198,18 +199,13 @@ model player_skills_log {
|
||||
@@index([username, timestamp])
|
||||
}
|
||||
|
||||
model koth_capture {
|
||||
model runite_mine {
|
||||
id Int @id @default(autoincrement())
|
||||
|
||||
timestamp DateTime
|
||||
profile String @default("main")
|
||||
username String
|
||||
|
||||
combat_level Int
|
||||
contenders Int
|
||||
loadout String
|
||||
|
||||
@@index([username, timestamp])
|
||||
@@index([timestamp])
|
||||
}
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
@@ -120,6 +120,7 @@ export type hiscore_outfit = {
|
||||
profile: Generated<string>;
|
||||
value: number;
|
||||
items: string;
|
||||
appearance: string | null;
|
||||
date: Generated<Timestamp>;
|
||||
};
|
||||
export type hiscore_bank = {
|
||||
@@ -151,14 +152,11 @@ export type player_telemetry_segment = {
|
||||
sample_count: number;
|
||||
data: Buffer;
|
||||
};
|
||||
export type koth_capture = {
|
||||
export type runite_mine = {
|
||||
id: Generated<number>;
|
||||
timestamp: Timestamp;
|
||||
profile: Generated<string>;
|
||||
username: string;
|
||||
combat_level: number;
|
||||
contenders: number;
|
||||
loadout: string;
|
||||
};
|
||||
export type player_skills_log = {
|
||||
id: Generated<number>;
|
||||
@@ -178,13 +176,13 @@ export type DB = {
|
||||
ignorelist: ignorelist;
|
||||
input_report: input_report;
|
||||
ipban: ipban;
|
||||
koth_capture: koth_capture;
|
||||
player_telemetry: player_telemetry;
|
||||
player_telemetry_segment: player_telemetry_segment;
|
||||
player_skills_log: player_skills_log;
|
||||
private_chat: private_chat;
|
||||
public_chat: public_chat;
|
||||
report: report;
|
||||
runite_mine: runite_mine;
|
||||
session: session;
|
||||
session_log: session_log;
|
||||
session_wealth: session_wealth;
|
||||
|
||||
@@ -1,189 +0,0 @@
|
||||
import InvType from '#/cache/config/InvType.js';
|
||||
import ObjType from '#/cache/config/ObjType.js';
|
||||
import Player from '#/engine/entity/Player.js';
|
||||
import { Visibility } from '#/network/rsbuf/index.js';
|
||||
import Environment from '#/util/Environment.js';
|
||||
|
||||
export type KothCaptureEvent = {
|
||||
timestamp: number;
|
||||
profile: string;
|
||||
username: string;
|
||||
combat_level: number;
|
||||
contenders: number;
|
||||
loadout: string;
|
||||
};
|
||||
|
||||
// King of the Hill at the Demonic Ruins (level ~46 wilderness, mapsquare 51_60).
|
||||
// The polygon is traced from the egyptian_wall_2 / egypt_crumblywall locs in
|
||||
// server/content/maps/m51_60.jm2 — the L-shaped main enclosure plus the
|
||||
// cross-shaped western annex and its corridor. Every edge follows the INTERIOR
|
||||
// face of its wall line, so tiles on the outside face of a wall (e.g. hugging
|
||||
// the north wall from z3892, or the east wall from x3293) never count.
|
||||
// Vertices are tile-corner coords; the final edge back to the first vertex is the
|
||||
// crumbled diagonal at the southwest corner.
|
||||
const KOTH_LEVEL: number = 0;
|
||||
const KOTH_POLYGON: [number, number][] = [
|
||||
[3286.5, 3879], // SW, south wall start (z3879 wall tiles are interior-side, they count)
|
||||
[3290, 3879], // SE of the southern chamber, at the x3290 wall's interior face
|
||||
[3290, 3883], // up the x3290 wall to the z3883 wall row
|
||||
[3293, 3883], // east along the z3883 wall's interior face (SE cut pocket is outside)
|
||||
[3293, 3892], // up the x3293 east wall's interior face (z3885-3886 door tiles excluded)
|
||||
[3287, 3892], // west along the z3892 north wall's interior face
|
||||
[3287, 3889], // down the x3287 wall's interior face (NW door tile 3286,3889 excluded)
|
||||
[3285, 3889], // west along the z3889 wall (tiles z3888 south of it count)
|
||||
[3285, 3887], // down to the corridor's NE corner
|
||||
[3283, 3887], // west along the corridor's north wall (x3283-3284/z3886 inside)
|
||||
[3283, 3888], // up the annex's NE inner corner
|
||||
[3281, 3888], // west along the annex's north stub (x3281-3282/z3887 inside)
|
||||
[3281, 3887], // down the north stub's west wall
|
||||
[3280, 3887], // step out to the annex's west arm
|
||||
[3280, 3885], // down the x3280 west wall
|
||||
[3281, 3885], // east under the west arm
|
||||
[3281, 3884], // down the south stub's west wall
|
||||
[3283, 3884], // east along the annex's south stub (x3281-3282/z3884 inside)
|
||||
[3283, 3885], // up the annex's SE inner corner
|
||||
[3285, 3885], // east along the corridor's south wall, rejoining the x3285 line
|
||||
[3285, 3880.5] // down to the SW diagonal; half-tile offsets keep the (unwalkable)
|
||||
// diagonal wall tiles inside while the corner notch tile (3285,3879) tests outside
|
||||
];
|
||||
|
||||
const CAPTURE_INTERVAL_MS: number = 60_000;
|
||||
|
||||
// crown announcements are local, not server-wide: only players within 3 zones
|
||||
// (3 x 8 tiles, Chebyshev) of the ruins hear about a new king
|
||||
const ANNOUNCE_CENTER_X: number = 3289;
|
||||
const ANNOUNCE_CENTER_Z: number = 3886;
|
||||
const ANNOUNCE_RADIUS: number = 3 * 8;
|
||||
|
||||
// tile-corner-space even-odd ray cast; tiles are tested at their center (x+0.5, z+0.5)
|
||||
// so points never land exactly on polygon edges
|
||||
function polygonContains(px: number, pz: number): boolean {
|
||||
let inside: boolean = false;
|
||||
for (let i = 0, j = KOTH_POLYGON.length - 1; i < KOTH_POLYGON.length; j = i++) {
|
||||
const [xi, zi] = KOTH_POLYGON[i];
|
||||
const [xj, zj] = KOTH_POLYGON[j];
|
||||
if (zi > pz !== zj > pz && px < ((xj - xi) * (pz - zi)) / (zj - zi) + xi) {
|
||||
inside = !inside;
|
||||
}
|
||||
}
|
||||
return inside;
|
||||
}
|
||||
|
||||
class Koth {
|
||||
currentHolder: string | null = null;
|
||||
holderSince: number = 0;
|
||||
private nextCaptureMs: number = 0;
|
||||
|
||||
inZone(player: Player): boolean {
|
||||
return player.level === KOTH_LEVEL && polygonContains(player.x + 0.5, player.z + 0.5);
|
||||
}
|
||||
|
||||
private eligible(player: Player): boolean {
|
||||
return this.inZone(player) && player.staffModLevel <= 1 && player.visibility === Visibility.DEFAULT;
|
||||
}
|
||||
|
||||
// snapshot the winner's visual appearance as 12 resolved protocol slots
|
||||
// (0 = empty, 0x100+idk = identity kit, 0x200+obj = worn item), mirroring
|
||||
// Player.generateAppearance — resolution must happen here because wearpos2/3
|
||||
// masking and idk fallback need engine config the web/viewer layers don't have
|
||||
private buildLoadout(player: Player): string {
|
||||
const worn = player.getInventory(InvType.WORN);
|
||||
|
||||
const skipped: number[] = [];
|
||||
if (worn) {
|
||||
for (let i = 0; i < worn.capacity; i++) {
|
||||
const equip = worn.get(i);
|
||||
if (!equip) {
|
||||
continue;
|
||||
}
|
||||
const config = ObjType.get(equip.id);
|
||||
if (config.wearpos2 !== -1 && !skipped.includes(config.wearpos2)) {
|
||||
skipped.push(config.wearpos2);
|
||||
}
|
||||
if (config.wearpos3 !== -1 && !skipped.includes(config.wearpos3)) {
|
||||
skipped.push(config.wearpos3);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const slots: number[] = [];
|
||||
for (let slot = 0; slot < 12; slot++) {
|
||||
if (skipped.includes(slot)) {
|
||||
slots.push(0);
|
||||
continue;
|
||||
}
|
||||
const equip = worn ? worn.get(slot) : null;
|
||||
if (equip) {
|
||||
slots.push(0x200 + equip.id);
|
||||
continue;
|
||||
}
|
||||
const appearanceValue = player.getAppearanceInSlot(slot);
|
||||
slots.push(appearanceValue >= 1 ? appearanceValue : 0);
|
||||
}
|
||||
|
||||
return JSON.stringify({
|
||||
gender: player.gender,
|
||||
colors: player.colors,
|
||||
slots
|
||||
});
|
||||
}
|
||||
|
||||
// called every tick; awards at most one point per wall-clock minute (tick counting
|
||||
// would drift: dev runs 400ms ticks, prod 300ms, and overload shortens catch-up ticks)
|
||||
cycle(players: IterableIterator<Player>): KothCaptureEvent | null {
|
||||
const now: number = Date.now();
|
||||
if (now < this.nextCaptureMs) {
|
||||
return null;
|
||||
}
|
||||
this.nextCaptureMs = now + CAPTURE_INTERVAL_MS;
|
||||
|
||||
const contenders: Player[] = [];
|
||||
const nearby: Player[] = [];
|
||||
for (const player of players) {
|
||||
if (player.level === KOTH_LEVEL && Math.max(Math.abs(player.x - ANNOUNCE_CENTER_X), Math.abs(player.z - ANNOUNCE_CENTER_Z)) <= ANNOUNCE_RADIUS) {
|
||||
nearby.push(player);
|
||||
}
|
||||
if (this.eligible(player)) {
|
||||
contenders.push(player);
|
||||
}
|
||||
}
|
||||
if (contenders.length === 0) {
|
||||
// an empty hill has no king: nobody scores, and the previous holder also
|
||||
// loses the tie-break advantage until they retake it
|
||||
this.currentHolder = null;
|
||||
return null;
|
||||
}
|
||||
|
||||
let maxCombat: number = 0;
|
||||
for (const player of contenders) {
|
||||
if (player.combatLevel > maxCombat) {
|
||||
maxCombat = player.combatLevel;
|
||||
}
|
||||
}
|
||||
const tied: Player[] = contenders.filter(p => p.combatLevel === maxCombat);
|
||||
|
||||
let winner: Player | undefined = tied.find(p => p.username === this.currentHolder);
|
||||
if (!winner) {
|
||||
winner = tied[Math.floor(Math.random() * tied.length)];
|
||||
}
|
||||
|
||||
if (winner.username !== this.currentHolder) {
|
||||
this.currentHolder = winner.username;
|
||||
this.holderSince = now;
|
||||
for (const player of nearby) {
|
||||
player.messageGame(`${winner.displayName} now controls the ruins.`);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
timestamp: now,
|
||||
profile: Environment.node.profile,
|
||||
username: winner.username,
|
||||
combat_level: winner.combatLevel,
|
||||
contenders: contenders.length,
|
||||
loadout: this.buildLoadout(winner)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default new Koth();
|
||||
@@ -104,7 +104,6 @@ import VarBitType from '#/cache/config/VarBitType.js';
|
||||
import FriendlistLoaded from '#/network/game/server/model/FriendlistLoaded.js';
|
||||
import HashTable from '#/datastruct/HashTable.js';
|
||||
import Midi from '#/cache/midi/Midi.js';
|
||||
import Koth from '#/engine/Koth.js';
|
||||
|
||||
const priv = forge.pki.privateKeyFromPem(fs.readFileSync('data/config/private.pem', 'ascii'));
|
||||
|
||||
@@ -491,16 +490,6 @@ class World {
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
const kothEvent = Koth.cycle(this.playerLoop.all());
|
||||
if (kothEvent) {
|
||||
this.loggerThread.postMessage({
|
||||
type: 'koth_capture',
|
||||
event: kothEvent
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (this.pendingTelemetry.length > 0 && tick % World.PLAYER_TELEMETRYRATE === 0) {
|
||||
this.loggerThread.postMessage({
|
||||
type: 'player_telemetry',
|
||||
@@ -2738,6 +2727,18 @@ class World {
|
||||
});
|
||||
}
|
||||
|
||||
// rs-sdk: one row per runite ore mined from a leaderboard rock (hiscores /runite)
|
||||
notifyRuniteMined(player: Player) {
|
||||
this.loggerThread.postMessage({
|
||||
type: 'runite_mine',
|
||||
event: {
|
||||
timestamp: Date.now(),
|
||||
profile: Environment.node.profile,
|
||||
username: player.username
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
submitInputTracking(player: Player, buf: Uint8Array) {
|
||||
this.loggerThread.postMessage({
|
||||
type: 'input_track',
|
||||
|
||||
@@ -448,7 +448,8 @@ export const enum ScriptOpcode {
|
||||
MAP_RANDOM_EVENTS,
|
||||
GETTIMESPENT, // custom: used to profile script execution (current duration)
|
||||
TIMESPENT, // custom: used to profile script execution (record start time)
|
||||
GE_OPEN = 10010 // rs-sdk: native physical Grand Exchange
|
||||
GE_OPEN = 10010, // rs-sdk: native physical Grand Exchange
|
||||
RUNITE_MINED = 10011 // rs-sdk: lava maze runite leaderboard
|
||||
}
|
||||
|
||||
export const ScriptOpcodeMap: Map<string, number> = new Map([
|
||||
@@ -870,6 +871,7 @@ export const ScriptOpcodeMap: Map<string, number> = new Map([
|
||||
['ERROR', ScriptOpcode.ERROR],
|
||||
['MAP_RANDOM_EVENTS', ScriptOpcode.MAP_RANDOM_EVENTS],
|
||||
['GE_OPEN', ScriptOpcode.GE_OPEN],
|
||||
['RUNITE_MINED', ScriptOpcode.RUNITE_MINED],
|
||||
['GETTIMESPENT', ScriptOpcode.GETTIMESPENT],
|
||||
['TIMESPENT', ScriptOpcode.TIMESPENT]
|
||||
]);
|
||||
|
||||
@@ -999,6 +999,11 @@ const ScriptOpcodePointers: {
|
||||
},
|
||||
[ScriptOpcode.DB_LISTALL_WITH_COUNT]: {
|
||||
set: ['find_db']
|
||||
},
|
||||
|
||||
// rs-sdk ops
|
||||
[ScriptOpcode.RUNITE_MINED]: {
|
||||
require: ['active_player']
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -2,13 +2,20 @@ import { CoordGrid } from '#/engine/CoordGrid.js';
|
||||
import { openExchange } from '#/engine/market/GrandExchange.js';
|
||||
import { ScriptOpcode } from '#/engine/script/ScriptOpcode.js';
|
||||
import { CommandHandlers } from '#/engine/script/ScriptRunner.js';
|
||||
import World from '#/engine/World.js';
|
||||
import Environment from '#/util/Environment.js';
|
||||
|
||||
// rs-sdk: the runite leaderboard only counts the two Lava Maze rocks (m47_60)
|
||||
const LEADERBOARD_RUNITE_ROCKS: Set<number> = new Set([CoordGrid.packCoord(0, 3059, 3885), CoordGrid.packCoord(0, 3060, 3884)]);
|
||||
|
||||
const DebugOps: CommandHandlers = {
|
||||
[ScriptOpcode.GE_OPEN]: state => {
|
||||
const { level, x, z } = CoordGrid.unpackCoord(state.popInt());
|
||||
if (level === 0) openExchange(state.activePlayer, x, z);
|
||||
},
|
||||
[ScriptOpcode.RUNITE_MINED]: state => {
|
||||
if (LEADERBOARD_RUNITE_ROCKS.has(state.popInt())) World.notifyRuniteMined(state.activePlayer);
|
||||
},
|
||||
[ScriptOpcode.ERROR]: state => {
|
||||
throw new Error(state.popString());
|
||||
},
|
||||
|
||||
@@ -57,7 +57,7 @@ export default class LoggerClient extends InternalClient {
|
||||
);
|
||||
}
|
||||
|
||||
public async kothCapture(event: object) {
|
||||
public async runiteMine(event: object) {
|
||||
await this.connect();
|
||||
|
||||
if (!this.ws || !this.wsr || !this.wsr.checkIfWsLive()) {
|
||||
@@ -66,7 +66,7 @@ export default class LoggerClient extends InternalClient {
|
||||
|
||||
this.ws.send(
|
||||
JSON.stringify({
|
||||
type: 'koth_capture',
|
||||
type: 'runite_mine',
|
||||
world: Environment.node.id,
|
||||
profile: Environment.node.profile,
|
||||
event
|
||||
|
||||
@@ -496,18 +496,15 @@ export default class LoggerServer {
|
||||
await db.insertInto('player_telemetry').values(rows).execute();
|
||||
break;
|
||||
}
|
||||
case 'koth_capture': {
|
||||
case 'runite_mine': {
|
||||
const { event } = msg;
|
||||
|
||||
await db
|
||||
.insertInto('koth_capture')
|
||||
.insertInto('runite_mine')
|
||||
.values({
|
||||
timestamp: toDbDate(event.timestamp),
|
||||
profile: event.profile,
|
||||
username: event.username,
|
||||
combat_level: event.combat_level,
|
||||
contenders: event.contenders,
|
||||
loadout: event.loadout
|
||||
username: event.username
|
||||
})
|
||||
.execute();
|
||||
break;
|
||||
|
||||
@@ -49,11 +49,11 @@ async function handleRequests(_parentPort: ParentPort, msg: any) {
|
||||
await client.playerTelemetry(events);
|
||||
break;
|
||||
}
|
||||
case 'koth_capture': {
|
||||
// not gated on Environment.logger.enabled - koth control time is gameplay
|
||||
case 'runite_mine': {
|
||||
// not gated on Environment.logger.enabled - the runite leaderboard is gameplay
|
||||
// data (hiscores), not moderation logging
|
||||
const { event } = msg;
|
||||
await client.kothCapture(event);
|
||||
await client.runiteMine(event);
|
||||
break;
|
||||
}
|
||||
case 'report': {
|
||||
|
||||
@@ -184,6 +184,38 @@ async function updateHiscores(account: HiscoreAccount | undefined, player: Playe
|
||||
await updateWealthHiscores(account, player, profile);
|
||||
}
|
||||
|
||||
// rs-sdk: the outfit as it renders on the player, for the equipment board's /sprite/player
|
||||
// images: 12 appearance-protocol slots (0 = empty, 0x100+idk = identity kit, 0x200+obj = worn
|
||||
// item), mirroring Player.generateAppearance. Resolved here because the wearpos2/3 masking
|
||||
// needs obj config the web layer doesn't load.
|
||||
function outfitAppearance(player: Player): string {
|
||||
const worn = player.getInventory(InvType.WORN);
|
||||
const hidden = new Set<number>();
|
||||
if (worn) {
|
||||
for (let slot = 0; slot < worn.capacity; slot++) {
|
||||
const item = worn.get(slot);
|
||||
if (item) {
|
||||
const config = ObjType.get(item.id);
|
||||
if (config.wearpos2 !== -1) hidden.add(config.wearpos2);
|
||||
if (config.wearpos3 !== -1) hidden.add(config.wearpos3);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const slots: number[] = [];
|
||||
for (let slot = 0; slot < 12; slot++) {
|
||||
const item = worn?.get(slot);
|
||||
if (hidden.has(slot)) {
|
||||
slots.push(0);
|
||||
} else if (item) {
|
||||
slots.push(0x200 + item.id);
|
||||
} else {
|
||||
slots.push(player.getAppearanceInSlot(slot));
|
||||
}
|
||||
}
|
||||
return JSON.stringify({ gender: player.gender, colors: player.colors, slots });
|
||||
}
|
||||
|
||||
// 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.
|
||||
@@ -208,13 +240,14 @@ async function updateWealthHiscores(account: HiscoreAccount, player: Player, pro
|
||||
|
||||
if (items.length > 0) {
|
||||
const itemsJson = JSON.stringify(items);
|
||||
const existingOutfit = await db.selectFrom('hiscore_outfit').select(['value', 'items']).where('account_id', '=', account.id).where('profile', '=', profile).executeTakeFirst();
|
||||
if (existingOutfit && existingOutfit.value === totalValue && existingOutfit.items === itemsJson) {
|
||||
const appearance = outfitAppearance(player);
|
||||
const existingOutfit = await db.selectFrom('hiscore_outfit').select(['value', 'items', 'appearance']).where('account_id', '=', account.id).where('profile', '=', profile).executeTakeFirst();
|
||||
if (existingOutfit && existingOutfit.value === totalValue && existingOutfit.items === itemsJson && existingOutfit.appearance === appearance) {
|
||||
// unchanged
|
||||
} else if (existingOutfit) {
|
||||
await db.updateTable('hiscore_outfit').set({ value: totalValue, items: itemsJson, date: toDbDate(new Date()) }).where('account_id', '=', account.id).where('profile', '=', profile).execute();
|
||||
await db.updateTable('hiscore_outfit').set({ value: totalValue, items: itemsJson, appearance, date: toDbDate(new Date()) }).where('account_id', '=', account.id).where('profile', '=', profile).execute();
|
||||
} else {
|
||||
await db.insertInto('hiscore_outfit').values({ account_id: account.id, profile, value: totalValue, items: itemsJson }).execute();
|
||||
await db.insertInto('hiscore_outfit').values({ account_id: account.id, profile, value: totalValue, items: itemsJson, appearance }).execute();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import Environment from '#/util/Environment.js';
|
||||
import { handleHiscoresPage, handleHiscoresPlayerPage, handleHiscoresOutfitPage, handleHiscoresBankPage, handleHiscoresKothPage } from './pages/hiscores.js';
|
||||
import { handleHiscoresPage, handleHiscoresPlayerPage, handleHiscoresOutfitPage, handleHiscoresBankPage, handleHiscoresRunitePage } from './pages/hiscores.js';
|
||||
import { handlePublicFiles } from './pages/static.js';
|
||||
import { handleSpriteRequest } from './sprites/SpriteRenderer.js';
|
||||
|
||||
@@ -139,8 +139,8 @@ export function startHiscoresWeb() {
|
||||
const hiscoresBankResponse = await handleHiscoresBankPage(url);
|
||||
if (hiscoresBankResponse) return hiscoresBankResponse;
|
||||
|
||||
const hiscoresKothResponse = await handleHiscoresKothPage(url);
|
||||
if (hiscoresKothResponse) return hiscoresKothResponse;
|
||||
const hiscoresRuniteResponse = await handleHiscoresRunitePage(url);
|
||||
if (hiscoresRuniteResponse) return hiscoresRuniteResponse;
|
||||
|
||||
const spriteResponse = await handleSpriteRequest(url);
|
||||
if (spriteResponse) return spriteResponse;
|
||||
|
||||
@@ -3,7 +3,7 @@ import { register } from 'prom-client';
|
||||
import Environment from '#/util/Environment.js';
|
||||
import World from '#/engine/World.js';
|
||||
import { handleClientPage, handleCacheEndpoints } from './pages/client.js';
|
||||
import { handleHiscoresPage, handleHiscoresPlayerPage, handleHiscoresOutfitPage, handleHiscoresBankPage, handleHiscoresKothPage } from './pages/hiscores.js';
|
||||
import { handleHiscoresPage, handleHiscoresPlayerPage, handleHiscoresOutfitPage, handleHiscoresBankPage, handleHiscoresRunitePage } from './pages/hiscores.js';
|
||||
import { handleViewerAssets } from './hiscoresServer.js';
|
||||
import { handleSpriteRequest } from './sprites/SpriteRenderer.js';
|
||||
import { handleScreenshotsListPage, handleScreenshotFilePage } from './pages/screenshots.js';
|
||||
@@ -307,8 +307,8 @@ async function handleRequest(req: Request, server: Bun.Server, url: URL): Promis
|
||||
const hiscoresBankResponse = await handleHiscoresBankPage(url);
|
||||
if (hiscoresBankResponse) return hiscoresBankResponse;
|
||||
|
||||
const hiscoresKothResponse = await handleHiscoresKothPage(url);
|
||||
if (hiscoresKothResponse) return hiscoresKothResponse;
|
||||
const hiscoresRuniteResponse = await handleHiscoresRunitePage(url);
|
||||
if (hiscoresRuniteResponse) return hiscoresRuniteResponse;
|
||||
|
||||
const spriteResponse = await handleSpriteRequest(url);
|
||||
if (spriteResponse) return spriteResponse;
|
||||
|
||||
@@ -2,7 +2,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 { playerSpriteUrl } from '#/web/sprites/SpriteRenderer.js';
|
||||
import { itemSpriteUrl, playerSpriteUrl } from '#/web/sprites/SpriteRenderer.js';
|
||||
|
||||
const hiddenNames = Environment.HISCORES_HIDDEN_NAMES;
|
||||
|
||||
@@ -133,6 +133,22 @@ function rankIn(rows: RankedRow[], username: string): number | null {
|
||||
return idx === -1 ? null : idx + 1;
|
||||
}
|
||||
|
||||
// "Select hiscore table" sidebar shared by the list pages
|
||||
function hiscoreTableLinks(profile: string): string {
|
||||
const skillOptions = [{ id: 0, name: 'Overall' }, ...ENABLED_SKILLS.map(s => ({ id: s.id + 1, name: s.name }))];
|
||||
return (
|
||||
skillOptions
|
||||
.map(s => {
|
||||
const icon = s.name === 'Overall' ? '' : `<img src="/img/skill/${s.name.toLowerCase()}.png" width="15" height="15" style="vertical-align:middle;margin-right:3px">`;
|
||||
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><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>`
|
||||
);
|
||||
}
|
||||
|
||||
// Player profile page handler
|
||||
export async function handleHiscoresPlayerPage(url: URL): Promise<Response | null> {
|
||||
const match = url.pathname.match(/^\/hi(?:gh)?scores\/player\/([^/]+)\/?$/);
|
||||
@@ -355,22 +371,8 @@ export async function handleHiscoresPage(url: URL): Promise<Response | null> {
|
||||
}
|
||||
}
|
||||
|
||||
const skillOptions = [{ id: 0, name: 'Overall' }, ...ENABLED_SKILLS.map(s => ({ id: s.id + 1, name: s.name }))];
|
||||
|
||||
const currentCategory = category === -1 ? 0 : category;
|
||||
|
||||
// Build skill links for sidebar
|
||||
const skillLinks =
|
||||
skillOptions
|
||||
.map(s => {
|
||||
const icon = s.name === 'Overall' ? '' : `<img src="/img/skill/${s.name.toLowerCase()}.png" width="15" height="15" style="vertical-align:middle;margin-right:3px">`;
|
||||
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><a href="/hiscores/bank?profile=${profile}" class="c text-orange">Bank</a></td></tr>` +
|
||||
`\n<tr><td><a href="/hiscores/koth?profile=${profile}" class="c text-orange">King of the Hill</a></td></tr>`;
|
||||
|
||||
// Build data rows
|
||||
const rankCol = rows.map(r => `${r.rank}<br>`).join('\n');
|
||||
const nameCol = rows.map(r => `<a href="/hiscores/player/${encodeURIComponent(r.username)}?profile=${profile}" class="c">${escapeHtml(r.username)}</a><br>`).join('\n');
|
||||
@@ -458,7 +460,7 @@ export async function handleHiscoresPage(url: URL): Promise<Response | null> {
|
||||
<td class="e" valign="top">
|
||||
<center>
|
||||
<table height="380" cellspacing="1" cellpadding="0">
|
||||
${skillLinks}
|
||||
${hiscoreTableLinks(profile)}
|
||||
</table>
|
||||
</center>
|
||||
</td>
|
||||
@@ -611,6 +613,17 @@ export async function handleHiscoresPage(url: URL): Promise<Response | null> {
|
||||
return new Response(html, { headers: { 'Content-Type': 'text/html' } });
|
||||
}
|
||||
|
||||
// Character sprite wearing the outfit, from the appearance the login server stores with the
|
||||
// row (null until the player's next save after the column was added)
|
||||
function outfitSprite(appearance: string | null): string {
|
||||
if (!appearance) return '';
|
||||
try {
|
||||
return `<img src="${playerSpriteUrl(JSON.parse(appearance), 52, 88)}" width="52" height="88" alt="" loading="lazy" decoding="async" style="image-rendering:pixelated;vertical-align:middle">`;
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
// Richest outfit leaderboard handler
|
||||
export async function handleHiscoresOutfitPage(url: URL): Promise<Response | null> {
|
||||
const match = url.pathname.match(/^\/hi(?:gh)?scores\/outfit\/?$/);
|
||||
@@ -621,7 +634,7 @@ export async function handleHiscoresOutfitPage(url: URL): Promise<Response | nul
|
||||
let query = db
|
||||
.selectFrom('hiscore_outfit')
|
||||
.innerJoin('account', 'account.id', 'hiscore_outfit.account_id')
|
||||
.select(['account.username', 'hiscore_outfit.value', 'hiscore_outfit.items'])
|
||||
.select(['account.username', 'hiscore_outfit.value', 'hiscore_outfit.items', 'hiscore_outfit.appearance'])
|
||||
.where('hiscore_outfit.profile', '=', profile)
|
||||
.where('account.staffmodlevel', '<=', 1)
|
||||
.orderBy('hiscore_outfit.value', 'desc')
|
||||
@@ -632,14 +645,16 @@ export async function handleHiscoresOutfitPage(url: URL): Promise<Response | nul
|
||||
|
||||
const results = await query.execute();
|
||||
|
||||
// sprites and icons are rendered server-side (see web/sprites) and cached by URL
|
||||
const rows = results.map((r, i) => {
|
||||
let itemsList = '';
|
||||
try {
|
||||
const items = JSON.parse(r.items) as { id?: number; name: string; value: number }[];
|
||||
itemsList = items
|
||||
.map(item => {
|
||||
const title = `${escapeHtml(item.name)} (${item.value.toLocaleString()} gp)`;
|
||||
if (item.id != null) {
|
||||
return `<canvas class="item-icon" data-item-id="${item.id}" title="${escapeHtml(item.name)} (${item.value.toLocaleString()} gp)" width="32" height="32" style="image-rendering:pixelated;vertical-align:middle"></canvas>`;
|
||||
return `<img src="${itemSpriteUrl(item.id)}" title="${title}" alt="${escapeHtml(item.name)}" width="32" height="32" loading="lazy" style="image-rendering:pixelated;vertical-align:middle">`;
|
||||
}
|
||||
return `<span title="${item.value.toLocaleString()} gp">${escapeHtml(item.name)}</span>`;
|
||||
})
|
||||
@@ -650,6 +665,7 @@ export async function handleHiscoresOutfitPage(url: URL): Promise<Response | nul
|
||||
return `
|
||||
<tr>
|
||||
<td align="right">${i + 1}</td>
|
||||
<td>${i < 10 ? outfitSprite(r.appearance) : ''}</td>
|
||||
<td><a href="/hiscores/player/${encodeURIComponent(r.username)}?profile=${profile}" class="c">${escapeHtml(r.username)}</a></td>
|
||||
<td align="right" class="yellow" title="${r.value.toLocaleString()} gp">${formatGold(r.value)}</td>
|
||||
<td style="font-size:11px">${itemsList}</td>
|
||||
@@ -657,19 +673,6 @@ export async function handleHiscoresOutfitPage(url: URL): Promise<Response | nul
|
||||
`;
|
||||
});
|
||||
|
||||
// Sidebar skill links
|
||||
const skillOptions = [{ id: 0, name: 'Overall' }, ...ENABLED_SKILLS.map(s => ({ id: s.id + 1, name: s.name }))];
|
||||
const skillLinks =
|
||||
skillOptions
|
||||
.map(s => {
|
||||
const icon = s.name === 'Overall' ? '' : `<img src="/img/skill/${s.name.toLowerCase()}.png" width="15" height="15" style="vertical-align:middle;margin-right:3px">`;
|
||||
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><a href="/hiscores/bank?profile=${profile}" class="c text-orange">Bank</a></td></tr>` +
|
||||
`\n<tr><td><a href="/hiscores/koth?profile=${profile}" class="c text-orange">King of the Hill</a></td></tr>`;
|
||||
|
||||
const html = `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
@@ -722,7 +725,7 @@ export async function handleHiscoresOutfitPage(url: URL): Promise<Response | nul
|
||||
<td class="e" valign="top">
|
||||
<center>
|
||||
<table height="380" cellspacing="1" cellpadding="0">
|
||||
${skillLinks}
|
||||
${hiscoreTableLinks(profile)}
|
||||
</table>
|
||||
</center>
|
||||
</td>
|
||||
@@ -742,6 +745,7 @@ export async function handleHiscoresOutfitPage(url: URL): Promise<Response | nul
|
||||
? `<table width="100%" cellspacing="2" cellpadding="2">
|
||||
<tr>
|
||||
<td><b>#</b></td>
|
||||
<td></td>
|
||||
<td><b>Name</b></td>
|
||||
<td align="right"><b>Value</b></td>
|
||||
<td><b>Items</b></td>
|
||||
@@ -778,100 +782,14 @@ export async function handleHiscoresOutfitPage(url: URL): Promise<Response | nul
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
<!-- Hidden canvas required by viewer internals -->
|
||||
<canvas id="canvas" width="256" height="256" style="display:none"></canvas>
|
||||
<script type="module">
|
||||
import { ItemViewer } from '/viewer/viewer.js';
|
||||
|
||||
const icons = document.querySelectorAll('canvas.item-icon');
|
||||
if (icons.length > 0) {
|
||||
const viewer = new ItemViewer();
|
||||
try {
|
||||
await viewer.init('');
|
||||
let rendered = 0, failed = 0;
|
||||
for (const el of icons) {
|
||||
const id = parseInt(el.dataset.itemId);
|
||||
if (isNaN(id)) continue;
|
||||
try {
|
||||
const icon = viewer.renderItemIconAsImageData(id);
|
||||
if (icon) {
|
||||
el.getContext('2d').putImageData(icon, 0, 0);
|
||||
rendered++;
|
||||
} else {
|
||||
el.style.display = 'none';
|
||||
const fallback = document.createElement('span');
|
||||
fallback.textContent = el.title.split(' (')[0];
|
||||
fallback.title = el.title;
|
||||
el.parentNode.insertBefore(fallback, el);
|
||||
failed++;
|
||||
}
|
||||
} catch (renderErr) {
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('ItemViewer init failed:', err);
|
||||
for (const el of icons) {
|
||||
const fallback = document.createElement('span');
|
||||
fallback.textContent = el.title.split(' (')[0];
|
||||
fallback.title = el.title;
|
||||
el.parentNode.insertBefore(fallback, el);
|
||||
el.style.display = 'none';
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
return new Response(html, { headers: { 'Content-Type': 'text/html' } });
|
||||
}
|
||||
|
||||
// King of the Hill (Demonic Ruins) leaderboard handler.
|
||||
// A loadout snapshot is the appearance-protocol encoding the engine captured:
|
||||
// 12 slots of 0 (empty) / 0x100+idk / 0x200+objId, plus gender and 5 colours.
|
||||
type KothLoadout = { gender: number; colors: number[]; slots: number[] };
|
||||
|
||||
// per-component mode across a player's capture snapshots: the outfit they
|
||||
// characteristically held the hill in ("median" loadout)
|
||||
function medianLoadout(loadouts: string[]): KothLoadout | null {
|
||||
const parsed: KothLoadout[] = [];
|
||||
for (const raw of loadouts) {
|
||||
try {
|
||||
const l = JSON.parse(raw);
|
||||
if (l && Array.isArray(l.colors) && Array.isArray(l.slots)) {
|
||||
parsed.push(l);
|
||||
}
|
||||
} catch {
|
||||
// skip malformed snapshots
|
||||
}
|
||||
}
|
||||
if (parsed.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const mode = (values: number[]): number => {
|
||||
const counts = new Map<number, number>();
|
||||
let best = values[0];
|
||||
let bestCount = 0;
|
||||
for (const v of values) {
|
||||
const c = (counts.get(v) ?? 0) + 1;
|
||||
counts.set(v, c);
|
||||
if (c > bestCount) {
|
||||
bestCount = c;
|
||||
best = v;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
};
|
||||
|
||||
return {
|
||||
gender: mode(parsed.map(l => l.gender)),
|
||||
colors: parsed[0].colors.map((_, i) => mode(parsed.map(l => l.colors[i] ?? 0))),
|
||||
slots: parsed[0].slots.map((_, i) => mode(parsed.map(l => l.slots[i] ?? 0)))
|
||||
};
|
||||
}
|
||||
|
||||
// Lava Maze runite leaderboard: one runite_mine row per ore mined from the two Lava Maze
|
||||
// rocks (see RUNITE_MINED in DebugOps.ts)
|
||||
function formatAgo(dbDate: string | Date): string {
|
||||
const then = typeof dbDate === 'string' ? new Date(dbDate.replace(' ', 'T') + 'Z').getTime() : dbDate.getTime();
|
||||
const mins = Math.floor((Date.now() - then) / 60_000);
|
||||
@@ -881,184 +799,79 @@ function formatAgo(dbDate: string | Date): string {
|
||||
return `${Math.floor(mins / 1440)}d ago`;
|
||||
}
|
||||
|
||||
// the reigning king of each window, shown side by side above the table
|
||||
const KING_WINDOWS = [
|
||||
{ label: 'All time', ms: 0 },
|
||||
{ label: 'This week', ms: 7 * 24 * 3600_000 },
|
||||
{ label: 'Today', ms: 24 * 3600_000 }
|
||||
];
|
||||
type RuniteRow = { username: string; ore: number; last_mined: string | Date };
|
||||
|
||||
type KothRow = { username: string; minutes: number; last_held: string | Date };
|
||||
type KothKing = { label: string; ms: number; leader: { username: string; minutes: number } | undefined; sprite: KothLoadout | undefined };
|
||||
type KothPageData = { results: KothRow[]; spriteFor: Map<string, KothLoadout>; kings: KothKing[] };
|
||||
// Same short TTL + in-flight dedup as getRankedList: these run on the tick thread.
|
||||
const runiteCache = new Map<string, { at: number; rows: RuniteRow[]; pending: Promise<RuniteRow[]> | null }>();
|
||||
|
||||
// Same short TTL + in-flight dedup as getRankedList: these run on the tick thread, and the
|
||||
// capture table grows by one row per minute so a group-by over it is not free.
|
||||
const kothCache = new Map<string, { at: number; data: KothPageData | null; pending: Promise<KothPageData> | null }>();
|
||||
|
||||
async function getKothPageData(profile: string, windowMs: number): Promise<KothPageData> {
|
||||
async function getRuniteRows(profile: string, windowMs: number): Promise<RuniteRow[]> {
|
||||
const key = `${profile}:${windowMs}`;
|
||||
const now = Date.now();
|
||||
const cached = kothCache.get(key);
|
||||
if (cached?.data && now - cached.at < RANKED_TTL_MS) {
|
||||
return cached.data;
|
||||
const cached = runiteCache.get(key);
|
||||
if (cached && now - cached.at < RANKED_TTL_MS) {
|
||||
return cached.rows;
|
||||
}
|
||||
if (cached?.pending) {
|
||||
return cached.pending;
|
||||
}
|
||||
const load = loadKothPageData(profile, windowMs).then(data => {
|
||||
kothCache.set(key, { at: Date.now(), data, pending: null });
|
||||
return data;
|
||||
});
|
||||
kothCache.set(key, { at: cached?.at ?? 0, data: cached?.data ?? null, pending: load });
|
||||
const load = (async () => {
|
||||
let query = db
|
||||
.selectFrom('runite_mine')
|
||||
.innerJoin('account', 'account.username', 'runite_mine.username')
|
||||
.select(({ fn }) => ['runite_mine.username', fn.countAll<number>().as('ore'), fn.max('runite_mine.timestamp').as('last_mined')])
|
||||
.where('runite_mine.profile', '=', profile)
|
||||
.where('account.staffmodlevel', '<=', 1)
|
||||
.groupBy('runite_mine.username')
|
||||
.orderBy('ore', 'desc')
|
||||
// ties go to whoever reached the count first
|
||||
.orderBy('last_mined', 'asc')
|
||||
.limit(50);
|
||||
if (windowMs > 0) {
|
||||
query = query.where('runite_mine.timestamp', '>', toDbDate(Date.now() - windowMs));
|
||||
}
|
||||
if (hiddenNames.length > 0) {
|
||||
query = query.where(eb => eb.not(eb(eb.fn('lower', ['runite_mine.username']), 'in', hiddenNames)));
|
||||
}
|
||||
const rows = (await query.execute()) as RuniteRow[];
|
||||
runiteCache.set(key, { at: Date.now(), rows, pending: null });
|
||||
return rows;
|
||||
})();
|
||||
runiteCache.set(key, { at: cached?.at ?? 0, rows: cached?.rows ?? [], pending: load });
|
||||
try {
|
||||
return await load;
|
||||
} catch (err) {
|
||||
kothCache.delete(key);
|
||||
runiteCache.delete(key);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadKothPageData(profile: string, windowMs: number): Promise<KothPageData> {
|
||||
let query = db
|
||||
.selectFrom('koth_capture')
|
||||
.innerJoin('account', 'account.username', 'koth_capture.username')
|
||||
.select(({ fn }) => ['koth_capture.username', fn.countAll<number>().as('minutes'), fn.max('koth_capture.timestamp').as('last_held')])
|
||||
.where('koth_capture.profile', '=', profile)
|
||||
.where('account.staffmodlevel', '<=', 1)
|
||||
.groupBy('koth_capture.username')
|
||||
.orderBy('minutes', 'desc')
|
||||
.limit(50);
|
||||
if (windowMs > 0) {
|
||||
query = query.where('koth_capture.timestamp', '>', toDbDate(Date.now() - windowMs));
|
||||
}
|
||||
if (hiddenNames.length > 0) {
|
||||
query = query.where(eb => eb.not(eb(eb.fn('lower', ['koth_capture.username']), 'in', hiddenNames)));
|
||||
}
|
||||
const results = await query.execute();
|
||||
|
||||
// median loadout for a set of players over a window
|
||||
const medianLoadouts = async (names: string[], ms: number): Promise<Map<string, KothLoadout>> => {
|
||||
const medians = new Map<string, KothLoadout>();
|
||||
if (names.length === 0) {
|
||||
return medians;
|
||||
}
|
||||
let loadoutQuery = db.selectFrom('koth_capture').select(['username', 'loadout']).where('profile', '=', profile).where('username', 'in', names);
|
||||
if (ms > 0) {
|
||||
loadoutQuery = loadoutQuery.where('timestamp', '>', toDbDate(Date.now() - ms));
|
||||
}
|
||||
const loadoutRows = await loadoutQuery.execute();
|
||||
const grouped = new Map<string, string[]>();
|
||||
for (const row of loadoutRows) {
|
||||
let list = grouped.get(row.username);
|
||||
if (!list) {
|
||||
list = [];
|
||||
grouped.set(row.username, list);
|
||||
}
|
||||
list.push(row.loadout);
|
||||
}
|
||||
for (const [name, list] of grouped) {
|
||||
const median = medianLoadout(list);
|
||||
if (median) {
|
||||
medians.set(name, median);
|
||||
}
|
||||
}
|
||||
return medians;
|
||||
};
|
||||
|
||||
const spriteFor = await medianLoadouts(
|
||||
results.slice(0, 10).map(r => r.username),
|
||||
windowMs
|
||||
);
|
||||
|
||||
const kings = await Promise.all(
|
||||
KING_WINDOWS.map(async w => {
|
||||
let kingQuery = db
|
||||
.selectFrom('koth_capture')
|
||||
.innerJoin('account', 'account.username', 'koth_capture.username')
|
||||
.select(({ fn }) => ['koth_capture.username', fn.countAll<number>().as('minutes')])
|
||||
.where('koth_capture.profile', '=', profile)
|
||||
.where('account.staffmodlevel', '<=', 1)
|
||||
.groupBy('koth_capture.username')
|
||||
.orderBy('minutes', 'desc')
|
||||
.limit(1);
|
||||
if (w.ms > 0) {
|
||||
kingQuery = kingQuery.where('koth_capture.timestamp', '>', toDbDate(Date.now() - w.ms));
|
||||
}
|
||||
if (hiddenNames.length > 0) {
|
||||
kingQuery = kingQuery.where(eb => eb.not(eb(eb.fn('lower', ['koth_capture.username']), 'in', hiddenNames)));
|
||||
}
|
||||
const leader = await kingQuery.executeTakeFirst();
|
||||
const sprite = leader ? (await medianLoadouts([leader.username], w.ms)).get(leader.username) : undefined;
|
||||
return { ...w, leader, sprite };
|
||||
})
|
||||
);
|
||||
|
||||
return { results: results as KothRow[], spriteFor, kings };
|
||||
}
|
||||
|
||||
export async function handleHiscoresKothPage(url: URL): Promise<Response | null> {
|
||||
const match = url.pathname.match(/^\/hi(?:gh)?scores\/koth\/?$/);
|
||||
export async function handleHiscoresRunitePage(url: URL): Promise<Response | null> {
|
||||
const match = url.pathname.match(/^\/hi(?:gh)?scores\/runite\/?$/);
|
||||
if (!match) return null;
|
||||
|
||||
const profile = (url.searchParams.get('profile') || 'main').replace(/[^a-zA-Z0-9_-]/g, '');
|
||||
const windowParam = url.searchParams.get('window') || 'all';
|
||||
const windowMs = windowParam === 'day' ? 24 * 3600_000 : windowParam === 'week' ? 7 * 24 * 3600_000 : 0;
|
||||
|
||||
const { results, spriteFor, kings } = await getKothPageData(profile, windowMs);
|
||||
const results = await getRuniteRows(profile, windowMs);
|
||||
|
||||
// sprites are rendered server-side (see web/sprites) and cached by URL, so the page is
|
||||
// plain HTML + <img> — no viewer bundle or model archives shipped to the browser
|
||||
const spriteImg = (loadout: KothLoadout, size: 'big' | 'small'): string => {
|
||||
const w = size === 'big' ? 78 : 52;
|
||||
const h = size === 'big' ? 130 : 88;
|
||||
return `<img src="${playerSpriteUrl(loadout, w, h)}" width="${w}" height="${h}" alt="" loading="lazy" decoding="async" style="image-rendering:pixelated;vertical-align:middle">`;
|
||||
};
|
||||
|
||||
const rows = results.map((r, i) => {
|
||||
const sprite = spriteFor.get(r.username);
|
||||
return `
|
||||
const rows = results.map(
|
||||
(r, i) => `
|
||||
<tr>
|
||||
<td align="right">${i + 1}</td>
|
||||
<td>${sprite ? spriteImg(sprite, 'small') : ''}</td>
|
||||
<td><a href="/hiscores/player/${encodeURIComponent(r.username)}?profile=${profile}" class="c">${escapeHtml(r.username)}</a></td>
|
||||
<td align="right" class="yellow">${Number(r.minutes).toLocaleString()}</td>
|
||||
<td align="right" style="font-size:11px">${formatAgo(r.last_held as string | Date)}</td>
|
||||
<td align="right" class="yellow">${Number(r.ore).toLocaleString()}</td>
|
||||
<td align="right" style="font-size:11px">${formatAgo(r.last_mined)}</td>
|
||||
</tr>
|
||||
`;
|
||||
});
|
||||
`
|
||||
);
|
||||
|
||||
// Sidebar skill links
|
||||
const skillOptions = [{ id: 0, name: 'Overall' }, ...ENABLED_SKILLS.map(s => ({ id: s.id + 1, name: s.name }))];
|
||||
const skillLinks =
|
||||
skillOptions
|
||||
.map(s => {
|
||||
const icon = s.name === 'Overall' ? '' : `<img src="/img/skill/${s.name.toLowerCase()}.png" width="15" height="15" style="vertical-align:middle;margin-right:3px">`;
|
||||
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><a href="/hiscores/bank?profile=${profile}" class="c text-orange">Bank</a></td></tr>` +
|
||||
`\n<tr><td><a href="/hiscores/koth?profile=${profile}" class="c text-orange">King of the Hill</a></td></tr>`;
|
||||
|
||||
const windowTab = (key: string, label: string): string => (windowParam === key ? `<b class="text-orange">${label}</b>` : `<a href="/hiscores/koth?window=${key}&profile=${profile}" class="c">${label}</a>`);
|
||||
|
||||
const kingBanner = `<table width="400" bgcolor="black" cellpadding="6"><tr>
|
||||
${kings
|
||||
.map(
|
||||
k => `<td class="e" width="33%" align="center" valign="bottom">
|
||||
<b style="font-size:11px">${k.label}</b><br>
|
||||
${k.sprite ? spriteImg(k.sprite, 'small') + '<br>' : ''}
|
||||
${k.leader ? `<a href="/hiscores/player/${encodeURIComponent(k.leader.username)}?profile=${profile}" class="c text-orange">${escapeHtml(k.leader.username)}</a><br><span class="yellow" style="font-size:11px">${Number(k.leader.minutes).toLocaleString()} min</span>` : '<span style="font-size:11px">unclaimed</span>'}
|
||||
</td>`
|
||||
)
|
||||
.join('')}
|
||||
</tr></table><br>`;
|
||||
const windowTab = (key: string, label: string): string => (windowParam === key ? `<b class="text-orange">${label}</b>` : `<a href="/hiscores/runite?window=${key}&profile=${profile}" class="c">${label}</a>`);
|
||||
|
||||
const html = `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>King of the Hill Hiscores</title>
|
||||
<title>Lava Maze Runite Hiscores</title>
|
||||
<style>${HISCORES_STYLES}</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -1088,7 +901,7 @@ export async function handleHiscoresKothPage(url: URL): Promise<Response | null>
|
||||
<tr>
|
||||
<td class="e">
|
||||
<center>
|
||||
<b>King of the Hill</b><br>
|
||||
<b>Lava Maze Runite</b><br>
|
||||
<a href="/" class="c">Main menu</a> | <a href="/hiscores?profile=${profile}" class="c">All Hiscores</a>
|
||||
</center>
|
||||
</td>
|
||||
@@ -1096,11 +909,14 @@ export async function handleHiscoresKothPage(url: URL): Promise<Response | null>
|
||||
</table>
|
||||
<br>
|
||||
|
||||
<!-- Rules blurb -->
|
||||
<table width="400" bgcolor="black" cellpadding="6">
|
||||
<!-- Location shot + rules blurb -->
|
||||
<table width="412" bgcolor="black" cellpadding="4">
|
||||
<tr>
|
||||
<td class="e" style="font-size:12px">
|
||||
<center>King of the hill control time for the Demonic Ruins walled area. </center>
|
||||
<center>
|
||||
<img src="/img/lava_maze_runite.jpg" width="400" height="250" alt="The two runite rocks in the Lava Maze" style="display:block">
|
||||
Runite ore mined from the two Lava Maze rocks, level 46 Wilderness.
|
||||
</center>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
@@ -1117,7 +933,7 @@ export async function handleHiscoresKothPage(url: URL): Promise<Response | null>
|
||||
<td class="e" valign="top">
|
||||
<center>
|
||||
<table height="380" cellspacing="1" cellpadding="0">
|
||||
${skillLinks}
|
||||
${hiscoreTableLinks(profile)}
|
||||
</table>
|
||||
</center>
|
||||
</td>
|
||||
@@ -1128,8 +944,7 @@ export async function handleHiscoresKothPage(url: URL): Promise<Response | null>
|
||||
|
||||
<td width="400" valign="top">
|
||||
<center>
|
||||
<b>Kings of the Hill</b><br>
|
||||
${kingBanner}
|
||||
<b>Runite ore mined</b><br>
|
||||
${windowTab('all', 'All time')} | ${windowTab('week', 'This week')} | ${windowTab('day', 'Today')}<br>
|
||||
<table width="400" bgcolor="black" cellpadding="4">
|
||||
<tr>
|
||||
@@ -1139,14 +954,13 @@ export async function handleHiscoresKothPage(url: URL): Promise<Response | null>
|
||||
? `<table align="center" cellspacing="2" cellpadding="2">
|
||||
<tr>
|
||||
<td><b>#</b></td>
|
||||
<td></td>
|
||||
<td><b>Name</b></td>
|
||||
<td align="right"><b>Minutes</b></td>
|
||||
<td align="right"><b>Last held</b></td>
|
||||
<td align="right"><b>Ore</b></td>
|
||||
<td align="right"><b>Last mined</b></td>
|
||||
</tr>
|
||||
${rows.join('')}
|
||||
</table>`
|
||||
: '<center><br>No one has held the ruins yet</center>'
|
||||
: '<center><br>No runite mined yet</center>'
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -1231,19 +1045,6 @@ export async function handleHiscoresBankPage(url: URL): Promise<Response | null>
|
||||
`;
|
||||
});
|
||||
|
||||
// Sidebar skill links
|
||||
const skillOptions = [{ id: 0, name: 'Overall' }, ...ENABLED_SKILLS.map(s => ({ id: s.id + 1, name: s.name }))];
|
||||
const skillLinks =
|
||||
skillOptions
|
||||
.map(s => {
|
||||
const icon = s.name === 'Overall' ? '' : `<img src="/img/skill/${s.name.toLowerCase()}.png" width="15" height="15" style="vertical-align:middle;margin-right:3px">`;
|
||||
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><a href="/hiscores/bank?profile=${profile}" class="c text-orange">Bank</a></td></tr>` +
|
||||
`\n<tr><td><a href="/hiscores/koth?profile=${profile}" class="c text-orange">King of the Hill</a></td></tr>`;
|
||||
|
||||
const html = `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
@@ -1296,7 +1097,7 @@ export async function handleHiscoresBankPage(url: URL): Promise<Response | null>
|
||||
<td class="e" valign="top">
|
||||
<center>
|
||||
<table height="380" cellspacing="1" cellpadding="0">
|
||||
${skillLinks}
|
||||
${hiscoreTableLinks(profile)}
|
||||
</table>
|
||||
</center>
|
||||
</td>
|
||||
|
||||
Reference in new issue
Block a user