From 66fe2ffb7e978349adeb97ed4e88949612e24789 Mon Sep 17 00:00:00 2001 From: Octoturge Date: Sat, 8 Aug 2026 19:21:07 +0200 Subject: [PATCH] Phase 4: web markers and JourneyMap/Xaero waypoint chat sharing Linked accounts can place 2D markers on the map (y auto-derived from the chunk store's heightmap); anonymous visitors keep a localStorage-only list via the same height lookup. Sharing a marker forwards a structured payload to the mod over its WS connection, which builds the actual chat text (see MCMapper-Mod for the JourneyMap/Xaero formatting). Built test-first per the project's TDD workflow. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_015tKdPZt78zbPUZMXWzKEKt --- THIRD_PARTY_NOTICES.md | 9 +- api/drizzle/0003_markers.sql | 16 ++ api/src/db/schema.ts | 26 +++ api/src/index.test.ts | 95 ++++++++++- api/src/index.ts | 78 +++++++++ api/src/markers.test.ts | 228 ++++++++++++++++++++++++++ api/src/markers.ts | 130 +++++++++++++++ api/src/ws-gateway.ts | 8 + frontend/src/index.ts | 1 + frontend/src/public/js/coords.js | 24 +++ frontend/src/public/js/coords.test.ts | 32 ++++ frontend/src/public/js/map.js | 167 ++++++++++++++++++- frontend/src/views/index.pug | 32 +++- 13 files changed, 836 insertions(+), 10 deletions(-) create mode 100644 api/drizzle/0003_markers.sql create mode 100644 api/src/markers.test.ts create mode 100644 api/src/markers.ts create mode 100644 frontend/src/public/js/coords.js create mode 100644 frontend/src/public/js/coords.test.ts diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 6d327f4..bec2ac2 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -7,7 +7,14 @@ use. Nothing is copied from closed-source projects — in particular, Xaero's Mi closed-source, so any Xaero-compatible waypoint link support is implemented purely from publicly documented/community-reverse-engineered wire format, never from Xaero's code. -No third-party code has been adapted yet. Entries will be added here as they land, in the form: +## JourneyMap / Xaero waypoint chat-link formats +- This backend only resolves a marker's `y` and forwards a structured `{name,x,y,z,dimension, + color,format}` payload to the mod over its WS connection (see `api/src/markers.ts`'s + `shareMarkerToChat`) — the actual JourneyMap/Xaero chat-text formats are built entirely on the + mod side. See `MCMapper-Mod/THIRD_PARTY_NOTICES.md` for the researched sources and attribution + (`common/.../protocol/WaypointChatFormatter.java`). + +Further entries will be added here as they land, in the form: ``` ## diff --git a/api/drizzle/0003_markers.sql b/api/drizzle/0003_markers.sql new file mode 100644 index 0000000..a3b6442 --- /dev/null +++ b/api/drizzle/0003_markers.sql @@ -0,0 +1,16 @@ +ALTER TABLE "servers" ADD COLUMN IF NOT EXISTS "waypoint_format" text NOT NULL DEFAULT 'journeymap'; + +CREATE TABLE IF NOT EXISTS "markers" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "account_id" uuid NOT NULL REFERENCES "accounts"("id") ON DELETE CASCADE, + "server_id" uuid NOT NULL REFERENCES "servers"("id") ON DELETE CASCADE, + "dimension" integer NOT NULL, + "x" integer NOT NULL, + "y" integer NOT NULL, + "z" integer NOT NULL, + "name" text NOT NULL, + "color" text NOT NULL, + "created_at" timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS "markers_account_server_idx" ON "markers" ("account_id", "server_id"); diff --git a/api/src/db/schema.ts b/api/src/db/schema.ts index 13c8956..0f4f676 100644 --- a/api/src/db/schema.ts +++ b/api/src/db/schema.ts @@ -10,6 +10,10 @@ export const servers = pgTable("servers", { // Phase 3: web chat is open to anonymous (unlinked) visitors by default; an admin can require // linking per-server. No admin UI to flip this yet (Phase 6) — set directly in the DB for now. anonymousChatAllowed: boolean("anonymous_chat_allowed").notNull().default(true), + // Phase 4: which format `/mcmapper`-side chat waypoint links are built in when a marker is + // 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"), createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), }); @@ -54,6 +58,28 @@ export const chatMessages = pgTable("chat_messages", { createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), }); +// A web-placed marker, owned by a linked account (anonymous visitors keep their marker list in +// browser localStorage only — see the plan's "Feature: web markers..." section — so there's +// nothing to persist here for them). Placement is 2D-only (x/z picked on the Leaflet map); `y` is +// resolved once at creation time from `chunkColumns`' heightmap and stored, not recomputed later, +// so a marker doesn't silently move if the terrain above it changes afterward. +export const markers = pgTable("markers", { + id: uuid("id").defaultRandom().primaryKey(), + accountId: uuid("account_id") + .notNull() + .references(() => accounts.id, { onDelete: "cascade" }), + serverId: uuid("server_id") + .notNull() + .references(() => servers.id, { onDelete: "cascade" }), + dimension: integer("dimension").notNull(), + x: integer("x").notNull(), + y: integer("y").notNull(), + z: integer("z").notNull(), + name: text("name").notNull(), + color: text("color").notNull(), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), +}); + // Column-granularity world state: the topmost non-air block per (dimension, x, z), plus its // height. Kept deliberately separate from `chunkSections` below — cheap to write/read for 2D // tile rendering, which never needs full voxel data. diff --git a/api/src/index.test.ts b/api/src/index.test.ts index e1eb4c5..0029d88 100644 --- a/api/src/index.test.ts +++ b/api/src/index.test.ts @@ -3,8 +3,9 @@ import { app } from "./index"; import { db } from "./db/client"; import { meshPointers, tilePointers } from "./db/schema"; import { minio, TILE_BUCKET } from "./minio"; -import { createTestServer, deleteTestServer } from "./test-helpers"; +import { createTestServer, deleteTestServer, createTestSession } from "./test-helpers"; import { storeLinkCode } from "./link"; +import { chunkColumns } from "./db/schema"; // Elysia's `.handle()` drives the app in-process against a plain Request/Response, without // binding a real port — avoids racing a real running instance for the port (see MCMapper's @@ -23,6 +24,10 @@ function post(path: string, body?: unknown, headers?: Record) { ); } +function del(path: string, headers?: Record) { + return app.handle(new Request(`http://localhost${path}`, { method: "DELETE", headers })); +} + describe("GET /health", () => { test("reports ok", async () => { const res = await get("/health"); @@ -202,3 +207,91 @@ describe("POST /api/link/redeem, GET /api/me, POST /api/unlink", () => { expect((await afterUnlink.json() as any).account).toBeNull(); }); }); + +describe("marker routes", () => { + let server: { id: string }; + let sessionToken: string; + + beforeAll(async () => { + server = await createTestServer("markers-route"); + ({ sessionToken } = await createTestSession("MarkerRouteTester")); + await db.insert(chunkColumns).values({ + serverId: server.id, + dimension: 0, + x: 42, + z: 43, + blockId: 2, + blockMeta: 0, + height: 75, + }); + }); + + afterAll(async () => { + await deleteTestServer(server.id); + }); + + test("POST /api/markers requires a session", async () => { + const res = await post("/api/markers", { serverId: server.id, dimension: 0, x: 42, z: 43, name: "N", color: "#fff" }); + expect(res.status).toBe(401); + }); + + test("POST /api/markers creates a marker with y resolved from the heightmap, then it's listable and deletable", async () => { + const createRes = await post( + "/api/markers", + { serverId: server.id, dimension: 0, x: 42, z: 43, name: "RouteMarker", color: "#ABCDEF" }, + { "X-MCMapper-Session": sessionToken }, + ); + expect(createRes.status).toBe(200); + const created = (await createRes.json()) as any; + expect(created.marker.y).toBe(75); + + const listRes = await get(`/api/markers/${server.id}`); + expect(listRes.status).toBe(401); + + const authedListRes = await app.handle( + new Request(`http://localhost/api/markers/${server.id}`, { + headers: { "X-MCMapper-Session": sessionToken }, + }), + ); + const list = (await authedListRes.json()) as any[]; + expect(list.some((m) => m.id === created.marker.id)).toBe(true); + + const deleteRes = await del(`/api/markers/${created.marker.id}`, { "X-MCMapper-Session": sessionToken }); + expect(deleteRes.status).toBe(200); + + const afterDeleteList = await app.handle( + new Request(`http://localhost/api/markers/${server.id}`, { + headers: { "X-MCMapper-Session": sessionToken }, + }), + ); + const listAfter = (await afterDeleteList.json()) as any[]; + expect(listAfter.some((m) => m.id === created.marker.id)).toBe(false); + }); + + test("GET /api/height/:serverId/:dimension/:x/:z needs no session (anonymous markers need a real y too)", async () => { + const res = await get(`/api/height/${server.id}/0/42/43`); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ height: 75 }); + }); + + test("GET /api/height/... 404s for an unmapped column", async () => { + const res = await get(`/api/height/${server.id}/0/999999/999999`); + expect(res.status).toBe(404); + }); + + test("POST /api/markers/:id/share reports server_not_connected when the mod isn't online", async () => { + const createRes = await post( + "/api/markers", + { serverId: server.id, dimension: 0, x: 42, z: 43, name: "ShareMe", color: "#111111" }, + { "X-MCMapper-Session": sessionToken }, + ); + const created = (await createRes.json()) as any; + + const shareRes = await post(`/api/markers/${created.marker.id}/share`, undefined, { + "X-MCMapper-Session": sessionToken, + }); + expect(shareRes.status).toBe(400); + const body = (await shareRes.json()) as any; + expect(body.error).toBe("server_not_connected"); + }); +}); diff --git a/api/src/index.ts b/api/src/index.ts index 464a717..cba9305 100644 --- a/api/src/index.ts +++ b/api/src/index.ts @@ -6,6 +6,13 @@ import { wsGateway } from "./ws-gateway"; import { chatGateway } from "./chat-gateway"; import { minio, TILE_BUCKET, ensureTileBucket } from "./minio"; import { redeemLinkCode, getAccountForSession, revokeSession } from "./link"; +import { createMarker, listMarkers, deleteMarker, shareMarkerToChat, resolveHeight } from "./markers"; + +const MARKER_SHARE_ERROR_STATUS: Record = { + not_found: 404, + waypoint_sharing_disabled: 400, + server_not_connected: 400, +}; await ensureTileBucket(); @@ -111,6 +118,77 @@ export const app = new Elysia() if (token) await revokeSession(token); return { ok: true }; }) + // Unauthenticated on purpose: anonymous visitors placing a localStorage-only marker still need + // a real y (see markers.ts's resolveHeight doc comment), and this is read-only, scoped to + // already-public tile data. + .get("/api/height/:serverId/:dimension/:x/:z", async ({ params, set }) => { + const height = await resolveHeight(params.serverId, Number(params.dimension), Number(params.x), Number(params.z)); + if (height === null) { + set.status = 404; + return { error: "column_not_mapped" }; + } + return { height }; + }) + // Markers are linked-account-only — anonymous visitors keep their marker list in browser + // localStorage instead (see markers.ts's doc comment and the plan's marker feature section), + // so every route here requires a valid session. + .post("/api/markers", async ({ body, headers, set }) => { + const token = headers["x-mcmapper-session"]; + const account = token ? await getAccountForSession(token) : null; + if (!account) { + set.status = 401; + return { error: "unauthenticated" }; + } + const { serverId, dimension, x, z, name, color } = body as { + serverId?: string; + dimension?: number; + x?: number; + z?: number; + name?: string; + color?: string; + }; + if (!serverId || dimension === undefined || x === undefined || z === undefined || !name || !color) { + set.status = 400; + return { error: "missing_fields" }; + } + const result = await createMarker({ accountId: account.id, serverId, dimension, x, z, name, color }); + if (!result.ok) { + set.status = 400; + return result; + } + return result; + }) + .get("/api/markers/:serverId", async ({ params, headers, set }) => { + const token = headers["x-mcmapper-session"]; + const account = token ? await getAccountForSession(token) : null; + if (!account) { + set.status = 401; + return { error: "unauthenticated" }; + } + return listMarkers(account.id, params.serverId); + }) + .delete("/api/markers/:markerId", async ({ params, headers, set }) => { + const token = headers["x-mcmapper-session"]; + const account = token ? await getAccountForSession(token) : null; + if (!account) { + set.status = 401; + return { error: "unauthenticated" }; + } + const result = await deleteMarker(account.id, params.markerId); + if (!result.ok) set.status = 404; + return result; + }) + .post("/api/markers/:markerId/share", async ({ params, headers, set }) => { + const token = headers["x-mcmapper-session"]; + const account = token ? await getAccountForSession(token) : null; + if (!account) { + set.status = 401; + return { error: "unauthenticated" }; + } + const result = await shareMarkerToChat(account.id, params.markerId); + if (!result.ok) set.status = MARKER_SHARE_ERROR_STATUS[result.error] ?? 400; + return result; + }) .ws("/ws", { open: wsGateway.open, message: wsGateway.message, diff --git a/api/src/markers.test.ts b/api/src/markers.test.ts new file mode 100644 index 0000000..2815a6e --- /dev/null +++ b/api/src/markers.test.ts @@ -0,0 +1,228 @@ +import { describe, test, expect, beforeAll, afterAll } from "bun:test"; +import { eq } from "drizzle-orm"; +import { db } from "./db/client"; +import { chunkColumns, servers } from "./db/schema"; +import { createMarker, listMarkers, deleteMarker, shareMarkerToChat, resolveHeight } from "./markers"; +import { createTestServer, deleteTestServer, createTestSession, FakeSocket } from "./test-helpers"; +import { wsGateway } from "./ws-gateway"; + +describe("createMarker", () => { + let server: { id: string }; + let accountId: string; + + beforeAll(async () => { + server = await createTestServer("markers-create"); + ({ accountId } = await createTestSession("MarkerPlacer")); + await db.insert(chunkColumns).values({ + serverId: server.id, + dimension: 0, + x: 100, + z: 200, + blockId: 2, + blockMeta: 0, + height: 71, + }); + }); + + afterAll(async () => { + await deleteTestServer(server.id); + }); + + test("resolves y from the chunk store's heightmap at that column", async () => { + const result = await createMarker({ + accountId, + serverId: server.id, + dimension: 0, + x: 100, + z: 200, + name: "Base", + color: "#3391FF", + }); + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.marker.y).toBe(71); + expect(result.marker.x).toBe(100); + expect(result.marker.z).toBe(200); + expect(result.marker.name).toBe("Base"); + }); + + test("fails rather than guessing a y for an unmapped column", async () => { + const result = await createMarker({ + accountId, + serverId: server.id, + dimension: 0, + x: 999999, + z: 999999, + name: "Nowhere", + color: "#FFFFFF", + }); + expect(result).toEqual({ ok: false, error: "column_not_mapped" }); + }); +}); + +describe("resolveHeight", () => { + let server: { id: string }; + + beforeAll(async () => { + server = await createTestServer("markers-resolve-height"); + await db.insert(chunkColumns).values({ + serverId: server.id, + dimension: 0, + x: 7, + z: 8, + blockId: 2, + blockMeta: 0, + height: 90, + }); + }); + + afterAll(async () => { + await deleteTestServer(server.id); + }); + + test("returns the mapped column's height", async () => { + expect(await resolveHeight(server.id, 0, 7, 8)).toBe(90); + }); + + test("returns null for an unmapped column, rather than guessing", async () => { + expect(await resolveHeight(server.id, 0, 12345, 12345)).toBeNull(); + }); +}); + +describe("listMarkers / deleteMarker ownership", () => { + let server: { id: string }; + let ownerAccountId: string; + let otherAccountId: string; + let ownedMarkerId: string; + + beforeAll(async () => { + server = await createTestServer("markers-owner"); + ({ accountId: ownerAccountId } = await createTestSession("Owner")); + ({ accountId: otherAccountId } = await createTestSession("Other")); + await db.insert(chunkColumns).values({ + serverId: server.id, + dimension: 0, + x: 5, + z: 5, + blockId: 2, + blockMeta: 0, + height: 64, + }); + const result = await createMarker({ + accountId: ownerAccountId, + serverId: server.id, + dimension: 0, + x: 5, + z: 5, + name: "Mine", + color: "#00FF00", + }); + if (!result.ok) throw new Error("setup failed"); + ownedMarkerId = result.marker.id; + }); + + afterAll(async () => { + await deleteTestServer(server.id); + }); + + test("listMarkers only returns the requesting account's markers for that server", async () => { + const ownerMarkers = await listMarkers(ownerAccountId, server.id); + expect(ownerMarkers).toHaveLength(1); + expect(ownerMarkers[0]!.id).toBe(ownedMarkerId); + + const otherMarkers = await listMarkers(otherAccountId, server.id); + expect(otherMarkers).toHaveLength(0); + }); + + test("deleteMarker refuses to delete another account's marker", async () => { + const result = await deleteMarker(otherAccountId, ownedMarkerId); + expect(result).toEqual({ ok: false, error: "not_found" }); + + const stillThere = await listMarkers(ownerAccountId, server.id); + expect(stillThere).toHaveLength(1); + }); + + test("deleteMarker succeeds for the owning account", async () => { + const result = await deleteMarker(ownerAccountId, ownedMarkerId); + expect(result).toEqual({ ok: true }); + + const afterDelete = await listMarkers(ownerAccountId, server.id); + expect(afterDelete).toHaveLength(0); + }); +}); + +describe("shareMarkerToChat", () => { + let server: { id: string }; + let accountId: string; + let markerId: string; + + beforeAll(async () => { + server = await createTestServer("markers-share"); + ({ accountId } = await createTestSession("Sharer")); + await db.insert(chunkColumns).values({ + serverId: server.id, + dimension: 0, + x: 10, + z: 20, + blockId: 2, + blockMeta: 0, + height: 80, + }); + const result = await createMarker({ + accountId, + serverId: server.id, + dimension: 0, + x: 10, + z: 20, + name: "Shareable", + color: "#B311CF", + }); + if (!result.ok) throw new Error("setup failed"); + markerId = result.marker.id; + }); + + afterAll(async () => { + await deleteTestServer(server.id); + }); + + test("fails when no server is not connected", async () => { + const result = await shareMarkerToChat(accountId, markerId); + expect(result).toEqual({ ok: false, error: "server_not_connected" }); + }); + + test("forwards a structured waypoint_share payload to the mod's live socket, defaulting to journeymap format", async () => { + const modSocket = new FakeSocket(); + await wsGateway.message(modSocket, JSON.stringify({ type: "hello", token: server.token })); + + const result = await shareMarkerToChat(accountId, markerId); + expect(result).toEqual({ ok: true }); + + const sent = modSocket.lastMessage(); + expect(sent).toEqual({ + type: "waypoint_share", + name: "Shareable", + x: 10, + y: 80, + z: 20, + dimension: 0, + color: "#B311CF", + format: "journeymap", + }); + }); + + test("refuses to share when the server's waypoint format is 'off'", async () => { + await db.update(servers).set({ waypointFormat: "off" }).where(eq(servers.id, server.id)); + + const modSocket = new FakeSocket(); + await wsGateway.message(modSocket, JSON.stringify({ type: "hello", token: server.token })); + + const result = await shareMarkerToChat(accountId, markerId); + expect(result).toEqual({ ok: false, error: "waypoint_sharing_disabled" }); + }); + + test("refuses to share someone else's marker", async () => { + const { accountId: otherAccountId } = await createTestSession("NotSharer"); + const result = await shareMarkerToChat(otherAccountId, markerId); + expect(result).toEqual({ ok: false, error: "not_found" }); + }); +}); diff --git a/api/src/markers.ts b/api/src/markers.ts new file mode 100644 index 0000000..543b5a5 --- /dev/null +++ b/api/src/markers.ts @@ -0,0 +1,130 @@ +import { and, eq } from "drizzle-orm"; +import { db } from "./db/client"; +import { chunkColumns, markers, servers } from "./db/schema"; +import { getModSocket } from "./ws-gateway"; + +interface NewMarkerInput { + accountId: string; + serverId: string; + dimension: number; + x: number; + z: number; + name: string; + color: string; +} + +export type CreateMarkerResult = { ok: true; marker: typeof markers.$inferSelect } | { ok: false; error: string }; + +/** + * Topmost non-air block's height at a column, straight from the chunk store's heightmap — `null` + * if that column hasn't been mapped by the mod yet. Exposed on its own (see index.ts's + * unauthenticated `/api/height` route) because anonymous visitors also need a real `y` for their + * localStorage-only marker list (see the plan's marker feature section: "either way the record + * includes x, y, z"), even though they never reach `createMarker` below. + */ +export async function resolveHeight(serverId: string, dimension: number, x: number, z: number): Promise { + const [column] = await db + .select({ height: chunkColumns.height }) + .from(chunkColumns) + .where( + and( + eq(chunkColumns.serverId, serverId), + eq(chunkColumns.dimension, dimension), + eq(chunkColumns.x, x), + eq(chunkColumns.z, z), + ), + ) + .limit(1); + return column ? column.height : null; +} + +/** + * Placement is 2D-only (see the plan's marker feature section) — the caller only supplies x/z, + * and `y` is auto-derived here from the chunk store's heightmap rather than picked by the player, + * so the marker is a genuine 3D point usable by both the 3D viewer and the in-game waypoint + * formats (both require a real y). Fails rather than guessing a y if that column hasn't been + * mapped by the mod yet. + */ +export async function createMarker(input: NewMarkerInput): Promise { + const height = await resolveHeight(input.serverId, input.dimension, input.x, input.z); + if (height === null) return { ok: false, error: "column_not_mapped" }; + + const [marker] = await db + .insert(markers) + .values({ + accountId: input.accountId, + serverId: input.serverId, + dimension: input.dimension, + x: input.x, + y: height, + z: input.z, + name: input.name, + color: input.color, + }) + .returning(); + + return { ok: true, marker: marker! }; +} + +export async function listMarkers(accountId: string, serverId: string) { + return db + .select() + .from(markers) + .where(and(eq(markers.accountId, accountId), eq(markers.serverId, serverId))); +} + +export type DeleteMarkerResult = { ok: true } | { ok: false; error: string }; + +/** Scopes the delete to the owning account in the WHERE clause, not a separate ownership check. */ +export async function deleteMarker(accountId: string, markerId: string): Promise { + const deleted = await db + .delete(markers) + .where(and(eq(markers.id, markerId), eq(markers.accountId, accountId))) + .returning({ id: markers.id }); + if (deleted.length === 0) return { ok: false, error: "not_found" }; + return { ok: true }; +} + +export type ShareMarkerResult = { ok: true } | { ok: false; error: string }; + +/** + * Forwards the marker as a structured payload to the mod over its existing WS connection — the + * mod owns building the actual JourneyMap/Xaero chat text (see WaypointShare.java's javadoc on + * the mod side for the researched wire formats and their attribution), since that's + * loader/version-specific text formatting, not something the backend needs to know about. + */ +export async function shareMarkerToChat(accountId: string, markerId: string): Promise { + const [marker] = await db + .select() + .from(markers) + .where(and(eq(markers.id, markerId), eq(markers.accountId, accountId))) + .limit(1); + if (!marker) return { ok: false, error: "not_found" }; + + const [server] = await db + .select({ waypointFormat: servers.waypointFormat }) + .from(servers) + .where(eq(servers.id, marker.serverId)) + .limit(1); + if (!server || server.waypointFormat === "off") { + return { ok: false, error: "waypoint_sharing_disabled" }; + } + + const modSocket = getModSocket(marker.serverId); + if (!modSocket) return { ok: false, error: "server_not_connected" }; + + modSocket.send( + JSON.stringify({ + type: "waypoint_share", + name: marker.name, + x: marker.x, + y: marker.y, + z: marker.z, + dimension: marker.dimension, + color: marker.color, + format: server.waypointFormat, + }), + ); + + return { ok: true }; +} diff --git a/api/src/ws-gateway.ts b/api/src/ws-gateway.ts index 2353c10..691d232 100644 --- a/api/src/ws-gateway.ts +++ b/api/src/ws-gateway.ts @@ -41,6 +41,14 @@ import { recordAndPublishChat } from "./chat"; // viewers, see chat.ts); api -> mod is sent by the browser-facing /ws/chat route (chat-gateway.ts) // looking up the mod's connection via getModSocket() and calling ChatBridge.injectWebChatMessage // on the mod side. +// +// api -> mod {"type":"waypoint_share","name":"...","x":..,"y":..,"z":..,"dimension":..,"color":"#RRGGBB","format":"journeymap"|"xaero"} +// +// Phase 4: sent by markers.ts's shareMarkerToChat() via getModSocket() when a linked account +// shares a placed marker to chat. The api only resolves *which* format the target server wants +// (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). interface Column { x: number; diff --git a/frontend/src/index.ts b/frontend/src/index.ts index 1e4db3f..530666a 100644 --- a/frontend/src/index.ts +++ b/frontend/src/index.ts @@ -13,6 +13,7 @@ const app = new Elysia() .get("/health", () => ({ status: "ok" })) .get("/css/tailwind.css", () => Bun.file(join(import.meta.dir, "public/css/tailwind.css"))) .get("/js/map.js", () => Bun.file(join(import.meta.dir, "public/js/map.js"))) + .get("/js/coords.js", () => Bun.file(join(import.meta.dir, "public/js/coords.js"))) .get("/js/mesh.js", () => Bun.file(join(import.meta.dir, "public/js/mesh.js"))) .get("/js/mesh-format.js", () => Bun.file(join(import.meta.dir, "public/js/mesh-format.js"))) .listen(Number(process.env.PORT ?? 3001)); diff --git a/frontend/src/public/js/coords.js b/frontend/src/public/js/coords.js new file mode 100644 index 0000000..da432db --- /dev/null +++ b/frontend/src/public/js/coords.js @@ -0,0 +1,24 @@ +// World-block <-> Leaflet-latlng conversion for the 2D map (marker placement, Phase 4). +// +// Leaflet's CRS.Simple maps a latlng to a pixel point as (lng, -lat) — see L.CRS.Simple's +// `transformation`. The `/api/tiles/:serverId/:dimension/:zoom/:tileX/:tileY` route takes +// whatever {x}/{y} Leaflet requests for the tiles on screen and treats {x} as chunkX directly, +// {y} as -chunkZ (see that route's comment in api/src/index.ts, and its test in index.test.ts +// which pins chunkZ=-3 to a requested {y} of 3). Working backward from that pins down the only +// linear world<->latlng mapping consistent with how tiles are actually placed: +// +// lat = 16 * blockZ +// lng = 16 * blockX +// +// (16 = 256px tile size / 16 blocks per chunk.) This is zoom-independent — a latlng always +// names the same world position regardless of the current view zoom — plain objects are used +// instead of Leaflet's `L.LatLng` class so this stays testable without a browser/Leaflet. +export const BLOCKS_PER_TILE = 16; + +export function worldToLatLng(x, z) { + return { lat: z * BLOCKS_PER_TILE, lng: x * BLOCKS_PER_TILE }; +} + +export function latLngToWorld(latlng) { + return { x: Math.round(latlng.lng / BLOCKS_PER_TILE), z: Math.round(latlng.lat / BLOCKS_PER_TILE) }; +} diff --git a/frontend/src/public/js/coords.test.ts b/frontend/src/public/js/coords.test.ts new file mode 100644 index 0000000..b741675 --- /dev/null +++ b/frontend/src/public/js/coords.test.ts @@ -0,0 +1,32 @@ +import { describe, test, expect } from "bun:test"; +import { worldToLatLng, latLngToWorld, BLOCKS_PER_TILE } from "./coords.js"; + +// This mapping is derived from (and pinned to) the /api/tiles route's tested contract — see +// index.test.ts's "negates tileY back to the stored chunkZ" case: chunkZ=-3 is requested by +// Leaflet as tileY=3. See coords.js's doc comment for the full derivation. +describe("worldToLatLng / latLngToWorld", () => { + test("round-trips arbitrary world coordinates through latlng and back", () => { + const world = { x: 137, z: -284 }; + const latlng = worldToLatLng(world.x, world.z); + expect(latLngToWorld(latlng)).toEqual(world); + }); + + test("scales by BLOCKS_PER_TILE (16 px per block at native zoom)", () => { + const latlng = worldToLatLng(10, 20); + expect(latlng.lng).toBe(10 * BLOCKS_PER_TILE); + expect(latlng.lat).toBe(20 * BLOCKS_PER_TILE); + }); + + test("matches the tested /api/tiles contract: chunk (0,-3) requests tileY=3", () => { + // Use the chunk's exact block boundary (z=-48, i.e. chunk -3 * 16) rather than an interior + // point — floor(-x/16) isn't -floor(x/16) for non-multiples of 16, so an interior point would + // spuriously fail this check without indicating any actual bug in the block<->tile mapping. + const latlng = worldToLatLng(0, -48); + const impliedTileY = Math.floor(-latlng.lat / 256); // Leaflet's own CRS.Simple pixel math + expect(impliedTileY).toBe(3); + }); + + test("rounds fractional latlng back to the nearest block", () => { + expect(latLngToWorld({ lat: 16.4, lng: -15.6 })).toEqual({ x: -1, z: 1 }); + }); +}); diff --git a/frontend/src/public/js/map.js b/frontend/src/public/js/map.js index 10e389b..551af5d 100644 --- a/frontend/src/public/js/map.js +++ b/frontend/src/public/js/map.js @@ -1,15 +1,14 @@ -// Barebones Leaflet viewer (Phase 1) + chat/linking (Phase 3). Marker tool and admin panel are -// still later phases. +// Barebones Leaflet viewer (Phase 1) + chat/linking (Phase 3) + markers (Phase 4). Admin panel +// is still a later phase. // // Tiles are one Minecraft chunk (16x16 blocks) each, upscaled to 256px, at a single native // zoom level (see api's tile route + worker/src/render/cpu.rs) — Leaflet stretches that one // native zoom to whatever zoom the user picks via `maxNativeZoom`/`minNativeZoom`. // -// Coordinate mapping: Leaflet's CRS.Simple treats [lat, lng] as [y, x] with y growing upward -// on screen. Minecraft's Z grows south (visually "down" on a conventional north-up map), so -// tile y = -chunkZ here; the api negates it back to chunkZ when looking up the tile pointer -// (see api/src/index.ts's /api/tiles route comment). -// +// Marker world<->latlng conversion lives in coords.js (see its doc comment for the derivation) +// — pulled out into its own module so it's unit-testable without a browser (see coords.test.ts). +import { worldToLatLng, latLngToWorld } from "./coords.js"; + // Session handling: the session token from /api/link/redeem is kept in localStorage and sent // back via the X-MCMapper-Session header / a WS message field, not an httpOnly cookie — see // api/src/link.ts's doc comment for why that's a deliberate Phase 3 MVP tradeoff. @@ -31,6 +30,14 @@ function mapmapper() { account: null, nickname: localStorage.getItem(NICKNAME_STORAGE_KEY) || "", + markers: [], + markerLayer: null, + placingMarker: false, + pendingMarker: null, // {x, z} — set by a map click while placingMarker is true + markerNameInput: "", + markerColorInput: "#3391ff", + markerStatus: "", + async init() { const servers = await fetch("/api/servers").then((r) => r.json()); this.loading = false; @@ -38,6 +45,8 @@ 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.leaflet.on("click", (e) => this.onMapClick(e)); if (this.server) { L.tileLayer(`/api/tiles/${this.server.id}/0/{z}/{x}/{y}.png`, { @@ -51,10 +60,147 @@ function mapmapper() { }).addTo(this.leaflet); await this.loadAccount(); + await this.loadMarkers(); this.connectChat(); } }, + // Marker placement is 2D-only (see the plan's marker feature section) — a click on this + // Leaflet map while `placingMarker` is on just records the clicked world x/z; `y` is always + // resolved server-side from the chunk heightmap (see confirmMarker), never picked here. + onMapClick(e) { + if (!this.placingMarker) return; + this.placingMarker = false; + this.pendingMarker = latLngToWorld(e.latlng); + this.markerNameInput = ""; + this.markerStatus = ""; + }, + + togglePlacingMarker() { + this.placingMarker = !this.placingMarker; + if (!this.placingMarker) this.pendingMarker = null; + }, + + cancelMarker() { + this.pendingMarker = null; + this.markerStatus = ""; + }, + + localMarkerStorageKey() { + return `mcmapper_markers_${this.server.id}`; + }, + + loadLocalMarkers() { + try { + return JSON.parse(localStorage.getItem(this.localMarkerStorageKey()) || "[]"); + } catch { + return []; + } + }, + + saveLocalMarkers(markers) { + localStorage.setItem(this.localMarkerStorageKey(), JSON.stringify(markers)); + }, + + async loadMarkers() { + if (this.account && this.sessionToken) { + const res = await fetch(`/api/markers/${this.server.id}`, { + headers: { "X-MCMapper-Session": this.sessionToken }, + }); + this.markers = res.ok ? await res.json() : []; + } else { + this.markers = this.loadLocalMarkers(); + } + this.renderMarkerLayer(); + }, + + renderMarkerLayer() { + this.markerLayer.clearLayers(); + for (const marker of this.markers) { + L.circleMarker(worldToLatLng(marker.x, marker.z), { + radius: 7, + color: marker.color, + fillColor: marker.color, + fillOpacity: 0.9, + }) + .bindTooltip(marker.name) + .addTo(this.markerLayer); + } + }, + + /** + * Linked accounts persist markers server-side (Postgres, via markers.ts — `y` auto-derived + * there from the chunk store's heightmap). Anonymous visitors keep markers in localStorage + * only, but still need a real `y`, so they hit the unauthenticated /api/height lookup + * instead — see markers.ts's resolveHeight doc comment for why that route exists. + */ + async confirmMarker() { + const name = this.markerNameInput.trim(); + if (!name || !this.pendingMarker) return; + const { x, z } = this.pendingMarker; + + if (this.account && this.sessionToken) { + const res = await fetch("/api/markers", { + method: "POST", + headers: { "content-type": "application/json", "X-MCMapper-Session": this.sessionToken }, + body: JSON.stringify({ serverId: this.server.id, dimension: 0, x, z, name, color: this.markerColorInput }), + }); + const data = await res.json(); + if (!data.ok) { + this.markerStatus = data.error; + return; + } + this.markers.push(data.marker); + } else { + const res = await fetch(`/api/height/${this.server.id}/0/${x}/${z}`); + if (!res.ok) { + this.markerStatus = "column_not_mapped"; + return; + } + const { height } = await res.json(); + const marker = { + id: crypto.randomUUID(), + serverId: this.server.id, + dimension: 0, + x, + y: height, + z, + name, + color: this.markerColorInput, + }; + this.markers.push(marker); + this.saveLocalMarkers(this.markers); + } + + this.pendingMarker = null; + this.markerStatus = ""; + this.renderMarkerLayer(); + }, + + async deleteMarker(marker) { + if (this.account && this.sessionToken) { + await fetch(`/api/markers/${marker.id}`, { + method: "DELETE", + headers: { "X-MCMapper-Session": this.sessionToken }, + }); + } else { + this.saveLocalMarkers(this.loadLocalMarkers().filter((m) => m.id !== marker.id)); + } + this.markers = this.markers.filter((m) => m.id !== marker.id); + this.renderMarkerLayer(); + }, + + // Only linked accounts can share — an anonymous marker never has a server-side row for + // shareMarkerToChat to look up (see markers.ts). The UI hides the share button accordingly. + async shareMarker(marker) { + const res = await fetch(`/api/markers/${marker.id}/share`, { + method: "POST", + headers: { "X-MCMapper-Session": this.sessionToken }, + }); + const data = await res.json(); + this.markerStatus = data.ok ? `shared "${marker.name}" to chat` : data.error; + }, + async loadAccount() { if (!this.sessionToken) return; const res = await fetch("/api/me", { headers: { "X-MCMapper-Session": this.sessionToken } }); @@ -112,6 +258,7 @@ function mapmapper() { this.account = data.account; this.linkStatus = `linked as ${data.account.username}`; this.linkCode = ""; + await this.loadMarkers(); } else { this.linkStatus = data.error; } @@ -124,6 +271,12 @@ function mapmapper() { localStorage.removeItem(SESSION_STORAGE_KEY); this.sessionToken = null; this.account = null; + await this.loadMarkers(); }, }; } + +// Loaded as `type="module"` (see index.pug) so the coords.js import above works — that takes +// `mapmapper` out of the global scope Alpine's `x-data="mapmapper()"` expects, so it's put back +// explicitly here. +window.mapmapper = mapmapper; diff --git a/frontend/src/views/index.pug b/frontend/src/views/index.pug index d7fbbef..31d1777 100644 --- a/frontend/src/views/index.pug +++ b/frontend/src/views/index.pug @@ -23,6 +23,36 @@ html(lang="en") div.flex-1.flex.overflow-hidden div#map.flex-1 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.space-y-2(style="max-height: 40%; overflow-y: auto;") + div.flex.items-center.justify-between + h2.text-sm.font-semibold Markers + button.px-2.py-1.rounded.text-xs( + x-bind:class="placingMarker ? 'bg-amber-600' : 'bg-neutral-700'" + x-on:click="togglePlacingMarker" + x-text="placingMarker ? 'click the map…' : '+ place marker'") + + template(x-if="pendingMarker") + div.space-y-1.bg-neutral-900.rounded.p-2 + p.text-xs.text-neutral-400(x-text="'at ' + pendingMarker.x + ', ' + pendingMarker.z") + div.flex.gap-1 + input.flex-1.bg-neutral-800.text-sm.px-2.py-1.rounded.border.border-neutral-700( + type="text" placeholder="marker name" x-model="markerNameInput" + x-on:keydown.enter="confirmMarker") + input.w-10(type="color" x-model="markerColorInput") + div.flex.gap-1 + button.flex-1.px-2.py-1.bg-emerald-700.rounded.text-xs(x-on:click="confirmMarker") Save + button.px-2.py-1.bg-neutral-700.rounded.text-xs(x-on:click="cancelMarker") Cancel + + template(x-for="marker in markers" x-bind:key="marker.id") + div.flex.items-center.gap-2.text-sm + span.inline-block.w-3.h-3.rounded-full.flex-shrink-0(x-bind:style="'background:' + marker.color") + span.flex-1.truncate(x-text="marker.name") + span.text-xs.text-neutral-500(x-text="marker.x + ', ' + marker.y + ', ' + marker.z") + button.text-xs.underline(x-show="account" x-on:click="shareMarker(marker)") share + button.text-xs.text-red-400(x-on:click="deleteMarker(marker)") × + + p.text-xs.text-amber-400(x-show="markerStatus" x-text="markerStatus") + div.flex-1.overflow-y-auto.p-2.space-y-1(x-ref="chatLog") template(x-for="msg in chatMessages" x-bind:key="msg.id") p.text-sm.break-words @@ -57,4 +87,4 @@ html(lang="en") button.px-2.py-1.bg-neutral-700.rounded.text-sm(x-on:click="redeemLink") Link p.text-xs.text-amber-400(x-show="linkStatus" x-text="linkStatus") - script(src="/js/map.js") + script(type="module" src="/js/map.js")