From 73e10d34e5568856525a65a5be92c6bef6861734 Mon Sep 17 00:00:00 2001 From: Octoturge Date: Sat, 8 Aug 2026 17:09:09 +0200 Subject: [PATCH] Phase 3: /mcmapper link command and two-way chat bridge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../mcmapper/common/BackendConnection.java | 23 ++++++- .../common/DefaultBackendConnection.java | 40 +++++++++++- .../mcmapper/common/LinkCodeGenerator.java | 28 ++++++++ .../common/LinkCodeGeneratorTest.java | 64 +++++++++++++++++++ .../forge1122/Forge1122ChatBridge.java | 29 +++++++++ .../mcmapper/forge1122/LinkCommand.java | 55 ++++++++++++++++ .../mcmapper/forge1122/MCMapperMod.java | 16 ++++- 7 files changed, 249 insertions(+), 6 deletions(-) create mode 100644 common/src/main/java/com/octoturge/mcmapper/common/LinkCodeGenerator.java create mode 100644 common/src/test/java/com/octoturge/mcmapper/common/LinkCodeGeneratorTest.java create mode 100644 forge-1_12_2/src/main/java/com/octoturge/mcmapper/forge1122/Forge1122ChatBridge.java create mode 100644 forge-1_12_2/src/main/java/com/octoturge/mcmapper/forge1122/LinkCommand.java 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 337ce4c..dee08cc 100644 --- a/common/src/main/java/com/octoturge/mcmapper/common/BackendConnection.java +++ b/common/src/main/java/com/octoturge/mcmapper/common/BackendConnection.java @@ -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() { } 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 e17a9c3..bb83ad5 100644 --- a/common/src/main/java/com/octoturge/mcmapper/common/DefaultBackendConnection.java +++ b/common/src/main/java/com/octoturge/mcmapper/common/DefaultBackendConnection.java @@ -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":"..."} * * + * 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} 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 logInfo, Consumer 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 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 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 message) { diff --git a/common/src/main/java/com/octoturge/mcmapper/common/LinkCodeGenerator.java b/common/src/main/java/com/octoturge/mcmapper/common/LinkCodeGenerator.java new file mode 100644 index 0000000..28ef8ae --- /dev/null +++ b/common/src/main/java/com/octoturge/mcmapper/common/LinkCodeGenerator.java @@ -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(); + } +} diff --git a/common/src/test/java/com/octoturge/mcmapper/common/LinkCodeGeneratorTest.java b/common/src/test/java/com/octoturge/mcmapper/common/LinkCodeGeneratorTest.java new file mode 100644 index 0000000..29a9d8a --- /dev/null +++ b/common/src/test/java/com/octoturge/mcmapper/common/LinkCodeGeneratorTest.java @@ -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 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 + ">"); + } + } +} 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 new file mode 100644 index 0000000..303eb3f --- /dev/null +++ b/forge-1_12_2/src/main/java/com/octoturge/mcmapper/forge1122/Forge1122ChatBridge.java @@ -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"); + } +} diff --git a/forge-1_12_2/src/main/java/com/octoturge/mcmapper/forge1122/LinkCommand.java b/forge-1_12_2/src/main/java/com/octoturge/mcmapper/forge1122/LinkCommand.java new file mode 100644 index 0000000..8cf7436 --- /dev/null +++ b/forge-1_12_2/src/main/java/com/octoturge/mcmapper/forge1122/LinkCommand.java @@ -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.")); + } +} 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 6765700..cba4faf 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 @@ -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;