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