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:
max bittkerandClaude Opus 5.5 committed 2026-09-26 11:56:17 -04:00
1 parent 4c2069cc0c
commit aa4bb5dbbf
23 files changed
+237 -541

No files matched your search

+19 -10
View File
@@ -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`)
+3
View File
@@ -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;
@@ -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");
@@ -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

+3 -5
View File
@@ -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;
-189
View File
@@ -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();
+12 -11
View File
@@ -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': {
+37 -4
View File
@@ -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();
}
}
}
+3 -3
View File
@@ -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 -3
View File
@@ -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;
+93 -292
View File
@@ -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>&nbsp;</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>&nbsp;</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>&nbsp;</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>&nbsp;</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>&nbsp;</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>