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
@@ -60,6 +60,7 @@ function adminpanel() {
authMode: server.authMode,
anonymousChatAllowed: server.anonymousChatAllowed,
waypointFormat: server.waypointFormat,
playerPositionsVisible: server.playerPositionsVisible,
}),
});
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.
const SESSION_STORAGE_KEY = "mcmapper_session";
const NICKNAME_STORAGE_KEY = "mcmapper_nickname";
const SHOW_PLAYERS_STORAGE_KEY = "mcmapper_show_players";
function mapmapper() {
return {
@@ -55,6 +56,16 @@ function mapmapper() {
regionStatus: "",
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() {
const servers = await fetch("/api/servers").then((r) => r.json());
this.loading = false;
@@ -63,6 +74,7 @@ function mapmapper() {
this.leaflet = L.map("map", { crs: L.CRS.Simple, minZoom: -4, maxZoom: 6 });
this.leaflet.setView([0, 0], 0);
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("mousedown", (e) => this.startRegionSelect(e));
@@ -87,6 +99,7 @@ function mapmapper() {
await this.loadAccount();
await this.loadMarkers();
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() {
const message = this.chatInput.trim();
if (!message || !this.chatSocket) return;