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
+27 -12
View File
@@ -90,17 +90,29 @@ the resulting token. Once the mod connects and sends its initial chunk backfill,
`/admin` (linked from the map page's header) manages the server registry: register new servers `/admin` (linked from the map page's header) manages the server registry: register new servers
(generates their token — never admin-supplied, so it can't collide with or be guessed from (generates their token — never admin-supplied, so it can't collide with or be guessed from
anything else), and edit `authMode`, `anonymousChatAllowed`, and `waypointFormat` per server. anything else), and edit `authMode`, `anonymousChatAllowed`, `waypointFormat`, and
It's gated behind a single shared secret, not a per-account role (this is a single-operator `playerPositionsVisible` per server. It's gated behind a single shared secret, not a per-account
backend) — set `MCMAPPER_ADMIN_TOKEN` in `api/`'s untracked `.env` (see `api/.env.example`), role (this is a single-operator backend) — set `MCMAPPER_ADMIN_TOKEN` in `api/`'s untracked
restart `api`, then enter that same value into the panel's unlock prompt. Leaving it unset `.env` (see `api/.env.example`), restart `api`, then enter that same value into the panel's
disables every `/api/admin/*` route (401), it does not default to open. The token is remembered unlock prompt. Leaving it unset disables every `/api/admin/*` route (401), it does not default to
in the browser's `localStorage` after unlocking, same pattern as the player-facing session token open. The token is remembered in the browser's `localStorage` after unlocking, same pattern as
(see `api/src/link.ts`'s doc comment). the player-facing session token (see `api/src/link.ts`'s doc comment).
Settings not exposed here yet (which map types render, dimension filtering, player-position Settings not exposed here yet (which map types render, dimension filtering) don't have underlying
visibility) don't have underlying features built for them either — no point in a knob nothing features built for them either — no point in a knob nothing reads. They'll gain admin UI
reads. They'll gain admin UI alongside the feature that needs them. alongside the feature that needs them.
### Player positions (Phase 7b)
The mod sends a throttled roster of online players (`player_positions` over `/ws`, see
`api/src/ws-gateway.ts`'s doc comment) — its own `playerTrackingEnabled`/
`playerPositionIntervalTicks` config decides whether/how often it sends this at all. The backend
relays it to any browser subscribed on `/ws/players/:serverId` (`api/src/players-gateway.ts`),
gated per-server on the admin panel's `playerPositionsVisible` toggle (default on) — an
independent, backend-side "should we show it" decision from the mod's own config. Not persisted
(no meaningful history for a live position), just the latest roster in Redis (`api/src/players.ts`)
so a browser tab that connects between mod flushes doesn't sit empty. The map renders players as
map markers with a `show`/hide toggle and an online count, right above the region-export panel.
## Running tests ## Running tests
@@ -153,8 +165,11 @@ fixed `MCMAPPER_ADMIN_TOKEN` for `tests/admin.spec.ts` to use — see `config.ts
UI flows most worth a real click-through: the marker click-to-place/edit popup UI flows most worth a real click-through: the marker click-to-place/edit popup
(`tests/markers.spec.ts`, including that a marker created while linked shows up in a second (`tests/markers.spec.ts`, including that a marker created while linked shows up in a second
browser context with the same session — the cross-device sync claim), the region-select drag + browser context with the same session — the cross-device sync claim), the region-select drag +
glTF export (`tests/region-export.spec.ts`, including a real triggered file download), and the glTF export (`tests/region-export.spec.ts`, including a real triggered file download), the
admin panel's token gate + server register/edit/delete round trip (`tests/admin.spec.ts`). admin panel's token gate + server register/edit/delete round trip (`tests/admin.spec.ts`), and
the player-position relay (`tests/players.spec.ts` — simulates a mod connection over the real
`/ws` protocol from inside the browser context and confirms a subscribed tab renders the roster,
respects the `show` toggle, and clears markers on an empty roster).
## Attribution ## Attribution
+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, { const result = await updateServerSettings(createdId, {
anonymousChatAllowed: false, anonymousChatAllowed: false,
waypointFormat: "xaero", waypointFormat: "xaero",
playerPositionsVisible: false,
}); });
expect(result.ok).toBe(true); expect(result.ok).toBe(true);
if (!result.ok) return; if (!result.ok) return;
expect(result.server.name).toBe("admin-test-update"); // untouched expect(result.server.name).toBe("admin-test-update"); // untouched
expect(result.server.anonymousChatAllowed).toBe(false); expect(result.server.anonymousChatAllowed).toBe(false);
expect(result.server.waypointFormat).toBe("xaero"); 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 () => { test("rejects an invalid waypointFormat", async () => {
+1
View File
@@ -45,6 +45,7 @@ export type UpdateServerSettingsInput = Partial<{
authMode: string; authMode: string;
anonymousChatAllowed: boolean; anonymousChatAllowed: boolean;
waypointFormat: string; waypointFormat: string;
playerPositionsVisible: boolean;
}>; }>;
export type UpdateServerSettingsResult = 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 // 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. // to flip this yet (Phase 6) — set directly in the DB for now, same as anonymousChatAllowed.
waypointFormat: text("waypoint_format").notNull().default("journeymap"), 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(), 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 { meshPointers, servers, tilePointers } from "./db/schema";
import { wsGateway } from "./ws-gateway"; import { wsGateway } from "./ws-gateway";
import { chatGateway } from "./chat-gateway"; import { chatGateway } from "./chat-gateway";
import { playersGateway } from "./players-gateway";
import { minio, TILE_BUCKET, ensureTileBucket } from "./minio"; import { minio, TILE_BUCKET, ensureTileBucket } from "./minio";
import { redeemLinkCode, getAccountForSession, revokeSession } from "./link"; import { redeemLinkCode, getAccountForSession, revokeSession } from "./link";
import { createMarker, listMarkers, deleteMarker, updateMarker, shareMarkerToChat } from "./markers"; import { createMarker, listMarkers, deleteMarker, updateMarker, shareMarkerToChat } from "./markers";
@@ -259,13 +260,20 @@ export const app = new Elysia()
set.status = 401; set.status = 401;
return { error: "unauthenticated" }; return { error: "unauthenticated" };
} }
const { name, authMode, anonymousChatAllowed, waypointFormat } = body as { const { name, authMode, anonymousChatAllowed, waypointFormat, playerPositionsVisible } = body as {
name?: string; name?: string;
authMode?: string; authMode?: string;
anonymousChatAllowed?: boolean; anonymousChatAllowed?: boolean;
waypointFormat?: string; 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; if (!result.ok) set.status = result.error === "not_found" ? 404 : 400;
return result; return result;
}) })
@@ -287,6 +295,11 @@ export const app = new Elysia()
open: chatGateway.open, open: chatGateway.open,
message: chatGateway.message, message: chatGateway.message,
close: chatGateway.close, 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 // 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 { describe, test, expect, beforeAll, afterAll } from "bun:test";
import { and, eq } from "drizzle-orm"; import { and, eq } from "drizzle-orm";
import { db } from "./db/client"; 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 { redis, DIRTY_CHUNK_STREAM } from "./redis";
import { wsGateway, chunkOf, getModSocket } from "./ws-gateway"; import { wsGateway, chunkOf, getModSocket } from "./ws-gateway";
import { createTestServer, deleteTestServer, FakeSocket } from "./test-helpers"; import { createTestServer, deleteTestServer, FakeSocket } from "./test-helpers";
import { redeemLinkCode } from "./link"; import { redeemLinkCode } from "./link";
import { chatChannel } from "./chat"; import { chatChannel } from "./chat";
import { playerChannel } from "./players";
describe("chunkOf", () => { describe("chunkOf", () => {
test("floors toward negative infinity, matching Math.floor(coord/16)", () => { test("floors toward negative infinity, matching Math.floor(coord/16)", () => {
@@ -219,4 +220,47 @@ describe("wsGateway.message", () => {
expect(rows).toHaveLength(1); expect(rows).toHaveLength(1);
expect(rows[0]!.source).toBe("game"); 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 { markChunkDirty } from "./redis";
import { storeLinkCode } from "./link"; import { storeLinkCode } from "./link";
import { recordAndPublishChat } from "./chat"; import { recordAndPublishChat } from "./chat";
import { arePlayerPositionsVisible, publishPlayerPositions, type PlayerPosition } from "./players";
// Wire protocol (mod <-> api), one JSON object per WS text frame: // 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 // (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 // the actual chat text in that format (see WaypointShare.java's javadoc for the researched wire
// formats and their attribution). // 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 { interface Column {
x: number; x: number;
@@ -202,6 +211,14 @@ export const wsGateway = {
}); });
return; 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) { close(ws: any) {
+96
View File
@@ -0,0 +1,96 @@
// Covers Phase 7b's player-position relay end to end: a simulated mod connection sends a
// player_positions roster over the real WS gateway, and a real browser tab — subscribed via
// /ws/players/:serverId — is expected to render it as a map marker and update the online count.
// Nothing here is seeded via SQL (unlike markers.spec.ts's terrain footprint) since there's
// nothing to seed: the roster only exists once the WS protocol actually runs, which is exactly
// the gap a headless unit test (ws-gateway.test.ts) can't close — it never proves a real browser
// on the other end of players-gateway.ts's Redis pub/sub actually receives and renders it.
import { test, expect, type Page } from "@playwright/test";
import { readE2eState, API_PORT } from "../config";
const state = readE2eState();
async function waitForMapReady(page: Page) {
await page.goto("/");
await expect(page.getByTestId("players-toggle")).toBeVisible();
await page.locator("#map.leaflet-container").waitFor();
}
/**
* Opens a throwaway WS connection *inside the browser context* (not Node-side) — this page
* already has other in-app sockets open (chat, players), but a raw WebSocket a test script opens
* here is entirely independent of those; Chromium has no trouble holding several at once. Playing
* the mod's own wire protocol (see ws-gateway.ts's doc comment) rather than reaching into
* Redis/Postgres directly is the point: it's what proves the real WS handler path works, not just
* whatever a lower-level test harness could fake.
*/
async function sendAsMod(page: Page, port: number, token: string, message: object) {
await page.evaluate(
([port, token, message]) => {
return new Promise<void>((resolve, reject) => {
const ws = new WebSocket(`ws://localhost:${port}/ws`);
ws.onopen = () => ws.send(JSON.stringify({ type: "hello", token }));
ws.onmessage = (ev) => {
const parsed = JSON.parse(ev.data as string);
if (parsed.type === "hello_ack" && parsed.ok) {
ws.send(JSON.stringify(message));
setTimeout(() => {
ws.close();
resolve();
}, 200);
} else if (parsed.type === "hello_ack" && !parsed.ok) {
reject(new Error(`hello rejected: ${parsed.error}`));
}
};
ws.onerror = () => reject(new Error("mod ws connection failed"));
});
},
[port, token, message] as const,
);
}
test("a player roster sent over the mod WS protocol shows up as a marker on the map", async ({ page }) => {
await waitForMapReady(page);
await expect(page.getByTestId("players-online-count")).toHaveText("0 online");
await sendAsMod(page, API_PORT, state.serverToken, {
type: "player_positions",
dimension: 0,
players: [{ uuid: "e2e-player-1", username: "E2EPlayer", x: 5, y: 64, z: 5 }],
});
await expect(page.getByTestId("players-online-count")).toHaveText("1 online");
await expect(page.locator(".leaflet-tooltip", { hasText: "E2EPlayer" })).toBeVisible();
});
test("unchecking 'show' hides player markers without dropping the roster count", async ({ page }) => {
await waitForMapReady(page);
await sendAsMod(page, API_PORT, state.serverToken, {
type: "player_positions",
dimension: 0,
players: [{ uuid: "e2e-player-2", username: "ToggleTest", x: 1, y: 64, z: 1 }],
});
await expect(page.getByTestId("players-online-count")).toHaveText("1 online");
await expect(page.locator(".leaflet-tooltip", { hasText: "ToggleTest" })).toBeVisible();
await page.getByTestId("players-toggle").uncheck();
await expect(page.locator(".leaflet-tooltip", { hasText: "ToggleTest" })).toHaveCount(0);
// The roster itself is untouched by the visibility toggle — only the map layer is.
await expect(page.getByTestId("players-online-count")).toHaveText("1 online");
});
test("an empty roster (everyone logged out) clears existing markers", async ({ page }) => {
await waitForMapReady(page);
await sendAsMod(page, API_PORT, state.serverToken, {
type: "player_positions",
dimension: 0,
players: [{ uuid: "e2e-player-3", username: "LoggingOut", x: 2, y: 64, z: 2 }],
});
await expect(page.locator(".leaflet-tooltip", { hasText: "LoggingOut" })).toBeVisible();
await sendAsMod(page, API_PORT, state.serverToken, { type: "player_positions", dimension: 0, players: [] });
await expect(page.getByTestId("players-online-count")).toHaveText("0 online");
await expect(page.locator(".leaflet-tooltip", { hasText: "LoggingOut" })).toHaveCount(0);
});
+1
View File
@@ -60,6 +60,7 @@ function adminpanel() {
authMode: server.authMode, authMode: server.authMode,
anonymousChatAllowed: server.anonymousChatAllowed, anonymousChatAllowed: server.anonymousChatAllowed,
waypointFormat: server.waypointFormat, waypointFormat: server.waypointFormat,
playerPositionsVisible: server.playerPositionsVisible,
}), }),
}); });
server.status = res.ok ? "saved" : "save failed"; server.status = res.ok ? "saved" : "save failed";
+44
View File
@@ -18,6 +18,7 @@ const DEFAULT_MARKER_HEIGHT = 60;
// api/src/link.ts's doc comment for why that's a deliberate Phase 3 MVP tradeoff. // api/src/link.ts's doc comment for why that's a deliberate Phase 3 MVP tradeoff.
const SESSION_STORAGE_KEY = "mcmapper_session"; const SESSION_STORAGE_KEY = "mcmapper_session";
const NICKNAME_STORAGE_KEY = "mcmapper_nickname"; const NICKNAME_STORAGE_KEY = "mcmapper_nickname";
const SHOW_PLAYERS_STORAGE_KEY = "mcmapper_show_players";
function mapmapper() { function mapmapper() {
return { return {
@@ -55,6 +56,16 @@ function mapmapper() {
regionStatus: "", regionStatus: "",
exporting: false, exporting: false,
// Phase 7b: live player positions, relayed from the mod via /ws/players/:serverId (see
// players-gateway.ts). `onlinePlayers` always reflects the full current roster (not a diff,
// same "current state" philosophy as markers/columns) — a player logging out just stops
// appearing in the next message. `showPlayers` only toggles the map layer; the roster (and
// the sidebar count) keeps updating either way.
playersSocket: null,
playerLayer: null,
onlinePlayers: [],
showPlayers: localStorage.getItem(SHOW_PLAYERS_STORAGE_KEY) !== "false",
async init() { async init() {
const servers = await fetch("/api/servers").then((r) => r.json()); const servers = await fetch("/api/servers").then((r) => r.json());
this.loading = false; this.loading = false;
@@ -63,6 +74,7 @@ function mapmapper() {
this.leaflet = L.map("map", { crs: L.CRS.Simple, minZoom: -4, maxZoom: 6 }); this.leaflet = L.map("map", { crs: L.CRS.Simple, minZoom: -4, maxZoom: 6 });
this.leaflet.setView([0, 0], 0); this.leaflet.setView([0, 0], 0);
this.markerLayer = L.layerGroup().addTo(this.leaflet); this.markerLayer = L.layerGroup().addTo(this.leaflet);
this.playerLayer = L.layerGroup().addTo(this.leaflet);
this.leaflet.on("click", (e) => this.onMapClick(e)); this.leaflet.on("click", (e) => this.onMapClick(e));
this.leaflet.on("mousedown", (e) => this.startRegionSelect(e)); this.leaflet.on("mousedown", (e) => this.startRegionSelect(e));
@@ -87,6 +99,7 @@ function mapmapper() {
await this.loadAccount(); await this.loadAccount();
await this.loadMarkers(); await this.loadMarkers();
this.connectChat(); this.connectChat();
this.connectPlayers();
} }
}, },
@@ -393,6 +406,37 @@ function mapmapper() {
}; };
}, },
connectPlayers() {
const proto = location.protocol === "https:" ? "wss" : "ws";
this.playersSocket = new WebSocket(`${proto}://${location.host}/ws/players/${this.server.id}`);
this.playersSocket.onmessage = (ev) => {
const { players } = JSON.parse(ev.data);
this.onlinePlayers = players;
this.renderPlayerLayer(players);
};
},
renderPlayerLayer(players) {
this.playerLayer.clearLayers();
if (!this.showPlayers) return;
for (const player of players) {
L.circleMarker(worldToLatLng(player.x, player.z), {
radius: 5,
color: "#facc15",
fillColor: "#facc15",
fillOpacity: 1,
weight: 2,
})
.bindTooltip(player.username, { permanent: true, direction: "top", offset: [0, -6] })
.addTo(this.playerLayer);
}
},
onShowPlayersChange() {
localStorage.setItem(SHOW_PLAYERS_STORAGE_KEY, String(this.showPlayers));
this.renderPlayerLayer(this.onlinePlayers);
},
sendChat() { sendChat() {
const message = this.chatInput.trim(); const message = this.chatInput.trim();
if (!message || !this.chatSocket) return; if (!message || !this.chatSocket) return;
+4 -1
View File
@@ -37,7 +37,7 @@ html(lang="en")
input.flex-1.bg-neutral-900.text-sm.px-2.py-1.rounded.border.border-neutral-700( input.flex-1.bg-neutral-900.text-sm.px-2.py-1.rounded.border.border-neutral-700(
type="text" x-model="server.name" data-testid="admin-server-name") type="text" x-model="server.name" data-testid="admin-server-name")
span.text-xs.text-neutral-500.font-mono.truncate(style="max-width: 14rem;" x-text="server.token" data-testid="admin-server-token") span.text-xs.text-neutral-500.font-mono.truncate(style="max-width: 14rem;" x-text="server.token" data-testid="admin-server-token")
div.grid.grid-cols-3.gap-2 div.grid.grid-cols-4.gap-2
select.bg-neutral-900.text-xs.px-2.py-1.rounded.border.border-neutral-700( select.bg-neutral-900.text-xs.px-2.py-1.rounded.border.border-neutral-700(
x-model="server.authMode" data-testid="admin-server-authmode") x-model="server.authMode" data-testid="admin-server-authmode")
option(value="online") online option(value="online") online
@@ -50,6 +50,9 @@ html(lang="en")
label.flex.items-center.gap-1.text-xs.text-neutral-300 label.flex.items-center.gap-1.text-xs.text-neutral-300
input(type="checkbox" x-model="server.anonymousChatAllowed" data-testid="admin-server-anonchat") input(type="checkbox" x-model="server.anonymousChatAllowed" data-testid="admin-server-anonchat")
| anonymous chat | anonymous chat
label.flex.items-center.gap-1.text-xs.text-neutral-300
input(type="checkbox" x-model="server.playerPositionsVisible" data-testid="admin-server-playerpositions")
| player positions
div.flex.gap-2 div.flex.gap-2
button.px-2.py-1.bg-emerald-700.rounded.text-xs(x-on:click="saveServer(server)" data-testid="admin-server-save") Save button.px-2.py-1.bg-emerald-700.rounded.text-xs(x-on:click="saveServer(server)" data-testid="admin-server-save") Save
button.px-2.py-1.bg-red-800.rounded.text-xs(x-on:click="removeServer(server)" data-testid="admin-server-delete") Delete button.px-2.py-1.bg-red-800.rounded.text-xs(x-on:click="removeServer(server)" data-testid="admin-server-delete") Delete
+8
View File
@@ -51,6 +51,14 @@ html(lang="en")
button.px-2.py-1.bg-neutral-700.rounded.text-xs(x-on:click="cancelMarkerForm" data-testid="marker-cancel") Cancel button.px-2.py-1.bg-neutral-700.rounded.text-xs(x-on:click="cancelMarkerForm" data-testid="marker-cancel") Cancel
p.text-xs.text-amber-400(x-show="markerStatus" x-text="markerStatus") p.text-xs.text-amber-400(x-show="markerStatus" x-text="markerStatus")
aside.w-80.flex.flex-col.border-l.border-neutral-700.bg-neutral-800(x-show="server") aside.w-80.flex.flex-col.border-l.border-neutral-700.bg-neutral-800(x-show="server")
div.border-b.border-neutral-700.p-2.flex.items-center.justify-between
h2.text-sm.font-semibold Players
div.flex.items-center.gap-2
span.text-xs.text-neutral-400(x-text="onlinePlayers.length + ' online'" data-testid="players-online-count")
label.flex.items-center.gap-1.text-xs.text-neutral-300
input(type="checkbox" x-model="showPlayers" x-on:change="onShowPlayersChange" data-testid="players-toggle")
| show
div.border-b.border-neutral-700.p-2.space-y-2 div.border-b.border-neutral-700.p-2.space-y-2
div.flex.items-center.justify-between div.flex.items-center.justify-between
h2.text-sm.font-semibold Region export h2.text-sm.font-semibold Region export