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:
@@ -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>
|
||||||
|
|||||||
@@ -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");
|
||||||
@@ -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
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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" });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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) };
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
Reference in New Issue
Block a user