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
(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.
It's gated behind a single shared secret, not a per-account role (this is a single-operator
backend) — set `MCMAPPER_ADMIN_TOKEN` in `api/`'s untracked `.env` (see `api/.env.example`),
restart `api`, then enter that same value into the panel's unlock prompt. Leaving it unset
disables every `/api/admin/*` route (401), it does not default to open. The token is remembered
in the browser's `localStorage` after unlocking, same pattern as the player-facing session token
(see `api/src/link.ts`'s doc comment).
anything else), and edit `authMode`, `anonymousChatAllowed`, `waypointFormat`, and
`playerPositionsVisible` per server. It's gated behind a single shared secret, not a per-account
role (this is a single-operator backend) — set `MCMAPPER_ADMIN_TOKEN` in `api/`'s untracked
`.env` (see `api/.env.example`), restart `api`, then enter that same value into the panel's
unlock prompt. Leaving it unset disables every `/api/admin/*` route (401), it does not default to
open. The token is remembered in the browser's `localStorage` after unlocking, same pattern as
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
visibility) don't have underlying features built for them either — no point in a knob nothing
reads. They'll gain admin UI alongside the feature that needs them.
Settings not exposed here yet (which map types render, dimension filtering) don't have underlying
features built for them either — no point in a knob nothing reads. They'll gain admin UI
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
@@ -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
(`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 +
glTF export (`tests/region-export.spec.ts`, including a real triggered file download), and the
admin panel's token gate + server register/edit/delete round trip (`tests/admin.spec.ts`).
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`), 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
+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) {
+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,
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;
+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(
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
+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
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