Add player position tracking relay + rendering (Phase 7b)

Relays the mod's throttled player_positions roster over a new
/ws/players/:serverId gateway (Redis pub/sub + snapshot key so a
tab connecting between mod flushes isn't empty), gated per-server
by a new playerPositionsVisible admin toggle independent of the
mod's own tracking config. Frontend renders the roster as map
markers with a show/hide toggle and online count. Covered by unit
tests (players.test.ts, ws-gateway.test.ts, admin.test.ts) and a
new e2e spec that plays the real mod WS protocol from inside a
browser context.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015tKdPZt78zbPUZMXWzKEKt
This commit is contained in:
2026-08-09 20:07:11 +02:00
parent cc3860ce08
commit 826233e10c
16 changed files with 444 additions and 16 deletions
+1
View File
@@ -0,0 +1 @@
ALTER TABLE "servers" ADD COLUMN IF NOT EXISTS "player_positions_visible" boolean NOT NULL DEFAULT true;
+9
View File
@@ -89,12 +89,21 @@ describe("updateServerSettings", () => {
const result = await updateServerSettings(createdId, {
anonymousChatAllowed: false,
waypointFormat: "xaero",
playerPositionsVisible: false,
});
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.server.name).toBe("admin-test-update"); // untouched
expect(result.server.anonymousChatAllowed).toBe(false);
expect(result.server.waypointFormat).toBe("xaero");
expect(result.server.playerPositionsVisible).toBe(false);
});
test("playerPositionsVisible defaults to true on a freshly registered server", async () => {
const created = await registerServer("admin-test-players-default", "offline");
if (!created.ok) throw new Error("setup failed");
createdId = created.server.id;
expect(created.server.playerPositionsVisible).toBe(true);
});
test("rejects an invalid waypointFormat", async () => {
+1
View File
@@ -45,6 +45,7 @@ export type UpdateServerSettingsInput = Partial<{
authMode: string;
anonymousChatAllowed: boolean;
waypointFormat: string;
playerPositionsVisible: boolean;
}>;
export type UpdateServerSettingsResult =
+4
View File
@@ -14,6 +14,10 @@ export const servers = pgTable("servers", {
// shared — 'journeymap' (default), 'xaero', or 'off' to disable sharing entirely. No admin UI
// to flip this yet (Phase 6) — set directly in the DB for now, same as anonymousChatAllowed.
waypointFormat: text("waypoint_format").notNull().default("journeymap"),
// Phase 7b: whether the api relays the mod's throttled player-position roster on to web
// viewers. Independent of the mod-local `playerTrackingEnabled` config (see MCMapperMod.java's
// doc comment) — this is the backend-side "should we show it" toggle, admin-configurable.
playerPositionsVisible: boolean("player_positions_visible").notNull().default(true),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
});
+15 -2
View File
@@ -4,6 +4,7 @@ import { db } from "./db/client";
import { meshPointers, servers, tilePointers } from "./db/schema";
import { wsGateway } from "./ws-gateway";
import { chatGateway } from "./chat-gateway";
import { playersGateway } from "./players-gateway";
import { minio, TILE_BUCKET, ensureTileBucket } from "./minio";
import { redeemLinkCode, getAccountForSession, revokeSession } from "./link";
import { createMarker, listMarkers, deleteMarker, updateMarker, shareMarkerToChat } from "./markers";
@@ -259,13 +260,20 @@ export const app = new Elysia()
set.status = 401;
return { error: "unauthenticated" };
}
const { name, authMode, anonymousChatAllowed, waypointFormat } = body as {
const { name, authMode, anonymousChatAllowed, waypointFormat, playerPositionsVisible } = body as {
name?: string;
authMode?: string;
anonymousChatAllowed?: boolean;
waypointFormat?: string;
playerPositionsVisible?: boolean;
};
const result = await updateServerSettings(params.id, { name, authMode, anonymousChatAllowed, waypointFormat });
const result = await updateServerSettings(params.id, {
name,
authMode,
anonymousChatAllowed,
waypointFormat,
playerPositionsVisible,
});
if (!result.ok) set.status = result.error === "not_found" ? 404 : 400;
return result;
})
@@ -287,6 +295,11 @@ export const app = new Elysia()
open: chatGateway.open,
message: chatGateway.message,
close: chatGateway.close,
})
.ws("/ws/players/:serverId", {
open: playersGateway.open,
message: playersGateway.message,
close: playersGateway.close,
});
// Only bind a real port when run directly (`bun run src/index.ts`) — tests import `app` and
+57
View File
@@ -0,0 +1,57 @@
import { redis } from "./redis";
import { playerChannel, getPlayerSnapshot } from "./players";
// Wire protocol for browsers connecting to /ws/players/:serverId, one JSON object per WS text
// frame, api -> web only (this socket never reads anything from the browser):
//
// api -> web {"dimension":0,"players":[{"uuid":"...","username":"...","x":..,"y":..,"z":..}]}
//
// Same payload shape the mod sent, forwarded verbatim (see players.ts's publishPlayerPositions
// and ws-gateway.ts's player_positions handler) — always the full current roster, never a diff.
// One shared subscriber connection for the whole process, same pattern as chat-gateway.ts.
const playerSubscriber = redis.duplicate();
const browserSocketsByServer = new Map<string, Set<any>>();
playerSubscriber.psubscribe("mcmapper:players:*");
playerSubscriber.on("pmessage", (_pattern: string, channel: string, message: string) => {
// "mcmapper:players:snapshot:<id>" also matches this pattern (players.ts stores the snapshot
// as a plain Redis key, not a pub/sub publish) — SET doesn't emit pmessage events, only
// PUBLISH does, so this listener only ever actually fires for genuine playerChannel(...) publishes.
const serverId = channel.slice("mcmapper:players:".length);
const sockets = browserSocketsByServer.get(serverId);
if (!sockets) return;
for (const ws of sockets) ws.send(message);
});
function serverIdOf(ws: any): string {
return ws.data.params.serverId;
}
export const playersGateway = {
async open(ws: any) {
const serverId = serverIdOf(ws);
let sockets = browserSocketsByServer.get(serverId);
if (!sockets) {
sockets = new Set();
browserSocketsByServer.set(serverId, sockets);
}
sockets.add(ws);
// Send the latest known roster immediately so a freshly opened tab doesn't sit empty until
// the next mod flush (up to playerPositionIntervalTicks away, default 2s — not long, but
// noticeable on a quiet server with infrequent flushes).
const snapshot = await getPlayerSnapshot(serverId);
if (snapshot) ws.send(snapshot);
},
// This socket is api -> web only, but Elysia's .ws() expects a message handler regardless —
// nothing meaningful for a browser to send here.
message() {},
close(ws: any) {
browserSocketsByServer.get(serverIdOf(ws))?.delete(ws);
},
};
export { playerChannel };
+69
View File
@@ -0,0 +1,69 @@
import { describe, test, expect, beforeAll, afterAll } from "bun:test";
import { eq } from "drizzle-orm";
import { db } from "./db/client";
import { servers } from "./db/schema";
import { redis } from "./redis";
import { arePlayerPositionsVisible, getPlayerSnapshot, playerChannel, publishPlayerPositions } from "./players";
import { createTestServer, deleteTestServer } from "./test-helpers";
describe("arePlayerPositionsVisible", () => {
let server: { id: string };
beforeAll(async () => {
server = await createTestServer("players-visible-toggle");
});
afterAll(async () => {
await deleteTestServer(server.id);
});
test("defaults to true", async () => {
expect(await arePlayerPositionsVisible(server.id)).toBe(true);
});
test("reflects a false override", async () => {
await db.update(servers).set({ playerPositionsVisible: false }).where(eq(servers.id, server.id));
expect(await arePlayerPositionsVisible(server.id)).toBe(false);
});
test("an unknown server has nothing to show", async () => {
expect(await arePlayerPositionsVisible(crypto.randomUUID())).toBe(false);
});
});
describe("publishPlayerPositions", () => {
let server: { id: string };
beforeAll(async () => {
server = await createTestServer("players-publish");
});
afterAll(async () => {
await deleteTestServer(server.id);
});
test("publishes on the server's channel and stashes a snapshot for late subscribers", async () => {
const sub = redis.duplicate();
await sub.subscribe(playerChannel(server.id));
const received = new Promise<string>((resolve) => {
sub.once("message", (_channel, message) => resolve(message));
});
const players = [{ uuid: "player-1", username: "Steve", x: 10, y: 64, z: -5 }];
await publishPlayerPositions(server.id, 0, players);
expect(JSON.parse(await received)).toEqual({ dimension: 0, players });
sub.disconnect();
const snapshot = await getPlayerSnapshot(server.id);
expect(JSON.parse(snapshot!)).toEqual({ dimension: 0, players });
});
test("an empty roster is still published (everyone logged out)", async () => {
await publishPlayerPositions(server.id, 0, [{ uuid: "p", username: "Solo", x: 0, y: 64, z: 0 }]);
await publishPlayerPositions(server.id, 0, []);
const snapshot = await getPlayerSnapshot(server.id);
expect(JSON.parse(snapshot!)).toEqual({ dimension: 0, players: [] });
});
});
+46
View File
@@ -0,0 +1,46 @@
import { eq } from "drizzle-orm";
import { db } from "./db/client";
import { servers } from "./db/schema";
import { redis } from "./redis";
export interface PlayerPosition {
uuid: string;
username: string;
x: number;
y: number;
z: number;
}
export function playerChannel(serverId: string): string {
return `mcmapper:players:${serverId}`;
}
function snapshotKey(serverId: string): string {
return `mcmapper:players:snapshot:${serverId}`;
}
export async function arePlayerPositionsVisible(serverId: string): Promise<boolean> {
const [row] = await db
.select({ playerPositionsVisible: servers.playerPositionsVisible })
.from(servers)
.where(eq(servers.id, serverId))
.limit(1);
return row?.playerPositionsVisible ?? false;
}
/**
* Fans a player roster out to every browser subscribed to this server's channel, and stashes it
* in Redis so a browser tab that connects between mod flushes gets the current roster immediately
* instead of waiting up to `playerPositionIntervalTicks` for the next one — see
* players-gateway.ts's `open()`. No history/persistence beyond the latest snapshot: unlike chat,
* player positions have no meaningful "history" to keep.
*/
export async function publishPlayerPositions(serverId: string, dimension: number, players: PlayerPosition[]) {
const payload = JSON.stringify({ dimension, players });
await redis.set(snapshotKey(serverId), payload);
await redis.publish(playerChannel(serverId), payload);
}
export async function getPlayerSnapshot(serverId: string): Promise<string | null> {
return redis.get(snapshotKey(serverId));
}
+45 -1
View File
@@ -1,12 +1,13 @@
import { describe, test, expect, beforeAll, afterAll } from "bun:test";
import { and, eq } from "drizzle-orm";
import { db } from "./db/client";
import { chatMessages, chunkColumns, chunkSections } from "./db/schema";
import { chatMessages, chunkColumns, chunkSections, servers } from "./db/schema";
import { redis, DIRTY_CHUNK_STREAM } from "./redis";
import { wsGateway, chunkOf, getModSocket } from "./ws-gateway";
import { createTestServer, deleteTestServer, FakeSocket } from "./test-helpers";
import { redeemLinkCode } from "./link";
import { chatChannel } from "./chat";
import { playerChannel } from "./players";
describe("chunkOf", () => {
test("floors toward negative infinity, matching Math.floor(coord/16)", () => {
@@ -219,4 +220,47 @@ describe("wsGateway.message", () => {
expect(rows).toHaveLength(1);
expect(rows[0]!.source).toBe("game");
});
test("player_positions from the mod is published for web viewers when visibility is enabled", async () => {
const socket = new FakeSocket();
await wsGateway.message(socket, JSON.stringify({ type: "hello", token: server.token }));
const sub = redis.duplicate();
await sub.subscribe(playerChannel(server.id));
const received = new Promise<string>((resolve) => {
sub.once("message", (_channel, message) => resolve(message));
});
const players = [{ uuid: "u1", username: "Alex", x: 3, y: 70, z: 3 }];
await wsGateway.message(socket, JSON.stringify({ type: "player_positions", dimension: 0, players }));
expect(JSON.parse(await received)).toEqual({ dimension: 0, players });
sub.disconnect();
});
test("player_positions is dropped, not published, when the server has visibility disabled", async () => {
const hiddenServer = await createTestServer("ws-gateway-players-hidden");
await db.update(servers).set({ playerPositionsVisible: false }).where(eq(servers.id, hiddenServer.id));
const socket = new FakeSocket();
await wsGateway.message(socket, JSON.stringify({ type: "hello", token: hiddenServer.token }));
const sub = redis.duplicate();
await sub.subscribe(playerChannel(hiddenServer.id));
let received = false;
sub.once("message", () => {
received = true;
});
await wsGateway.message(
socket,
JSON.stringify({ type: "player_positions", dimension: 0, players: [{ uuid: "u1", username: "Alex", x: 0, y: 64, z: 0 }] }),
);
// No reliable "nothing was published" signal beyond a short wait — publishing is
// synchronous within wsGateway.message's await chain, so if it were going to happen it
// already has by the time this line runs.
expect(received).toBe(false);
sub.disconnect();
await deleteTestServer(hiddenServer.id);
});
});
+17
View File
@@ -4,6 +4,7 @@ import { chunkColumns, chunkSections, servers } from "./db/schema";
import { markChunkDirty } from "./redis";
import { storeLinkCode } from "./link";
import { recordAndPublishChat } from "./chat";
import { arePlayerPositionsVisible, publishPlayerPositions, type PlayerPosition } from "./players";
// Wire protocol (mod <-> api), one JSON object per WS text frame:
//
@@ -49,6 +50,14 @@ import { recordAndPublishChat } from "./chat";
// (per-server `waypointFormat` config) and forwards the structured point — the mod owns building
// the actual chat text in that format (see WaypointShare.java's javadoc for the researched wire
// formats and their attribution).
//
// mod -> api {"type":"player_positions","dimension":0,"players":[{"uuid":"...","username":"...","x":..,"y":..,"z":..}]}
//
// Phase 7b: the mod's throttled online-player roster, always the full current list (not a diff —
// see PlayerPosition.java's javadoc). Not persisted (no meaningful history) — just fanned out via
// players.ts's publishPlayerPositions to whatever browsers are subscribed on /ws/players/:serverId
// (players-gateway.ts), gated on the per-server `playerPositionsVisible` admin toggle (independent
// of the mod-local `playerTrackingEnabled` config that decides whether this message is sent at all).
interface Column {
x: number;
@@ -202,6 +211,14 @@ export const wsGateway = {
});
return;
}
if (msg.type === "player_positions") {
if (!(await arePlayerPositionsVisible(state.serverId))) return;
const dimension: number = msg.dimension;
const players: PlayerPosition[] = msg.players ?? [];
await publishPlayerPositions(state.serverId, dimension, players);
return;
}
},
close(ws: any) {