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:
@@ -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 -> 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"}
|
||||
* </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;
|
||||
|
||||
+94
@@ -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;
|
||||
}
|
||||
}
|
||||
+101
@@ -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 + ">");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user