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
|
`/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
|
||||||
|
|
||||||
|
|||||||
@@ -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, {
|
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 () => {
|
||||||
|
|||||||
@@ -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 =
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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 { 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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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,
|
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";
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user