Phase 3: /mcmapper link command and two-way chat bridge

Test-first from here on (per request after Phase 2's backend commit):
LinkCodeGenerator has a standalone test (common/src/test) written and
confirmed failing before the implementation existed.

common: LinkCodeGenerator produces a 6-character code from an
unambiguous charset (excludes 0/O/1/I/L — it gets read off a chat line
and typed back). BackendConnection grows sendLinkRequest (now actually
implemented, was a Phase 1 stub), sendChatMessage, and setChatListener/
ChatListener for inbound web->game chat, all wired into
DefaultBackendConnection's existing JSON wire protocol.

forge-1_12_2: LinkCommand (/mcmapper link) generates a code, resolves
online/offline from the server's actual auth mode
(MinecraftServer#isServerInOnlineMode), and shows it to the player.
Forge1122ChatBridge implements the inbound half (injects web chat into
real in-game chat via the player list) — injectWaypointShare is a
documented Phase 4 stub, same pattern as sendLinkRequest was in Phase 1.
MCMapperMod hooks ServerChatEvent to forward in-game chat out and wires
the chat listener to the bridge.

Verified end-to-end against a live MCMapper-Backend instance through the
real Java client (not a stand-in): a mod-generated link code correctly
redeems via the backend's HTTP endpoint to an account with the mod-
supplied username, and a browser chat message correctly round-trips all
the way to the mod's live ChatListener callback.
This commit is contained in:
2026-08-08 17:09:09 +02:00
parent ab94cca1b2
commit 73e10d34e5
7 changed files with 249 additions and 6 deletions
@@ -11,8 +11,9 @@ import java.util.List;
* modules (pure Java, no Minecraft API usage) — a docker-network hostname, LAN IP, or public
* domain in {@code MapperConfig#backendUrl} all work identically.
*
* Real implementation ({@link DefaultBackendConnection}) landed in Phase 1 (column deltas) and
* grew {@link #sendSections} in Phase 2 (full-voxel 3D mesh backfill).
* Real implementation ({@link DefaultBackendConnection}) landed in Phase 1 (column deltas), grew
* {@link #sendSections} in Phase 2 (full-voxel 3D mesh backfill), and grew chat/linking in
* Phase 3.
*/
public interface BackendConnection {
void connect(String url, String serverToken);
@@ -23,8 +24,18 @@ public interface BackendConnection {
void sendLinkRequest(LinkRequest request);
/** Forwards one in-game chat message to the backend — see ChatBridge.OutboundSink's javadoc. */
void sendChatMessage(String uuid, String username, String message);
/** Registers the callback for web-originated chat messages the backend relays back to us. */
void setChatListener(ChatListener listener);
void disconnect();
interface ChatListener {
void onChatMessage(String username, String message);
}
final class NoOp implements BackendConnection {
@Override
public void connect(String url, String serverToken) {
@@ -42,6 +53,14 @@ public interface BackendConnection {
public void sendLinkRequest(LinkRequest request) {
}
@Override
public void sendChatMessage(String uuid, String username, String message) {
}
@Override
public void setChatListener(ChatListener listener) {
}
@Override
public void disconnect() {
}
@@ -27,8 +27,16 @@ import java.util.function.Consumer;
*
* mod -> api {"type":"columns","dimension":0,"columns":[{"x":..,"z":..,"height":..,"blockId":..,"blockMeta":..}]}
* mod -> api {"type":"sections","dimension":0,"chunkX":..,"chunkZ":..,"sections":[{"sectionY":..,"blocks":"<base64>"}]}
* mod -> api {"type":"link_request","code":"...","uuid":"...","username":"...","authMode":"online"}
* mod -> api {"type":"chat","uuid":"...","username":"...","message":"..."}
* api -> mod {"type":"chat","username":"...","message":"..."}
* </pre>
*
* Phase 3: {@code link_request} is sent by {@code /mcmapper link}; {@code chat} both directions
* is the in-game/web chat bridge (see {@link ChatBridge}) — inbound {@code chat} messages are
* delivered through whatever {@link ChatListener} the leaf registered via
* {@link #setChatListener}.
*
* 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
@@ -61,6 +69,7 @@ public class DefaultBackendConnection implements BackendConnection {
private String url;
private String serverToken;
private volatile ChatListener chatListener;
public DefaultBackendConnection(Consumer<String> logInfo, Consumer<String> logWarn) {
this.logInfo = logInfo;
@@ -151,6 +160,11 @@ public class DefaultBackendConnection implements BackendConnection {
}
} else if ("error".equals(type)) {
logWarn.accept("backend reported error: " + obj.get("error"));
} else if ("chat".equals(type)) {
ChatListener listener = chatListener;
if (listener != null) {
listener.onChatMessage(String.valueOf(obj.get("username")), String.valueOf(obj.get("message")));
}
}
}
@@ -214,8 +228,30 @@ public class DefaultBackendConnection implements BackendConnection {
@Override
public void sendLinkRequest(LinkRequest request) {
// The `/mcmapper link` flow (Phase 3) isn't wired up yet — nothing calls this in Phase 1.
logWarn.accept("sendLinkRequest called before Phase 3's link flow is implemented — ignoring");
if (!serverReady) return;
Map<String, Object> msg = new LinkedHashMap<>();
msg.put("type", "link_request");
msg.put("code", request.code);
msg.put("uuid", request.uuid);
msg.put("username", request.username);
msg.put("authMode", request.authMode == LinkRequest.AuthMode.ONLINE ? "online" : "offline");
sendRaw("link_request", msg);
}
@Override
public void sendChatMessage(String uuid, String username, String message) {
if (!serverReady) return;
Map<String, Object> msg = new LinkedHashMap<>();
msg.put("type", "chat");
msg.put("uuid", uuid);
msg.put("username", username);
msg.put("message", message);
sendRaw("chat", msg);
}
@Override
public void setChatListener(ChatListener listener) {
this.chatListener = listener;
}
private void sendRaw(String label, Map<String, Object> message) {
@@ -0,0 +1,28 @@
package com.octoturge.mcmapper.common;
import java.security.SecureRandom;
/**
* Generates the short code a player reads off their screen and types into the web link page —
* see {@code /mcmapper link} on the mod side and {@code link.ts}'s storeLinkCode/redeemLinkCode
* on the backend. The mod generates this itself (rather than asking the backend for one) so it
* can show it to the player immediately, without waiting on a network round-trip.
*/
public final class LinkCodeGenerator {
// Excludes visually-confusable characters (0/O, 1/I/L) since this gets read off a chat line
// and typed back on a keyboard/phone.
static final String CHARSET = "23456789ABCDEFGHJKMNPQRSTUVWXYZ";
private static final int LENGTH = 6;
private static final SecureRandom RANDOM = new SecureRandom();
private LinkCodeGenerator() {
}
public static String generate() {
StringBuilder sb = new StringBuilder(LENGTH);
for (int i = 0; i < LENGTH; i++) {
sb.append(CHARSET.charAt(RANDOM.nextInt(CHARSET.length())));
}
return sb.toString();
}
}
@@ -0,0 +1,64 @@
package com.octoturge.mcmapper.common;
import java.util.HashSet;
import java.util.Set;
public class LinkCodeGeneratorTest {
private static int passed = 0;
private static int failed = 0;
public static void main(String[] args) {
test("generate() returns a 6-character code", LinkCodeGeneratorTest::isSixCharacters);
test("generate() only uses the unambiguous charset", LinkCodeGeneratorTest::usesExpectedCharset);
test("generate() doesn't repeat across many calls", LinkCodeGeneratorTest::isNotObviouslyRepeating);
System.out.println();
System.out.println(passed + " passed, " + failed + " failed");
if (failed > 0) {
System.exit(1);
}
}
private static void isSixCharacters() {
String code = LinkCodeGenerator.generate();
assertEquals(6, code.length());
}
private static void usesExpectedCharset() {
String code = LinkCodeGenerator.generate();
for (char c : code.toCharArray()) {
if (LinkCodeGenerator.CHARSET.indexOf(c) < 0) {
throw new AssertionError("code '" + code + "' contains char '" + c + "' outside the expected charset");
}
}
}
private static void isNotObviouslyRepeating() {
Set<String> seen = new HashSet<>();
for (int i = 0; i < 200; i++) {
seen.add(LinkCodeGenerator.generate());
}
// Not a proof of uniqueness (codes aren't guaranteed collision-free), but 200 calls
// landing on fewer than ~195 distinct values would indicate a broken/degenerate RNG.
if (seen.size() < 195) {
throw new AssertionError("expected close to 200 distinct codes from 200 calls, got " + seen.size());
}
}
private static void test(String name, Runnable body) {
try {
body.run();
passed++;
System.out.println("PASS " + name);
} catch (AssertionError e) {
failed++;
System.out.println("FAIL " + name + "" + e.getMessage());
}
}
private static void assertEquals(Object expected, Object actual) {
if (expected == null ? actual != null : !expected.equals(actual)) {
throw new AssertionError("expected <" + expected + "> but got <" + actual + ">");
}
}
}
@@ -0,0 +1,29 @@
package com.octoturge.mcmapper.forge1122;
import com.octoturge.mcmapper.common.ChatBridge;
import com.octoturge.mcmapper.common.protocol.WaypointShare;
import net.minecraft.server.MinecraftServer;
import net.minecraft.util.text.TextComponentString;
import org.apache.logging.log4j.Logger;
public class Forge1122ChatBridge implements ChatBridge {
private final MinecraftServer server;
private final Logger logger;
public Forge1122ChatBridge(MinecraftServer server, Logger logger) {
this.server = server;
this.logger = logger;
}
@Override
public void injectWebChatMessage(String displayName, String message) {
server.getPlayerList().sendMessage(new TextComponentString("[Web] " + displayName + ": " + message));
}
@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");
}
}
@@ -0,0 +1,55 @@
package com.octoturge.mcmapper.forge1122;
import com.octoturge.mcmapper.common.BackendConnection;
import com.octoturge.mcmapper.common.LinkCodeGenerator;
import com.octoturge.mcmapper.common.protocol.LinkRequest;
import net.minecraft.command.CommandBase;
import net.minecraft.command.CommandException;
import net.minecraft.command.ICommandSender;
import net.minecraft.entity.player.EntityPlayerMP;
import net.minecraft.server.MinecraftServer;
import net.minecraft.util.text.TextComponentString;
/** {@code /mcmapper link} — see LinkRequest.java's javadoc and MCMapper-Backend's link.ts. */
public class LinkCommand extends CommandBase {
private final BackendConnection connection;
public LinkCommand(BackendConnection connection) {
this.connection = connection;
}
@Override
public String getName() {
return "mcmapper";
}
@Override
public String getUsage(ICommandSender sender) {
return "/mcmapper link";
}
@Override
public int getRequiredPermissionLevel() {
return 0; // any player may link their own account
}
@Override
public void execute(MinecraftServer server, ICommandSender sender, String[] args) throws CommandException {
if (args.length == 0 || !"link".equals(args[0])) {
sender.sendMessage(new TextComponentString("Usage: /mcmapper link"));
return;
}
if (!(sender instanceof EntityPlayerMP)) {
sender.sendMessage(new TextComponentString("Only players can link an account."));
return;
}
EntityPlayerMP player = (EntityPlayerMP) sender;
String code = LinkCodeGenerator.generate();
LinkRequest.AuthMode authMode =
server.isServerInOnlineMode() ? LinkRequest.AuthMode.ONLINE : LinkRequest.AuthMode.OFFLINE;
connection.sendLinkRequest(new LinkRequest(player.getUniqueID().toString(), player.getName(), code, authMode));
player.sendMessage(new TextComponentString(
"Your MCMapper link code: " + code + " — enter it on the map site within 10 minutes."));
}
}
@@ -5,9 +5,11 @@ import com.octoturge.mcmapper.common.DefaultBackendConnection;
import com.octoturge.mcmapper.common.config.MapperConfig;
import com.octoturge.mcmapper.common.protocol.DeltaEvent;
import com.octoturge.mcmapper.common.protocol.SectionData;
import net.minecraft.entity.player.EntityPlayerMP;
import net.minecraft.world.WorldServer;
import net.minecraftforge.common.MinecraftForge;
import net.minecraftforge.common.config.Configuration;
import net.minecraftforge.event.ServerChatEvent;
import net.minecraftforge.fml.common.Mod;
import net.minecraftforge.fml.common.event.FMLPreInitializationEvent;
import net.minecraftforge.fml.common.event.FMLServerStartingEvent;
@@ -27,8 +29,8 @@ import java.util.Set;
* Entry point for the 1.12.2 leaf — the primary/first-implemented target (Enigmatica 2).
* Phase 1: connects to the backend over WS, backfills the overworld's loaded chunks, and
* flushes event-driven column deltas on a timer (no periodic reconciliation sweep yet — see
* plan's Phase 7). Phase 2 adds full-section backfill/flush for 3D mesh rendering. Chat bridge
* and `/mcmapper link` land in Phase 3.
* plan's Phase 7). Phase 2 adds full-section backfill/flush for 3D mesh rendering. Phase 3 adds
* `/mcmapper link` and the two-way chat bridge (see LinkCommand, Forge1122ChatBridge).
*/
@Mod(modid = MCMapperMod.MOD_ID, name = "MCMapper", version = MCMapperMod.VERSION)
public class MCMapperMod {
@@ -88,9 +90,19 @@ public class MCMapperMod {
}
});
Forge1122ChatBridge chatBridge = new Forge1122ChatBridge(event.getServer(), LOGGER);
connection.setChatListener(chatBridge::injectWebChatMessage);
event.registerServerCommand(new LinkCommand(connection));
MinecraftForge.EVENT_BUS.register(this);
}
@SubscribeEvent
public void onServerChat(ServerChatEvent event) {
EntityPlayerMP player = event.getPlayer();
connection.sendChatMessage(player.getUniqueID().toString(), player.getName(), event.getMessage());
}
@SubscribeEvent
public void onServerTick(TickEvent.ServerTickEvent event) {
if (event.phase != TickEvent.Phase.END) return;