diff --git a/README.md b/README.md index b86c312..0903fbf 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/api/drizzle/0004_player_positions.sql b/api/drizzle/0004_player_positions.sql new file mode 100644 index 0000000..60f3f5a --- /dev/null +++ b/api/drizzle/0004_player_positions.sql @@ -0,0 +1 @@ +ALTER TABLE "servers" ADD COLUMN IF NOT EXISTS "player_positions_visible" boolean NOT NULL DEFAULT true; diff --git a/api/src/admin.test.ts b/api/src/admin.test.ts index 2a00571..5df3dff 100644 --- a/api/src/admin.test.ts +++ b/api/src/admin.test.ts @@ -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 () => { diff --git a/api/src/admin.ts b/api/src/admin.ts index 41e6b60..7590ff1 100644 --- a/api/src/admin.ts +++ b/api/src/admin.ts @@ -45,6 +45,7 @@ export type UpdateServerSettingsInput = Partial<{ authMode: string; anonymousChatAllowed: boolean; waypointFormat: string; + playerPositionsVisible: boolean; }>; export type UpdateServerSettingsResult = diff --git a/api/src/db/schema.ts b/api/src/db/schema.ts index e0d4834..4755700 100644 --- a/api/src/db/schema.ts +++ b/api/src/db/schema.ts @@ -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(), }); diff --git a/api/src/index.ts b/api/src/index.ts index 9498be8..f5dab70 100644 --- a/api/src/index.ts +++ b/api/src/index.ts @@ -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 diff --git a/api/src/players-gateway.ts b/api/src/players-gateway.ts new file mode 100644 index 0000000..a768ee2 --- /dev/null +++ b/api/src/players-gateway.ts @@ -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>(); + +playerSubscriber.psubscribe("mcmapper:players:*"); +playerSubscriber.on("pmessage", (_pattern: string, channel: string, message: string) => { + // "mcmapper:players:snapshot:" 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 }; diff --git a/api/src/players.test.ts b/api/src/players.test.ts new file mode 100644 index 0000000..8b6fa8d --- /dev/null +++ b/api/src/players.test.ts @@ -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((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: [] }); + }); +}); diff --git a/api/src/players.ts b/api/src/players.ts new file mode 100644 index 0000000..f9e3326 --- /dev/null +++ b/api/src/players.ts @@ -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 { + 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 { + return redis.get(snapshotKey(serverId)); +} diff --git a/api/src/ws-gateway.test.ts b/api/src/ws-gateway.test.ts index 5c61902..fc27aa2 100644 --- a/api/src/ws-gateway.test.ts +++ b/api/src/ws-gateway.test.ts @@ -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((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); + }); }); diff --git a/api/src/ws-gateway.ts b/api/src/ws-gateway.ts index 691d232..02c0219 100644 --- a/api/src/ws-gateway.ts +++ b/api/src/ws-gateway.ts @@ -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) { diff --git a/e2e/tests/players.spec.ts b/e2e/tests/players.spec.ts new file mode 100644 index 0000000..9cda0a3 --- /dev/null +++ b/e2e/tests/players.spec.ts @@ -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((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); +}); diff --git a/frontend/src/public/js/admin.js b/frontend/src/public/js/admin.js index f8f2cf5..d3fbebf 100644 --- a/frontend/src/public/js/admin.js +++ b/frontend/src/public/js/admin.js @@ -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"; diff --git a/frontend/src/public/js/map.js b/frontend/src/public/js/map.js index cecc2dc..d47b669 100644 --- a/frontend/src/public/js/map.js +++ b/frontend/src/public/js/map.js @@ -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; diff --git a/frontend/src/views/admin.pug b/frontend/src/views/admin.pug index 4b6851c..4cf5ebf 100644 --- a/frontend/src/views/admin.pug +++ b/frontend/src/views/admin.pug @@ -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 diff --git a/frontend/src/views/index.pug b/frontend/src/views/index.pug index 61d1815..d693c3d 100644 --- a/frontend/src/views/index.pug +++ b/frontend/src/views/index.pug @@ -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