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
+8 -1
View File
@@ -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 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. 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:
``` ```
## <thing adapted> ## <thing adapted>
+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 // 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. // 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), 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(), 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(), 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 // 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 // height. Kept deliberately separate from `chunkSections` below — cheap to write/read for 2D
// tile rendering, which never needs full voxel data. // 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 { db } from "./db/client";
import { meshPointers, tilePointers } from "./db/schema"; import { meshPointers, tilePointers } from "./db/schema";
import { minio, TILE_BUCKET } from "./minio"; import { minio, TILE_BUCKET } from "./minio";
import { createTestServer, deleteTestServer } from "./test-helpers"; import { createTestServer, deleteTestServer, createTestSession } from "./test-helpers";
import { storeLinkCode } from "./link"; import { storeLinkCode } from "./link";
import { chunkColumns } from "./db/schema";
// Elysia's `.handle()` drives the app in-process against a plain Request/Response, without // 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 // 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", () => { describe("GET /health", () => {
test("reports ok", async () => { test("reports ok", async () => {
const res = await get("/health"); 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(); 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 { chatGateway } from "./chat-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, 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(); await ensureTileBucket();
@@ -111,6 +118,77 @@ export const app = new Elysia()
if (token) await revokeSession(token); if (token) await revokeSession(token);
return { ok: true }; 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", { .ws("/ws", {
open: wsGateway.open, open: wsGateway.open,
message: wsGateway.message, 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) // 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 // looking up the mod's connection via getModSocket() and calling ChatBridge.injectWebChatMessage
// on the mod side. // 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 { interface Column {
x: number; x: number;
+1
View File
@@ -13,6 +13,7 @@ const app = new Elysia()
.get("/health", () => ({ status: "ok" })) .get("/health", () => ({ status: "ok" }))
.get("/css/tailwind.css", () => Bun.file(join(import.meta.dir, "public/css/tailwind.css"))) .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/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.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"))) .get("/js/mesh-format.js", () => Bun.file(join(import.meta.dir, "public/js/mesh-format.js")))
.listen(Number(process.env.PORT ?? 3001)); .listen(Number(process.env.PORT ?? 3001));
+24
View File
@@ -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) };
}
+32
View File
@@ -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 });
});
});
+160 -7
View File
@@ -1,15 +1,14 @@
// Barebones Leaflet viewer (Phase 1) + chat/linking (Phase 3). Marker tool and admin panel are // Barebones Leaflet viewer (Phase 1) + chat/linking (Phase 3) + markers (Phase 4). Admin panel
// still later phases. // is still a later phase.
// //
// Tiles are one Minecraft chunk (16x16 blocks) each, upscaled to 256px, at a single native // 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 // 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`. // 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 // Marker world<->latlng conversion lives in coords.js (see its doc comment for the derivation)
// on screen. Minecraft's Z grows south (visually "down" on a conventional north-up map), so // — pulled out into its own module so it's unit-testable without a browser (see coords.test.ts).
// tile y = -chunkZ here; the api negates it back to chunkZ when looking up the tile pointer import { worldToLatLng, latLngToWorld } from "./coords.js";
// (see api/src/index.ts's /api/tiles route comment).
//
// Session handling: the session token from /api/link/redeem is kept in localStorage and sent // 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 // 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. // 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, account: null,
nickname: localStorage.getItem(NICKNAME_STORAGE_KEY) || "", 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() { 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;
@@ -38,6 +45,8 @@ 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.leaflet.on("click", (e) => this.onMapClick(e));
if (this.server) { if (this.server) {
L.tileLayer(`/api/tiles/${this.server.id}/0/{z}/{x}/{y}.png`, { L.tileLayer(`/api/tiles/${this.server.id}/0/{z}/{x}/{y}.png`, {
@@ -51,10 +60,147 @@ function mapmapper() {
}).addTo(this.leaflet); }).addTo(this.leaflet);
await this.loadAccount(); await this.loadAccount();
await this.loadMarkers();
this.connectChat(); 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() { async loadAccount() {
if (!this.sessionToken) return; if (!this.sessionToken) return;
const res = await fetch("/api/me", { headers: { "X-MCMapper-Session": this.sessionToken } }); const res = await fetch("/api/me", { headers: { "X-MCMapper-Session": this.sessionToken } });
@@ -112,6 +258,7 @@ function mapmapper() {
this.account = data.account; this.account = data.account;
this.linkStatus = `linked as ${data.account.username}`; this.linkStatus = `linked as ${data.account.username}`;
this.linkCode = ""; this.linkCode = "";
await this.loadMarkers();
} else { } else {
this.linkStatus = data.error; this.linkStatus = data.error;
} }
@@ -124,6 +271,12 @@ function mapmapper() {
localStorage.removeItem(SESSION_STORAGE_KEY); localStorage.removeItem(SESSION_STORAGE_KEY);
this.sessionToken = null; this.sessionToken = null;
this.account = 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;
+31 -1
View File
@@ -23,6 +23,36 @@ html(lang="en")
div.flex-1.flex.overflow-hidden div.flex-1.flex.overflow-hidden
div#map.flex-1 div#map.flex-1
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.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") 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") template(x-for="msg in chatMessages" x-bind:key="msg.id")
p.text-sm.break-words 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 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") p.text-xs.text-amber-400(x-show="linkStatus" x-text="linkStatus")
script(src="/js/map.js") script(type="module" src="/js/map.js")