Phase 4: receive waypoint shares and render JourneyMap/Xaero chat links

DefaultBackendConnection handles the new waypoint_share message and
dispatches it through a WaypointShareListener. WaypointChatFormatter
builds the actual chat text for both formats from publicly documented
wire formats (see THIRD_PARTY_NOTICES.md for sources/attribution) —
neither needs a click-event component, since both client mods
auto-detect the right plain-text shape. Forge1122ChatBridge wires this
into a real broadcast; MCMapperMod registers the listener.

Built test-first per the project's TDD workflow; verified live against
the real backend, including a byte-exact JourneyMap-format chat
message produced from a real WS payload.

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:30 +02:00
parent 73e10d34e5
commit 41034356ac
7 changed files with 264 additions and 3 deletions
@@ -3,6 +3,7 @@ package com.octoturge.mcmapper.common;
import com.octoturge.mcmapper.common.protocol.DeltaEvent;
import com.octoturge.mcmapper.common.protocol.LinkRequest;
import com.octoturge.mcmapper.common.protocol.SectionData;
import com.octoturge.mcmapper.common.protocol.WaypointShare;
import java.util.List;
@@ -30,12 +31,19 @@ public interface BackendConnection {
/** Registers the callback for web-originated chat messages the backend relays back to us. */
void setChatListener(ChatListener listener);
/** Registers the callback for markers a web visitor shared to chat — see WaypointShare's javadoc. */
void setWaypointShareListener(WaypointShareListener listener);
void disconnect();
interface ChatListener {
void onChatMessage(String username, String message);
}
interface WaypointShareListener {
void onWaypointShare(WaypointShare share);
}
final class NoOp implements BackendConnection {
@Override
public void connect(String url, String serverToken) {
@@ -61,6 +69,10 @@ public interface BackendConnection {
public void setChatListener(ChatListener listener) {
}
@Override
public void setWaypointShareListener(WaypointShareListener listener) {
}
@Override
public void disconnect() {
}
@@ -4,6 +4,7 @@ import com.octoturge.mcmapper.common.json.MiniJson;
import com.octoturge.mcmapper.common.protocol.DeltaEvent;
import com.octoturge.mcmapper.common.protocol.LinkRequest;
import com.octoturge.mcmapper.common.protocol.SectionData;
import com.octoturge.mcmapper.common.protocol.WaypointShare;
import com.octoturge.mcmapper.common.ws.SimpleWebSocketClient;
import java.net.URI;
@@ -30,6 +31,7 @@ import java.util.function.Consumer;
* mod -&gt; api {"type":"link_request","code":"...","uuid":"...","username":"...","authMode":"online"}
* mod -&gt; api {"type":"chat","uuid":"...","username":"...","message":"..."}
* api -&gt; mod {"type":"chat","username":"...","message":"..."}
* api -&gt; mod {"type":"waypoint_share","name":"...","x":..,"y":..,"z":..,"dimension":..,"color":"#RRGGBB","format":"journeymap"|"xaero"}
* </pre>
*
* Phase 3: {@code link_request} is sent by {@code /mcmapper link}; {@code chat} both directions
@@ -37,6 +39,12 @@ import java.util.function.Consumer;
* delivered through whatever {@link ChatListener} the leaf registered via
* {@link #setChatListener}.
*
* Phase 4: {@code waypoint_share} is sent when a linked account shares a placed marker to chat
* (see MCMapper-Backend's {@code markers.ts}) — delivered through whatever
* {@link WaypointShareListener} the leaf registered via {@link #setWaypointShareListener}. The
* mod's job is just to turn the point into the right chat text (see
* {@link com.octoturge.mcmapper.common.protocol.WaypointChatFormatter}) and broadcast it.
*
* A "columns" message doubles as both initial backfill (one message per loaded chunk) and live
* deltas (one message per flush tick) — see {@link DeltaEvent}'s javadoc for how a
* {@code List<DeltaEvent>} maps onto it. {@code dimension} in {@link DeltaEvent} is a string so
@@ -70,6 +78,7 @@ public class DefaultBackendConnection implements BackendConnection {
private String url;
private String serverToken;
private volatile ChatListener chatListener;
private volatile WaypointShareListener waypointShareListener;
public DefaultBackendConnection(Consumer<String> logInfo, Consumer<String> logWarn) {
this.logInfo = logInfo;
@@ -165,6 +174,18 @@ public class DefaultBackendConnection implements BackendConnection {
if (listener != null) {
listener.onChatMessage(String.valueOf(obj.get("username")), String.valueOf(obj.get("message")));
}
} else if ("waypoint_share".equals(type)) {
WaypointShareListener listener = waypointShareListener;
if (listener != null) {
listener.onWaypointShare(new WaypointShare(
String.valueOf(obj.get("name")),
((Number) obj.get("x")).intValue(),
((Number) obj.get("y")).intValue(),
((Number) obj.get("z")).intValue(),
String.valueOf(((Number) obj.get("dimension")).intValue()),
String.valueOf(obj.get("color")),
"xaero".equals(obj.get("format")) ? WaypointShare.Format.XAERO : WaypointShare.Format.JOURNEYMAP));
}
}
}
@@ -254,6 +275,11 @@ public class DefaultBackendConnection implements BackendConnection {
this.chatListener = listener;
}
@Override
public void setWaypointShareListener(WaypointShareListener listener) {
this.waypointShareListener = listener;
}
private void sendRaw(String label, Map<String, Object> message) {
SimpleWebSocketClient client = ws;
if (client == null || !client.isOpen()) return;
@@ -0,0 +1,94 @@
package com.octoturge.mcmapper.common.protocol;
/**
* Builds a plain-text chat message that a player's own client-side map mod auto-detects and
* offers to import as a waypoint — neither format needs a click-event text component, just the
* right string shape in the message body (see the two format-specific methods for sources).
* Pure string logic, loader-independent, so it lives in {@code common} and is unit-tested
* directly (see common/run-tests.sh) rather than only exercised through a real Forge chat event.
*/
public final class WaypointChatFormatter {
private WaypointChatFormatter() {
}
public static String format(WaypointShare share) {
return share.format == WaypointShare.Format.XAERO ? xaeroText(share) : journeyMapText(share);
}
/**
* JourneyMap's own chat-waypoint auto-detect syntax: a message containing
* {@code [x:..,y:..,z:..,dim:..,name:..,color:..,delete:..]} is recognized and offered as an
* importable waypoint. Source: 1whohears/JourneyMapQOL_1.7.10 (MIT), corroborated by
* TeamJM/journeymap issue #465 ("Waypoint Chat option") — both public, no JourneyMap source
* copied, just the documented text convention.
*/
public static String journeyMapText(WaypointShare share) {
return "[x:" + share.x + ",y:" + share.y + ",z:" + share.z + ",dim:" + share.dimension
+ ",name:" + share.name + ",color:" + share.color + ",delete:false]";
}
private static final int XAERO_NAME_MAX = 32;
private static final int XAERO_MARKER_MAX = 2;
/**
* Xaero's Minimap intercepts chat messages starting with {@code xaero-waypoint:} and offers
* to add the encoded waypoint. Xaero's Minimap/Worldmap are closed-source, so this schema is
* *not* from Xaero's own docs (none exist) — it's community-reverse-engineered, per the gist
* "my rough interpretation on xaero-waypoint schema" by macimas
* (gist.github.com/macimas/937a392be075b1bce7a2ae69ea933ef5): nine colon-separated fields,
* {@code name:marker:x:y:z:color:use_yaw:yaw:dimension}, where {@code color} is 0-15
* (Minecraft's chat color codes in decimal) and {@code dimension} is empty (defaults to the
* player's current dimension) or {@code Internal-<overworld|the-nether|the-end>-waypoints}.
* Flagged as best-effort: even community sources disagree on field count for this
* undocumented format, so treat this as a starting point to verify against a real Xaero
* install rather than a guaranteed-correct implementation.
*/
public static String xaeroText(WaypointShare share) {
String name = share.name.length() > XAERO_NAME_MAX ? share.name.substring(0, XAERO_NAME_MAX) : share.name;
String marker = name.length() > XAERO_MARKER_MAX ? name.substring(0, XAERO_MARKER_MAX) : name;
marker = marker.toUpperCase();
return "xaero-waypoint:" + name + ":" + marker + ":" + share.x + ":" + share.y + ":" + share.z
+ ":" + nearestChatColorCode(share.color) + ":false:0:" + xaeroDimensionSet(share.dimension);
}
private static String xaeroDimensionSet(String dimension) {
switch (dimension) {
case "0":
return "Internal-overworld-waypoints";
case "-1":
return "Internal-the-nether-waypoints";
case "1":
return "Internal-the-end-waypoints";
default:
// Anything else (a modded dimension id) isn't safely mappable to one of Xaero's
// three built-in waypoint sets — leave it empty so Xaero falls back to whichever
// dimension the receiving player is currently in.
return "";
}
}
// The 16 standard Minecraft chat/formatting colors, index == the decimal code Xaero expects.
private static final int[] CHAT_COLORS = {
0x000000, 0x0000AA, 0x00AA00, 0x00AAAA, 0xAA0000, 0xAA00AA, 0xFFAA00, 0xAAAAAA,
0x555555, 0x5555FF, 0x55FF55, 0x55FFFF, 0xFF5555, 0xFF55FF, 0xFFFF55, 0xFFFFFF,
};
/** Nearest of the 16 Minecraft chat colors to an arbitrary "#RRGGBB" hex string, by squared RGB distance. */
public static int nearestChatColorCode(String hex) {
int rgb = Integer.parseInt(hex.replace("#", ""), 16);
int r = (rgb >> 16) & 0xFF, g = (rgb >> 8) & 0xFF, b = rgb & 0xFF;
int bestCode = 0;
long bestDist = Long.MAX_VALUE;
for (int i = 0; i < CHAT_COLORS.length; i++) {
int cr = (CHAT_COLORS[i] >> 16) & 0xFF, cg = (CHAT_COLORS[i] >> 8) & 0xFF, cb = CHAT_COLORS[i] & 0xFF;
long dist = (long) (r - cr) * (r - cr) + (long) (g - cg) * (g - cg) + (long) (b - cb) * (b - cb);
if (dist < bestDist) {
bestDist = dist;
bestCode = i;
}
}
return bestCode;
}
}
@@ -0,0 +1,101 @@
package com.octoturge.mcmapper.common.protocol;
import java.util.ArrayList;
import java.util.List;
/** Hand-rolled test runner (no JUnit) — see common/run-tests.sh. */
public class WaypointChatFormatterTest {
private static final List<String> failures = new ArrayList<>();
private static int passed = 0;
public static void main(String[] args) {
journeyMapProducesTheDocumentedBracketSyntax();
journeyMapHandlesNegativeCoordinates();
xaeroProducesTheDocumentedColonSyntaxForOverworld();
xaeroMapsKnownDimensionsToInternalWaypointSets();
xaeroDefaultsUnknownDimensionsToEmpty();
xaeroTruncatesLongNamesAndDerivesAnUppercaseMarker();
xaeroPicksTheNearestOfTheSixteenChatColorCodes();
formatDispatchesOnTheShareFormat();
System.out.println();
System.out.println((passed) + " passed, " + failures.size() + " failed");
if (!failures.isEmpty()) System.exit(1);
}
private static void journeyMapProducesTheDocumentedBracketSyntax() {
WaypointShare share = new WaypointShare("Base", 105, 72, -723, "0", "#B311CF", WaypointShare.Format.JOURNEYMAP);
check(
"journeyMap produces the documented bracket syntax",
WaypointChatFormatter.journeyMapText(share),
"[x:105,y:72,z:-723,dim:0,name:Base,color:#B311CF,delete:false]");
}
private static void journeyMapHandlesNegativeCoordinates() {
WaypointShare share = new WaypointShare("Deep", -10, 5, -20, "-1", "#FFFFFF", WaypointShare.Format.JOURNEYMAP);
check(
"journeyMap handles negative x/y/z",
WaypointChatFormatter.journeyMapText(share),
"[x:-10,y:5,z:-20,dim:-1,name:Deep,color:#FFFFFF,delete:false]");
}
private static void xaeroProducesTheDocumentedColonSyntaxForOverworld() {
WaypointShare share = new WaypointShare("Base", 105, 72, -723, "0", "#FF5555", WaypointShare.Format.XAERO);
check(
"xaero produces the documented colon syntax for the overworld",
WaypointChatFormatter.xaeroText(share),
"xaero-waypoint:Base:BA:105:72:-723:12:false:0:Internal-overworld-waypoints");
}
private static void xaeroMapsKnownDimensionsToInternalWaypointSets() {
WaypointShare nether = new WaypointShare("N", 1, 2, 3, "-1", "#FFFFFF", WaypointShare.Format.XAERO);
check("xaero maps dimension -1 to the nether waypoint set",
WaypointChatFormatter.xaeroText(nether).endsWith(":Internal-the-nether-waypoints"), true);
WaypointShare end = new WaypointShare("E", 1, 2, 3, "1", "#FFFFFF", WaypointShare.Format.XAERO);
check("xaero maps dimension 1 to the end waypoint set",
WaypointChatFormatter.xaeroText(end).endsWith(":Internal-the-end-waypoints"), true);
}
private static void xaeroDefaultsUnknownDimensionsToEmpty() {
WaypointShare modded = new WaypointShare("M", 1, 2, 3, "42", "#FFFFFF", WaypointShare.Format.XAERO);
check("xaero leaves the dimension field empty for an unrecognized dimension id (defaults to the player's current dimension)",
WaypointChatFormatter.xaeroText(modded).endsWith(":"), true);
}
private static void xaeroTruncatesLongNamesAndDerivesAnUppercaseMarker() {
String longName = "ThisWaypointNameIsDefinitelyLongerThanThirtyTwoCharacters";
WaypointShare share = new WaypointShare(longName, 0, 0, 0, "0", "#FFFFFF", WaypointShare.Format.XAERO);
String text = WaypointChatFormatter.xaeroText(share);
String[] fields = text.split(":");
check("xaero truncates the name to 32 characters", fields[1].length() <= 32, true);
check("xaero derives a 2-character uppercase marker from the name", fields[2], "TH");
}
private static void xaeroPicksTheNearestOfTheSixteenChatColorCodes() {
check("pure red maps to chat color code 12 (red)", WaypointChatFormatter.nearestChatColorCode("#FF5555"), 12);
check("pure white maps to chat color code 15 (white)", WaypointChatFormatter.nearestChatColorCode("#FFFFFF"), 15);
check("pure black maps to chat color code 0 (black)", WaypointChatFormatter.nearestChatColorCode("#000000"), 0);
}
private static void formatDispatchesOnTheShareFormat() {
WaypointShare jm = new WaypointShare("A", 1, 2, 3, "0", "#FFFFFF", WaypointShare.Format.JOURNEYMAP);
check("format() dispatches to journeyMapText for JOURNEYMAP",
WaypointChatFormatter.format(jm).startsWith("["), true);
WaypointShare xaero = new WaypointShare("A", 1, 2, 3, "0", "#FFFFFF", WaypointShare.Format.XAERO);
check("format() dispatches to xaeroText for XAERO",
WaypointChatFormatter.format(xaero).startsWith("xaero-waypoint:"), true);
}
private static void check(String name, Object actual, Object expected) {
boolean ok = actual == null ? expected == null : actual.equals(expected);
if (ok) {
passed++;
System.out.println("PASS " + name);
} else {
failures.add(name);
System.out.println("FAIL " + name + " -- expected <" + expected + "> but got <" + actual + ">");
}
}
}