From 41034356acfb32429b2561295ed6742f68432fba Mon Sep 17 00:00:00 2001 From: Octoturge Date: Sat, 8 Aug 2026 19:21:30 +0200 Subject: [PATCH] Phase 4: receive waypoint shares and render JourneyMap/Xaero chat links MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_015tKdPZt78zbPUZMXWzKEKt --- THIRD_PARTY_NOTICES.md | 24 +++++ .../mcmapper/common/BackendConnection.java | 12 +++ .../common/DefaultBackendConnection.java | 26 +++++ .../protocol/WaypointChatFormatter.java | 94 ++++++++++++++++ .../protocol/WaypointChatFormatterTest.java | 101 ++++++++++++++++++ .../forge1122/Forge1122ChatBridge.java | 9 +- .../mcmapper/forge1122/MCMapperMod.java | 1 + 7 files changed, 264 insertions(+), 3 deletions(-) create mode 100644 common/src/main/java/com/octoturge/mcmapper/common/protocol/WaypointChatFormatter.java create mode 100644 common/src/test/java/com/octoturge/mcmapper/common/protocol/WaypointChatFormatterTest.java diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index cde72c1..0fba62b 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -21,6 +21,30 @@ publicly documented/community-reverse-engineered wire format, never from Xaero's - What was adapted: not adapted/copied — used as-is as a build-tool dependency (Gradle plugin), same rationale as the FG2.3 fork above, one Forge-tooling generation further back for 1.7.10. +## JourneyMap chat-waypoint bracket syntax +- Source: https://github.com/1whohears/JourneyMapQOL_1.7.10, corroborated by + https://github.com/TeamJM/journeymap/issues/465 ("Waypoint Chat option") +- License: MIT (JourneyMapQOL_1.7.10) +- Used in: `common/src/main/java/com/octoturge/mcmapper/common/protocol/WaypointChatFormatter.java` + (`journeyMapText`) +- What was adapted: not code — the documented plain-text convention JourneyMap itself + auto-detects in chat (`[x:..,y:..,z:..,dim:..,name:..,color:..,delete:..]`), reimplemented as + our own string builder from the publicly described field list. + +## Xaero's Minimap `xaero-waypoint:` chat-share schema (community-reverse-engineered) +- Source: https://gist.github.com/macimas/937a392be075b1bce7a2ae69ea933ef5 ("my rough + interpretation on xaero-waypoint schema formatty") +- License: none stated (personal gist notes) — used only as a documentation reference for an + otherwise-undocumented wire format, no code copied +- Used in: `common/src/main/java/com/octoturge/mcmapper/common/protocol/WaypointChatFormatter.java` + (`xaeroText`) +- What was adapted: not code — the documented nine-field colon-separated schema + (`name:marker:x:y:z:color:use_yaw:yaw:dimension`), reimplemented as our own string builder. + Xaero's Minimap/Worldmap are closed-source and this format has no official documentation, so + this is flagged in the formatter's javadoc as best-effort/unverified — worth testing against a + real Xaero install before relying on it, since even community sources disagree on field count + for this specific format. + Further entries will be added here as more third-party material lands, in the form: ``` diff --git a/common/src/main/java/com/octoturge/mcmapper/common/BackendConnection.java b/common/src/main/java/com/octoturge/mcmapper/common/BackendConnection.java index dee08cc..41690c9 100644 --- a/common/src/main/java/com/octoturge/mcmapper/common/BackendConnection.java +++ b/common/src/main/java/com/octoturge/mcmapper/common/BackendConnection.java @@ -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() { } diff --git a/common/src/main/java/com/octoturge/mcmapper/common/DefaultBackendConnection.java b/common/src/main/java/com/octoturge/mcmapper/common/DefaultBackendConnection.java index bb83ad5..899f913 100644 --- a/common/src/main/java/com/octoturge/mcmapper/common/DefaultBackendConnection.java +++ b/common/src/main/java/com/octoturge/mcmapper/common/DefaultBackendConnection.java @@ -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 -> api {"type":"link_request","code":"...","uuid":"...","username":"...","authMode":"online"} * mod -> api {"type":"chat","uuid":"...","username":"...","message":"..."} * api -> mod {"type":"chat","username":"...","message":"..."} + * api -> mod {"type":"waypoint_share","name":"...","x":..,"y":..,"z":..,"dimension":..,"color":"#RRGGBB","format":"journeymap"|"xaero"} * * * 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} 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 logInfo, Consumer 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 message) { SimpleWebSocketClient client = ws; if (client == null || !client.isOpen()) return; diff --git a/common/src/main/java/com/octoturge/mcmapper/common/protocol/WaypointChatFormatter.java b/common/src/main/java/com/octoturge/mcmapper/common/protocol/WaypointChatFormatter.java new file mode 100644 index 0000000..1ee89fb --- /dev/null +++ b/common/src/main/java/com/octoturge/mcmapper/common/protocol/WaypointChatFormatter.java @@ -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--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; + } +} diff --git a/common/src/test/java/com/octoturge/mcmapper/common/protocol/WaypointChatFormatterTest.java b/common/src/test/java/com/octoturge/mcmapper/common/protocol/WaypointChatFormatterTest.java new file mode 100644 index 0000000..409c329 --- /dev/null +++ b/common/src/test/java/com/octoturge/mcmapper/common/protocol/WaypointChatFormatterTest.java @@ -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 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 + ">"); + } + } +} diff --git a/forge-1_12_2/src/main/java/com/octoturge/mcmapper/forge1122/Forge1122ChatBridge.java b/forge-1_12_2/src/main/java/com/octoturge/mcmapper/forge1122/Forge1122ChatBridge.java index 303eb3f..9578b4f 100644 --- a/forge-1_12_2/src/main/java/com/octoturge/mcmapper/forge1122/Forge1122ChatBridge.java +++ b/forge-1_12_2/src/main/java/com/octoturge/mcmapper/forge1122/Forge1122ChatBridge.java @@ -1,6 +1,7 @@ package com.octoturge.mcmapper.forge1122; import com.octoturge.mcmapper.common.ChatBridge; +import com.octoturge.mcmapper.common.protocol.WaypointChatFormatter; import com.octoturge.mcmapper.common.protocol.WaypointShare; import net.minecraft.server.MinecraftServer; import net.minecraft.util.text.TextComponentString; @@ -22,8 +23,10 @@ public class Forge1122ChatBridge implements ChatBridge { @Override public void injectWaypointShare(WaypointShare waypoint) { - // Marker sharing lands in Phase 4 (needs the JourneyMap/Xaero wire-format research spike - // the plan calls for) — nothing calls this yet. - logger.warn("injectWaypointShare called before Phase 4's waypoint sharing is implemented — ignoring"); + // Neither JourneyMap's nor Xaero's chat-waypoint syntax needs a click-event component — + // both client mods auto-detect the right plain-text shape in a normal chat message (see + // WaypointChatFormatter's javadoc for sources), so a broadcast TextComponentString is + // enough, same as injectWebChatMessage above. + server.getPlayerList().sendMessage(new TextComponentString(WaypointChatFormatter.format(waypoint))); } } diff --git a/forge-1_12_2/src/main/java/com/octoturge/mcmapper/forge1122/MCMapperMod.java b/forge-1_12_2/src/main/java/com/octoturge/mcmapper/forge1122/MCMapperMod.java index cba4faf..a90770f 100644 --- a/forge-1_12_2/src/main/java/com/octoturge/mcmapper/forge1122/MCMapperMod.java +++ b/forge-1_12_2/src/main/java/com/octoturge/mcmapper/forge1122/MCMapperMod.java @@ -92,6 +92,7 @@ public class MCMapperMod { Forge1122ChatBridge chatBridge = new Forge1122ChatBridge(event.getServer(), LOGGER); connection.setChatListener(chatBridge::injectWebChatMessage); + connection.setWaypointShareListener(chatBridge::injectWaypointShare); event.registerServerCommand(new LinkCommand(connection)); MinecraftForge.EVENT_BUS.register(this);