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:
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -37,7 +37,7 @@ html(lang="en")
|
||||
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")
|
||||
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(
|
||||
x-model="server.authMode" data-testid="admin-server-authmode")
|
||||
option(value="online") online
|
||||
@@ -50,6 +50,9 @@ html(lang="en")
|
||||
label.flex.items-center.gap-1.text-xs.text-neutral-300
|
||||
input(type="checkbox" x-model="server.anonymousChatAllowed" data-testid="admin-server-anonchat")
|
||||
| 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
|
||||
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
|
||||
|
||||
@@ -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
|
||||
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")
|
||||
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.flex.items-center.justify-between
|
||||
h2.text-sm.font-semibold Region export
|
||||
|
||||
Reference in New Issue
Block a user