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:
@@ -11,8 +11,9 @@ import java.util.List;
|
|||||||
* modules (pure Java, no Minecraft API usage) — a docker-network hostname, LAN IP, or public
|
* modules (pure Java, no Minecraft API usage) — a docker-network hostname, LAN IP, or public
|
||||||
* domain in {@code MapperConfig#backendUrl} all work identically.
|
* domain in {@code MapperConfig#backendUrl} all work identically.
|
||||||
*
|
*
|
||||||
* Real implementation ({@link DefaultBackendConnection}) landed in Phase 1 (column deltas) and
|
* Real implementation ({@link DefaultBackendConnection}) landed in Phase 1 (column deltas), grew
|
||||||
* grew {@link #sendSections} in Phase 2 (full-voxel 3D mesh backfill).
|
* {@link #sendSections} in Phase 2 (full-voxel 3D mesh backfill), and grew chat/linking in
|
||||||
|
* Phase 3.
|
||||||
*/
|
*/
|
||||||
public interface BackendConnection {
|
public interface BackendConnection {
|
||||||
void connect(String url, String serverToken);
|
void connect(String url, String serverToken);
|
||||||
@@ -23,8 +24,18 @@ public interface BackendConnection {
|
|||||||
|
|
||||||
void sendLinkRequest(LinkRequest request);
|
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();
|
void disconnect();
|
||||||
|
|
||||||
|
interface ChatListener {
|
||||||
|
void onChatMessage(String username, String message);
|
||||||
|
}
|
||||||
|
|
||||||
final class NoOp implements BackendConnection {
|
final class NoOp implements BackendConnection {
|
||||||
@Override
|
@Override
|
||||||
public void connect(String url, String serverToken) {
|
public void connect(String url, String serverToken) {
|
||||||
@@ -42,6 +53,14 @@ public interface BackendConnection {
|
|||||||
public void sendLinkRequest(LinkRequest request) {
|
public void sendLinkRequest(LinkRequest request) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void sendChatMessage(String uuid, String username, String message) {
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void setChatListener(ChatListener listener) {
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void disconnect() {
|
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":"columns","dimension":0,"columns":[{"x":..,"z":..,"height":..,"blockId":..,"blockMeta":..}]}
|
||||||
* mod -> api {"type":"sections","dimension":0,"chunkX":..,"chunkZ":..,"sections":[{"sectionY":..,"blocks":"<base64>"}]}
|
* 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>
|
* </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
|
* 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
|
* 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
|
* {@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 url;
|
||||||
private String serverToken;
|
private String serverToken;
|
||||||
|
private volatile ChatListener chatListener;
|
||||||
|
|
||||||
public DefaultBackendConnection(Consumer<String> logInfo, Consumer<String> logWarn) {
|
public DefaultBackendConnection(Consumer<String> logInfo, Consumer<String> logWarn) {
|
||||||
this.logInfo = logInfo;
|
this.logInfo = logInfo;
|
||||||
@@ -151,6 +160,11 @@ public class DefaultBackendConnection implements BackendConnection {
|
|||||||
}
|
}
|
||||||
} else if ("error".equals(type)) {
|
} else if ("error".equals(type)) {
|
||||||
logWarn.accept("backend reported error: " + obj.get("error"));
|
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
|
@Override
|
||||||
public void sendLinkRequest(LinkRequest request) {
|
public void sendLinkRequest(LinkRequest request) {
|
||||||
// The `/mcmapper link` flow (Phase 3) isn't wired up yet — nothing calls this in Phase 1.
|
if (!serverReady) return;
|
||||||
logWarn.accept("sendLinkRequest called before Phase 3's link flow is implemented — ignoring");
|
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) {
|
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.config.MapperConfig;
|
||||||
import com.octoturge.mcmapper.common.protocol.DeltaEvent;
|
import com.octoturge.mcmapper.common.protocol.DeltaEvent;
|
||||||
import com.octoturge.mcmapper.common.protocol.SectionData;
|
import com.octoturge.mcmapper.common.protocol.SectionData;
|
||||||
|
import net.minecraft.entity.player.EntityPlayerMP;
|
||||||
import net.minecraft.world.WorldServer;
|
import net.minecraft.world.WorldServer;
|
||||||
import net.minecraftforge.common.MinecraftForge;
|
import net.minecraftforge.common.MinecraftForge;
|
||||||
import net.minecraftforge.common.config.Configuration;
|
import net.minecraftforge.common.config.Configuration;
|
||||||
|
import net.minecraftforge.event.ServerChatEvent;
|
||||||
import net.minecraftforge.fml.common.Mod;
|
import net.minecraftforge.fml.common.Mod;
|
||||||
import net.minecraftforge.fml.common.event.FMLPreInitializationEvent;
|
import net.minecraftforge.fml.common.event.FMLPreInitializationEvent;
|
||||||
import net.minecraftforge.fml.common.event.FMLServerStartingEvent;
|
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).
|
* 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
|
* 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
|
* 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
|
* plan's Phase 7). Phase 2 adds full-section backfill/flush for 3D mesh rendering. Phase 3 adds
|
||||||
* and `/mcmapper link` land in Phase 3.
|
* `/mcmapper link` and the two-way chat bridge (see LinkCommand, Forge1122ChatBridge).
|
||||||
*/
|
*/
|
||||||
@Mod(modid = MCMapperMod.MOD_ID, name = "MCMapper", version = MCMapperMod.VERSION)
|
@Mod(modid = MCMapperMod.MOD_ID, name = "MCMapper", version = MCMapperMod.VERSION)
|
||||||
public class MCMapperMod {
|
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);
|
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
|
@SubscribeEvent
|
||||||
public void onServerTick(TickEvent.ServerTickEvent event) {
|
public void onServerTick(TickEvent.ServerTickEvent event) {
|
||||||
if (event.phase != TickEvent.Phase.END) return;
|
if (event.phase != TickEvent.Phase.END) return;
|
||||||
|
|||||||
Reference in New Issue
Block a user