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:
@@ -0,0 +1 @@
|
||||
ALTER TABLE "servers" ADD COLUMN IF NOT EXISTS "player_positions_visible" boolean NOT NULL DEFAULT true;
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -45,6 +45,7 @@ export type UpdateServerSettingsInput = Partial<{
|
||||
authMode: string;
|
||||
anonymousChatAllowed: boolean;
|
||||
waypointFormat: string;
|
||||
playerPositionsVisible: boolean;
|
||||
}>;
|
||||
|
||||
export type UpdateServerSettingsResult =
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
@@ -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: [] });
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user