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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015tKdPZt78zbPUZMXWzKEKt
This commit is contained in:
2026-08-08 19:21:07 +02:00
parent 6770bc23cc
commit 66fe2ffb7e
13 changed files with 836 additions and 10 deletions
+16
View File
@@ -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");
+26
View File
@@ -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.
+94 -1
View File
@@ -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<string, string>) {
);
}
function del(path: string, headers?: Record<string, string>) {
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");
});
});
+78
View File
@@ -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<string, number> = {
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,
+228
View File
@@ -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" });
});
});
+130
View File
@@ -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<number | null> {
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<CreateMarkerResult> {
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<DeleteMarkerResult> {
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<ShareMarkerResult> {
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 };
}
+8
View File
@@ -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;