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:
@@ -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
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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